Compare commits

...

420 Commits

Author SHA1 Message Date
Enrico Ros 2f59e12e20 Remove log 2024-03-06 22:20:40 -08:00
Enrico Ros 30e8652c2a 1.14.1: Release for Claude-3 2024-03-06 22:10:41 -08:00
Enrico Ros 5ee6aceb60 cleanups 2024-03-06 21:51:15 -08:00
Enrico Ros 6940b6a6d1 Anthropic: Full support for Claude-3 models. Closes #443, #450
Thanks to @slapglif in #450 for a reference implementation.
2024-03-06 21:50:24 -08:00
Enrico Ros 4e33ce9415 misc 2024-03-06 20:56:32 -08:00
Enrico Ros 944e22bde6 Anthropic: if there's a single system message, treat it as-if it was a user message 2024-03-06 20:49:59 -08:00
Enrico Ros 6054fa0a26 Anthropic: use the new Messages format (thanks @slapglif #450) 2024-03-06 20:42:33 -08:00
Enrico Ros 4db13cfed4 Anthropic: wire types (fully switch to the new Messages API) 2024-03-06 20:33:59 -08:00
Enrico Ros 6a6adda2e0 misc 2024-03-06 20:33:12 -08:00
Enrico Ros 4afa55c0db Anthropic: update models 2024-03-06 18:36:07 -08:00
Enrico Ros bc120bfb2b Merge branch 'release-1.14.0' 2024-03-05 22:41:40 -08:00
Enrico Ros 88966699e7 1.14.0: Changelog and README 2024-03-05 22:40:49 -08:00
Enrico Ros 9a5db3dcfb 1.14.0: release date 2024-03-05 22:38:56 -08:00
Enrico Ros 392aa1e654 1.14.0: README and Changelog 2024-03-05 22:16:02 -08:00
Enrico Ros f2b32e47ff 1.14.0: README and Changelog 2024-03-05 22:13:59 -08:00
Enrico Ros 58136d0181 maintainers: release name and cover 2024-03-05 21:58:04 -08:00
Enrico Ros 02733e55cb 1.14.0: News items 2024-03-05 21:55:44 -08:00
Enrico Ros 60df8456a7 1.14.0: Icons support 2024-03-05 21:54:48 -08:00
Enrico Ros 6d0ecc805c 1.14.0: Cover Image 2024-03-05 21:54:38 -08:00
Enrico Ros a0e9dd24a3 Explain the Debug page 2024-03-05 20:12:40 -08:00
Enrico Ros d1eb89057d Update flow 2024-03-05 14:51:00 -08:00
Enrico Ros 161c6dc83a 1.14.0: News Version 2024-03-05 14:50:51 -08:00
Enrico Ros 54848b8a7e 1.14.0: news: move around 2024-03-05 14:50:46 -08:00
Enrico Ros 990563c604 1.14.0: Version 2024-03-05 14:45:35 -08:00
Enrico Ros 8489ca8c8d Anthropic: add status update tracking #443 2024-03-05 14:40:43 -08:00
Enrico Ros b57e2c89e3 miniroll 2024-03-05 14:32:44 -08:00
Enrico Ros 66bedf78ac anthropic: cutoff dates 2024-03-04 22:30:09 -08:00
Enrico Ros 592c5cce60 roll packages 2024-03-04 22:01:19 -08:00
Enrico Ros 2ccf9a4e92 swap items 2024-03-04 21:38:30 -08:00
Enrico Ros ed333c0513 make 127.0.0.1 work on airgaped connections 2024-03-04 16:26:20 -08:00
Enrico Ros 89b65b7009 unbreak build #444 2024-03-04 15:20:08 -08:00
Enrico Ros 0cc2d346af Merge pull request #444 from jacksongoode/claude-3
Add Claude 3 models
2024-03-04 14:18:19 -08:00
Jackson 5f81e78bc4 Add Claude 2 to old models 2024-03-04 12:29:04 -08:00
Jackson 554b5fd4b5 Add Claude 3 models 2024-03-04 12:24:12 -08:00
Enrico Ros a58c3a6a52 Merge branch 'Penagwin-groq-provider' 2024-03-01 15:35:13 -08:00
Enrico Ros 6147f1131b Groq review: perfect. 2024-03-01 15:34:48 -08:00
Enrico Ros 26552aa996 Update Groq icon 2024-03-01 15:28:26 -08:00
Paul Lang 17cc31f376 Added support to fetch models for groq 2024-03-01 13:23:20 -05:00
Paul Lang 41f7a63392 Added Groq as an endpoint 2024-02-29 13:06:56 -05:00
Enrico Ros 70474ce517 Chat Drawer: improve view menu 2024-02-28 03:47:37 -08:00
Enrico Ros 365f144c57 System messages: improve menu 2024-02-28 03:07:22 -08:00
Enrico Ros ff1e1c249f System messages: differentiate looks 2024-02-28 03:02:41 -08:00
Enrico Ros e3ed6f802d Browse: disambiguate more 2024-02-27 00:48:06 -08:00
Enrico Ros b5ed078260 Stable: fix news disappearing 2024-02-27 00:46:23 -08:00
Enrico Ros 64310292da Browse: disambiguate 2024-02-27 00:43:21 -08:00
Enrico Ros 2656d0dfa5 GA: Infrastructure. Enables data analysis for product improvement. 2024-02-27 00:26:13 -08:00
Enrico Ros 70a7f0aaf4 GA: Build-time validation 2024-02-26 23:24:32 -08:00
Enrico Ros d405dcaa3a GA: Docs 2024-02-26 23:24:24 -08:00
Enrico Ros 5ecef67855 GA: Docker and GitHub actions support 2024-02-26 23:24:12 -08:00
Enrico Ros 8f6d9f8c31 Debug: add frontend variables (as a reminder, they're set at build time by next.js) 2024-02-26 23:22:36 -08:00
Enrico Ros 8662437b1a Bring back Dev mode settings 2024-02-26 14:34:16 -08:00
Enrico Ros ce3e5629e7 Bits 2024-02-26 14:26:39 -08:00
Enrico Ros d4c487534d Optimize heavily: ChatMessages can finally be memoed 2024-02-26 14:25:35 -08:00
Enrico Ros 2b9577b87d Beam: subordinate to option 2024-02-26 13:28:48 -08:00
Enrico Ros 6a0f8564f3 Beam: as command 2024-02-26 13:12:28 -08:00
Enrico Ros e9f74946e3 Beam: cleanups 2024-02-26 13:00:10 -08:00
Enrico Ros e043ab8710 Temp: hide Draw and Workspaces 2024-02-26 12:45:43 -08:00
Enrico Ros 79dd2f5f6b Update #434: online models with a 🌐 2024-02-26 12:42:00 -08:00
Enrico Ros 76e6ca8f0c Update #434: deprecated as hidden, and new sort by capabilities, descending 2024-02-26 12:28:09 -08:00
Enrico Ros 0f310e866f Merge pull request #434
Added Perplexity's new models, updated context lengths, new deprecations
2024-02-26 12:15:21 -08:00
Enrico Ros 1f66221bbd Mistral: elegance. 2024-02-26 12:14:10 -08:00
Enrico Ros 635b70fb6c Roll packages 2024-02-26 12:14:00 -08:00
Enrico Ros d113801b18 Beam: begin 2024-02-26 12:05:45 -08:00
Enrico Ros ac74efed4a Cleanups 2024-02-26 12:05:43 -08:00
Enrico Ros 52e1dc2fb2 Mistral: support for Large models 2024-02-26 12:04:29 -08:00
Paul Lang 7564fd5e03 Added Perplexity's new models, updated context lengths, new deprecations
https://docs.perplexity.ai/changelog/api-updates-february-2024
2024-02-26 14:25:54 -05:00
Enrico Ros 96810328ee Remove Ephemerals from the Chat Store 2024-02-26 04:32:36 -08:00
Enrico Ros 5603a98df9 Reposition Ephemerals 2024-02-26 02:47:40 -08:00
Enrico Ros 5c800e35f2 Extract simil-controllers for the chat 2024-02-26 02:30:50 -08:00
Enrico Ros dd15eecdf1 Improve debug message 2024-02-26 02:26:51 -08:00
Enrico Ros b6cb68bfcf Fix: react to the change 2024-02-25 19:02:53 -08:00
Enrico Ros 07c5143f1e Fix: don't hide the persona of the current chat in the dropdown 2024-02-25 19:00:05 -08:00
Enrico Ros e8c0cf3306 LocalAI: user-configurable API Key. Fixes #432. Additionally, full server-side config is allowed. 2024-02-23 05:24:31 -08:00
Enrico Ros 5e86d16442 LocalAI: support for backend configuration env-vars. Part 1/2 of #432.
- LOCALAI_API_HOST: Sets the URL of the LocalAI server, or defaults to http://127.0.0.1:8080
 - LOCALAI_API_KEY: The (Optional) API key for LocalAI
2024-02-23 04:46:24 -08:00
Enrico Ros 5ff246a241 Gemini: detect max_tokens, and safer parsing 2024-02-23 04:20:48 -08:00
Enrico Ros 58d54682ab Tryfix #431 2024-02-23 03:02:11 -08:00
Enrico Ros 5ab547d434 Improve usability of the llm list dialog 2024-02-23 02:57:11 -08:00
Enrico Ros 96a5868543 Fonts: rationalize sizes 2024-02-23 02:18:31 -08:00
Enrico Ros 0422c03efe Comments 2024-02-22 23:22:56 -08:00
Enrico Ros 2745c7295e Fix the client variable destructuring 2024-02-22 23:06:54 -08:00
Enrico Ros 82f6ec5839 Bits 2024-02-22 22:56:52 -08:00
Enrico Ros 8e1a155cff Document the PlantUML Server variable, and improve error checking in the renderer. 2024-02-22 22:55:16 -08:00
Fred Liu 521578c4aa Enable custom PlantUML server, Fixes #415
(cherry picked from commit 49392acfd6ab55cc4ba8a951272e921b7e8ff64c, fredliubojin main)
2024-02-22 22:34:14 -08:00
Enrico Ros a04f5f8c94 Call: fix overflow on Telephone 2024-02-22 10:49:40 -08:00
Enrico Ros fb6f96689b Improve Image warning & style. Closes #419 2024-02-22 10:36:49 -08:00
Enrico Ros 69a12d45f3 Restore and improve calls on the main branch. Closes #424 2024-02-22 09:19:54 -08:00
Enrico Ros bf4dd37a1b Calls: improve message looks 2024-02-22 09:18:00 -08:00
Enrico Ros b1230a9758 Clarify isVercelFromBackendOrSSR 2024-02-22 08:55:46 -08:00
Enrico Ros 23621c57ed Fix Share title 2024-02-22 08:49:02 -08:00
Enrico Ros 5f49a9f8ef [bug] Hide personas from the dropdown if hidden in the selector. 2024-02-22 08:48:17 -08:00
Enrico Ros c5b31c3975 Fix -kebab-case 2024-02-22 08:47:39 -08:00
Enrico Ros 74dbe11d4a Improve gfx on split screen 2024-02-22 08:40:23 -08:00
Enrico Ros 64b18c0a0a Dev2: prefer tables 2024-02-22 08:30:50 -08:00
Enrico Ros 7c6cec8eea Persona selection: improve first time experience 2024-02-22 08:06:17 -08:00
Enrico Ros 2b1869e1b3 Bits 2024-02-22 07:54:16 -08:00
Enrico Ros 87e5a155ba Revert 2024-02-22 07:52:18 -08:00
Enrico Ros d5c7071f1b Default to ContentScaling: 'sm' 2024-02-22 07:45:49 -08:00
Enrico Ros 04eb2210e6 Use a less sharp edge 2024-02-22 07:42:53 -08:00
Enrico Ros 4748b00be1 Show a warning when the page is being translated. Closes #429 2024-02-22 07:33:11 -08:00
Enrico Ros 18968ba985 Button to unhide models. Closes #430 2024-02-22 06:50:45 -08:00
Enrico Ros 59b300b71e Merge remote-tracking branch 'opensource/main-stable' 2024-02-22 06:39:12 -08:00
Enrico Ros 5916ef74f9 Gemini: support for createTunedModel 2024-02-22 06:38:45 -08:00
Enrico Ros f5602723c7 Improve outputs 2024-02-21 16:34:13 -08:00
Enrico Ros 59795dcd22 Improve export scripts 2024-02-21 16:17:24 -08:00
Enrico Ros 127a5cbf96 LocalAI: update docs 2024-02-21 15:02:07 -08:00
Enrico Ros 2b040664cb Use /info/debug to export App and Browser info for debugging 2024-02-21 02:10:39 -08:00
Enrico Ros 4ffbdfd16c Improve Placeholder App 2024-02-21 01:25:11 -08:00
Enrico Ros e200cbf312 Update name 2024-02-21 01:24:57 -08:00
Enrico Ros f4edd192fd Package min chunks of 40kb 2024-02-21 00:34:30 -08:00
Enrico Ros dd07167087 Roll packages 2024-02-20 21:36:51 -08:00
Enrico Ros 81aa8468a7 Merge remote-tracking branch 'opensource/main-stable'
# Conflicts:
#	package-lock.json
#	package.json
2024-02-20 21:31:47 -08:00
Enrico Ros 871e72b655 Update Vercel packages 2024-02-20 21:30:25 -08:00
Enrico Ros 9825d8e2f3 Specialize React->Next 2024-02-20 21:09:05 -08:00
Enrico Ros 58c5569beb Bundle: min 50kb - optimization trial 2024-02-20 17:20:43 -08:00
Enrico Ros c975511c74 Merge remote-tracking branch 'opensource/main-stable' 2024-02-20 16:51:59 -08:00
Enrico Ros e3c52fb1f9 Flush 2024-02-20 16:51:49 -08:00
Enrico Ros 397517e666 Tryfix CLS with delayed attraction 2024-02-20 16:51:26 -08:00
Enrico Ros 09088febe8 Tryfix CLS with fast bootup routing 2024-02-20 16:50:48 -08:00
Enrico Ros bbf5dc078e Increase the sample rate
(cherry picked from commit ce0dca86ac)
2024-02-20 15:31:00 -08:00
Enrico Ros 14d57aa622 Use Vercel components only on Vercel deployments.
(cherry picked from commit 72bb31881a)
2024-02-20 15:31:00 -08:00
Enrico Ros bcfc4921ca Publish docs 2024-02-20 04:26:13 -08:00
Enrico Ros cff70ebadd Fix derived logo 2024-02-20 03:03:22 -08:00
Enrico Ros 4b9c958d65 Docs: cleanup - in preparation for web docs 2024-02-20 01:51:47 -08:00
Enrico Ros 7dc7116a2f Docs: cleanup and add index (README.md). 2024-02-20 01:32:05 -08:00
Enrico Ros 92a2c93644 Roll package 2024-02-19 05:25:11 -08:00
Enrico Ros 7be0d88794 Remove Experiment 2024-02-19 05:25:11 -08:00
Enrico Ros ff6ca01813 Flush changes 2024-02-19 05:25:11 -08:00
Enrico Ros ce0dca86ac Increase the sample rate 2024-02-19 00:32:50 -08:00
Enrico Ros 6c51a36dbc Prevent standalone builds from modifying the tsconfig.json. 2024-02-18 15:12:59 -08:00
Enrico Ros 72bb31881a Use Vercel components only on Vercel deployments. 2024-02-18 15:12:24 -08:00
Enrico Ros c6fcad03cd Add Sharp as suggested by Next 2024-02-18 14:57:36 -08:00
Enrico Ros 70de7133a9 Electron: skeleton 2024-02-18 02:20:04 -08:00
Enrico Ros ef36751eac Electron: structure 2024-02-18 02:19:44 -08:00
Enrico Ros dee1461b9c Draggable App Bar 2024-02-18 01:29:10 -08:00
Enrico Ros 3b775fc817 Export: automate static exports 2024-02-18 00:39:10 -08:00
Enrico Ros da52eff9d3 Try fixing the dockerfile again. 2024-02-17 20:49:19 -08:00
Enrico Ros a7efaa7720 Export Frontend: to the 'dist/' folder 2024-02-17 20:13:56 -08:00
Enrico Ros a42587c498 Fix Docker build after moving Prisma 2024-02-17 20:04:48 -08:00
Enrico Ros d29265f042 Relocate Prisma to src/server/prisma 2024-02-17 19:44:36 -08:00
Enrico Ros c305b44c41 Fix Chat interactivity on drag-to-collapse 2024-02-17 19:07:30 -08:00
Enrico Ros 32ff65be1c Roll packages 2024-02-17 18:33:11 -08:00
Enrico Ros b550cbdfc7 Pipeline, add support for .MJS 2024-02-17 18:09:54 -08:00
Enrico Ros f767ad81ce Export Frontend: work around NextJS aborting on the nodejs API.
This introduces a pre-build step on Next Build, which hides the files
in the app/api directory when the EXPORT_FRONTEND environment
variable is true-ish.

Hopefully there won't be disruption due to the post-processing step.

Also check https://github.com/vercel/next.js/issues/61213 for
upstream updates.
2024-02-17 18:03:21 -08:00
Enrico Ros 35d04055ac Update OpenAI models cutoff date, as OpenAI swapped it for existing models... Fixes #422. 2024-02-17 17:59:16 -08:00
Enrico Ros c7fe75829f Export Frontend: first steps
Note: needs deletion of app/api/trpc-node/[trpc]/route.ts due to
an upstream issue
2024-02-17 16:52:32 -08:00
Enrico Ros 8299b4c148 Clearly mark frontend fetches 2024-02-17 15:30:35 -08:00
Enrico Ros 5bb84f8930 Elevenlabs: mode handler to module 2024-02-17 15:17:07 -08:00
Enrico Ros 047c9a2f07 Backend -> Services fetchers 2024-02-17 15:06:59 -08:00
Enrico Ros 8c11925444 Delete also when searching. 2024-02-16 16:30:42 -08:00
Enrico Ros 1cbb4fd11a Update to parse more. #361 2024-02-16 10:14:02 -08:00
Enrico Ros 0a8d9ebd55 Render Latex when in \[..\] blocks. Requires newlines "\[\n ... \n\]\n". Fixes #361 2024-02-16 10:11:57 -08:00
Enrico Ros 386724655e Pre-select the last added Model Source (not the earliest) 2024-02-16 09:54:17 -08:00
Enrico Ros 7b37b9e204 Google Gemini: auto-detect symlink targets 2024-02-16 09:49:23 -08:00
Enrico Ros 3b02612124 Google Gemini: show symlink models 2024-02-16 09:45:23 -08:00
Enrico Ros 32b040cbcf Search results heading 2024-02-16 09:33:40 -08:00
Enrico Ros 75a15a12a6 Message when no chats in active folder/search. Fixes #394 2024-02-16 09:28:33 -08:00
Enrico Ros 0cb7be8381 Fix state 2024-02-16 09:10:44 -08:00
Enrico Ros 20d3c267a3 Persist user edited model settings. Fixes #398 2024-02-16 08:50:59 -08:00
Enrico Ros 84313ffa8c Inconclusive. #401 2024-02-16 08:37:06 -08:00
Enrico Ros be66ce0f32 Perplexity: full support. Fixes #407 2024-02-16 08:21:49 -08:00
Enrico Ros 12c1194009 Move Discord icon 2024-02-16 07:39:24 -08:00
Enrico Ros 82b83a39dd Import/Export All: save/restore folders (and folder presence state). Fixes #416 2024-02-16 07:11:07 -08:00
Enrico Ros ac617de4ae Update 2024-02-15 15:57:29 -08:00
Enrico Ros b6731c9afa Less is more 2024-02-14 05:30:58 -08:00
Enrico Ros 3a7ece6508 Improve 2024-02-14 05:27:28 -08:00
Enrico Ros 2c69d2805d While at it 2024-02-14 05:21:48 -08:00
Enrico Ros 87b03c67ec Expandable sections 2024-02-14 05:19:34 -08:00
Enrico Ros 569b08288e More moves 2024-02-14 05:12:05 -08:00
Enrico Ros 049fa90832 More moves 2024-02-14 05:06:42 -08:00
Enrico Ros f23347de7e Refer to CoolAGI 2024-02-14 05:02:09 -08:00
Enrico Ros 0272283f94 LocalAI: mention Voice Cloning after the chat with @mudler 2024-02-13 23:04:05 -08:00
Enrico Ros 64640c1331 Group by date by default 2024-02-13 19:28:53 -08:00
Enrico Ros ff1471cfe8 Call it like it is 2024-02-13 04:15:46 -08:00
Enrico Ros aae3783f67 LocalAI: Model Gallery Admin panel. Fixes #411 2024-02-13 04:12:14 -08:00
Enrico Ros 053aa12a91 Improve messages 2024-02-13 02:13:51 -08:00
Enrico Ros 17a006db8f LocalAI: status of integration 2024-02-13 01:41:41 -08:00
Enrico Ros 56d912da3d Add Sx and expandedVariant to ExpanderAccordion 2024-02-13 01:41:30 -08:00
Enrico Ros 3c60284e6e LocalAI: raise to 4 max instances 2024-02-13 00:02:58 -08:00
Enrico Ros 76ddff4820 Source Add Dialog: improvement, esp. icons and badges 2024-02-12 15:25:42 -08:00
Enrico Ros 1bd6dc0a1a LocalAI: update icon 2024-02-12 14:39:30 -08:00
Enrico Ros 5c7d289123 Placeholder 2024-02-12 13:56:45 -08:00
Enrico Ros 8f6d646a1f Perfect diagrams auto-resize on mobile 2024-02-12 13:48:59 -08:00
Enrico Ros c42123fe2a Fix mobile zIndex 2024-02-12 13:42:15 -08:00
Enrico Ros 58bd84b600 Grouping by Date or Persona 2024-02-12 03:50:18 -08:00
Enrico Ros 621eb4a54c Chat: filtered deletion + navigation Rendrer tree != chats list. Fixes #324
This is a good feature. The 'Delete All' will always operate on the current selection.

If a Folder restricts to 10 conversasions, and a search narrows it down to 3,
then the 3 will be deleted. This works really
well for quick cleanups.
2024-02-12 03:04:53 -08:00
Enrico Ros 9073cff1c1 AppChat: perfect filtered deletion 2024-02-12 02:59:55 -08:00
Enrico Ros d69516df5c Add a ProcessingQueue, fixes #409 2024-02-11 22:36:34 -08:00
Enrico Ros 7322280d3d Try a change for #408 2024-02-11 21:50:42 -08:00
Enrico Ros 5f79569ea9 Style fix 2024-02-11 13:26:54 -08:00
Enrico Ros fe8b8472b7 Share Blocks
Note: there's one dependency to ../../app/chat inside
2024-02-11 12:55:30 -08:00
Enrico Ros cb2b1a89b5 Draw: temp click to remove 2024-02-11 03:38:59 -08:00
Enrico Ros 6ece7b884a Draw: mobile/desktop grid seems fine 2024-02-11 03:37:18 -08:00
Enrico Ros 04fc9264cb Draw: draw xN images 2024-02-11 03:13:42 -08:00
Enrico Ros 016c2df942 Draw: show the first images 2024-02-11 02:57:10 -08:00
Enrico Ros bf6a2b60b9 Draw: improbetterment 2024-02-11 02:42:59 -08:00
Enrico Ros 5093e70552 ditto 2024-02-11 02:39:53 -08:00
Enrico Ros 3bd50e1b45 More targeted error message 2024-02-11 02:37:40 -08:00
Enrico Ros 793383f70d Slight change to the error format 2024-02-11 02:23:32 -08:00
Enrico Ros 3b84e42932 Draw: Begin rendering 2024-02-11 02:03:51 -08:00
Enrico Ros 09efc9b148 Draw: Designer: uids per each prompt coming out 2024-02-11 02:00:34 -08:00
Enrico Ros 90c2542486 Image Renderer: cleanups 2024-02-11 01:59:38 -08:00
Enrico Ros 9259fa3b6d Improved New Chat button - fits better 2024-02-11 00:54:30 -08:00
Enrico Ros 0c8f102830 Folders: dynamic scaling 2024-02-11 00:13:52 -08:00
Enrico Ros 02972a0fb6 Folders: move button in the pane 2024-02-11 00:12:41 -08:00
Enrico Ros 2a4a65f129 Improve Icon 2024-02-10 23:44:04 -08:00
Enrico Ros e16270e1ec Chat Drawer: Folder Toggle Icons 2024-02-10 23:40:55 -08:00
Enrico Ros 201a884828 Add dependency 2024-02-10 23:26:35 -08:00
Enrico Ros 2a32139be3 Text Scaling: scale the drawer contents 2024-02-10 23:26:14 -08:00
Enrico Ros 7955bf2b86 Show error messages - where they belong. 2024-02-10 21:57:47 -08:00
Enrico Ros a5d70e4ca3 Memory Optimization in ChatMessages: Memo the non-in-flux (and only the root), and within the in-flux'd memo only the baked parts (and not the on the fly) - massive GC savings 2024-02-10 21:10:01 -08:00
Enrico Ros 12eb08ee08 Optimized Composer down to 2.8ms: stable callbacks, stable const styles, memoed Buttons. 2024-02-10 21:07:36 -08:00
Enrico Ros fe74583bae Optimize Tooltip timeouts 2024-02-10 20:07:09 -08:00
Enrico Ros b8b1dd2cfb Optimize 2024-02-10 20:06:35 -08:00
Enrico Ros 9723b328c3 Chat Drawer Item: denser menu, with disappearing items on Delete Arming, for Focus 2024-02-10 19:48:28 -08:00
Enrico Ros edc3ab6d00 Branch Icon on the Chats 2024-02-10 19:47:35 -08:00
Enrico Ros 0e243cd167 Update {{LocaleNow}} and 'Generic' 2024-02-10 10:19:50 -08:00
Enrico Ros b8e0064381 Adding {{LocaleNow}} with enough info to get on the same page as the user 2024-02-10 10:18:24 -08:00
Enrico Ros 018c77901d Labs Setting: Performance Mode - Unlocks updates (otherwise visually capped at 12Hz) 2024-02-09 22:38:44 -08:00
Enrico Ros 5849fd9c94 Turning on revealing client-side debug messages. 2024-02-09 22:07:41 -08:00
Enrico Ros 6a5d1eb5c2 Write Scheduler for iDB 2024-02-09 21:37:21 -08:00
Enrico Ros fc70857fae Limit Assistant responses editMessages to 12Hz and decrease sqrt with the number of chats 2024-02-09 21:18:35 -08:00
Enrico Ros 5cd6fe23d8 Sharing: add the title on native shares 2024-02-09 21:13:05 -08:00
Enrico Ros beffcdcba9 Clenaups on the streaming client, to clarify incrementalness 2024-02-09 21:13:05 -08:00
Enrico Ros cdd39457ff Begin cleanup of the streaming client 2024-02-09 21:13:05 -08:00
Enrico Ros 937b2806ef roll packages 2024-02-09 01:29:41 -08:00
Enrico Ros 34552190c6 Merge placeholder - will remove the feature-promptFX branch 2024-02-09 00:36:10 -08:00
Enrico Ros 7e762d5ddc Alt Chat Title 2024-02-09 00:33:28 -08:00
Enrico Ros 8e78b21a5c AI Auto-Title: async 2024-02-09 00:32:21 -08:00
Enrico Ros ae85fdf59f Open Code only on complete blocks 2024-02-09 00:31:19 -08:00
Enrico Ros e39dc428cc Fix CSS 2024-02-09 00:31:07 -08:00
Enrico Ros cc178efacb Labs: toggle Chat title 2024-02-08 23:52:21 -08:00
Enrico Ros 8a7a3afc10 LinkChat: mark messages as mobile to auto-scale charts on mobile 2024-02-08 23:18:26 -08:00
Enrico Ros e0f1689125 LinkChat: move indications 2024-02-08 23:12:10 -08:00
Enrico Ros 3acdd75863 Update CodePen, add StackBlitz, JSFiddle 2024-02-08 22:58:53 -08:00
Enrico Ros 1ca5ff726c Remove Replit support - Replit does not support to be sent code anymore. Looking for alternatives. 2024-02-08 21:02:12 -08:00
Enrico Ros 464051c319 LinkChat: renames 2024-02-08 20:53:27 -08:00
Enrico Ros 548859fa65 Customize existing prompts into new 2024-02-08 20:50:44 -08:00
Enrico Ros f57c10508f Dev2: reduce annoyances 2024-02-08 20:50:06 -08:00
Enrico Ros b7f53d965f Merge remote-tracking branch 'opensource/main-stable' 2024-02-08 18:13:11 -08:00
Enrico Ros 28b1090fd7 Enable horizontally scrollable attachments. Fixes #406 2024-02-08 18:11:12 -08:00
Enrico Ros 566bf8d38e Share Link: font size setting 2024-02-08 15:16:35 -08:00
Enrico Ros 663306bd3b Improve spacings / list buttons sizes 2024-02-08 07:20:00 -08:00
Enrico Ros 165a5e60d3 Scaling: improve 2024-02-08 06:56:48 -08:00
Enrico Ros 3b01a26eed Scaling: clean 2024-02-08 06:21:52 -08:00
Enrico Ros 65f997a2ba Roll packages 2024-02-08 05:31:53 -08:00
Enrico Ros c1217ed8ed No tRPC fixes 2024-02-08 04:38:13 -08:00
Enrico Ros 6ae76c553f Improve DallE 1hr dialog. 2024-02-08 04:09:08 -08:00
Enrico Ros 141096eace Links: open them externally so big-AGI is not interrupted. 2024-02-08 03:11:00 -08:00
Enrico Ros c4003a888a Shortcuts for text size: ctrl + shift + '+' / '-' 2024-02-08 02:56:39 -08:00
Enrico Ros d1c22e12a7 Consolidate the 3 dynamic imports into 1 - faster, smaller. 2024-02-08 02:44:42 -08:00
Enrico Ros 9461cab182 Save 3kb from dynamically importing this module 2024-02-08 02:15:04 -08:00
Enrico Ros dcceead4ca Remove unused icon (~0.6kb bundle) 2024-02-08 02:02:04 -08:00
Enrico Ros ae8ac5111c Reduce total bundle size for React-Player (YouTube only) 2024-02-08 01:49:34 -08:00
Enrico Ros 1e35fceb61 Best-Of: custom icon 2024-02-08 01:35:11 -08:00
Enrico Ros 88d0ffd712 Best-Of: input wires 2024-02-08 01:35:11 -08:00
Enrico Ros 6cbc3fbf28 1.14: begin mentioning 2024-02-08 01:35:00 -08:00
Enrico Ros 4eb6f6da9d Re-open the 2 Dev Sections 2024-02-08 01:34:19 -08:00
Enrico Ros 5bc320385f Update README 2024-02-08 00:26:07 -08:00
Enrico Ros 3d39a35c03 Release fix: decrease visual clutter 2024-02-07 23:52:24 -08:00
Enrico Ros 5ca9475bb6 1.13.0: Update README 2024-02-07 23:50:07 -08:00
Enrico Ros f12386c614 Merge branch 'release-1.13.0' 2024-02-07 23:47:29 -08:00
Enrico Ros 485dd0d91f 1.13.0: README and Changelog 2024-02-07 23:46:51 -08:00
Enrico Ros fc137176bd 1.13.0: Rename 2024-02-07 23:22:25 -08:00
Enrico Ros b34fe2f9f6 1.13.0: Disable Draw & Workspace for release 2024-02-07 23:16:07 -08:00
Enrico Ros 3b7916c536 1.13.0: Fix date 2024-02-07 23:13:22 -08:00
Enrico Ros d11a2b59ee Move release Covers 2024-02-07 23:13:15 -08:00
Enrico Ros 63d1ec4c30 Fix Cover Image sizing to absorb the border 2024-02-07 23:01:10 -08:00
Enrico Ros 4ed49be67e 1.13.0: Cover Image 2024-02-07 23:00:54 -08:00
Enrico Ros 3a0749c5b2 1.13.0: News 2024-02-07 22:16:10 -08:00
Enrico Ros 63470adc0f Explicitly call out the code line height 2024-02-07 21:35:50 -08:00
Enrico Ros 0bbfad4b41 1.13.0: Version 2024-02-07 20:58:07 -08:00
Enrico Ros f9cb97ca49 For later 2024-02-07 20:57:49 -08:00
Enrico Ros b63636cf2f Style 2024-02-07 20:51:44 -08:00
Enrico Ros 54b388c9ae Reorder Developer2 2024-02-07 18:14:25 -08:00
Enrico Ros d233f0946f Zen mode: do not show chat list underbars 2024-02-07 18:13:27 -08:00
Enrico Ros 671ac36946 PMix: notes 2024-02-07 18:11:58 -08:00
Enrico Ros e6ba217302 PMix: improve local time 2024-02-07 18:02:20 -08:00
Enrico Ros b9a18a5442 Dev2: add icon 2024-02-07 17:53:39 -08:00
Enrico Ros f8d0f25f72 On mobile, auto-fit mermaid and PlantUML by default. 2024-02-07 17:45:46 -08:00
Enrico Ros 2213c61760 Reuse 2024-02-07 17:36:43 -08:00
Enrico Ros e7edffa237 Add a Dev2 Example/Preview 2024-02-07 17:33:47 -08:00
Enrico Ros fd83aca7a4 Bare bones prompt mixer 2024-02-07 17:33:30 -08:00
Enrico Ros bdc2d07747 PersonaSelector: show prompt 2024-02-07 17:03:36 -08:00
Enrico Ros 1953f7d31a Can Scale (up/dn) SVG, Mermaid and PlantUMLs 2024-02-07 09:57:53 -08:00
Enrico Ros 054ed80bbe GitHub Markdown style: scaleable spacing. #399 2024-02-07 09:08:58 -08:00
Enrico Ros 13b64e65c3 Dynamic Text Size switching. Fixes #399 2024-02-07 09:07:19 -08:00
Enrico Ros ee9ee72505 Fix a few styling issues on the blocks 2024-02-07 07:59:19 -08:00
Enrico Ros 1b631a91b3 Improve Markdown rendering spacing. Blocks break the top/bottom margins. 2024-02-07 07:52:17 -08:00
Enrico Ros 118d2cb2ad Nits. 2024-02-07 07:26:03 -08:00
Enrico Ros b6acfa9d49 LM Studio Config: add @techfren's video 2024-02-07 06:59:46 -08:00
Enrico Ros 4798ba3fd0 Dynamic Video Player 2024-02-07 06:59:00 -08:00
Enrico Ros 14608f97da Roll packages - hold back Joy which depends on yet another version of MUI 2024-02-07 06:29:48 -08:00
Enrico Ros 901d590159 Update config-lmstudio.md 2024-02-07 06:10:50 -08:00
Enrico Ros 28e71d4ac7 LMStudio: make the doc and link the Video by @techfren 2024-02-07 05:36:15 -08:00
Enrico Ros 7f958c9e66 Multi-Chats: super-power to create new 2024-02-07 04:25:09 -08:00
Enrico Ros 910f0c5556 New Chats: improve appearance 2024-02-07 04:25:08 -08:00
Enrico Ros 427ef8c108 MultiChat: show where windows are open, nicely 2024-02-07 04:08:23 -08:00
Enrico Ros 2efdfca7e5 MultiChat: improve color, to better relate to the drawer 2024-02-07 04:00:56 -08:00
Enrico Ros bc113b08f7 Do a better job at signaling which window is where 2024-02-07 03:59:39 -08:00
Enrico Ros 262a6d2560 Bring branch with split 2024-02-07 03:31:15 -08:00
Enrico Ros f9224aa25d Split-open: say it's already open 2024-02-07 03:29:11 -08:00
Enrico Ros 6d0f7949f8 Persona Selector: show newly missing 2024-02-07 03:29:10 -08:00
Enrico Ros 1a679bcf90 Use the Memo RenderMarkdown 2024-02-07 03:06:52 -08:00
Enrico Ros 5de34fe3af Split Screen: Duplicate into new (but disable this while testing it) 2024-02-07 03:02:13 -08:00
Enrico Ros 420b4565dd Add a command (/clear all) to reset chats. 2024-02-07 02:23:47 -08:00
Enrico Ros 27eb9adb16 Memo code and markdown rendering for the current message. Shall help vigorously. #402. It's a tradeoff with mem tho. 2024-02-07 02:05:10 -08:00
Enrico Ros c4277b9ef0 Optimization on the message being typed - recycles references to speed up React. Fixes #402 2024-02-07 01:53:28 -08:00
Enrico Ros ec39c58474 Message Render: cleanup diffing pipeline 2024-02-07 01:31:07 -08:00
Enrico Ros 3ce2e86a66 Reminders for #401 2024-02-07 00:56:59 -08:00
Enrico Ros d62757d94a Blocks Renderers: extraction, cleanups, more maintainable and optimized 2024-02-07 00:15:58 -08:00
Enrico Ros 7ba315c796 Font Size: UI Setting 2024-02-06 21:34:36 -08:00
Enrico Ros 75e909e0e7 Font Size: add persisted variable 2024-02-06 21:33:41 -08:00
Enrico Ros 285c6a3fac Update Labels width 2024-02-06 21:26:55 -08:00
Enrico Ros 9bcdbf8db6 PersonaSelector: support for imageUri 2024-02-06 20:17:18 -08:00
Enrico Ros ae9d85d2cd Fix accessibility 2024-02-06 20:09:17 -08:00
Enrico Ros ad3191fcaf Optimize with negligible loss of functionality 2024-02-06 19:39:32 -08:00
Enrico Ros d6c98bd304 Models: auto symlink labeling 2024-02-06 17:35:09 -08:00
Enrico Ros 52c1be20d9 Update knowledge cutoff function 2024-02-06 17:27:03 -08:00
Enrico Ros 69fb879439 Update default models 2024-02-06 17:26:23 -08:00
Enrico Ros 135153464a Fix build. 2024-02-06 17:11:23 -08:00
Enrico Ros 87e556d6c4 PersonaSelector: collapse examples on Custom 2024-02-06 16:54:08 -08:00
Enrico Ros 46866ac061 PersonaSelector: fix h-scroll 2024-02-06 16:51:38 -08:00
Enrico Ros 9f222caadf Increase resiliency, and relax deletion/creation of new chats. 2024-02-06 16:42:49 -08:00
Enrico Ros f82ac7a476 PersonaSelector: improve highlight 2024-02-06 15:58:42 -08:00
Enrico Ros 4fa5d875e9 PersonaSelector: animated collapse 2024-02-06 15:47:33 -08:00
Enrico Ros e2b1c6aff0 PersonaSelector: toggleable examples 2024-02-06 15:40:05 -08:00
Enrico Ros 16b25fcc1f PersonaSelector: recycle tile 2024-02-06 15:13:00 -08:00
Enrico Ros 17cd765d00 PersonaSelector: style 2024-02-06 14:53:57 -08:00
Enrico Ros 1ea8b42e5f PersonaSelector: smaller tiles 2024-02-06 14:53:57 -08:00
Enrico Ros 6b5a207522 Merge pull request #397 from oblivio/main
Update config-database.md
2024-02-06 06:53:55 -08:00
Enrico Ros 85d5fef3fb Further improve the Persona selector 2024-02-06 06:12:46 -08:00
Enrico Ros e9a77abd83 Nit 2024-02-06 05:28:21 -08:00
Enrico Ros 9d2857d41e Persona Selector: improve layouts 2024-02-06 05:24:59 -08:00
Enrico Ros 62e71307d0 Explain Shift+Enter 2024-02-06 03:23:47 -08:00
Enrico Ros f517f12b7e Composer: improve layout (but keep the grid that stacks on mobile, for now) 2024-02-06 03:05:06 -08:00
Enrico Ros 510b1d178d MultiChat: button on mobile 2024-02-06 02:05:11 -08:00
Enrico Ros 890e8afd47 Fix for issue reported by @frigjord 2024-02-06 01:03:36 -08:00
Enrico Ros c25ce6db9d Multiple panes splits 2024-02-06 00:25:00 -08:00
Enrico Ros ec789de1d1 Improve the 'Clear folder' and no-folders appearance 2024-02-05 22:56:27 -08:00
Enrico Ros e96ac16d85 Branch: assign to the same folder 2024-02-05 22:51:53 -08:00
Enrico Ros 9d6fe97b11 Assign to folder 2024-02-05 22:51:36 -08:00
Enrico Ros 8e90552fec PageBarDropdowns: extensive improvements 2024-02-05 22:42:45 -08:00
Fabian Valle 71c8d5527e Update config-database.md
Include specific changes required when using MongoDB Atlas. The LinkStorage model needs to change, as well as the db in the Prisma configuration.
2024-02-05 22:49:00 -05:00
Enrico Ros 9fef95303a News: fix CLS 2024-02-05 18:28:38 -08:00
Enrico Ros 8458da826e Merge branch 'main-stable' 2024-02-05 18:15:16 -08:00
Enrico Ros df59f5eb6b News: improve layout, move roadmap as the second item 2024-02-05 18:15:10 -08:00
Enrico Ros 7c0ec8677f News: improve layout, move roadmap as the second item 2024-02-05 18:10:32 -08:00
Enrico Ros 2e23026690 Support for Cover images for releases
(cherry picked from commit 7bc110820e)
2024-02-05 18:10:29 -08:00
Enrico Ros 7bc110820e Support for Cover images for releases 2024-02-05 17:08:16 -08:00
Enrico Ros d3cddd5b60 Merge pull request #393 from oblivio/main
MongoDB Atlas Support
2024-02-05 14:17:32 -08:00
Fabian Valle 24cff721dc update database docs 2024-02-05 10:40:45 -05:00
Fabian Valle 054df44e05 update database docs 2024-02-05 10:39:56 -05:00
Fabian Valle 2dc3af3761 update database docs 2024-02-05 10:38:50 -05:00
Fabian Valle 3d9bf70c85 update database docs 2024-02-05 10:34:44 -05:00
Fabian Valle 30f4f6e7b8 update database docs 2024-02-05 10:33:25 -05:00
Enrico Ros c5c71859f9 Merge branch 'main-stable' 2024-02-05 01:39:05 -08:00
Enrico Ros b1a12d88a1 Delay the Models dialog to the idle cycles (for CLS) 2024-02-05 01:38:53 -08:00
Enrico Ros 78d06e79a5 Merge branch 'main-stable' 2024-02-05 00:07:27 -08:00
Enrico Ros 7580f1526f Optimize Persona Selector (includes fixing CLS). 2024-02-05 00:07:15 -08:00
Fabian Valle 198e76c291 update documentation to explain how to setup MongoDB by modifying the schema.prisma file 2024-02-04 21:10:58 -05:00
Fabian Valle f47bb1484c modify prisma back to original for backwards compatibility 2024-02-04 21:10:23 -05:00
Enrico Ros 91f5136e29 Clarify News button 2024-02-04 15:08:47 -08:00
Enrico Ros da3be58eec Move Files in Chats 2024-02-04 14:20:59 -08:00
Fabian Valle 94432b496b Update env.mjs
+MDB_URI
2024-02-04 10:57:06 -05:00
Fabian Valle eab2550b88 Update backend.router.ts
+MDB_URI
2024-02-04 10:55:56 -05:00
Fabian Valle 179a496737 Update schema.prisma
+MDB_URI
2024-02-04 10:54:47 -05:00
Fabian Valle 8f62c2ab78 Update environment-variables.md
+MDB_URI
2024-02-04 10:53:45 -05:00
Enrico Ros 9eaee22e3b Optimize rendering of PageBarDropdowns 2024-02-04 04:18:20 -08:00
Enrico Ros 2bdfe8399d Optimize rendering of DrawerItems - the Memo is working now 2024-02-04 03:15:00 -08:00
Enrico Ros 001570464c Show Split chats in the Drawer. Fixes #389 2024-02-04 01:58:27 -08:00
Enrico Ros 90e77010bb Merge branch 'aj47-main' 2024-02-03 21:55:56 -08:00
Enrico Ros 6b73294186 Style the button 2024-02-03 21:55:23 -08:00
Enrico Ros 101237aa75 Merge branch 'main' of https://github.com/aj47/big-AGI into aj47-main 2024-02-03 21:05:31 -08:00
Arash Joobandi 8d3377aeb3 misssing commit 2024-02-04 15:51:21 +11:00
Arash Joobandi 3ad350b10b implement react-csv download 2024-02-04 15:49:47 +11:00
Arash Joobandi ce00480d99 add download csv button 2024-02-04 15:31:41 +11:00
Enrico Ros 2e7f2b6004 Rename 'broadcast' to 'multicast' in code, and much improve the Panes and Multicase modes - #388 2024-02-03 19:59:27 -08:00
Enrico Ros aad0eae1b2 Split Chats: Broadcast mode. Fixes #388 2024-02-03 18:40:41 -08:00
Enrico Ros be3e64b1aa Improve Chat Page Menu 2024-02-03 15:46:47 -08:00
Enrico Ros c089ea7499 Chat Split: land. Controls in Page Menu. Fixes #208 2024-02-03 15:31:59 -08:00
Enrico Ros 190010b3e3 Uniform PageMenu (vs. ChatMessage Menu) looks 2024-02-03 14:53:15 -08:00
Enrico Ros 4dcdc175ee Style: slightly smaller radios 2024-02-03 14:46:17 -08:00
Enrico Ros 35fe54c713 Let's just do the opposite, shall we 2024-02-03 14:45:51 -08:00
Enrico Ros fd22d55835 Split view: layout panes vertically on mobile 2024-02-03 14:32:23 -08:00
Enrico Ros c978d78bd4 Improve Menus fit on mobile 2024-02-03 14:22:36 -08:00
Enrico Ros fb488596b8 Fix build 2024-02-03 04:55:18 -08:00
Enrico Ros 9edfa48e23 Split panes: perfect radius 2024-02-03 04:44:07 -08:00
Enrico Ros 25360c5fba Fix drag to resize chat panes: close on drag, 'gray-out' effect, perfect duplicate. 2024-02-03 04:08:33 -08:00
Enrico Ros e8ed346f20 Move Vendor icons 2024-02-03 03:17:10 -08:00
Enrico Ros 507a35a826 Panes: correctly remove when un-splitting 2024-02-03 02:08:29 -08:00
Enrico Ros e604cf97ae Rename closeOpsMenu 2024-02-03 02:08:29 -08:00
Enrico Ros 510753ae1c UXLabs: improve settings 2024-02-03 01:21:10 -08:00
Enrico Ros 828dfb56a2 Screenshots: attach window/screen captures (in 'labs' mode for now). Fixes #387 2024-02-03 01:13:06 -08:00
Enrico Ros 843a8dcd69 HTML5Video ops: use async/await 2024-02-03 00:33:52 -08:00
Enrico Ros 53255d5524 Extract HTML5 Video Frame rendering utils 2024-02-03 00:19:13 -08:00
Enrico Ros 0f8a5149b5 Readme: remove outdated screenshots 2024-02-02 16:30:52 -08:00
Enrico Ros 442d7e5fb5 Readme: update picture 2024-02-02 16:26:05 -08:00
Enrico Ros 11011d5367 OpenAI: improve model sorting, and update the 3.5-Turbo symlink and 3.5 0125 model description. Fixes #380 2024-02-02 16:15:33 -08:00
Enrico Ros b80afca458 Improve Export/Import looks and behavior - Fixes #375 2024-02-02 16:01:30 -08:00
Enrico Ros a93d9aab08 Roll packages 2024-02-02 15:10:11 -08:00
Enrico Ros 721d31d98d Uniform Menu appearances (smaller icons, dense by default). Fixes #382 2024-02-02 15:06:45 -08:00
Enrico Ros 8d83cff966 Share ZIndex 2024-02-02 15:06:44 -08:00
Enrico Ros 7643ee7749 ChatMessage: reorder operation menu items 2024-02-02 15:06:44 -08:00
Enrico Ros 78b0d5eb96 Draw: multiplier (mock) 2024-02-01 02:56:45 -08:00
Enrico Ros 517252240a Draw: roll placeholder 2024-02-01 02:45:53 -08:00
Enrico Ros 173635cfd1 Draw: show count 2024-02-01 02:45:09 -08:00
Enrico Ros 051a05435e Draw: Vector indicator 2024-02-01 02:44:15 -08:00
Enrico Ros cb367596d1 Draw: improve layout 2024-02-01 02:15:42 -08:00
Enrico Ros 37de238f92 Workspace: add placeholder 2024-02-01 01:09:25 -08:00
Enrico Ros b977c0e31c Call: recolor PTT 2024-02-01 01:06:56 -08:00
Enrico Ros f58c4ec8d7 Call: use <ScrollToBottom/> 2024-02-01 01:04:50 -08:00
Enrico Ros 48b0815363 Hamburger: animate click 2024-02-01 00:22:28 -08:00
Enrico Ros 4f15c9f749 Roll packages 2024-02-01 00:04:38 -08:00
Enrico Ros 7dd5175063 Merge branch 'main-stable' 2024-01-30 14:48:52 -08:00
Enrico Ros cb9c6739cb Avoid 404s on this asset 2024-01-30 14:48:40 -08:00
Enrico Ros e541430891 Roll packages 2024-01-30 02:53:59 -08:00
Enrico Ros 60057716ae LLMOptions: fix corners 2024-01-29 18:03:17 -08:00
Enrico Ros f684442cc0 Update text description 2024-01-29 17:27:02 -08:00
Enrico Ros d4246d305e Draw: Commit to v1.13 2024-01-29 17:21:40 -08:00
Enrico Ros d13fafb2da Azure: improve model naming for deployments named exactly after OpenAI models. 2024-01-29 17:10:35 -08:00
291 changed files with 10972 additions and 4317 deletions
+5
View File
@@ -1,7 +1,12 @@
# big-AGI non-code files
/docs/
/dist/
README.md
# Ignore build and log files
Dockerfile
/.dockerignore
# Node build artifacts
/node_modules
/.pnp
+24 -2
View File
@@ -21,8 +21,9 @@ assignees: enricoros
- [ ] Create a temporary tag `git tag v1.2.3 && git push opensource --tags`
- [ ] Create a [New Draft GitHub Release](https://github.com/enricoros/big-agi/releases/new), and generate the automated changelog (for new contributors)
- [ ] Update the release version in package.json, and `npm i`
- [ ] Update in-app News [src/apps/news/news.data.tsx](/src/apps/news/news.data.tsx)
- [ ] Update the in-app News version number
- [ ] Update in-app News [src/apps/news/news.data.tsx](/src/apps/news/news.data.tsx)
- [ ] Update in-app Cover graphics
- [ ] Update the README.md with the new release
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
- Release:
@@ -79,11 +80,32 @@ I need the following from you:
1. a table summarizing all the new features in 1.2.3 with the following columns: 4 words description (exactly what it is), short description, usefulness (what it does for the user), significance, link to the issue number (not the commit)), which will be used for the artifacts later
2. then double-check the git log to see if there are any features of significance that are not in the table
3. then score each feature in terms of importance for users (1-10), relative impact of the feature (1-10, where 10 applies to the broadest user base), and novelty and uniqueness (1-10, where 10 is truly unique and novel from what exists already)
3. then score each feature in terms of importance for users (1-10), relative impact of the feature (1-10, where 10 applies to the broadest user base), and novelty and uniqueness (1-10, where 10 is truly unique and novel from what exists already)
4. then improve the table, in decreasing order of importance for features, fixing any detail that's missing, in particular check if there are commits of significance from a user or developer point of view, which are not contained in the table
5. then I want you then to update the news.data.tsx for the new release
```
### release name
```markdown
please brainstorm 10 different names for this release. see the former names here: https://big-agi.com/blog
```
You can follow with 'What do you think of Modelmorphic?' or other selected name
### cover images
```markdown
Great, now I need to generate images for this. Before I used the following prompts (2 releases before).
// An image of a capybara sculpted entirely from black cotton candy, set against a minimalist backdrop with splashes of bright, contrasting sparkles. The capybara is using a computer with split screen made of origami, split keyboard and is wearing origami sunglasses with very different split reflections. Split halves are very contrasting. Close up photography, bokeh, white background.
import coverV113 from '../../../public/images/covers/release-cover-v1.13.0.png';
// An image of a capybara sculpted entirely from black cotton candy, set against a minimalist backdrop with splashes of bright, contrasting sparkles. The capybara is calling on a 3D origami old-school pink telephone and the camera is zooming on the telephone. Close up photography, bokeh, white background.
import coverV112 from '../../../public/images/covers/release-cover-v1.12.0.png';
What can I do now as far as images? Give me 4 prompt ideas with the same style as looks as the former, but different scene or action
```
### Readme (and Changelog)
```markdown
+2 -1
View File
@@ -57,4 +57,5 @@ jobs:
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
build-args: NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
+4
View File
@@ -1,5 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Frontend Build: ignore API files disabled for this build
/app/**/*.backup
# dependencies
/node_modules
/.pnp
@@ -10,6 +13,7 @@
# next.js
/.next/
/dist/
/out/
# production
+12 -4
View File
@@ -2,22 +2,28 @@
FROM node:18-alpine AS base
ENV NEXT_TELEMETRY_DISABLED 1
# Dependencies
FROM base AS deps
WORKDIR /app
# Dependency files
COPY package*.json ./
COPY prisma ./prisma
COPY src/server/prisma ./src/server/prisma
# Install dependencies, including dev (release builds should use npm ci)
ENV NODE_ENV development
RUN npm ci
# Builder
FROM base AS builder
WORKDIR /app
# Optional argument to configure GA4 at build time (see: docs/deploy-analytics.md)
ARG NEXT_PUBLIC_GA4_MEASUREMENT_ID
ENV NEXT_PUBLIC_GA4_MEASUREMENT_ID=${NEXT_PUBLIC_GA4_MEASUREMENT_ID}
# Copy development deps and source
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -29,6 +35,7 @@ RUN npm run build
# Reduce installed packages to production-only
RUN npm prune --production
# Runner
FROM base AS runner
WORKDIR /app
@@ -38,9 +45,10 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy Built app
COPY --from=builder --chown=nextjs:nodejs /app/public public
COPY --from=builder --chown=nextjs:nodejs /app/.next .next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules node_modules
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/src/server/prisma ./src/server/prisma
# Minimal ENV for production
ENV NODE_ENV production
+115 -50
View File
@@ -1,7 +1,7 @@
# BIG-AGI 🧠✨
Welcome to big-AGI 👋, the GPT application for professionals that need function, form,
simplicity, and speed. Powered by the latest models from 11 vendors and
simplicity, and speed. Powered by the latest models from 12 vendors and
open-source model servers, `big-AGI` offers best-in-class Voice and Chat with AI Personas,
visualizations, coding, drawing, calling, and quite more -- all in a polished UX.
@@ -11,17 +11,37 @@ Pros use big-AGI. 🚀 Developers love big-AGI. 🤖
Or fork & run on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-agi)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2)
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [documentation](docs/README.md)
big-AGI is an open book; our **[public roadmap](https://github.com/users/enricoros/projects/4/views/2)**
shows the current developments and future ideas.
big-AGI is an open book; see the **[ready-to-ship and future ideas](https://github.com/users/enricoros/projects/4/views/2)** in our open roadmap
- Got a suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
- Want to contribute? [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
### What's New in 1.14.1 · March 7, 2024 · Modelmorphic
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
- **Anthropic** [Claude-3](https://www.anthropic.com/news/claude-3-family) model family support. [#443](https://github.com/enricoros/big-AGI/issues/443)
- New **[Perplexity](https://www.perplexity.ai/)** and **[Groq](https://groq.com/)** integration (thanks @Penagwin). [#407](https://github.com/enricoros/big-AGI/issues/407), [#427](https://github.com/enricoros/big-AGI/issues/427)
- **[LocalAI](https://localai.io/models/)** deep integration, including support for [model galleries](https://github.com/enricoros/big-AGI/issues/411)
- **Mistral** Large and Google **Gemini 1.5** support
- Performance optimizations: runs [much faster](https://twitter.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
- Enhanced UX with auto-sizing charts, refined search and folder functionalities, perfected scaling
- And with more UI improvements, documentation, bug fixes (20 tickets), and developer enhancements
- [Release notes](https://github.com/enricoros/big-AGI/releases/tag/v1.14.0), and changes [v1.13.1...v1.14.1](https://github.com/enricoros/big-AGI/compare/v1.13.1...v1.14.1) (233 commits, 8,000+ lines changed)
### What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
- **Side-by-Side Split Windows**: multitask with parallel conversations. [#208](https://github.com/enricoros/big-AGI/issues/208)
- **Multi-Chat Mode**: message everyone, all at once. [#388](https://github.com/enricoros/big-AGI/issues/388)
- **Export tables as CSV**: big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
- Adjustable text size: customize density. [#399](https://github.com/enricoros/big-AGI/issues/399)
- Dev2 Persona Technology Preview
- Better looking chats with improved spacing, fonts, and menus
- More: new video player, [LM Studio tutorial](https://github.com/enricoros/big-AGI/blob/main/docs/config-local-lmstudio.md) (thanks @aj47), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/deploy-database.md) (thanks @ranfysvalle02), and speedups
<details>
<summary>What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline</summary>
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
@@ -34,7 +54,10 @@ https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317be
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
</details>
<details>
<summary>What's New in 1.11.0 · Jan 16, 2024 · Singularity</summary>
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
@@ -45,7 +68,10 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
- Enable adding up to five custom OpenAI-compatible endpoints
- Developer enhancements: new 'Actiles' framework
### What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI
</details>
<details>
<summary>What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI</summary>
- **New UI**: for both desktop and mobile, sets the stage for future scale. [#201](https://github.com/enricoros/big-AGI/issues/201)
- **Conversation Folders**: enhanced conversation organization. [#321](https://github.com/enricoros/big-AGI/issues/321)
@@ -54,45 +80,86 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
- Large performance optimizations
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
</details>
For full details and former releases, check out the [changelog](docs/changelog.md).
## ✨ Key Features 👊
![Ask away, paste a ton, copy the gems](docs/pixels/big-AGI-compo1.png)
[More](docs/pixels/big-AGI-compo2b.png), [screenshots](docs/pixels).
| ![Advanced AI](https://img.shields.io/badge/Advanced%20AI-32383e?style=for-the-badge&logo=ai&logoColor=white) | ![100+ AI Models](https://img.shields.io/badge/100%2B%20AI%20Models-32383e?style=for-the-badge&logo=ai&logoColor=white) | ![Flow-state UX](https://img.shields.io/badge/Flow--state%20UX-32383e?style=for-the-badge&logo=flow&logoColor=white) | ![Privacy First](https://img.shields.io/badge/Privacy%20First-32383e?style=for-the-badge&logo=privacy&logoColor=white) | ![Advanced Tools](https://img.shields.io/badge/Fun%20To%20Use-f22a85?style=for-the-badge&logo=tools&logoColor=white) |
|---------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|
| **Chat**<br/>**Call** AGI<br/>**Draw** images<br/>**Agents**, ... | Local & Cloud<br/>Open & Closed<br/>Cheap & Heavy<br/>Google, Mistral, ... | Attachments<br/>Diagrams<br/>Multi-Chat<br/>Mobile-first UI | Stored Locally<br/>Easy self-Host<br/>Local actions<br/>Data = Gold | AI Personas<br/>Voice Modes<br/>Screen Capture<br/>Camera + OCR |
- **AI Personas**: Tailor your AI interactions with customizable personas
- **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface
- **Efficient Interaction**: Voice commands, OCR, and drag-and-drop file uploads
- **Multiple AI Models**: Choose from a variety of leading AI providers
- **Privacy First**: Self-host and use your own API keys for full control
- **Advanced Tools**: Execute code, import PDFs, and summarize documents
- **Seamless Integrations**: Enhance functionality with various third-party services
- **Open Roadmap**: Contribute to the progress of big-AGI
![big-AGI screenshot](docs/pixels/big-AGI-compo-20240201_small.png)
## 💖 Support
You can easily configure 100s of AI models in big-AGI:
| **AI models** | _supported vendors_ |
|:--------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Opensource Servers | [LocalAI](https://localai.com) (multimodal) · [Ollama](https://ollama.com/) · [Oobabooga](https://github.com/oobabooga/text-generation-webui) |
| Local Servers | [LM Studio](https://lmstudio.ai/) |
| Multimodal services | [Azure](https://azure.microsoft.com/en-us/products/ai-services/openai-service) · [Google Gemini](https://ai.google.dev/) · [OpenAI](https://platform.openai.com/docs/overview) |
| Language services | [Anthropic](https://anthropic.com) · [Groq](https://wow.groq.com/) · [Mistral](https://mistral.ai/) · [OpenRouter](https://openrouter.ai/) · [Perplexity](https://www.perplexity.ai/) · [Together AI](https://www.together.ai/) |
| Image services | [Prodia](https://prodia.com/) (SDXL) |
| Speech services | [ElevenLabs](https://elevenlabs.io) (Voice synthesis / cloning) |
Add extra functionality with these integrations:
| **More** | _integrations_ |
|:-------------|:---------------------------------------------------------------------------------------------------------------|
| Web Browse | [Browserless](https://www.browserless.io/) · [Puppeteer](https://pptr.dev/)-based |
| Web Search | [Google CSE](https://programmablesearchengine.google.com/) |
| Code Editors | [CodePen](https://codepen.io/pen/) · [StackBlitz](https://stackblitz.com/) · [JSFiddle](https://jsfiddle.net/) |
| Sharing | [Paste.gg](https://paste.gg/) (Paste chats) |
| Tracking | [Helicone](https://www.helicone.ai) (LLM Observability) |
[//]: # (- [x] **Flow-state UX** for uncompromised productivity)
[//]: # (- [x] **AI Personas**: Tailor your AI interactions with customizable personas)
[//]: # (- [x] **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface)
[//]: # (- [x] **Efficient Interaction**: Voice commands, OCR, and drag-and-drop file uploads)
[//]: # (- [x] **Privacy First**: Self-host and use your own API keys for full control)
[//]: # (- [x] **Advanced Tools**: Execute code, import PDFs, and summarize documents)
[//]: # (- [x] **Seamless Integrations**: Enhance functionality with various third-party services)
[//]: # (- [x] **Open Roadmap**: Contribute to the progress of big-AGI)
<br/>
# 🌟 Get Involved!
[//]: # ([![Official Discord]&#40;https://img.shields.io/discord/1098796266906980422?label=discord&logo=discord&logoColor=%23fff&style=for-the-badge&#41;]&#40;https://discord.gg/MkH4qj2Jp9&#41;)
[![Official Discord](https://discordapp.com/api/guilds/1098796266906980422/widget.png?style=banner2)](https://discord.gg/MkH4qj2Jp9)
* Enjoy the hosted open-source app on [big-AGI.com](https://big-agi.com)
* [Chat with us](https://discord.gg/MkH4qj2Jp9)
* Deploy your [fork](https://github.com/enricoros/big-agi/fork) for your friends and family
* send PRs! ...
🎭[Editing Personas](https://github.com/enricoros/big-agi/issues/35),
🧩[Reasoning Systems](https://github.com/enricoros/big-agi/issues/36),
🌐[Community Templates](https://github.com/enricoros/big-agi/issues/35),
and [your big-IDEAs](https://github.com/enricoros/big-agi/issues/new?labels=RFC&body=Describe+the+idea)
- [ ] 📢️ [**Chat with us** on Discord](https://discord.gg/MkH4qj2Jp9)
- [ ]**Give us a star** on GitHub 👆
- [ ] 🚀 **Do you like code**? You'll love this gem of a project! [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
- [ ] 💡 Got a feature suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
- [ ] ✨ Deploy your [fork](docs/customizations.md) for your friends and family, or [customize it for work](docs/customizations.md)
- [ ] Check out some of the big-AGI [**community projects**](docs/customizations.md)
| Project | Features | GitHub |
|---------|----------------------------------------------------|-------------------------------------------------------------------------------------|
| CoolAGI | Code Interpreter, Vision, Mind maps, and much more | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
<br/>
## 🧩 Develop
# 🧩 Develop
![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=&logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-61DAFB?style=&logo=react&logoColor=black)
![Next.js](https://img.shields.io/badge/Next.js-000000?style=&logo=vercel&logoColor=white)
[//]: # (![TypeScript]&#40;https://img.shields.io/badge/TypeScript-007ACC?style=&logo=typescript&logoColor=white&#41;)
Clone this repo, install the dependencies (all locally), and run the development server (which auto-watches the
[//]: # (![React]&#40;https://img.shields.io/badge/React-61DAFB?style=&logo=react&logoColor=black&#41;)
[//]: # (![Next.js]&#40;https://img.shields.io/badge/Next.js-000000?style=&logo=vercel&logoColor=white&#41;)
To download and run this Typescript/React/Next.js project locally, the only prerequisite is Node.js with the `npm` package manager.
Clone this repo, install the dependencies (all local), and run the development server (which auto-watches the
files for changes):
```bash
@@ -100,12 +167,18 @@ git clone https://github.com/enricoros/big-agi.git
cd big-agi
npm install
npm run dev
# You will see something like:
#
# ▲ Next.js 14.1.0
# - Local: http://localhost:3000
# ✓ Ready in 2.6s
```
The development app will be running on `http://localhost:3000`. Development builds have the advantage of not requiring
a build step, but can be slower than production builds. Also, development builds won't have timeout on edge functions.
## 🌐 Deploy manually
## 🛠️ Deploy from source
The _production_ build of the application is optimized for performance and is performed by the `npm run build` command,
after installing the required dependencies.
@@ -144,25 +217,17 @@ Please refer to the [Cloudflare deployment documentation](docs/deploy-cloudflare
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-agi)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
## Integrations:
* Local models: Ollama, Oobabooga, LocalAi, etc.
* [ElevenLabs](https://elevenlabs.io/) Voice Synthesis (bring your own voice too) - Settings > Text To Speech
* [Helicone](https://www.helicone.ai/) LLM Observability Platform - Models > OpenAI > Advanced > API Host: 'oai.hconeai.com'
* [Paste.gg](https://paste.gg/) Paste Sharing - Chat Menu > Share via paste.gg
* [Prodia](https://prodia.com/) Image Generation - Settings > Image Generation > Api Key & Model
[//]: # ([![GitHub stars]&#40;https://img.shields.io/github/stars/enricoros/big-agi&#41;]&#40;https://github.com/enricoros/big-agi/stargazers&#41;)
<br/>
[//]: # ([![GitHub forks]&#40;https://img.shields.io/github/forks/enricoros/big-agi&#41;]&#40;https://github.com/enricoros/big-agi/network&#41;)
This project is licensed under the MIT License.
[//]: # ([![GitHub pull requests]&#40;https://img.shields.io/github/issues-pr/enricoros/big-agi&#41;]&#40;https://github.com/enricoros/big-agi/pulls&#41;)
[![GitHub stars](https://img.shields.io/github/stars/enricoros/big-agi)](https://github.com/enricoros/big-agi/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/enricoros/big-agi)](https://github.com/enricoros/big-agi/network)
[![GitHub pull requests](https://img.shields.io/github/issues-pr/enricoros/big-agi)](https://github.com/enricoros/big-agi/pulls)
[![License](https://img.shields.io/github/license/enricoros/big-agi)](https://github.com/enricoros/big-agi/LICENSE)
[//]: # ([![License]&#40;https://img.shields.io/github/license/enricoros/big-agi&#41;]&#40;https://github.com/enricoros/big-agi/LICENSE&#41;)
[//]: # ([![GitHub issues]&#40;https://img.shields.io/github/issues/enricoros/big-agi&#41;]&#40;https://github.com/enricoros/big-agi/issues&#41;)
---
Made with 💙
2023-2024 · Enrico Ros x [big-AGI](https://big-agi.com) · License: [MIT](LICENSE) · Made with 💙
+1 -51
View File
@@ -1,52 +1,2 @@
import { createEmptyReadableStream, safeErrorString, serverFetchOrThrow } from '~/server/wire';
import { elevenlabsAccess, elevenlabsVoiceId, ElevenlabsWire, speechInputSchema } from '~/modules/elevenlabs/elevenlabs.router';
/* NOTE: Why does this file even exist?
This file is a workaround for a limitation in tRPC; it does not support ArrayBuffer responses,
and that would force us to use base64 encoding for the audio data, which would be a waste of
bandwidth. So instead, we use this file to make the request to ElevenLabs, and then return the
response as an ArrayBuffer. Unfortunately this means duplicating the code in the server-side
and client-side vs. the tRPC implementation. So at lease we recycle the input structures.
*/
const handler = async (req: Request) => {
try {
// construct the upstream request
const {
elevenKey, text, voiceId, nonEnglish,
streaming, streamOptimization,
} = speechInputSchema.parse(await req.json());
const path = `/v1/text-to-speech/${elevenlabsVoiceId(voiceId)}` + (streaming ? `/stream?optimize_streaming_latency=${streamOptimization || 1}` : '');
const { headers, url } = elevenlabsAccess(elevenKey, path);
const body: ElevenlabsWire.TTSRequest = {
text: text,
...(nonEnglish && { model_id: 'eleven_multilingual_v1' }),
};
// elevenlabs POST
const upstreamResponse: Response = await serverFetchOrThrow(url, 'POST', headers, body);
// NOTE: this is disabled, as we pass-through what we get upstream for speed, as it is not worthy
// to wait for the entire audio to be downloaded before we send it to the client
// if (!streaming) {
// const audioArrayBuffer = await upstreamResponse.arrayBuffer();
// return new NextResponse(audioArrayBuffer, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } });
// }
// stream the data to the client
const audioReadableStream = upstreamResponse.body || createEmptyReadableStream();
return new Response(audioReadableStream, { status: 200, headers: { 'Content-Type': 'audio/mpeg' } });
} catch (error: any) {
const fetchOrVendorError = safeErrorString(error) + (error?.cause ? ' · ' + error.cause : '');
console.log(`api/elevenlabs/speech: fetch issue: ${fetchOrVendorError}`);
return new Response(`[Issue] elevenlabs: ${fetchOrVendorError}`, { status: 500 });
}
};
export const runtime = 'edge';
export { handler as POST };
export { elevenLabsHandler as POST } from '~/modules/elevenlabs/elevenlabs.server';
+1 -1
View File
@@ -11,7 +11,7 @@ const handlerEdgeRoutes = (req: Request) =>
createContext: createTRPCFetchContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? '<no-path>'}:`, error)
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? "<no-path>"}: ${error.message}`)
: undefined,
});
+1 -1
View File
@@ -11,7 +11,7 @@ const handlerNodeRoutes = (req: Request) =>
createContext: createTRPCFetchContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}:`, error)
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}: ${error.message}`)
: undefined,
});
+65
View File
@@ -0,0 +1,65 @@
# big-AGI Documentation
Find all the information you need to get started, configure, and effectively use big-AGI.
[//]: # (## Quick Start)
[//]: # (- **[Introduction]&#40;big-agi.md&#41;**: Overview of big-AGI's features.)
## Configuration Guides
Detailed guides to configure your big-AGI interface and models.
👉 The following applies to the users of big-AGI.com, as the public instance is empty and to be configured by the user.
- **Cloud Model Services**:
- **[Azure OpenAI](config-azure-openai.md)**
- **[OpenRouter](config-openrouter.md)**
- easy API key: **Anthropic**, **Google AI**, **Groq**, **Mistral**, **OpenAI**, **Perplexity**, **TogetherAI**
- **Local Model Servers**:
- **[LocalAI](config-local-localai.md)**
- **[LM Studio](config-local-lmstudio.md)**
- **[Ollama](config-local-ollama.md)**
- **[Oobabooga](config-local-oobabooga.md)**
- **Advanced Feature Configuration**:
- **[Browse](config-feature-browse.md)**: Enable web page download through third-party services or your own cloud (advanced)
- **ElevenLabs API**: Voice and cutom voice generation, only requires their API key
- **Google Search API**: guide not yet available, see the Google options in 'Environment Variables'
- **Prodia API**: Stable Diffusion XL image generation, only requires their API key, alternative to DALL·E
## Deployment
System integrators, administrators, whitelabelers: instead of using the public big-AGI instance on get.big-agi.com, you can deploy your own instance.
Step-by-step deployment and system configuration instructions.
- **Deploy Your Own**
- straightforward: **Local development**, **Vercel 1-Click**
- **[Cloudflare Deployment](deploy-cloudflare.md)**
- **[Docker Deployment](deploy-docker.md)**: Containers for Local or Cloud deployments
- **Deployment Server Features**
- **[Database Setup](deploy-database.md)**: Optional, only required to enable "Chat Link Sharing"
- **[Environment Variables](environment-variables.md)**: 📌 Set server-side API keys and special features in your deployments
- **[HTTP Basic Authentication](deploy-authentication.md)**: Optional, Secure your big-AGI instance with a username and password
## Customization & Derivative UIs
👏 Customize big-AGI to fit your needs.
- **[Customizing big-AGI](customizations.md)**: how to alter source code and server-side configuration
## Support and Community
Join our community or get support:
- Visit our [GitHub repository](https://github.com/enricoros/big-AGI) for source code and issue tracking
- Check the latest updates and features on [Changelog](changelog.md) or the in-app [News](https://get.big-agi.com/news)
- Connect with us and other users on [Discord](https://discord.gg/MkH4qj2Jp9) for discussions, help, and sharing your experiences with big-AGI
Thank you for choosing big-AGI. We're excited to see what you'll build.
+31 -6
View File
@@ -5,12 +5,37 @@ by release.
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
### 1.13.0 - Feb 2024
### 1.15.0 - Mar 2024
- milestone: [1.13.0](https://github.com/enricoros/big-agi/milestone/13)
Prediction: OpenAI will release GPT-5 on March 14, 2024. We will support it on day 1.
- milestone: [1.15.0](https://github.com/enricoros/big-agi/milestone/15)
- work in progress: [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), [help here](https://github.com/users/enricoros/projects/4/views/4)
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
### What's New in 1.14.1 · March 7, 2024 · Modelmorphic
- **Anthropic** [Claude-3](https://www.anthropic.com/news/claude-3-family) model family support. [#443](https://github.com/enricoros/big-AGI/issues/443)
- New **[Perplexity](https://www.perplexity.ai/)** and **[Groq](https://groq.com/)** integration (thanks @Penagwin). [#407](https://github.com/enricoros/big-AGI/issues/407), [#427](https://github.com/enricoros/big-AGI/issues/427)
- **[LocalAI](https://localai.io/models/)** deep integration, including support for [model galleries](https://github.com/enricoros/big-AGI/issues/411)
- **Mistral** Large and Google **Gemini 1.5** support
- Performance optimizations: runs [much faster](https://twitter.com/enricoros/status/1756553038293303434?utm_source=localhost:3000&utm_medium=big-agi), saves lots of power, reduces memory usage
- Enhanced UX with auto-sizing charts, refined search and folder functionalities, perfected scaling
- And with more UI improvements, documentation, bug fixes (20 tickets), and developer enhancements
- [Release notes](https://github.com/enricoros/big-AGI/releases/tag/v1.14.0), and changes [v1.13.1...v1.14.0](https://github.com/enricoros/big-AGI/compare/v1.13.1...v1.14.0) (233 commits, 8,000+ lines changed)
### What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
- **Side-by-Side Split Windows**: multitask with parallel conversations. [#208](https://github.com/enricoros/big-AGI/issues/208)
- **Multi-Chat Mode**: message everyone, all at once. [#388](https://github.com/enricoros/big-AGI/issues/388)
- **Export tables as CSV**: big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
- Adjustable text size: customize density. [#399](https://github.com/enricoros/big-AGI/issues/399)
- Dev2 Persona Technology Preview
- Better looking chats with improved spacing, fonts, and menus
- More: new video player, [LM Studio tutorial](https://github.com/enricoros/big-AGI/blob/main/docs/config-local-lmstudio.md) (thanks @aj47), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/deploy-database.md) (thanks @ranfysvalle02), and speedups
### What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
@@ -69,7 +94,7 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
- **Attachments System Overhaul**: Drag, paste, link, snap, text, images, PDFs and more. [#251](https://github.com/enricoros/big-agi/issues/251)
- **Desktop Webcam Capture**: Image capture now available as Labs feature. [#253](https://github.com/enricoros/big-agi/issues/253)
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-feature-browse.md)
- **Overheat LLMs**: Push the creativity with higher LLM temperatures. [#256](https://github.com/enricoros/big-agi/issues/256)
- **Model Options Shortcut**: Quick adjust with `Ctrl+Shift+O`
- Optimized Voice Input and Performance
@@ -78,7 +103,7 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
### What's New in 1.6.0 - Nov 28, 2023 · Surf's Up
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-feature-browse.md)
- **Branching Discussions**: Create new conversations from any message
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
- **Performance Boost**: Faster rendering for a smoother experience
@@ -152,7 +177,7 @@ For Developers:
- **[Install Mobile APP](../docs/pixels/feature_pwa.png)** 📲 looks like native (@harlanlewis)
- **[UI language](../docs/pixels/feature_language.png)** with auto-detect, and future app language! (@tbodyston)
- **PDF Summarization** 🧩🤯 - ask questions to a PDF! (@fredliubojin)
- **Code Execution: [Codepen](https://codepen.io/)/[Replit](https://replit.com/)** 💻 (@harlanlewis)
- **Code Execution: [Codepen](https://codepen.io/)** 💻 (@harlanlewis)
- **[SVG Drawing](../docs/pixels/feature_svg_drawing.png)** - draw with AI 🎨
- Chats: multiple chats, AI titles, Import/Export, Selection mode
- Rendering: Markdown, SVG, improved Code blocks
@@ -3,11 +3,16 @@
Allows users to load web pages across various components of `big-AGI`. This feature is supported by Puppeteer-based
browsing services, which are the most common way to render web pages in a headless environment.
Once configured, the Browsing service provides this functionality:
Once configured, the Browsing service provides the following functionality:
- **Paste a URL**: Simply paste/drag a URL into the chat, and `big-AGI` will load and attach the page (very effective)
- **Use /browse**: Type `/browse [URL]` in the chat to command `big-AGI` to load the specified web page
- **ReAct**: ReAct will automatically use the `loadURL()` function whenever a URL is encountered
- **Paste a URL**: Simply paste/drag a URL into the chat, and `big-AGI` will load and attach the page (very effective)
- **Use /browse**: Type `/browse [URL]` in the chat to command `big-AGI` to load the specified web page
- **ReAct**: ReAct will automatically use the `loadURL()` function whenever a URL is encountered
It does not yet support the following functionality:
- ✖️ **Auto-browsing by LLMs**: if an LLM encounters a URL, it will NOT load the page and will likely respond
that it cannot browse the web - No technical limitation, just haven't gotten to implement this yet outside of `/react` yet
First of all, you need to procure a Puppteer web browsing service endpoint. `big-AGI` supports services like:
@@ -109,3 +114,5 @@ If you encounter any issues or have questions about configuring the browse funct
---
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
Last updated on Feb 27, 2024 ([edit on GitHub](https://github.com/enricoros/big-AGI/edit/main/docs/config-feature-browse.md))
+51
View File
@@ -0,0 +1,51 @@
# Integrating LM Studio with big-AGI
Quickly set up LM Studio with big-AGI to run local and open LLMs on your computer for enhanced privacy and control over AI interactions.
## Video Tutorial
For a visual step-by-step guide, watch our [YouTube tutorial](https://www.youtube.com/watch?v=MqXzxVokMDk).
[![Running big-AGI locally with LM Studio YouTube Tutorial](http://img.youtube.com/vi/MqXzxVokMDk/0.jpg)](http://www.youtube.com/watch?v=MqXzxVokMDk "Running big-AGI locally with LM Studio")
## Quick Setup Guide
### Installing big-AGI
Clone and set up big-AGI:
```bash
git clone https://github.com/enricoros/big-agi.git && cd big-agi
npm install # Or: yarn install
npm run dev # Or: yarn dev
# If missing dependencies:
npm install @mui/material # Or: yarn add @mui/material
```
### Configuring LM Studio
Ensure LM Studio is running (default: [http://localhost:1234](http://localhost:1234)).
Check the URL and modify if different.
1. Download local models in LM Studio
2. Start the LM Studio server
3. Optionally. Check the logs
### Integration in big-AGI
1. In big-AGI, navigate to **Models** > **Add** > **LM Studio**
2. Enter the API URL: `http://localhost:1234` (modify if different)
3. Refresh by clicking on the `Models` button to load models from LM Studio
## Troubleshooting
- **Missing @mui/material**: Execute `npm install @mui/material` or `yarn add @mui/material`
- **Connection Issues**: Check LM Studio's URL and ensure it's operational
## Further Assistance
Advanced configurations and more:
- big-AGI Community: [Discord](https://discord.gg/MkH4qj2Jp9)
- LM Studio: [LM Studio home page](https://lmstudio.ai/)
+49 -22
View File
@@ -1,34 +1,61 @@
# Local LLM integration with `localai`
# Run your models with `LocalAI` x `big-AGI`
Integrate local Large Language Models (LLMs) with [LocalAI](https://localai.io).
[LocalAI](https://localai.io) lets you run your AI models locally, or in the cloud. It supports text, image, asr, speech, and more models.
_Last updated Nov 7, 2023_
We are deepening the integration between the two products. As of the time of writing, we integrate the following features:
## Instructions
- ✅ [Text generation](https://localai.io/features/text-generation/) with GPTs
- ✅ [Function calling](https://localai.io/features/openai-functions/) by GPTs 🆕
- ✅ [Model Gallery](https://localai.io/models/) to list and install models
- ✖️ [Vision API](https://localai.io/features/gpt-vision/) for image chats
- ✖️ [Image generation](https://localai.io/features/image-generation) with stable diffusion
- ✖️ [Audio to Text](https://localai.io/features/audio-to-text/)
- ✖️ [Text to Audio](https://localai.io/features/text-to-audio/)
- ✖️ [Embeddings generation](https://localai.io/features/embeddings/)
- ✖️ [Constrained grammars](https://localai.io/features/constrained_grammars/) (JSON output)
- ✖️ Voice cloning 🆕
_Last updated Feb 21, 2024_
## Guide
### LocalAI installation and configuration
Follow the guide at: https://localai.io/basics/getting_started/
For instance with [Use luna-ai-llama2 with docker compose](https://localai.io/basics/getting_started/#example-use-luna-ai-llama2-model-with-docker-compose):
- verify it works by browsing to [http://localhost:8080/v1/models](http://localhost:8080/v1/models)
(or the IP:Port of the machine, if running remotely) and seeing listed the model(s) you downloaded
listed in the JSON response.
- clone LocalAI
- get the model
- copy the prompt template
- start docker
- -> the server will be listening on `localhost:8080`
- verify it works by going to [http://localhost:8080/v1/models](http://localhost:8080/v1/models) on
your browser and seeing listed the model you downloaded
### Integrating LocalAI with big-AGI
### Integration: chat with LocalAI
- Go to Models > Add a model source of type: **LocalAI**
- Enter the address: `http://localhost:8080` (default)
- If running remotely, replace localhost with the IP of the machine. Make sure to use the **IP:Port** format
- Load the models
- Select model & Chat
- Enter the default address: `http://localhost:8080`, or the address of your localAI cloud instance
![configure models](pixels/config-localai-1-models.png)
- If running remotely, replace localhost with the IP of the machine. Make sure to use the **IP:Port** format
- Load the models (click on `Models 🔄`)
- Select the model and chat
> NOTE: LocalAI does not list details about the mdoels. Every model is assumed to be
> capable of chatting, and with a context window of 4096 tokens.
> Please update the [src/modules/llms/transports/server/openai/models.data.ts](../src/modules/llms/server/openai/models.data.ts)
> file with the mapping information between LocalAI model IDs and names/descriptions/tokens, etc.
### Integration: Models Gallery
If the running LocalAI instance is configured with a [Model Gallery](https://localai.io/models/):
- Go to Models > LocalAI
- Click on `Gallery Admin`
- Select the models to install, and view installation progress
![img.png](pixels/config-localai-2-gallery.png)
## Troubleshooting
##### Unknown Context Window Size
At the time of writing, LocalAI does not publish the model `context window size`.
Every model is assumed to be capable of chatting, and with a context window of 4096 tokens.
Please update the [src/modules/llms/transports/server/openai/models.data.ts](../src/modules/llms/server/openai/models.data.ts)
file with the mapping information between LocalAI model IDs and names/descriptions/tokens, etc.
# 🤝 Support
- Hop into the [LocalAI Discord](https://discord.gg/uJAeKSAGDy) for support and questions
- Hop into the [big-AGI Discord](https://discord.gg/MkH4qj2Jp9) for questions
- For big-AGI support, please open an issue in our [big-AGI issue tracker](https://bit.ly/agi-request)
+71
View File
@@ -0,0 +1,71 @@
# Customizing and Creating Derivative Applications
This document outlines how to develop applications derived from big-AGI.
## Manual Customization
Application customization _requires manual code modifications or the use of environment variables_. Currently, **there is no admin panel to "managed" deployment customization** for enterprise use cases.
| Required Code Alteration | Not Required |
|---------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
| - Persona changes<br>- UI theme customization<br>- Feature additions or modifications | - Setting API keys in [environment variables](environment-variables.md)<br>- Toggling features with environment variables |
| Apply these to the source code before building the application | Set these post-build on local machines or cloud deployment, before application launch |
<br/>
## Code Alterations
Start by creating a fork of the [big-AGI repository](https://github.com/enricoros/big-AGI) on GitHub for a personal development space.
Understand the Architecture: big-AGI uses Next.js, React for the front end, and Node.js (Next.js edge functions) for the back end.
### Add Authentication
This necessitates a code change (file renaming) before build initiation, detailed in [deploy-authentication.md](deploy-authentication.md).
### Change the Personas
Edit the `src/data.ts` file to customize personas. This file houses the default personas. You can add, remove, or modify these to meet your project's needs.
- [ ] Modify `src/data.ts` to alter default personas
### Change the UI
Adapt the UI to match your project's aesthetic, incorporate new features, or exclude unnecessary ones.
- [ ] Adjust `src/common/app.theme.ts` for theme changes: colors, spacing, button appearance, animations, etc
- [ ] Modify `src/common/app.config.tsx` to alter the application's name
- [ ] Update `src/common/app.nav.tsx` to revise the navigation bar
## Testing & Deployment
Test your application thoroughly using local development (refer to README.md for local build instructions). Deploy using your preferred hosting service. big-AGI supports deployment on platforms like Vercel, Docker, or any Node.js-compatible service, especially those supporting NextJS's "Edge Runtime."
- [deploy-cloudflare.md](deploy-cloudflare.md): for Cloudflare Workers deployment
- [deploy-docker.md](deploy-docker.md): for Docker deployment instructions and examples
## Debugging
We introduced the `/info/debug` page that provides a detailed overview of the application's environment, including the API keys, environment variables, and other configuration settings.
<br/>
## Community Projects - Share Your Project
After deployment, share your project with the community. We will link to your project to help others discover and learn from your work.
| Project | Features | GitHub |
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
| 🚀 CoolAGI: Where AI meets Imagination<br/>![CoolAGI Logo](https://github.com/nextgen-user/freegpt4plus/assets/150797204/9b0e1232-4791-4d61-b949-16f9eb284c22) | Code Interpreter, Vision, Mind maps, Web Searches, Advanced Data Analytics, Large Data Handling and more! | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
For public projects, update your README.md with your modifications and submit a pull request to add your project to our list, aiding in its discovery.
<br/>
## Best Practices
- **Stay Updated**: Frequently merge updates from the main big-AGI repository to incorporate bug fixes and new features.
- **Keep It Open Source**: Consider maintaining your derivative as open source to foster community contributions.
- **Engage with the Community**: Leverage platforms like GitHub, Discord, or Reddit for feedback, collaboration, and project promotion.
Developing a derivative application is an opportunity to explore new possibilities with AI and share your innovations with the global community. We look forward to seeing your contributions.
+63
View File
@@ -0,0 +1,63 @@
# big-AGI Analytics
The open-source big-AGI project provides support for the following analytics services:
- **Vercel Analytics**: automatic when deployed to Vercel
- **Google Analytics 4**: manual setup required
The following is a quick overview of the Analytics options for the deployers of this open-source project.
big-AGI is deployed to many large-scale and enterprise though various ways (custom builds, Docker, Vercel, Cloudflare, etc.),
and this guide is for its customization.
## Service Configuration
### Vercel Analytics
- Why: understand coarse traction, and identify deployment issues - all without tracking individual users
- What: top pages, top referrers, country of origin, operating system, browser, and page speed metrics
Vercel Analytics and Speed Insights are local API endpoints deployed to your domain, so everything stays within your
domain. Furthermore, the Vercel Analytics service is privacy-friendly, and does not track individual users.
This service is avaialble to system administrators when deploying to Vercel. It is automatically enabled when deploying to Vercel.
The code that activates Vercel Analytics is located in the `src/pages/_app.tsx` file:
```tsx
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) => <>
...
{isVercelFromFrontend && <VercelAnalytics debug={false} />}
{isVercelFromFrontend && <VercelSpeedInsights debug={false} sampleRate={1 / 2} />}
...
</>;
```
When big-AGI is served on Vercel hosts, the ```process.env.NEXT_PUBLIC_VERCEL_URL``` environment variable is trueish, and
analytics will be sent by default to the Vercel Analytics service which is deployed by Vercel IF configured from the
Vercel project dashboard.
In summary: to turn it on: activate the `Analytics` service in the Vercel project dashboard.
### Google Analytics 4
- Why: user engagement and retention, performance insights, personalization, content optimization
- What: https://support.google.com/analytics/answer/11593727
Google Analytics 4 (GA4) is a powerful tool for understanding user behavior and engagement.
This can help optimize big-AGI, understanding which features are needed/users and which aren't.
To enable Google Analytics 4, you need to set the `NEXT_PUBLIC_GA4_MEASUREMENT_ID` environment variable
before starting the local build or the docker build (i.e. at build time), at which point the
server/container will be able to report analytics to your Google Analytics 4 property.
As of Feb 27, 2024, this feature is in development.
## Configurations
| Scope | Default | Description / Instructions |
|-----------------------------------------------------------------------------------------|------------------|-------------------------------------------------------------------------------------------------------------------------|
| Your source builds of big-AGI | None | **Vercel**: enable Vercel Analytics from the dashboard. · **Google Analytics**: set environment variable at build time. |
| Your docker builds of big-AGI | None | **Vercel**: n/a. · **Google Analytics**: set environment variable at `docker build` time. |
| [big-agi.com](https://big-agi.com) | Vercel + Google | The main website ([privacy policy](https://big-agi.com/privacy)) hosted for free for anyone. |
| [official Docker packages](https://github.com/enricoros/big-AGI/pkgs/container/big-agi) | Google Analytics | **Vercel**: n/a · **Google Analytics**: set to the big-agi.com Google Analytics for analytics and improvements. |
Note: this information is updated as of Feb 27, 2024 and can change at any time.
+66
View File
@@ -0,0 +1,66 @@
**Connecting Your Database for Enhanced Features:**
This guide outlines the database options and setup steps for enabling features like Chat Link Sharing in your application.
### Choose Your Database:
**1. Serverless Postgres (default):**
- Available on Vercel, Neon, and other platforms.
- Less feature-rich but a suitable option depending on your needs.
- **Connection String:** Replace placeholders with your Postgres credentials.
- `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15`
**2. MongoDB Atlas (alternative):**
- **Highly Recommended:** More than a database, it's a data platform. MongoDB Atlas is a robust cloud-based platform that offers scalability, security, and a suite of developer tools. No need for a separate vector database, you can query your vector embeddings right within your operational database!
- **Additional Features:** MongoDB Atlas is packed with unique features designed to streamline the development process such as: Atlas App Services, Atlas search (with vector search), Atlas charts, Data Federation, and more.
- **Connection String:** Replace placeholders with your Atlas credentials.
- `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority`
### Environment Variables:
#### Postgres:
| Variable | |
|---------------------------------------|------------------------------------------------------------------------------------------------------|
| `POSTGRES_PRISMA_URL` | `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15` |
| `POSTGRES_URL_NON_POOLING` (optional) | URL for the Postgres database without pooling (specific use cases) |
#### MongoDB:
| Variable | |
|-----------|------------------------------------------------------------------------------------------|
| `MDB_URI` | `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority` |
### MongoDB Atlas + Prisma
When using MongoDB Atlas, you'll need to make the below changes to the file [`src/server/prisma/schema.prisma`](../src/server/prisma/schema.prisma).
```
...
datasource db {
provider = "mongodb"
url = env("MDB_URI")
}
//
// Storage of Linked Data
//
model LinkStorage {
id String @id @default(uuid()) @map("_id")
// ...rest of file
```
### Initial Setup Steps:
1. **Run `npx prisma db push`:** Create or update the database schema (run once after connecting).
### Additional Resources:
- Prisma documentation: [https://www.prisma.io/docs/](https://www.prisma.io/docs/)
- MongoDB Atlas: [https://www.mongodb.com/atlas/database](https://www.mongodb.com/atlas/database)
- Atlas App Services: [https://www.mongodb.com/docs/atlas/app-services/](https://www.mongodb.com/docs/atlas/app-services/)
- Atlas vector search: [https://www.mongodb.com/products/platform/atlas-vector-search/](https://www.mongodb.com/products/platform/atlas-vector-search)
- Atlas Data Federation: [https://www.mongodb.com/products/platform/atlas-data-federation](https://www.mongodb.com/products/platform/atlas-data-federation)
+1 -1
View File
@@ -50,7 +50,7 @@ docker-compose up -d
### Make Local Services Visible to Docker 🌐
To make local services running on your host machine accessible to a Docker container, such as a
[Browseless](./config-browse.md) service or a local API, you can follow this simplified guide:
[Browseless](./config-feature-browse.md) service or a local API, you can follow this simplified guide:
| Operating System | Steps to Make Local Services Visible to Docker |
|:------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+14
View File
@@ -0,0 +1,14 @@
# Why big-AGI?
Placeholder for a document that demonstrates the productivity and unique features of Big-AGI.
## Exclusive features
- [x] Call AGI
- [x] Continuous Voice mode
- [x] Diagram generation
- [ ] ...
## Productivity Features
- [x] Multi-window to never wait
- [x] Multi-Chat to explore different solutions
- [x] Rendering of graphs, charts, mindmaps
- [ ] ...
+43 -17
View File
@@ -12,10 +12,13 @@ Environment variables can be set by creating a `.env` file in the root directory
The following is an example `.env` for copy-paste convenience:
```bash
# Database
# Database (Postgres)
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
# Database (MongoDB)
MDB_URI=
# LLMs
OPENAI_API_KEY=
OPENAI_API_HOST=
@@ -25,9 +28,13 @@ AZURE_OPENAI_API_KEY=
ANTHROPIC_API_KEY=
ANTHROPIC_API_HOST=
GEMINI_API_KEY=
GROQ_API_KEY=
LOCALAI_API_HOST=
LOCALAI_API_KEY=
MISTRAL_API_KEY=
OLLAMA_API_HOST=
OPENROUTER_API_KEY=
PERPLEXITY_API_KEY=
TOGETHERAI_API_KEY=
# Model Observability: Helicone
@@ -51,22 +58,22 @@ BACKEND_ANALYTICS=
# Backend HTTP Basic Authentication (see `deploy-authentication.md` for turning on authentication)
HTTP_BASIC_AUTH_USERNAME=
HTTP_BASIC_AUTH_PASSWORD=
# Frontend variables
NEXT_PUBLIC_GA4_MEASUREMENT_ID=
NEXT_PUBLIC_PLANTUML_SERVER_URL=
```
## Variables Documentation
## Backend Variables
These variables are used only by the server-side code, at runtime. Define them before running the nextjs local server (in development or
cloud deployment), or pass them to Docker (--env-file or -e) when starting the container.
### Database
To enable features such as Chat Link Shring, you need to connect the backend to a database. We require
serverless Postgres, which is available on Vercel, Neon and more.
To enable Chat Link Sharing, you need to connect the backend to a database. We currently support Postgres and MongoDB.
Also make sure that you run `npx prisma db:push` to create the initial schema on the database for the
first time (or update it on a later stage).
| Variable | Description |
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `POSTGRES_PRISMA_URL` | The URL of the Postgres database used by Prisma - example: `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15` |
| `POSTGRES_URL_NON_POOLING` | The URL of the Postgres database without pooling |
For Database configuration see [deploy-database.md](deploy-database.md).
### LLMs
@@ -83,12 +90,16 @@ requiring the user to enter an API key
| `ANTHROPIC_API_KEY` | The API key for Anthropic | Optional |
| `ANTHROPIC_API_HOST` | Changes the backend host for the Anthropic vendor, to enable platforms such as [config-aws-bedrock.md](config-aws-bedrock.md) | Optional |
| `GEMINI_API_KEY` | The API key for Google AI's Gemini | Optional |
| `GROQ_API_KEY` | The API key for Groq Cloud | Optional |
| `LOCALAI_API_HOST` | Sets the URL of the LocalAI server, or defaults to http://127.0.0.1:8080 | Optional |
| `LOCALAI_API_KEY` | The (Optional) API key for LocalAI | Optional |
| `MISTRAL_API_KEY` | The API key for Mistral | Optional |
| `OLLAMA_API_HOST` | Changes the backend host for the Ollama vendor. See [config-ollama.md](config-ollama.md) | |
| `OLLAMA_API_HOST` | Changes the backend host for the Ollama vendor. See [config-local-ollama.md](config-local-ollama) | |
| `OPENROUTER_API_KEY` | The API key for OpenRouter | Optional |
| `PERPLEXITY_API_KEY` | The API key for Perplexity | Optional |
| `TOGETHERAI_API_KEY` | The API key for Together AI | Optional |
### Model Observability: Helicone
### LLM Observability: Helicone
Helicone provides observability to your LLM calls. It is a paid service, with a generous free tier.
It is currently supported for:
@@ -100,7 +111,7 @@ It is currently supported for:
|--------------------|--------------------------|
| `HELICONE_API_KEY` | The API key for Helicone |
### Specials
### Features
Enable the app to Talk, Draw, and Google things up.
@@ -110,16 +121,31 @@ Enable the app to Talk, Draw, and Google things up.
| `ELEVENLABS_API_KEY` | ElevenLabs API Key - used for calls, etc. |
| `ELEVENLABS_API_HOST` | Custom host for ElevenLabs |
| `ELEVENLABS_VOICE_ID` | Default voice ID for ElevenLabs |
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
| **Google Custom Search** | [Google Programmable Search Engine](https://programmablesearchengine.google.com/about/) produces links to pages |
| `GOOGLE_CLOUD_API_KEY` | Google Cloud API Key, used with the '/react' command - [Link to GCP](https://console.cloud.google.com/apis/credentials) |
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
| **Browse** | |
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing, etc. |
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing (pade downloadeing), etc. |
| **Backend** | |
| `BACKEND_ANALYTICS` | Semicolon-separated list of analytics flags (see backend.analytics.ts). Flags: `domain` logs the responding domain. |
| `HTTP_BASIC_AUTH_USERNAME` | See the [Authentication](deploy-authentication.md) guide. Username for HTTP Basic Authentication. |
| `HTTP_BASIC_AUTH_PASSWORD` | Password for HTTP Basic Authentication. |
### Frontend Variables
The value of these variables are passed to the frontend (Web UI) - make sure they do not contain secrets.
| Variable | Description |
|:----------------------------------|:-----------------------------------------------------------------------------------------|
| `NEXT_PUBLIC_GA4_MEASUREMENT_ID` | The measurement ID for Google Analytics 4. (see [deploy-analytics](deploy-analytics.md)) |
| `NEXT_PUBLIC_PLANTUML_SERVER_URL` | The URL of the PlantUML server, used for rendering UML diagrams. (code in RederCode.tsx) |
> Important: these variables must be set at build time, which is required by Next.js to pass them to the frontend.
> This is in contrast to the backend variables, which can be set when starting the local server/container.
---
For a higher level overview of backend code and environemnt customization,
see the [big-AGI Customization](customizations.md) guide.
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+30 -6
View File
@@ -1,13 +1,26 @@
// Non-default build types
const buildType =
process.env.BIG_AGI_BUILD === 'standalone' ? 'standalone'
: process.env.BIG_AGI_BUILD === 'static' ? 'export'
: undefined;
buildType && console.log(` 🧠 big-AGI: building for ${buildType}...\n`);
/** @type {import('next').NextConfig} */
let nextConfig = {
reactStrictMode: true,
// Note: disabled to chech whether the project becomes slower with this
// modularizeImports: {
// '@mui/icons-material': {
// transform: '@mui/icons-material/{{member}}',
// },
// },
// [exports] https://nextjs.org/docs/advanced-features/static-html-export
...buildType && {
output: buildType,
distDir: 'dist',
// disable image optimization for exports
images: { unoptimized: true },
// Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html`
// trailingSlash: true,
},
// [puppeteer] https://github.com/puppeteer/puppeteer/issues/11052
experimental: {
@@ -24,9 +37,20 @@ let nextConfig = {
layers: true,
};
// prevent too many small chunks (40kb min) on 'client' packs (not 'server' or 'edge-server')
if (typeof config.optimization.splitChunks === 'object' && config.optimization.splitChunks.minSize)
config.optimization.splitChunks.minSize = 40 * 1024;
return config;
},
// Note: disabled to check whether the project becomes slower with this
// modularizeImports: {
// '@mui/icons-material': {
// transform: '@mui/icons-material/{{member}}',
// },
// },
// Uncomment the following leave console messages in production
// compiler: {
// removeConsole: false,
+1250 -526
View File
File diff suppressed because it is too large Load Diff
+32 -22
View File
@@ -1,7 +1,9 @@
{
"name": "big-agi",
"version": "1.12.0",
"version": "1.14.1",
"private": true,
"author": "Enrico Ros <enrico.ros@gmail.com>",
"repository": "https://github.com/enricoros/big-agi",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -12,63 +14,71 @@
"db:studio": "prisma studio",
"vercel:env:pull": "npx vercel env pull .env.development.local"
},
"prisma": {
"schema": "src/server/prisma/schema.prisma"
},
"dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.3",
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.6",
"@mui/joy": "^5.0.0-beta.24",
"@next/bundle-analyzer": "^14.1.0",
"@prisma/client": "^5.8.1",
"@mui/icons-material": "^5.15.11",
"@mui/joy": "^5.0.0-beta.29",
"@next/bundle-analyzer": "^14.1.2",
"@next/third-parties": "^14.1.2",
"@prisma/client": "^5.10.2",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.8.0",
"@t3-oss/env-nextjs": "^0.9.2",
"@tanstack/react-query": "~4.36.1",
"@trpc/client": "10.44.1",
"@trpc/next": "10.44.1",
"@trpc/react-query": "10.44.1",
"@trpc/server": "10.44.1",
"@vercel/analytics": "^1.1.2",
"@vercel/speed-insights": "^1.0.8",
"@vercel/analytics": "^1.2.2",
"@vercel/speed-insights": "^1.0.10",
"browser-fs-access": "^0.35.0",
"eventsource-parser": "^1.1.1",
"eventsource-parser": "^1.1.2",
"idb-keyval": "^6.2.1",
"next": "^14.1.0",
"next": "^14.1.2",
"nprogress": "^0.2.0",
"pdfjs-dist": "4.0.379",
"plantuml-encoder": "^1.4.0",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-csv": "^2.2.2",
"react-dom": "^18.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^1.0.9",
"react-player": "^2.15.1",
"react-resizable-panels": "^2.0.12",
"react-timeago": "^7.2.0",
"remark-gfm": "^4.0.0",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"tesseract.js": "^5.0.4",
"tiktoken": "^1.0.11",
"tesseract.js": "^5.0.5",
"tiktoken": "^1.0.13",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zustand": "^4.5.0"
"zustand": "^4.5.2"
},
"devDependencies": {
"@cloudflare/puppeteer": "^0.0.5",
"@types/node": "^20.11.7",
"@types/node": "^20.11.24",
"@types/nprogress": "^0.2.3",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.48",
"@types/react": "^18.2.62",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.18",
"@types/react-csv": "^1.1.10",
"@types/react-dom": "^18.2.19",
"@types/react-katex": "^3.0.4",
"@types/react-timeago": "^4.1.7",
"@types/uuid": "^9.0.8",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"prettier": "^3.2.4",
"prisma": "^5.8.1",
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.2",
"prettier": "^3.2.5",
"prisma": "^5.10.2",
"typescript": "^5.3.3"
},
"engines": {
+6 -4
View File
@@ -1,10 +1,9 @@
import * as React from 'react';
import Head from 'next/head';
import { MyAppProps } from 'next/app';
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
import { Analytics as VercelAnalytics } from '@vercel/analytics/next';
import { SpeedInsights as VercelSpeedInsights } from '@vercel/speed-insights/next';
import { Brand } from '~/common/app.config';
import { apiQuery } from '~/common/util/trpc.client';
@@ -20,6 +19,8 @@ import { ProviderSingleTab } from '~/common/providers/ProviderSingleTab';
import { ProviderSnacks } from '~/common/providers/ProviderSnacks';
import { ProviderTRPCQueryClient } from '~/common/providers/ProviderTRPCQueryClient';
import { ProviderTheming } from '~/common/providers/ProviderTheming';
import { hasGoogleAnalytics, OptionalGoogleAnalytics } from '~/common/components/GoogleAnalytics';
import { isVercelFromFrontend } from '~/common/util/pwaUtils';
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
@@ -44,8 +45,9 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
</ProviderSingleTab>
</ProviderTheming>
<VercelAnalytics debug={false} />
<VercelSpeedInsights debug={false} sampleRate={1 / 10} />
{isVercelFromFrontend && <VercelAnalytics debug={false} />}
{isVercelFromFrontend && <VercelSpeedInsights debug={false} sampleRate={1 / 2} />}
{hasGoogleAnalytics && <OptionalGoogleAnalytics />}
</>;
+1 -1
View File
@@ -24,7 +24,7 @@ export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
<link rel='shortcut icon' href='/favicon.ico' />
<link rel='icon' type='image/png' sizes='32x32' href='/icons/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/icons/favicon-16x16.png' />
<link rel='apple-touch-icon' sizes='180x180' href='/icons/apple-touch-icon.png' />
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
<link rel='manifest' href='/manifest.json' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-status-bar-style' content='black' />
+1 -4
View File
@@ -1,16 +1,13 @@
import * as React from 'react';
import { AppChat } from '../src/apps/chat/AppChat';
import { useRedirectToNewsOnUpdates } from '../src/apps/news/news.hooks';
import { withLayout } from '~/common/layout/withLayout';
export default function IndexPage() {
// show the News page if there are unseen updates
useRedirectToNewsOnUpdates();
// TODO: This Index page will point to the Dashboard (or a landing page) soon
// TODO: This Index page will point to the Dashboard (or a landing page)
// For now it offers the chat experience, but this will change. #299
return withLayout({ type: 'optima' }, <AppChat />);
+167
View File
@@ -0,0 +1,167 @@
import * as React from 'react';
import { fileSave } from 'browser-fs-access';
import { Box, Button, Card, CardContent, Typography } from '@mui/joy';
import DownloadIcon from '@mui/icons-material/Download';
import { AppPlaceholder } from '../../src/apps/AppPlaceholder';
import { backendCaps } from '~/modules/backend/state-backend';
import { getPlantUmlServerUrl } from '~/modules/blocks/code/RenderCode';
import { withLayout } from '~/common/layout/withLayout';
// app config
import { Brand } from '~/common/app.config';
import { ROUTE_APP_CHAT, ROUTE_INDEX } from '~/common/app.routes';
// apps access
import { incrementalNewsVersion } from '../../src/apps/news/news.version';
// capabilities access
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapabilityTextToImage } from '~/common/components/useCapabilities';
// stores access
import { getLLMsDebugInfo } from '~/modules/llms/store-llms';
import { useAppStateStore } from '~/common/state/store-appstate';
import { useChatStore } from '~/common/state/store-chats';
import { useFolderStore } from '~/common/state/store-folders';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
// utils access
import { clientHostName, isChromeDesktop, isFirefox, isIPhoneUser, isMacUser, isPwa, isVercelFromFrontend } from '~/common/util/pwaUtils';
import { getGA4MeasurementId } from '~/common/components/GoogleAnalytics';
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
function DebugCard(props: { title: string, children: React.ReactNode }) {
return (
<Box>
<Typography level='title-lg'>
{props.title}
</Typography>
{props.children}
</Box>
);
}
function prettifyJsonString(jsonString: string, deleteChars: number, removeDoubleQuotes: boolean, removeTrailComma: boolean): string {
return jsonString.split('\n').map(l => {
if (deleteChars > 0)
l = l.substring(deleteChars);
if (removeDoubleQuotes)
l = l.replaceAll('\"', '');
if (removeTrailComma && l.endsWith(','))
l = l.substring(0, l.length - 1);
return l;
}).join('\n').trim();
}
function DebugJsonCard(props: { title: string, data: any }) {
return (
<DebugCard title={props.title}>
<Typography level='body-sm' sx={{ whiteSpace: 'break-spaces', fontFamily: 'code', fontSize: { xs: 'xs' } }}>
{prettifyJsonString(JSON.stringify(props.data, null, 2), 2, true, true)}
</Typography>
</DebugCard>
);
}
function AppDebug() {
// state
const [saved, setSaved] = React.useState(false);
// external state
const backendCapabilities = backendCaps();
const chatsCount = useChatStore.getState().conversations?.length;
const uxLabsExperiments = Object.entries(useUXLabsStore.getState()).filter(([_k, v]) => v === true).map(([k, _]) => k).join(', ');
const { folders, enableFolders } = useFolderStore.getState();
const { lastSeenNewsVersion, usageCount } = useAppStateStore.getState();
// derived state
const cClient = {
// isBrowser,
isChromeDesktop,
isFirefox,
isIPhone: isIPhoneUser,
isMac: isMacUser,
isPWA: isPwa(),
supportsClipboardPaste: supportsClipboardRead,
supportsScreenCapture,
};
const cProduct = {
capabilities: {
mic: useCapabilityBrowserSpeechRecognition(),
elevenLabs: useCapabilityElevenLabs(),
textToImage: useCapabilityTextToImage(),
},
models: getLLMsDebugInfo(),
state: {
chatsCount,
foldersCount: folders?.length,
foldersEnabled: enableFolders,
newsCurrent: incrementalNewsVersion,
newsSeen: lastSeenNewsVersion,
labsActive: uxLabsExperiments,
reloads: usageCount,
},
};
const cBackend = {
configuration: backendCapabilities,
deployment: {
home: Brand.URIs.Home,
hostName: clientHostName(),
isVercelFromFrontend,
measurementId: getGA4MeasurementId(),
plantUmlServerUrl: getPlantUmlServerUrl(),
routeIndex: ROUTE_INDEX,
routeChat: ROUTE_APP_CHAT,
},
};
const handleDownload = async () => {
fileSave(
new Blob([JSON.stringify({ client: cClient, agi: cProduct, backend: cBackend }, null, 2)], { type: 'application/json' }),
{ fileName: `big-agi-debug-${new Date().toISOString().replace(/:/g, '-')}.json`, extensions: ['.json'] },
)
.then(() => setSaved(true))
.catch(e => console.error('Error saving debug.json', e));
};
return (
<AppPlaceholder title={`${Brand.Title.Common} Debug`}>
<Box sx={{ display: 'grid', gap: 3, my: 3 }}>
<Button
variant={saved ? 'soft' : 'outlined'} color={saved ? 'success' : 'neutral'}
onClick={handleDownload}
endDecorator={<DownloadIcon />}
sx={{
backgroundColor: saved ? undefined : 'background.surface',
boxShadow: 'sm',
placeSelf: 'start',
minWidth: 260,
}}
>
Download debug JSON
</Button>
<Card>
<CardContent sx={{ display: 'grid', gap: 3 }}>
<DebugJsonCard title='Client' data={cClient} />
<DebugJsonCard title='AGI' data={cProduct} />
<DebugJsonCard title='Backend' data={cBackend} />
</CardContent>
</Card>
</Box>
</AppPlaceholder>
);
}
export default function DebugPage() {
return withLayout({ type: 'plain' }, <AppDebug />);
};
+1 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { AppLinkChat } from '../../../src/apps/link/AppLinkChat';
import { AppLinkChat } from '../../../src/apps/link-chat/AppLinkChat';
import { useRouterQuery } from '~/common/app.routes';
import { withLayout } from '~/common/layout/withLayout';
+2 -2
View File
@@ -1,14 +1,14 @@
import * as React from 'react';
import { AppNews } from '../src/apps/news/AppNews';
import { useMarkNewsAsSeen } from '../src/apps/news/news.hooks';
import { markNewsAsSeen } from '../src/apps/news/news.version';
import { withLayout } from '~/common/layout/withLayout';
export default function NewsPage() {
// 'touch' the last seen news version
useMarkNewsAsSeen();
React.useEffect(() => markNewsAsSeen(), []);
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppNews />);
}
+12
View File
@@ -0,0 +1,12 @@
import * as React from 'react';
import { Box } from '@mui/joy';
// import { AppWorkspace } from '../src/apps/personas/AppWorkspace';
import { withLayout } from '~/common/layout/withLayout';
export default function PersonasPage() {
return withLayout({ type: 'optima' }, <Box />);
}

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+13 -5
View File
@@ -9,13 +9,17 @@ import { useRouterRoute } from '~/common/app.routes';
/**
* https://github.com/enricoros/big-AGI/issues/299
*/
export function AppPlaceholder(props: { text?: string }) {
export function AppPlaceholder(props: {
title?: string,
text?: React.ReactNode,
children?: React.ReactNode,
}) {
// external state
const route = useRouterRoute();
// derived state
const placeholderAppName = capitalizeFirstLetter(route.replace('/', '') || 'Home');
const placeholderAppName = props.title || capitalizeFirstLetter(route.replace('/', '') || 'Home');
return (
<Box sx={{
@@ -35,12 +39,16 @@ export function AppPlaceholder(props: { text?: string }) {
<Typography level='h1'>
{placeholderAppName}
</Typography>
<Typography>
{props.text || 'Intelligent applications to help you learn, think, and do'}
</Typography>
{!!props.text && (
<Typography>
{props.text}
</Typography>
)}
</Box>
{props.children}
</Box>
);
}
+2
View File
@@ -65,6 +65,8 @@ export function AppCall() {
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: hasIntent ? 'space-evenly' : undefined,
gap: hasIntent ? 1 : undefined,
// shall force the contacts or telephone to stay within the container
overflowY: hasIntent ? 'hidden' : undefined,
}}>
{!hasIntent ? (
+5 -5
View File
@@ -4,10 +4,10 @@ import { Box, Button, Card, CardContent, IconButton, ListItemDecorator, Typograp
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ChatIcon from '@mui/icons-material/Chat';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import MicIcon from '@mui/icons-material/Mic';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import WarningIcon from '@mui/icons-material/Warning';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
@@ -67,7 +67,7 @@ function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: s
{props.button}
</Typography>
<ListItemDecorator>
{props.hasIssue ? <WarningIcon color='warning' /> : <CheckIcon color='success' />}
{props.hasIssue ? <WarningRoundedIcon color='warning' /> : <CheckIcon color='success' />}
</ListItemDecorator>
</CardContent>
</Card>
@@ -122,7 +122,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
<Box sx={{ flexGrow: 0.5 }} />
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, textAlign: 'center' }}>
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 'sm', textAlign: 'center' }}>
Welcome to<br />
<Box component='span' sx={{ animation: `${cssRainbowColorKeyframes} 15s linear infinite` }}>
your first call
@@ -208,7 +208,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string | n
// boxShadow: allGood ? 'md' : 'none',
}}
>
{allGood ? <ArrowForwardIcon sx={{ fontSize: '1.5em' }} /> : <CloseIcon sx={{ fontSize: '1.5em' }} />}
{allGood ? <ArrowForwardIcon sx={{ fontSize: '1.5em' }} /> : <CloseRoundedIcon sx={{ fontSize: '1.5em' }} />}
</IconButton>
</Box>
+72 -42
View File
@@ -9,7 +9,9 @@ import MicIcon from '@mui/icons-material/Mic';
import MicNoneIcon from '@mui/icons-material/MicNone';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import { useChatLLMDropdown } from '../chat/components/applayout/useLLMDropdown';
import { ScrollToBottom } from '../chat/components/scroll-to-bottom/ScrollToBottom';
import { ScrollToBottomButton } from '../chat/components/scroll-to-bottom/ScrollToBottomButton';
import { useChatLLMDropdown } from '../chat/components/useLLMDropdown';
import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client';
import { SystemPurposeId, SystemPurposes } from '../../data';
@@ -222,8 +224,9 @@ export function Telephone(props: {
responseAbortController.current = new AbortController();
let finalText = '';
let error: any | null = null;
llmStreamingChatGenerate(chatLLMId, callPrompt, null, null, responseAbortController.current.signal, (updatedMessage: Partial<DMessage>) => {
const text = updatedMessage.text?.trim();
setPersonaTextInterim('💭...');
llmStreamingChatGenerate(chatLLMId, callPrompt, null, null, responseAbortController.current.signal, ({ textSoFar }) => {
const text = textSoFar?.trim();
if (text) {
finalText = text;
setPersonaTextInterim(text);
@@ -313,52 +316,75 @@ export function Telephone(props: {
{/* Live Transcript, w/ streaming messages, audio indication, etc. */}
{(isConnected || isEnded) && (
<Card variant='soft' sx={{
<Card variant='outlined' sx={{
flexGrow: 1,
maxHeight: '24%',
minHeight: '15%',
maxHeight: '28%',
minHeight: '20%',
width: '100%',
// style
backgroundColor: 'background.surface',
// backgroundColor: 'background.surface',
borderRadius: 'lg',
boxShadow: 'sm',
// boxShadow: 'sm',
// children
display: 'flex', flexDirection: 'column-reverse',
overflow: 'auto',
padding: 0, // move this to the ScrollToBottom component
}}>
{/* Messages in reverse order, for auto-scroll from the bottom */}
<Box sx={{ display: 'flex', flexDirection: 'column-reverse', gap: 1 }}>
<ScrollToBottom
// bootToBottom
stickToBottom
sx={{
// allows the content to be scrolled (all browsers)
overflowY: 'auto',
// actually make sure this scrolls & fills
height: '100%',
{/* Listening... */}
{isRecording && (
<CallMessage
text={<>{speechInterim?.transcript ? speechInterim.transcript + ' ' : ''}<i>{speechInterim?.interimTranscript}</i></>}
variant={isRecordingSpeech ? 'solid' : 'outlined'}
role='user'
/>
)}
// content
display: 'grid',
padding: 1,
}}
>
{/* Persona streaming text... */}
{!!personaTextInterim && (
<CallMessage
text={personaTextInterim}
variant='solid' color='neutral'
role='assistant'
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Messages (last 6 messages, in reverse order) */}
{callMessages.slice(-6).reverse().map((message) =>
<CallMessage
key={message.id}
text={message.text}
variant={message.role === 'assistant' ? 'solid' : 'soft'} color='neutral'
role={message.role} />,
)}
</Box>
{/* Call Messages [] */}
{callMessages.map((message) =>
<CallMessage
key={message.id}
text={message.text}
variant={message.role === 'assistant' ? 'solid' : 'soft'}
color={message.role === 'assistant' ? 'neutral' : 'primary'}
role={message.role}
/>,
)}
{/* Persona streaming text... */}
{!!personaTextInterim && (
<CallMessage
text={personaTextInterim}
variant='outlined'
color='neutral'
role='assistant'
/>
)}
{/* Listening... */}
{isRecording && (
<CallMessage
text={<>{speechInterim?.transcript.trim() || null}{speechInterim?.interimTranscript.trim() ? <i> {speechInterim.interimTranscript}</i> : null}</>}
variant={(isRecordingSpeech || !!speechInterim?.transcript) ? 'soft' : 'outlined'}
color='primary'
role='user'
/>
)}
</Box>
{/* Visibility and actions are handled via Context */}
<ScrollToBottomButton />
</ScrollToBottom>
</Card>
)}
@@ -371,11 +397,15 @@ export function Telephone(props: {
{/* [Calling] Hang / PTT (mute not enabled yet) */}
{isConnected && <CallButton Icon={CallEndIcon} text='Hang up' color='danger' variant='soft' onClick={handleCallStop} />}
{isConnected && (pushToTalk
? <CallButton Icon={MicIcon} onClick={toggleRecording}
text={isRecordingSpeech ? 'Listening...' : isRecording ? 'Listening' : 'Push To Talk'}
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'} sx={!isRecording ? { backgroundColor: 'background.surface' } : undefined} />
: null
{isConnected && (pushToTalk ? (
<CallButton
Icon={MicIcon} onClick={toggleRecording}
text={isRecordingSpeech ? 'Listening...' : isRecording ? 'Listening' : 'Push To Talk'}
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'}
color='primary'
sx={!isRecording ? { backgroundColor: 'background.surface' } : undefined}
/>
) : null
// <CallButton disabled={true} Icon={MicOffIcon} onClick={() => setMicMuted(muted => !muted)}
// text={micMuted ? 'Muted' : 'Mute'}
// color={micMuted ? 'warning' : undefined} variant={micMuted ? 'solid' : 'outlined'} />
+8 -2
View File
@@ -12,16 +12,22 @@ export function CallMessage(props: {
role: VChatMessageIn['role'],
sx?: SxProps,
}) {
const isUserMessage = props.role === 'user';
return (
<Chip
color={props.color} variant={props.variant}
sx={{
alignSelf: props.role === 'user' ? 'end' : 'start',
alignSelf: isUserMessage ? 'end' : 'start',
whiteSpace: 'break-spaces',
borderRadius: 'lg',
mt: 'auto',
...(isUserMessage ? {
borderBottomRightRadius: 0,
} : {
borderBottomLeftRadius: 0,
}),
// boxShadow: 'md',
py: 1,
px: 1.5,
...(props.sx || {}),
}}
>
+237 -155
View File
@@ -1,6 +1,5 @@
import * as React from 'react';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import { Panel, PanelGroup } from 'react-resizable-panels';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useTheme } from '@mui/joy';
@@ -14,60 +13,73 @@ import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { Brand } from '~/common/app.config';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { ConversationManager } from '~/common/chats/ConversationHandler';
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
import { GoodPanelResizeHandler } from '~/common/components/panes/GoodPanelResizeHandler';
import { PanelResizeInset } from '~/common/components/panes/GoodPanelResizeHandler';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
import { createDMessage, DConversationId, DMessage, getConversation, getConversationSystemPurposeId, useConversation } from '~/common/state/store-chats';
import { getUXLabsHighPerformance, useUXLabsStore } from '~/common/state/store-ux-labs';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { useFolderStore } from '~/common/state/store-folders';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
import { ChatDrawerMemo } from './components/applayout/ChatDrawer';
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
import { Beam } from './components/beam/Beam';
import { ChatDrawerMemo } from './components/ChatDrawer';
import { ChatDropdowns } from './components/ChatDropdowns';
import { ChatMessageList } from './components/ChatMessageList';
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
import { ChatTitle } from './components/ChatTitle';
import { Composer } from './components/composer/Composer';
import { Ephemerals } from './components/Ephemerals';
import { ScrollToBottom } from './components/scroll-to-bottom/ScrollToBottom';
import { ScrollToBottomButton } from './components/scroll-to-bottom/ScrollToBottomButton';
import { usePanesManager } from './components/panes/usePanesManager';
import { getInstantAppChatPanesCount, usePanesManager } from './components/panes/usePanesManager';
import { extractChatCommand, findAllChatCommands } from './commands/commands.registry';
import { runAssistantUpdatingState } from './editors/chat-stream';
import { runBrowseUpdatingState } from './editors/browse-load';
import { runBrowseGetPageUpdatingState } from './editors/browse-load';
import { runImageGenerationUpdatingState } from './editors/image-generate';
import { runReActUpdatingState } from './editors/react-tangent';
// what to say when a chat is new and has no title
export const CHAT_NOVEL_TITLE = 'Chat';
/**
* Mode: how to treat the input from the Composer
*/
export type ChatModeId =
| 'generate-text'
| 'generate-text-beam'
| 'append-user'
| 'generate-image'
| 'generate-react';
const SPECIAL_ID_WIPE_ALL: DConversationId = 'wipe-chats';
export function AppChat() {
// state
const [isComposerMulticast, setIsComposerMulticast] = React.useState(false);
const [isMessageSelectionMode, setIsMessageSelectionMode] = React.useState(false);
const [diagramConfig, setDiagramConfig] = React.useState<DiagramConfig | null>(null);
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(null);
const [clearConversationId, setClearConversationId] = React.useState<DConversationId | null>(null);
const [deleteConversationId, setDeleteConversationId] = React.useState<DConversationId | null>(null);
const [deleteConversationIds, setDeleteConversationIds] = React.useState<DConversationId[] | null>(null);
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
const showNextTitle = React.useRef(false);
const showNextTitleChange = React.useRef(false);
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const [_activeFolderId, setActiveFolderId] = React.useState<string | null>(null);
// external state
const theme = useTheme();
const isMobile = useIsMobile();
const showAltTitleBar = useUXLabsStore(state => state.labsChatBarAlt === 'title');
const { openLlmOptions } = useOptimaLayout();
const { chatLLM } = useChatLLM();
@@ -78,42 +90,46 @@ export function AppChat() {
navigateHistoryInFocusedPane,
openConversationInFocusedPane,
openConversationInSplitPane,
paneIndex,
duplicatePane,
focusedPaneIndex,
removePane,
setFocusedPane,
} = usePanesManager();
const {
title: focusedChatTitle,
chatIdx: focusedChatNumber,
isChatEmpty: isFocusedChatEmpty,
isDeveloper: isFocusedChatDeveloper,
areChatsEmpty,
conversationIdx: focusedChatNumber,
newConversationId,
conversationsLength,
_remove_systemPurposeId: focusedSystemPurposeId,
prependNewConversation,
branchConversation,
deleteConversation,
wipeAllConversations,
deleteConversations,
setMessages,
} = useConversation(focusedConversationId);
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
const { activeFolderId, activeFolderConversationsCount } = useFolderStore(({ enableFolders, folders }) => {
const { activeFolderId } = useFolderStore(({ enableFolders, folders }) => {
const activeFolderId = enableFolders ? _activeFolderId : null;
const activeFolder = activeFolderId ? folders.find(folder => folder.id === activeFolderId) : null;
return {
activeFolderId: activeFolder?.id ?? null,
activeFolderConversationsCount: activeFolder ? activeFolder.conversationIds.length : conversationsLength,
};
});
// Window actions
const panesConversationIDs = chatPanes.length > 0 ? chatPanes.map((pane) => pane.conversationId) : [null];
const isSplitPane = chatPanes.length > 1;
const isMultiPane = chatPanes.length >= 2;
const isMultiAddable = chatPanes.length < 4;
const isMultiConversationId = isMultiPane && new Set(chatPanes.map((pane) => pane.conversationId)).size >= 2;
const willMulticast = isComposerMulticast && isMultiConversationId;
const disableNewButton = isFocusedChatEmpty && !isMultiPane;
const chatHandlers = React.useMemo(() => chatPanes.map(pane => {
return pane.conversationId ? ConversationManager.getHandler(pane.conversationId) : null;
}), [chatPanes]);
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
conversationId && openConversationInFocusedPane(conversationId);
@@ -123,27 +139,21 @@ export function AppChat() {
conversationId && openConversationInSplitPane(conversationId);
}, [openConversationInSplitPane]);
const toggleSplitPane = React.useCallback(() => {
if (isSplitPane)
removePane(paneIndex ?? chatPanes.length - 1);
else
duplicatePane(paneIndex ?? chatPanes.length - 1);
}, [chatPanes.length, duplicatePane, isSplitPane, paneIndex, removePane]);
const handleNavigateHistory = React.useCallback((direction: 'back' | 'forward') => {
if (navigateHistoryInFocusedPane(direction))
showNextTitle.current = true;
showNextTitleChange.current = true;
}, [navigateHistoryInFocusedPane]);
React.useEffect(() => {
if (showNextTitle.current) {
showNextTitle.current = false;
if (showNextTitleChange.current) {
showNextTitleChange.current = false;
const title = (focusedChatNumber >= 0 ? `#${focusedChatNumber + 1} · ` : '') + (focusedChatTitle || 'New Chat');
const id = addSnackbar({ key: 'focused-title', message: title, type: 'title' });
return () => removeSnackbar(id);
}
}, [focusedChatNumber, focusedChatTitle]);
// Execution
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
@@ -156,9 +166,12 @@ export function AppChat() {
const chatCommand = extractChatCommand(lastMessage.text)[0];
if (chatCommand && chatCommand.type === 'cmd') {
switch (chatCommand.providerId) {
case 'ass-beam':
return ConversationManager.getHandler(conversationId).beamStore.create(history);
case 'ass-browse':
setMessages(conversationId, history);
return await runBrowseUpdatingState(conversationId, chatCommand.params!);
return await runBrowseGetPageUpdatingState(conversationId, chatCommand.params!);
case 'ass-t2i':
setMessages(conversationId, history);
@@ -169,6 +182,13 @@ export function AppChat() {
return await runReActUpdatingState(conversationId, chatCommand.params!, chatLLMId);
case 'chat-alter':
if (chatCommand.command === '/clear') {
if (chatCommand.params === 'all')
return setMessages(conversationId, []);
const helpMessage = createDMessage('assistant', 'This command requires the \'all\' parameter to confirm the operation.');
helpMessage.originLLM = Brand.Title.Base;
return setMessages(conversationId, [...history, helpMessage]);
}
Object.assign(lastMessage, {
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
sender: 'Bot',
@@ -183,15 +203,26 @@ export function AppChat() {
const helpMessage = createDMessage('assistant', 'Available Chat Commands:\n' + chatCommandsText);
helpMessage.originLLM = Brand.Title.Base;
return setMessages(conversationId, [...history, helpMessage]);
default:
return setMessages(conversationId, [...history, createDMessage('assistant', 'This command is not supported.')]);
}
}
}
// get the focused system purpose (note: we don't react to it, or it would invalidate half UI components..)
const conversationSystemPurposeId = getConversationSystemPurposeId(conversationId);
if (!conversationSystemPurposeId)
return setMessages(conversationId, [...history, createDMessage('assistant', 'No persona selected.')]);
// synchronous long-duration tasks, which update the state as they go
if (chatLLMId && focusedSystemPurposeId) {
if (chatLLMId) {
switch (chatModeId) {
case 'generate-text':
return await runAssistantUpdatingState(conversationId, history, chatLLMId, focusedSystemPurposeId);
return await runAssistantUpdatingState(conversationId, history, chatLLMId, conversationSystemPurposeId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
case 'generate-text-beam':
return ConversationManager.getHandler(conversationId).beamStore.create(history);
case 'append-user':
return setMessages(conversationId, history);
@@ -217,9 +248,9 @@ export function AppChat() {
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
console.log('handleExecuteConversation: issue running', chatModeId, conversationId, lastMessage);
setMessages(conversationId, history);
}, [focusedSystemPurposeId, setMessages]);
}, [setMessages]);
const handleComposerAction = (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
const handleComposerAction = React.useCallback((chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
// validate inputs
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
addSnackbar({
@@ -234,21 +265,26 @@ export function AppChat() {
}
const userText = multiPartMessage[0].text;
// find conversation
const conversation = getConversation(conversationId);
if (!conversation)
return false;
// multicast: send the message to all the panes
const uniqueIds = new Set([conversationId]);
if (willMulticast)
chatPanes.forEach(pane => pane.conversationId && uniqueIds.add(pane.conversationId));
// start execution (async)
void _handleExecute(chatModeId, conversationId, [
...conversation.messages,
createDMessage('user', userText),
]);
return true;
};
// we loop to handle both the normal and multicast modes
let enqueued = false;
for (const _cId of uniqueIds) {
const _conversation = getConversation(_cId);
if (_conversation) {
// start execution fire/forget
void _handleExecute(chatModeId, _cId, [..._conversation.messages, createDMessage('user', userText)]);
enqueued = true;
}
}
return enqueued;
}, [chatPanes, willMulticast, _handleExecute]);
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
await _handleExecute('generate-text', conversationId, history);
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[], chatEffectBeam: boolean): Promise<void> => {
await _handleExecute(!chatEffectBeam ? 'generate-text' : 'generate-text-beam', conversationId, history);
}, [_handleExecute]);
const handleMessageRegenerateLast = React.useCallback(async () => {
@@ -279,14 +315,15 @@ export function AppChat() {
await speakText(text);
}, []);
// Chat actions
const handleConversationNew = React.useCallback(() => {
const handleConversationNew = React.useCallback((forceNoRecycle?: boolean) => {
// activate an existing new conversation if present, or create another
const conversationId = newConversationId
const conversationId = (newConversationId && !forceNoRecycle)
? newConversationId
: prependNewConversation(focusedSystemPurposeId ?? undefined);
: prependNewConversation(getConversationSystemPurposeId(focusedConversationId) ?? undefined);
setFocusedConversationId(conversationId);
// if a folder is active, add the new conversation to the folder
@@ -296,33 +333,33 @@ export function AppChat() {
// focus the composer
composerTextAreaRef.current?.focus();
}, [activeFolderId, focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
}, [activeFolderId, focusedConversationId, newConversationId, prependNewConversation, setFocusedConversationId]);
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
const handleConversationExport = React.useCallback((conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId }), []);
const handleConversationExport = React.useCallback((conversationId: DConversationId | null, exportAll: boolean) => {
setTradeConfig({ dir: 'export', conversationId, exportAll });
}, []);
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
showNextTitle.current = true;
const branchedConversationId = branchConversation(conversationId, messageId);
addSnackbar({
key: 'branch-conversation',
message: 'Branch started.',
type: 'success',
overrides: {
autoHideDuration: 3000,
startDecorator: <ForkRightIcon />,
},
});
const branchInAltPanel = useUXLabsStore.getState().labsSplitBranching;
if (branchInAltPanel)
const handleConversationBranch = React.useCallback((srcConversationId: DConversationId, messageId: string | null): DConversationId | null => {
// clone data
const branchedConversationId = branchConversation(srcConversationId, messageId);
// if a folder is active, add the new conversation to the folder
if (activeFolderId && branchedConversationId)
useFolderStore.getState().addConversationToFolder(activeFolderId, branchedConversationId);
// replace/open a new pane with this
showNextTitleChange.current = true;
if (isMultiAddable)
openSplitConversationId(branchedConversationId);
else
setFocusedConversationId(branchedConversationId);
return branchedConversationId;
}, [branchConversation, openSplitConversationId, setFocusedConversationId]);
const handleConversationFlatten = (conversationId: DConversationId) => setFlattenConversationId(conversationId);
return branchedConversationId;
}, [activeFolderId, branchConversation, isMultiAddable, openSplitConversationId, setFocusedConversationId]);
const handleConversationFlatten = React.useCallback((conversationId: DConversationId) => setFlattenConversationId(conversationId), []);
const handleConfirmedClearConversation = React.useCallback(() => {
if (clearConversationId) {
@@ -333,27 +370,22 @@ export function AppChat() {
const handleConversationClear = React.useCallback((conversationId: DConversationId) => setClearConversationId(conversationId), []);
const handleConfirmedDeleteConversation = () => {
if (deleteConversationId) {
let nextConversationId: DConversationId | null;
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, activeFolderId);
else
nextConversationId = deleteConversation(deleteConversationId);
setFocusedConversationId(nextConversationId);
setDeleteConversationId(null);
}
};
const handleDeleteConversations = React.useCallback((conversationIds: DConversationId[], bypassConfirmation: boolean) => {
if (!bypassConfirmation)
return setDeleteConversationIds(conversationIds);
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
// perform deletion
const nextConversationId = deleteConversations(conversationIds, /*focusedSystemPurposeId ??*/ undefined);
setFocusedConversationId(nextConversationId);
setDeleteConversationIds(null);
}, [deleteConversations, setFocusedConversationId]);
const handleConfirmedDeleteConversations = React.useCallback(() => {
!!deleteConversationIds?.length && handleDeleteConversations(deleteConversationIds, true);
}, [deleteConversationIds, handleDeleteConversations]);
const handleConversationDelete = React.useCallback(
(conversationId: DConversationId, bypassConfirmation: boolean) => {
if (bypassConfirmation) setFocusedConversationId(deleteConversation(conversationId));
else setDeleteConversationId(conversationId);
},
[deleteConversation, setFocusedConversationId],
);
// Shortcuts
@@ -369,84 +401,112 @@ export function AppChat() {
['n', true, false, true, handleConversationNew],
['b', true, false, true, () => isFocusedChatEmpty || (focusedConversationId && handleConversationBranch(focusedConversationId, null))],
['x', true, false, true, () => isFocusedChatEmpty || (focusedConversationId && handleConversationClear(focusedConversationId))],
['d', true, false, true, () => focusedConversationId && handleConversationDelete(focusedConversationId, false)],
['d', true, false, true, () => focusedConversationId && handleDeleteConversations([focusedConversationId], false)],
['+', true, true, false, useUIPreferencesStore.getState().increaseContentScaling],
['-', true, true, false, useUIPreferencesStore.getState().decreaseContentScaling],
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationNew, handleDeleteConversations, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
useGlobalShortcuts(shortcuts);
// Pluggable ApplicationBar components
const centerItems = React.useMemo(() =>
<ChatDropdowns
conversationId={focusedConversationId}
isSplitPanes={isSplitPane}
onToggleSplitPanes={toggleSplitPane}
/>,
[focusedConversationId, isSplitPane, toggleSplitPane],
// Pluggable Optima components
const barAltTitle = showAltTitleBar ? focusedChatTitle ?? 'No Chat' : null;
const barContent = React.useMemo(() =>
(barAltTitle === null)
? <ChatDropdowns conversationId={focusedConversationId} />
: <ChatTitle conversationId={focusedConversationId} conversationTitle={barAltTitle} />
, [focusedConversationId, barAltTitle],
);
const drawerContent = React.useMemo(() =>
<ChatDrawerMemo
isMobile={isMobile}
activeConversationId={focusedConversationId}
activeFolderId={activeFolderId}
disableNewButton={isFocusedChatEmpty}
chatPanesConversationIds={chatPanes.map(pane => pane.conversationId).filter(Boolean) as DConversationId[]}
disableNewButton={disableNewButton}
onConversationActivate={setFocusedConversationId}
onConversationDelete={handleConversationDelete}
onConversationExportDialog={handleConversationExport}
onConversationImportDialog={handleConversationImportDialog}
onConversationBranch={handleConversationBranch}
onConversationNew={handleConversationNew}
onConversationsDeleteAll={handleConversationsDeleteAll}
onConversationsDelete={handleDeleteConversations}
onConversationsExportDialog={handleConversationExport}
onConversationsImportDialog={handleConversationImportDialog}
setActiveFolderId={setActiveFolderId}
/>,
[activeFolderId, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, setFocusedConversationId],
[activeFolderId, chatPanes, disableNewButton, focusedConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleDeleteConversations, isMobile, setFocusedConversationId],
);
const menuItems = React.useMemo(() =>
<ChatMenuItems
<ChatPageMenuItems
isMobile={isMobile}
conversationId={focusedConversationId}
disableItems={!focusedConversationId || isFocusedChatEmpty}
hasConversations={!areChatsEmpty}
isConversationEmpty={isFocusedChatEmpty}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationClear={handleConversationClear}
onConversationFlatten={handleConversationFlatten}
// onConversationNew={handleConversationNew}
setIsMessageSelectionMode={setIsMessageSelectionMode}
/>,
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, isFocusedChatEmpty, isMessageSelectionMode],
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationFlatten, /*handleConversationNew,*/ isFocusedChatEmpty, isMessageSelectionMode, isMobile],
);
usePluggableOptimaLayout(drawerContent, centerItems, menuItems, 'AppChat');
usePluggableOptimaLayout(drawerContent, barContent, menuItems, 'AppChat');
return <>
<PanelGroup
direction='horizontal'
direction={isMobile ? 'vertical' : 'horizontal'}
id='app-chat-panels'
>
{panesConversationIDs.map((_conversationId, idx, panels) =>
<React.Fragment key={`chat-pane-${idx}-${panels.length}-${_conversationId}`}>
{chatPanes.map((pane, idx) => {
const _paneConversationId = pane.conversationId;
const _paneChatHandler = chatHandlers[idx] ?? null;
const _panesCount = chatPanes.length;
const _keyAndId = `chat-pane-${idx}-${_paneConversationId}`;
const _sepId = `sep-pane-${idx}-${_paneConversationId}`;
return <React.Fragment key={_keyAndId}>
<Panel
id={'chat-pane-' + _conversationId}
id={_keyAndId}
order={idx}
collapsible
defaultSize={panels.length > 0 ? Math.round(100 / panels.length) : undefined}
collapsible={chatPanes.length === 2}
defaultSize={(_panesCount === 3 && idx === 1) ? 34 : Math.round(100 / _panesCount)}
minSize={20}
onClick={(event) => {
const setFocus = chatPanes.length < 2 || !event.altKey;
setFocusedPane(setFocus ? idx : -1);
}}
onCollapse={() => setTimeout(() => removePane(idx), 50)}
onCollapse={() => {
// NOTE: despite the delay to try to let the draggin settle, there seems to be an issue with the Pane locking the screen
// setTimeout(() => removePane(idx), 50);
// more than 2 will result in an assertion from the framework
if (chatPanes.length === 2) removePane(idx);
}}
style={{
// for anchoring the scroll button in place
position: 'relative',
// border only for active pane (if two or more panes)
...(panesConversationIDs.length < 2
? {}
: (_conversationId === focusedConversationId)
? { border: `2px solid ${theme.palette.primary.solidBg}` }
: { border: `2px solid ${theme.palette.background.level1}` }),
...(isMultiPane ? {
borderRadius: '0.375rem',
border: `2px solid ${idx === focusedPaneIndex
? ((willMulticast || !isMultiConversationId) ? theme.palette.primary.solidBg : theme.palette.primary.solidBg)
: ((willMulticast || !isMultiConversationId) ? theme.palette.warning.softActiveBg : theme.palette.background.level1)}`,
filter: (!willMulticast && idx !== focusedPaneIndex)
? (!isMultiConversationId ? 'grayscale(66.67%)' /* clone of the same */ : 'grayscale(66.67%)')
: undefined,
} : {
// NOTE: this is a workaround for the 'stuck-after-collapse-close' issue. We will collapse the 'other' pane, which
// will get it removed (onCollapse), and somehow this pane will be stuck with a pointerEvents: 'none' style, which de-facto
// disables further interaction with the chat. This is a workaround to re-enable the pointer events.
// The root cause seems to be a Dragstate not being reset properly, however the pointerEvents has been set since 0.0.56 while
// it was optional before: https://github.com/bvaughn/react-resizable-panels/issues/241
pointerEvents: 'auto',
}),
}}
>
@@ -462,9 +522,11 @@ export function AppChat() {
>
<ChatMessageList
conversationId={_conversationId}
conversationId={_paneConversationId}
conversationHandler={_paneChatHandler}
capabilityHasT2I={capabilityHasT2I}
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
fitScreen={isMobile || isMultiPane}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
@@ -477,37 +539,60 @@ export function AppChat() {
}}
/>
<Ephemerals
conversationId={_conversationId}
sx={{
// TODO: Fixme post panels?
// flexGrow: 0.1,
flexShrink: 0.5,
overflowY: 'auto',
minHeight: 64,
}}
/>
{/*<Ephemerals*/}
{/* conversationId={_paneConversationId}*/}
{/* sx={{*/}
{/* // TODO: Fixme post panels?*/}
{/* // flexGrow: 0.1,*/}
{/* flexShrink: 0.5,*/}
{/* overflowY: 'auto',*/}
{/* minHeight: 64,*/}
{/* }}*/}
{/*/>*/}
{/* Visibility and actions are handled via Context */}
<ScrollToBottomButton />
</ScrollToBottom>
{/* Best-Of Mode */}
<Beam
conversationHandler={_paneChatHandler}
isMobile={isMobile}
sx={{
overflowY: 'auto',
backgroundColor: 'background.level2',
position: 'absolute',
inset: 0,
zIndex: 1, // stay on top of Chips :shrug:
}}
/>
</Panel>
{/* Panel Separators & Resizers */}
{idx < panels.length - 1 && <GoodPanelResizeHandler />}
{idx < _panesCount - 1 && (
<PanelResizeHandle id={_sepId}>
<PanelResizeInset />
</PanelResizeHandle>
)}
</React.Fragment>)}
</React.Fragment>;
})}
</PanelGroup>
<Composer
isMobile={isMobile}
chatLLM={chatLLM}
composerTextAreaRef={composerTextAreaRef}
conversationId={focusedConversationId}
capabilityHasT2I={capabilityHasT2I}
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
isMulticast={!isMultiConversationId ? null : isComposerMulticast}
isDeveloperMode={isFocusedChatDeveloper}
onAction={handleComposerAction}
onTextImagine={handleTextImagine}
setIsMulticast={setIsComposerMulticast}
sx={{
zIndex: 21, // position: 'sticky', bottom: 0,
backgroundColor: themeBgAppChatComposer,
@@ -541,23 +626,20 @@ export function AppChat() {
{/* [confirmation] Reset Conversation */}
{!!clearConversationId && (
<ConfirmationModal
open
onClose={() => setClearConversationId(null)}
onPositive={handleConfirmedClearConversation}
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText='Are you sure you want to discard all messages?'
positiveActionText='Clear conversation'
/>
)}
{/* [confirmation] Delete All */}
{!!deleteConversationId && <ConfirmationModal
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
? `Are you absolutely sure you want to delete ${activeFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
: 'Are you sure you want to delete this conversation?'}
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
? `Yes, delete all ${activeFolderConversationsCount} conversations`
: 'Delete conversation'}
/>}
{!!deleteConversationIds?.length && (
<ConfirmationModal
open onClose={() => setDeleteConversationIds(null)} onPositive={handleConfirmedDeleteConversations}
confirmationText={`Are you absolutely sure you want to delete ${deleteConversationIds.length === 1 ? 'this conversation' : 'these conversations'}? This action cannot be undone.`}
positiveActionText={deleteConversationIds.length === 1 ? 'Delete conversation' : `Yes, delete all ${deleteConversationIds.length} conversations`}
/>
)}
</>;
}
+8 -1
View File
@@ -1,8 +1,10 @@
import ClearIcon from '@mui/icons-material/Clear';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsAlter: ICommandsProvider = {
id: 'chat-alter',
rank: 20,
rank: 25,
getCommands: () => [{
primary: '/assistant',
@@ -14,6 +16,11 @@ export const CommandsAlter: ICommandsProvider = {
alternatives: ['/s'],
arguments: ['text'],
description: 'Injects system message',
}, {
primary: '/clear',
arguments: ['all'],
description: 'Clears the chat (removes all messages)',
Icon: ClearIcon,
}],
};
+17
View File
@@ -0,0 +1,17 @@
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { getUXLabsChatBeam } from '~/common/state/store-ux-labs';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsBeam: ICommandsProvider = {
id: 'ass-beam',
rank: 9,
getCommands: () => getUXLabsChatBeam() ? [{
primary: '/beam',
arguments: ['prompt'],
description: 'Best of multiple replies',
Icon: ChatBeamIcon,
}] : [],
};
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsBrowse: ICommandsProvider = {
id: 'ass-browse',
rank: 25,
rank: 20,
getCommands: () => [{
primary: '/browse',
+3 -1
View File
@@ -1,13 +1,14 @@
import { ChatCommand, ICommandsProvider } from './ICommandsProvider';
import { CommandsAlter } from './CommandsAlter';
import { CommandsBeam } from './CommandsBeam';
import { CommandsBrowse } from './CommandsBrowse';
import { CommandsDraw } from './CommandsDraw';
import { CommandsHelp } from './CommandsHelp';
import { CommandsReact } from './CommandsReact';
export type CommandsProviderId = 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help';
export type CommandsProviderId = 'ass-beam' | 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help';
type TextCommandPiece =
| { type: 'text'; value: string; }
@@ -15,6 +16,7 @@ type TextCommandPiece =
const ChatCommandsProviders: Record<CommandsProviderId, ICommandsProvider> = {
'ass-beam': CommandsBeam,
'ass-browse': CommandsBrowse,
'ass-react': CommandsReact,
'ass-t2i': CommandsDraw,
+371
View File
@@ -0,0 +1,371 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Dropdown, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Menu, MenuButton, MenuItem, Tooltip, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import CheckIcon from '@mui/icons-material/Check';
import ClearIcon from '@mui/icons-material/Clear';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
import FolderIcon from '@mui/icons-material/Folder';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import type { DConversationId } from '~/common/state/store-chats';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { DebounceInputMemo } from '~/common/components/DebounceInput';
import { FoldersToggleOff } from '~/common/components/icons/FoldersToggleOff';
import { FoldersToggleOn } from '~/common/components/icons/FoldersToggleOn';
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { themeScalingMap, themeZIndexOverMobileDrawer } from '~/common/app.theme';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ChatDrawerItemMemo, FolderChangeRequest } from './ChatDrawerItem';
import { ChatFolderList } from './folders/ChatFolderList';
import { ChatNavGrouping, useChatNavRenderItems } from './useChatNavRenderItems';
import { ClearFolderText } from './folders/useFolderDropdown';
import { useChatShowRelativeSize } from '../store-app-chat';
// this is here to make shallow comparisons work on the next hook
const noFolders: DFolder[] = [];
/*
* Lists folders and returns the active folder
*/
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders, toggleEnableFolders }) => {
// finds the active folder if any
const activeFolder = (enableFolders && activeFolderId)
? folders.find(folder => folder.id === activeFolderId) ?? null
: null;
return {
activeFolder,
allFolders: enableFolders ? folders : noFolders,
enableFolders,
toggleEnableFolders,
};
}, shallow);
export const ChatDrawerMemo = React.memo(ChatDrawer);
function ChatDrawer(props: {
isMobile: boolean,
activeConversationId: DConversationId | null,
activeFolderId: string | null,
chatPanesConversationIds: DConversationId[],
disableNewButton: boolean,
onConversationActivate: (conversationId: DConversationId) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationNew: (forceNoRecycle: boolean) => void,
onConversationsDelete: (conversationIds: DConversationId[], bypassConfirmation: boolean) => void,
onConversationsExportDialog: (conversationId: DConversationId | null, exportAll: boolean) => void,
onConversationsImportDialog: () => void,
setActiveFolderId: (folderId: string | null) => void,
}) {
const { onConversationActivate, onConversationBranch, onConversationNew, onConversationsDelete, onConversationsExportDialog } = props;
// local state
const [navGrouping, setNavGrouping] = React.useState<ChatNavGrouping>('date');
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
// external state
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
const { showRelativeSize, toggleRelativeSize } = useChatShowRelativeSize();
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
const { filteredChatsCount, filteredChatIDs, filteredChatsAreEmpty, filteredChatsBarBasis, filteredChatsIncludeActive, renderNavItems } = useChatNavRenderItems(
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, navGrouping, showRelativeSize,
);
const { contentScaling, showSymbols } = useUIPreferencesStore(state => ({
contentScaling: state.contentScaling,
showSymbols: state.zenMode !== 'cleaner',
}), shallow);
// New/Activate/Delete Conversation
const isMultiPane = props.chatPanesConversationIds.length >= 2;
const disableNewButton = props.disableNewButton && filteredChatsIncludeActive;
const newButtonDontRecycle = isMultiPane || !filteredChatsIncludeActive;
const handleButtonNew = React.useCallback(() => {
onConversationNew(newButtonDontRecycle);
closeDrawerOnMobile();
}, [closeDrawerOnMobile, newButtonDontRecycle, onConversationNew]);
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
onConversationActivate(conversationId);
if (closeMenu)
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationActivate]);
const handleConversationsDeleteFiltered = React.useCallback(() => {
!!filteredChatIDs?.length && onConversationsDelete(filteredChatIDs, false);
}, [filteredChatIDs, onConversationsDelete]);
const handleConversationDeleteNoConfirmation = React.useCallback((conversationId: DConversationId) => {
conversationId && onConversationsDelete([conversationId], true);
}, [onConversationsDelete]);
const handleConversationsExport = React.useCallback(() => {
props.activeConversationId && onConversationsExportDialog(props.activeConversationId, true);
}, [onConversationsExportDialog, props.activeConversationId]);
// Folder change request
const handleConversationFolderChange = React.useCallback((folderChangeRequest: FolderChangeRequest) => setFolderChangeRequest(folderChangeRequest), []);
const handleConversationFolderCancel = React.useCallback(() => setFolderChangeRequest(null), []);
const handleConversationFolderSet = React.useCallback((conversationId: DConversationId, nextFolderId: string | null) => {
// Remove conversation from existing folders
const { addConversationToFolder, folders, removeConversationFromFolder } = useFolderStore.getState();
folders.forEach(folder => folder.conversationIds.includes(conversationId) && removeConversationFromFolder(folder.id, conversationId));
// Add conversation to the selected folder
nextFolderId && addConversationToFolder(nextFolderId, conversationId);
// Close the menu
setFolderChangeRequest(null);
}, []);
// memoize the group dropdown
const groupingComponent = React.useMemo(() => (
<Dropdown>
<MenuButton
aria-label='View options'
slots={{ root: IconButton }}
slotProps={{ root: { size: 'sm' } }}
>
<MoreVertIcon sx={{ fontSize: 'xl' }} />
</MenuButton>
<Menu placement='bottom-start' sx={{ minWidth: 180, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
<ListItem>
<Typography level='body-sm'>Group By</Typography>
</ListItem>
{(['date', 'persona'] as const).map(_gName => (
<MenuItem
key={'group-' + _gName}
aria-label={`Group by ${_gName}`}
selected={navGrouping === _gName}
onClick={() => setNavGrouping(grouping => grouping === _gName ? false : _gName)}
>
<ListItemDecorator>{navGrouping === _gName && <CheckIcon />}</ListItemDecorator>
{capitalizeFirstLetter(_gName)}
</MenuItem>
))}
<ListDivider />
<ListItem>
<Typography level='body-sm'>Show</Typography>
</ListItem>
<MenuItem onClick={toggleRelativeSize}>
<ListItemDecorator>{showRelativeSize && <CheckIcon />}</ListItemDecorator>
Relative Size
</MenuItem>
</Menu>
</Dropdown>
), [navGrouping, showRelativeSize, toggleRelativeSize]);
return <>
{/* Drawer Header */}
<PageDrawerHeader title='Chats' onClose={closeDrawer}>
<Tooltip title={enableFolders ? 'Hide Folders' : 'Use Folders'}>
<IconButton onClick={toggleEnableFolders}>
{enableFolders ? <FoldersToggleOn /> : <FoldersToggleOff />}
</IconButton>
</Tooltip>
</PageDrawerHeader>
{/* Folders List */}
{/*<Box sx={{*/}
{/* display: 'grid',*/}
{/* gridTemplateRows: !enableFolders ? '0fr' : '1fr',*/}
{/* transition: 'grid-template-rows 0.42s cubic-bezier(.17,.84,.44,1)',*/}
{/* '& > div': {*/}
{/* padding: enableFolders ? 2 : 0,*/}
{/* transition: 'padding 0.42s cubic-bezier(.17,.84,.44,1)',*/}
{/* overflow: 'hidden',*/}
{/* },*/}
{/*}}>*/}
{enableFolders && (
<ChatFolderList
folders={allFolders}
contentScaling={contentScaling}
activeFolderId={props.activeFolderId}
onFolderSelect={props.setActiveFolderId}
/>
)}
{/*</Box>*/}
{/* Chats List */}
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
{/* Search Input Field */}
<DebounceInputMemo
minChars={2}
onDebounce={setDebouncedSearchQuery}
debounceTimeout={300}
placeholder='Search...'
aria-label='Search'
endDecorator={groupingComponent}
sx={{ m: 2 }}
/>
{/* New Chat Button */}
<ListItem sx={{ mx: '0.25rem', mb: 0.5 }}>
<ListItemButton
// variant='outlined'
variant={disableNewButton ? undefined : 'outlined'}
disabled={disableNewButton}
onClick={handleButtonNew}
sx={{
// ...PageDrawerTallItemSx,
px: 'calc(var(--ListItem-paddingX) - 0.25rem)',
// text size
fontSize: 'sm',
fontWeight: 'lg',
// style
borderRadius: 'md',
boxShadow: (disableNewButton || props.isMobile) ? 'none' : 'sm',
backgroundColor: 'background.popup',
transition: 'box-shadow 0.2s',
}}
>
<ListItemDecorator><AddIcon sx={{ '--Icon-fontSize': 'var(--joy-fontSize-xl)', pl: '0.125rem' }} /></ListItemDecorator>
New chat
</ListItemButton>
</ListItem>
{/*<ListDivider sx={{ mt: 0 }} />*/}
{/* List of Chat Titles (and actions) */}
<Box sx={{ flex: 1, overflowY: 'auto', ...themeScalingMap[contentScaling].chatDrawerItemSx }}>
{/*<ListItem sticky sx={{ justifyContent: 'space-between', boxShadow: 'sm' }}>*/}
{/* <Typography level='body-sm'>*/}
{/* Conversations*/}
{/* </Typography>*/}
{/* <ToggleButtonGroup variant='soft' size='sm' value={grouping} onChange={(_event, newValue) => newValue && setGrouping(newValue)}>*/}
{/* <IconButton value='off'>*/}
{/* <AccessTimeIcon />*/}
{/* </IconButton>*/}
{/* <IconButton value='persona'>*/}
{/* <PersonIcon />*/}
{/* </IconButton>*/}
{/* </ToggleButtonGroup>*/}
{/*</ListItem>*/}
{renderNavItems.map((item, idx) => item.type === 'nav-item-chat-data' ? (
<ChatDrawerItemMemo
key={'nav-chat-' + item.conversationId}
item={item}
showSymbols={showSymbols}
bottomBarBasis={filteredChatsBarBasis}
onConversationActivate={handleConversationActivate}
onConversationBranch={onConversationBranch}
onConversationDelete={handleConversationDeleteNoConfirmation}
onConversationExport={onConversationsExportDialog}
onConversationFolderChange={handleConversationFolderChange}
/>
) : item.type === 'nav-item-group' ? (
<Typography key={'nav-divider-' + idx} level='body-xs' sx={{ textAlign: 'center', my: 'calc(var(--ListItem-minHeight) / 4)' }}>
{item.title}
</Typography>
) : item.type === 'nav-item-info-message' ? (
<Typography key={'nav-info-' + idx} level='body-xs' sx={{ textAlign: 'center', my: 'calc(var(--ListItem-minHeight) / 2)' }}>
{item.message}
</Typography>
) : null,
)}
</Box>
<ListDivider sx={{ my: 0 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ListItemButton onClick={props.onConversationsImportDialog} sx={{ flex: 1 }}>
<ListItemDecorator>
<FileUploadOutlinedIcon />
</ListItemDecorator>
Import
{/*<OpenAIIcon sx={{ ml: 'auto' }} />*/}
</ListItemButton>
<ListItemButton disabled={filteredChatsAreEmpty} onClick={handleConversationsExport} sx={{ flex: 1 }}>
<ListItemDecorator>
<FileDownloadOutlinedIcon />
</ListItemDecorator>
Export
</ListItemButton>
</Box>
<ListItemButton disabled={filteredChatsAreEmpty} onClick={handleConversationsDeleteFiltered}>
<ListItemDecorator>
<DeleteOutlineIcon />
</ListItemDecorator>
Delete {filteredChatsCount >= 2 ? `all ${filteredChatsCount} chats` : 'chat'}
</ListItemButton>
</PageDrawerList>
{/* [Menu] Chat Item Folder Change */}
{!!folderChangeRequest?.anchorEl && (
<CloseableMenu
bigIcons
open anchorEl={folderChangeRequest.anchorEl} onClose={handleConversationFolderCancel}
placement='bottom-start'
zIndex={themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
{/* Folder Assignment Buttons */}
{allFolders.map(folder => {
const isRequestFolder = folder === folderChangeRequest.currentFolder;
return (
<ListItem
key={folder.id}
variant={isRequestFolder ? 'soft' : 'plain'}
onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, folder.id)}
>
<ListItemButton>
<ListItemDecorator>
<FolderIcon sx={{ color: folder.color }} />
</ListItemDecorator>
{folder.title}
</ListItemButton>
</ListItem>
);
})}
{/* Remove Folder Assignment */}
{!!folderChangeRequest.currentFolder && (
<ListItem onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, null)}>
<ListItemButton>
<ListItemDecorator>
<ClearIcon />
</ListItemDecorator>
{ClearFolderText}
</ListItemButton>
</ListItem>
)}
</CloseableMenu>
)}
</>;
}
@@ -1,22 +1,26 @@
import * as React from 'react';
import { Avatar, Box, Divider, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
import { Avatar, Box, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import CloseIcon from '@mui/icons-material/Close';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditIcon from '@mui/icons-material/Edit';
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import type { DFolder } from '~/common/state/store-folders';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { isDeepEqual } from '~/common/util/jsUtils';
import { CHAT_NOVEL_TITLE } from '../AppChat';
// set to true to display the conversation IDs
@@ -30,18 +34,31 @@ export const FadeInButton = styled(IconButton)({
});
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem);
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem, (prev, next) =>
// usign a custom function because `ChatNavigationItemData` is a complex object and memo won't work
isDeepEqual(prev.item, next.item) &&
prev.showSymbols === next.showSymbols &&
prev.bottomBarBasis === next.bottomBarBasis &&
prev.onConversationActivate === next.onConversationActivate &&
prev.onConversationBranch === next.onConversationBranch &&
prev.onConversationDelete === next.onConversationDelete &&
prev.onConversationExport === next.onConversationExport &&
prev.onConversationFolderChange === next.onConversationFolderChange,
);
export interface ChatNavigationItemData {
type: 'nav-item-chat-data',
conversationId: DConversationId;
isActive: boolean;
isAlsoOpen: string | false;
isEmpty: boolean;
title: string;
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
updatedAt: number;
messageCount: number;
assistantTyping: boolean;
systemPurposeId: SystemPurposeId;
searchFrequency?: number;
searchFrequency: number;
}
export interface FolderChangeRequest {
@@ -51,23 +68,25 @@ export interface FolderChangeRequest {
}
function ChatDrawerItem(props: {
// NOTE: always update the Memo comparison if you add or remove props
item: ChatNavigationItemData,
isLonely: boolean,
showSymbols: boolean,
bottomBarBasis: number,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationDelete: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId, exportAll: boolean) => void,
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
}) {
// state
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [isAutoEditingTitle, setIsAutoEditingTitle] = React.useState(false);
const [deleteArmed, setDeleteArmed] = React.useState(false);
// derived state
const { onConversationExport, onConversationFolderChange } = props;
const { conversationId, isActive, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const { onConversationBranch, onConversationExport, onConversationFolderChange } = props;
const { conversationId, isActive, isAlsoOpen, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const isNew = messageCount === 0;
@@ -84,11 +103,19 @@ function ChatDrawerItem(props: {
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
// branch
const handleConversationBranch = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
conversationId && onConversationBranch(conversationId, null);
}, [conversationId, onConversationBranch]);
// export
const handleConversationExport = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
conversationId && onConversationExport(conversationId);
conversationId && onConversationExport(conversationId, false);
}, [conversationId, onConversationExport]);
@@ -117,8 +144,10 @@ function ChatDrawerItem(props: {
useChatStore.getState().setUserTitle(conversationId, text.trim());
}, [conversationId]);
const handleTitleEditAuto = React.useCallback(() => {
conversationAutoTitle(conversationId, true);
const handleTitleEditAuto = React.useCallback(async () => {
setIsAutoEditingTitle(true);
await conversationAutoTitle(conversationId, true);
setIsAutoEditingTitle(false);
}, [conversationId]);
@@ -139,8 +168,7 @@ function ChatDrawerItem(props: {
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
const progress = props.bottomBarBasis ? 100 * (searchFrequency ?? messageCount) / props.bottomBarBasis : 0;
const progress = props.bottomBarBasis ? 100 * (searchFrequency || messageCount) / props.bottomBarBasis : 0;
const titleRowComponent = React.useMemo(() => <>
@@ -158,16 +186,17 @@ function ChatDrawerItem(props: {
}}
/>
) : (
<Typography>
{isNew ? '' : textSymbol}
<Typography sx={isNew ? { opacity: 0.4, filter: 'grayscale(0.75)' } : undefined}>
{/*{isNew ? '' : textSymbol}*/}
{textSymbol}
</Typography>
)}
</ListItemDecorator>}
{/* Title */}
{!isEditingTitle ? (
<Typography
// level={isActive ? 'title-md' : 'body-md'}
// using Box to not reset the parent font scaling
<Box
onDoubleClick={handleTitleEditBegin}
sx={{
color: isActive ? 'text.primary' : 'text.secondary',
@@ -175,8 +204,8 @@ function ChatDrawerItem(props: {
}}
>
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
{title.trim() ? title : 'Chat'}{assistantTyping && '...'}
</Typography>
{title.trim() ? title : CHAT_NOVEL_TITLE}{assistantTyping && '...'}
</Box>
) : (
<InlineTextarea
invertedColors
@@ -191,7 +220,7 @@ function ChatDrawerItem(props: {
)}
{/* Display search frequency if it exists and is greater than 0 */}
{searchFrequency && searchFrequency > 0 && (
{searchFrequency > 0 && (
<Box sx={{ ml: 1 }}>
<Typography level='body-sm'>
{searchFrequency}
@@ -204,25 +233,33 @@ function ChatDrawerItem(props: {
const progressBarFixedComponent = React.useMemo(() =>
progress > 0 && (
<Box sx={{
backgroundColor: 'neutral.softBg',
backgroundColor: 'neutral.softHoverBg',
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
}} />
), [progress]);
return (isActive || isAlsoOpen) ? (
return isActive ? (
// Active Conversation
// Active or Also Open
<Sheet
variant={isActive ? 'solid' : 'plain'}
variant={isActive ? 'solid' : 'outlined'}
invertedColors={isActive}
onClick={!isActive ? handleConversationActivate : undefined}
sx={{
// common
// position: 'relative', // for the progress bar (now disabled)
'--ListItem-minHeight': '2.75rem',
position: 'relative', // for the progress bar
// '--variant-borderWidth': '0.125rem',
border: 'none', // there's a default border of 1px and invisible.. hmm
// differences between primary and secondary variants
...(isActive ? {
border: 'none', // there's a default border of 1px and invisible.. hmm
} : {
// '--variant-borderWidth': '0.125rem',
cursor: 'pointer',
}),
// style
backgroundColor: isActive ? 'neutral.solidActiveBg' : 'neutral.softBg',
borderRadius: 'md',
mx: '0.25rem',
'&:hover > button': {
@@ -235,64 +272,68 @@ function ChatDrawerItem(props: {
{/* Title row */}
<Box sx={{ display: 'flex', gap: 'var(--ListItem-gap)', minHeight: '2.25rem', alignItems: 'center' }}>
{titleRowComponent}
</Box>
{/* buttons row */}
<Box sx={{ display: 'flex', gap: 1, minHeight: '2.25rem', alignItems: 'center' }}>
{isActive && (
<Box sx={{ display: 'flex', gap: 0.5, minHeight: '2.25rem', alignItems: 'center' }}>
<ListItemDecorator />
<ListItemDecorator />
{/* Current Folder color, and change initiator */}
{!deleteArmed && <>
{(folder !== undefined) && <>
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
{folder ? (
<IconButton size='sm' onClick={handleFolderChangeBegin}>
<FolderIcon style={{ color: folder.color || 'inherit' }} />
</IconButton>
) : (
<FadeInButton size='sm' onClick={handleFolderChangeBegin}>
<FolderOutlinedIcon />
</FadeInButton>
)}
</Tooltip>
{/* Current Folder color, and change initiator */}
{(folder !== undefined) && <>
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
{folder ? (
<IconButton size='sm' onClick={handleFolderChangeBegin}>
<FolderIcon style={{ color: folder.color || 'inherit' }} />
</IconButton>
) : (
<FadeInButton size='sm' onClick={handleFolderChangeBegin}>
<FolderOutlinedIcon />
{/*<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />*/}
</>}
<Tooltip disableInteractive title='Rename'>
<FadeInButton size='sm' disabled={isEditingTitle || isAutoEditingTitle} onClick={handleTitleEditBegin}>
<EditIcon />
</FadeInButton>
)}
</Tooltip>
</Tooltip>
<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />
</>}
{!isNew && <>
<Tooltip disableInteractive title='Auto-Title'>
<FadeInButton size='sm' disabled={isEditingTitle || isAutoEditingTitle} onClick={handleTitleEditAuto}>
<AutoFixHighIcon />
</FadeInButton>
</Tooltip>
<Tooltip disableInteractive title='Rename'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditBegin}>
<EditIcon />
</FadeInButton>
</Tooltip>
<Tooltip disableInteractive title='Branch'>
<FadeInButton size='sm' onClick={handleConversationBranch}>
<ForkRightIcon />
</FadeInButton>
</Tooltip>
{!isNew && <>
<Tooltip disableInteractive title='Auto-Title'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
<AutoFixHighIcon />
</FadeInButton>
</Tooltip>
<Tooltip disableInteractive title='Export Chat'>
<FadeInButton size='sm' onClick={handleConversationExport}>
<FileDownloadOutlinedIcon />
</FadeInButton>
</Tooltip>
</>}
<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />
</>}
<Tooltip disableInteractive title='Export'>
<FadeInButton size='sm' onClick={handleConversationExport}>
<FileDownloadOutlinedIcon />
</FadeInButton>
</Tooltip>
</>}
{/* --> */}
<Box sx={{ flex: 1 }} />
{/* --> */}
<Box sx={{ flex: 1 }} />
{/* Delete [armed, arming] buttons */}
{!props.isLonely && !searchFrequency && <>
{/* Delete [armed, arming] buttons */}
{/*{!searchFrequency && <>*/}
{deleteArmed && (
<Tooltip disableInteractive title='Confirm Deletion'>
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1 }}>
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1, mr: 0.5 }}>
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
</FadeInButton>
</Tooltip>
@@ -300,24 +341,34 @@ function ChatDrawerItem(props: {
<Tooltip disableInteractive title={deleteArmed ? 'Cancel Delete' : 'Delete'}>
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
{deleteArmed ? <CloseIcon /> : <DeleteOutlineIcon />}
{deleteArmed ? <CloseRoundedIcon /> : <DeleteOutlineIcon />}
</FadeInButton>
</Tooltip>
</>}
{/*</>}*/}
</Box>
)}
</Box>
{/* View places row */}
{isAlsoOpen && (
<Typography level='body-xs' sx={{ mx: 'auto' }}>
<em>In view {isAlsoOpen}</em>
</Typography>
)}
</ListItem>
{/* Optional progress bar, underlay */}
{progressBarFixedComponent}
{/* NOTE: disabled on 20240204: quite distracting on the active chat sheet */}
{/*{progressBarFixedComponent}*/}
</Sheet>
) : (
// Inactive Conversation - click to activate
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
<ListItem
// sx={{ '--ListItem-minHeight': '2.75rem' }}
>
<ListItemButton
onClick={handleConversationActivate}
@@ -1,20 +1,14 @@
import * as React from 'react';
import { IconButton } from '@mui/joy';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import type { DConversationId } from '~/common/state/store-chats';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { useChatLLMDropdown } from './useLLMDropdown';
import { usePersonaIdDropdown } from './usePersonaDropdown';
import { useFolderDropdown } from './folder/useFolderDropdown';
import { useFolderDropdown } from './folders/useFolderDropdown';
export function ChatDropdowns(props: {
conversationId: DConversationId | null
isSplitPanes: boolean
onToggleSplitPanes: () => void
}) {
// state
@@ -22,9 +16,6 @@ export function ChatDropdowns(props: {
const { personaDropdown } = usePersonaIdDropdown(props.conversationId);
const { folderDropdown } = useFolderDropdown(props.conversationId);
// external state
const labsSplitBranching = useUXLabsStore(state => state.labsSplitBranching);
return <>
{/* Persona selector */}
@@ -36,16 +27,5 @@ export function ChatDropdowns(props: {
{/* Folder selector */}
{folderDropdown}
{/* Split Panes button */}
{labsSplitBranching && <IconButton
variant={props.isSplitPanes ? 'solid' : undefined}
onClick={props.onToggleSplitPanes}
// sx={{
// ml: 'auto',
// }}
>
<VerticalSplitIcon />
</IconButton>}
</>;
}
+65 -35
View File
@@ -6,14 +6,18 @@ import { SxProps } from '@mui/joy/styles/types';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
import { InlineError } from '~/common/components/InlineError';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating';
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useEphemerals } from '~/common/chats/EphemeralsStore';
import { ChatMessageMemo } from './message/ChatMessage';
import { ChatMessage, ChatMessageMemo } from './message/ChatMessage';
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
import { Ephemerals } from './Ephemerals';
import { PersonaSelector } from './persona-selector/PersonaSelector';
import { useChatShowSystemMessages } from '../store-app-chat';
import { useScrollToBottom } from './scroll-to-bottom/useScrollToBottom';
@@ -24,14 +28,17 @@ import { useScrollToBottom } from './scroll-to-bottom/useScrollToBottom';
*/
export function ChatMessageList(props: {
conversationId: DConversationId | null,
conversationHandler: ConversationHandler | null,
capabilityHasT2I: boolean,
chatLLMContextTokens: number | null,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
fitScreen: boolean,
isMessageSelectionMode: boolean,
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[], chatEffectBeam: boolean) => Promise<void>,
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
onTextSpeak: (selectedText: string) => Promise<void>,
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
sx?: SxProps,
}) {
@@ -44,6 +51,7 @@ export function ChatMessageList(props: {
const { notifyBooting } = useScrollToBottom();
const { openPreferencesTab } = useOptimaLayout();
const [showSystemMessages] = useChatShowSystemMessages();
const optionalTranslationWarning = useBrowserTranslationWarning();
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
@@ -54,6 +62,7 @@ export function ChatMessageList(props: {
setMessages: state.setMessages,
};
}, shallow);
const ephemerals = useEphemerals(props.conversationHandler);
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
// derived state
@@ -63,7 +72,7 @@ export function ChatMessageList(props: {
// text actions
const handleRunExample = React.useCallback(async (text: string) => {
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)], false);
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
@@ -73,11 +82,11 @@ export function ChatMessageList(props: {
conversationId && onConversationBranch(conversationId, messageId);
}, [conversationId, onConversationBranch]);
const handleConversationRestartFrom = React.useCallback(async (messageId: string, offset: number) => {
const handleConversationRestartFrom = React.useCallback(async (messageId: string, offset: number, chatEffectBeam: boolean) => {
const messages = getConversation(conversationId)?.messages;
if (messages) {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
conversationId && await onConversationExecuteHistory(conversationId, truncatedHistory);
conversationId && await onConversationExecuteHistory(conversationId, truncatedHistory, chatEffectBeam);
}
}, [conversationId, onConversationExecuteHistory]);
@@ -148,17 +157,17 @@ export function ChatMessageList(props: {
});
// text-diff functionality, find the messages to diff with
// text-diff functionality: only diff the last message and when it's complete (not typing), and they're similar in size
const { diffMessage, diffText } = React.useMemo(() => {
const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
if (msgB?.text && msgA?.text && !msgB?.typing) {
const textA = msgA.text, textB = msgB.text;
const lenA = textA.length, lenB = textB.length;
if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
return { diffMessage: msgB, diffText: textA };
return { diffTargetMessage: msgB, diffPrevText: textA };
}
return { diffMessage: undefined, diffText: undefined };
return { diffTargetMessage: undefined, diffPrevText: undefined };
}, [conversationMessages]);
@@ -194,6 +203,8 @@ export function ChatMessageList(props: {
// marginBottom: '-1px',
}}>
{optionalTranslationWarning}
{props.isMessageSelectionMode && (
<MessagesSelectionHeader
hasSelected={selectedMessages.size > 0}
@@ -204,35 +215,54 @@ export function ChatMessageList(props: {
/>
)}
{filteredMessages.map((message, idx, { length: count }) =>
props.isMessageSelectionMode ? (
{filteredMessages.map((message, idx, { length: count }) => {
<CleanerMessage
key={'sel-' + message.id}
message={message}
remainingTokens={props.chatLLMContextTokens ? (props.chatLLMContextTokens - historyTokenCount) : undefined}
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
/>
// Optimization: if the component is going to change (e.g. the message is typing), we don't want to memoize it to not throw garbage in memory
const ChatMessageMemoOrNot = message.typing ? ChatMessage : ChatMessageMemo;
) : (
return props.isMessageSelectionMode ? (
<ChatMessageMemo
key={'msg-' + message.id}
message={message}
diffPreviousText={message === diffMessage ? diffText : undefined}
isBottom={idx === count - 1}
isImagining={isImagining} isSpeaking={isSpeaking}
onConversationBranch={handleConversationBranch}
onConversationRestartFrom={handleConversationRestartFrom}
onConversationTruncate={handleConversationTruncate}
onMessageDelete={handleMessageDelete}
onMessageEdit={handleMessageEdit}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
/>
<CleanerMessage
key={'sel-' + message.id}
message={message}
remainingTokens={props.chatLLMContextTokens ? (props.chatLLMContextTokens - historyTokenCount) : undefined}
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
/>
),
) : (
<ChatMessageMemoOrNot
key={'msg-' + message.id}
message={message}
diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
fitScreen={props.fitScreen}
isBottom={idx === count - 1}
isImagining={isImagining}
isSpeaking={isSpeaking}
onConversationBranch={handleConversationBranch}
onConversationRestartFrom={handleConversationRestartFrom}
onConversationTruncate={handleConversationTruncate}
onMessageDelete={handleMessageDelete}
onMessageEdit={handleMessageEdit}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
/>
);
},
)}
{!!ephemerals.length && (
<Ephemerals
ephemerals={ephemerals}
conversationId={props.conversationId}
sx={{
mt: 'auto',
overflowY: 'auto',
minHeight: 64,
}}
/>
)}
</List>
@@ -0,0 +1,151 @@
import * as React from 'react';
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem, Switch, Tooltip } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlineBlankOutlined';
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
import ClearIcon from '@mui/icons-material/Clear';
import CompressIcon from '@mui/icons-material/Compress';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import HorizontalSplitIcon from '@mui/icons-material/HorizontalSplit';
import HorizontalSplitOutlinedIcon from '@mui/icons-material/HorizontalSplitOutlined';
import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutlined';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import VerticalSplitOutlinedIcon from '@mui/icons-material/VerticalSplitOutlined';
import type { DConversationId } from '~/common/state/store-chats';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useChatShowSystemMessages } from '../store-app-chat';
import { usePaneDuplicateOrClose } from './panes/usePanesManager';
export function ChatPageMenuItems(props: {
isMobile: boolean,
conversationId: DConversationId | null,
disableItems: boolean,
hasConversations: boolean,
isMessageSelectionMode: boolean,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationClear: (conversationId: DConversationId) => void,
onConversationFlatten: (conversationId: DConversationId) => void,
// onConversationNew: (forceNoRecycle: boolean) => void,
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
}) {
// external state
const { closePageMenu } = useOptimaDrawers();
const { canAddPane, isMultiPane, duplicateFocusedPane, removeOtherPanes } = usePaneDuplicateOrClose();
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
const handleIncreaseMultiPane = React.useCallback((event?: React.MouseEvent) => {
event?.stopPropagation();
// create a new pane with the current conversation
duplicateFocusedPane();
// load a brand new conversation inside
// FIXME: still testing this
// props.onConversationNew(true);
}, [duplicateFocusedPane]);
const handleToggleMultiPane = React.useCallback((_event: React.MouseEvent) => {
if (isMultiPane)
removeOtherPanes();
else
handleIncreaseMultiPane(undefined);
}, [handleIncreaseMultiPane, isMultiPane, removeOtherPanes]);
const closeMenu = (event: React.MouseEvent) => {
event.stopPropagation();
closePageMenu();
};
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationClear(props.conversationId);
};
const handleConversationBranch = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationBranch(props.conversationId, null);
};
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationFlatten(props.conversationId);
};
const handleToggleMessageSelectionMode = (event: React.MouseEvent) => {
closeMenu(event);
props.setIsMessageSelectionMode(!props.isMessageSelectionMode);
};
const handleToggleSystemMessages = () => setShowSystemMessages(!showSystemMessages);
return <>
{/* System Message(s) */}
<MenuItem onClick={handleToggleSystemMessages}>
<ListItemDecorator><SettingsSuggestOutlinedIcon /></ListItemDecorator>
System messages
<Switch checked={showSystemMessages} onChange={handleToggleSystemMessages} sx={{ ml: 'auto' }} />
</MenuItem>
{/* Un /Split */}
<MenuItem onClick={handleToggleMultiPane}>
<ListItemDecorator>{props.isMobile
? (isMultiPane ? <HorizontalSplitIcon /> : <HorizontalSplitOutlinedIcon />)
: (isMultiPane ? <VerticalSplitIcon /> : <VerticalSplitOutlinedIcon />)
}</ListItemDecorator>
{/* Unsplit / Split text*/}
{isMultiPane ? 'Unsplit' : props.isMobile ? 'Split Down' : 'Split Right'}
{/* '+' */}
{isMultiPane && (
<Tooltip title='Add Another Split'>
<IconButton
size='sm'
variant='outlined'
disabled={!canAddPane}
onClick={handleIncreaseMultiPane}
sx={{ ml: 'auto', /*mr: '2px',*/ my: '-0.25rem' /* absorb the menuItem padding */ }}
>
<AddIcon />
</IconButton>
</Tooltip>
)}
</MenuItem>
<MenuItem disabled={props.disableItems} onClick={handleConversationBranch}>
<ListItemDecorator><ForkRightIcon /></ListItemDecorator>
Branch
</MenuItem>
<ListDivider />
<MenuItem disabled={props.disableItems} onClick={handleToggleMessageSelectionMode} sx={props.isMessageSelectionMode ? { fontWeight: 'lg' } : {}}>
<ListItemDecorator>{props.isMessageSelectionMode ? <CheckBoxOutlinedIcon /> : <CheckBoxOutlineBlankOutlinedIcon />}</ListItemDecorator>
Cleanup ...
</MenuItem>
<MenuItem disabled={props.disableItems} onClick={handleConversationFlatten}>
<ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>
Compress ...
</MenuItem>
<ListDivider />
<MenuItem disabled={props.disableItems} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Reset Chat
{!props.disableItems && <KeyStroke combo='Ctrl + Alt + X' />}
</Box>
</MenuItem>
</>;
}
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react';
import { Box, Typography } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import type { DConversationId } from '~/common/state/store-chats';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { CHAT_NOVEL_TITLE } from '../AppChat';
import { FadeInButton } from './ChatDrawerItem';
export function ChatTitle(props: {
conversationId: DConversationId | null,
conversationTitle: string,
}) {
// state
const [isEditingTitle, setIsEditingTitle] = React.useState<boolean>(false);
// derived state
const { conversationId, conversationTitle } = props;
const hasConversation = !!conversationId;
const handleTitleEditAuto = React.useCallback(async () => {
if (!conversationId) return;
setIsEditingTitle(true);
await conversationAutoTitle(conversationId, true);
setIsEditingTitle(false);
}, [conversationId]);
return (
<Box sx={{ display: 'flex', gap: { xs: 1, md: 3 }, alignItems: 'center' }}>
<Typography>
{capitalizeFirstLetter(conversationTitle?.trim() || CHAT_NOVEL_TITLE)}
</Typography>
{hasConversation && (
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
<AutoFixHighIcon />
</FadeInButton>
)}
</Box>
);
}
+23 -14
View File
@@ -1,12 +1,13 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Grid, IconButton, Sheet, styled, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import CloseIcon from '@mui/icons-material/Close';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
import { lineHeightChatText } from '~/common/app.theme';
import { ConversationManager } from '~/common/chats/ConversationHandler';
import { DConversationId } from '~/common/state/store-chats';
import { DEphemeral } from '~/common/chats/EphemeralsStore';
import { lineHeightChatTextMd } from '~/common/app.theme';
const StateLine = styled(Typography)(({ theme }) => ({
@@ -16,7 +17,7 @@ const StateLine = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSize.xs,
fontFamily: theme.fontFamily.code,
marginLeft: theme.spacing(1),
lineHeight: lineHeightChatText,
lineHeight: lineHeightChatTextMd,
}));
function isPrimitive(value: any): boolean {
@@ -75,6 +76,11 @@ function StateRenderer(props: { state: object }) {
function EphemeralItem({ conversationId, ephemeral }: { conversationId: string, ephemeral: DEphemeral }) {
const handleDelete = React.useCallback(() => {
ConversationManager.getHandler(conversationId).ephemeralsStore.delete(ephemeral.id);
}, [conversationId, ephemeral.id]);
return <Box
sx={{
p: { xs: 1, md: 2 },
@@ -93,7 +99,7 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
{/* Left pane (console) */}
<Grid xs={12} md={ephemeral.state ? 6 : 12}>
<Typography fontSize='smaller' sx={{ overflowWrap: 'anywhere', whiteSpace: 'break-spaces', lineHeight: lineHeightChatText }}>
<Typography fontSize='smaller' sx={{ overflowWrap: 'anywhere', whiteSpace: 'break-spaces', lineHeight: lineHeightChatTextMd }}>
{ephemeral.text}
</Typography>
</Grid>
@@ -112,12 +118,12 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
{/* Close button (right of title) */}
<IconButton
size='sm'
onClick={() => useChatStore.getState().deleteEphemeral(conversationId, ephemeral.id)}
onClick={handleDelete}
sx={{
position: 'absolute', top: 8, right: 8,
opacity: { xs: 1, sm: 0.5 }, transition: 'opacity 0.3s',
}}>
<CloseIcon />
<CloseRoundedIcon />
</IconButton>
</Box>;
@@ -130,19 +136,22 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
// `);
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
export function Ephemerals(props: { ephemerals: DEphemeral[], conversationId: DConversationId | null, sx?: SxProps }) {
// global state
const ephemerals = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return conversation ? conversation.ephemerals : [];
}, shallow);
// const ephemerals = useChatStore(state => {
// const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
// return conversation ? conversation.ephemerals : [];
// }, shallow);
if (!ephemerals?.length) return null;
const ephemerals = props.ephemerals;
// if (!ephemerals?.length) return null;
return (
<Sheet
variant='soft' color='success' invertedColors
sx={{
borderTop: '1px solid',
borderTopColor: 'divider',
// backgroundImage: `url("data:image/svg+xml,${dashedBorderSVG.replace('currentColor', '%23A1E8A1')}")`,
// backgroundSize: '100% 100%',
// backgroundRepeat: 'no-repeat',
@@ -1,367 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import DebounceInput from '~/common/components/DebounceInput';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
import { PageDrawerList, PageDrawerTallItemSx } from '~/common/layout/optima/components/PageDrawerList';
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
import { ChatFolderList } from './folder/ChatFolderList';
import { ClearFolderText } from './folder/useFolderDropdown';
// this is here to make shallow comparisons work on the next hook
const noFolders: DFolder[] = [];
/*
* Lists folders and returns the active folder
*/
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders, toggleEnableFolders }) => {
// finds the active folder if any
const activeFolder = (enableFolders && activeFolderId)
? folders.find(folder => folder.id === activeFolderId) ?? null
: null;
return {
activeFolder,
allFolders: enableFolders ? folders : noFolders,
enableFolders,
toggleEnableFolders,
};
}, shallow);
/*
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
* to avoid unnecessary re-renders on each new character typed by the assistant
*/
export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
useChatStore(({ conversations }) => {
const activeConversations = activeFolder
? conversations.filter(_c => activeFolder.conversationIds.includes(_c.id))
: conversations;
return activeConversations.map((_c): ChatNavigationItemData => ({
conversationId: _c.id,
isActive: _c.id === activeConversationId,
isEmpty: !_c.messages.length && !_c.userTitle,
title: conversationTitle(_c),
folder: !allFolders.length
? undefined // don't show folder select if folders are disabled
: _c.id === activeConversationId // only show the folder for active conversation(s)
? allFolders.find(folder => folder.conversationIds.includes(_c.id)) ?? null
: null,
messageCount: _c.messages.length,
assistantTyping: !!_c.abortController,
systemPurposeId: _c.systemPurposeId,
}));
}, (a, b) => {
// custom equality function to avoid unnecessary re-renders
return a.length === b.length && a.every((_a, i) => shallow(_a, b[i]));
});
export const ChatDrawerMemo = React.memo(ChatDrawer);
function ChatDrawer(props: {
activeConversationId: DConversationId | null,
activeFolderId: string | null,
disableNewButton: boolean,
onConversationActivate: (conversationId: DConversationId) => void,
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
onConversationExportDialog: (conversationId: DConversationId | null) => void,
onConversationImportDialog: () => void,
onConversationNew: () => void,
onConversationsDeleteAll: () => void,
setActiveFolderId: (folderId: string | null) => void,
}) {
const { onConversationActivate, onConversationDelete, onConversationExportDialog, onConversationNew } = props;
// local state
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
// external state
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId);
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
// derived state
const selectConversationsCount = chatNavItems.length;
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
const singleChat = selectConversationsCount === 1;
const softMaxReached = selectConversationsCount >= 10;
const handleButtonNew = React.useCallback(() => {
onConversationNew();
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationNew]);
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
onConversationActivate(conversationId);
if (closeMenu)
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationActivate]);
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
!singleChat && conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete, singleChat]);
// Folder change request
const handleConversationFolderChange = React.useCallback((folderChangeRequest: FolderChangeRequest) => setFolderChangeRequest(folderChangeRequest), []);
const handleConversationFolderCancel = React.useCallback(() => setFolderChangeRequest(null), []);
const handleConversationFolderSet = React.useCallback((conversationId: DConversationId, nextFolderId: string | null) => {
// Remove conversation from existing folders
const { addConversationToFolder, folders, removeConversationFromFolder } = useFolderStore.getState();
folders.forEach(folder => folder.conversationIds.includes(conversationId) && removeConversationFromFolder(folder.id, conversationId));
// Add conversation to the selected folder
nextFolderId && addConversationToFolder(nextFolderId, conversationId);
// Close the menu
setFolderChangeRequest(null);
}, []);
// Filter chatNavItems based on the search query and rank them by search frequency
const filteredChatNavItems = React.useMemo(() => {
if (!debouncedSearchQuery) return chatNavItems;
return chatNavItems
.map(item => {
// Get the conversation by ID
const conversation = useChatStore.getState().conversations.find(c => c.id === item.conversationId);
// Calculate the frequency of the search term in the title and messages
const titleFrequency = (item.title.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
const messageFrequency = conversation?.messages.reduce((count, message) => {
return count + (message.text.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
}, 0) || 0;
// Return the item with the searchFrequency property
return {
...item,
searchFrequency: titleFrequency + messageFrequency,
};
})
// Exclude items with a searchFrequency of 0
.filter(item => item.searchFrequency > 0)
// Sort the items by searchFrequency in descending order
.sort((a, b) => b.searchFrequency! - a.searchFrequency!);
}, [chatNavItems, debouncedSearchQuery]);
// basis for the underline bar
const bottomBarBasis = filteredChatNavItems.reduce((longest, _c) => Math.max(longest, _c.searchFrequency ?? _c.messageCount), 1);
// grouping
/*let sortedIds = conversationIDs;
if (grouping === 'persona') {
const conversations = useChatStore.getState().conversations;
// group conversations by persona
const groupedConversations: { [personaId: string]: string[] } = {};
conversations.forEach(conversation => {
const persona = conversation.systemPurposeId;
if (persona) {
if (!groupedConversations[persona])
groupedConversations[persona] = [];
groupedConversations[persona].push(conversation.id);
}
});
// flatten grouped conversations
sortedIds = Object.values(groupedConversations).flat();
}*/
return <>
{/* Drawer Header */}
<PageDrawerHeader title='Chats' onClose={closeDrawer}>
<Tooltip title={enableFolders ? 'Hide Folders' : 'Use Folders'}>
<IconButton onClick={toggleEnableFolders}>
{enableFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
</IconButton>
</Tooltip>
</PageDrawerHeader>
{/* Folders List */}
{/*<Box sx={{*/}
{/* display: 'grid',*/}
{/* gridTemplateRows: !enableFolders ? '0fr' : '1fr',*/}
{/* transition: 'grid-template-rows 0.42s cubic-bezier(.17,.84,.44,1)',*/}
{/* '& > div': {*/}
{/* padding: enableFolders ? 2 : 0,*/}
{/* transition: 'padding 0.42s cubic-bezier(.17,.84,.44,1)',*/}
{/* overflow: 'hidden',*/}
{/* },*/}
{/*}}>*/}
{enableFolders && (
<ChatFolderList
folders={allFolders}
activeFolderId={props.activeFolderId}
onFolderSelect={props.setActiveFolderId}
/>
)}
{/*</Box>*/}
{/* Chats List */}
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
{/* Search Input Field */}
<DebounceInput
minChars={2}
onDebounce={setDebouncedSearchQuery}
debounceTimeout={300}
placeholder='Search...'
aria-label='Search'
sx={{ m: 2 }}
/>
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
<ListItemButton disabled={props.disableNewButton} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Box sx={{
// style
fontSize: 'sm',
fontWeight: 'lg',
// content
flexGrow: 1,
display: 'flex',
justifyContent: 'space-between',
gap: 1,
}}>
New chat
{/*<KeyStroke combo='Ctrl + Alt + N' sx={props.disableNewButton ? { opacity: 0.5 } : undefined} />*/}
</Box>
</ListItemButton>
</ListItem>
{/*<ListDivider sx={{ mt: 0 }} />*/}
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{/*<ListItem sticky sx={{ justifyContent: 'space-between', boxShadow: 'sm' }}>*/}
{/* <Typography level='body-sm'>*/}
{/* Conversations*/}
{/* </Typography>*/}
{/* <ToggleButtonGroup variant='soft' size='sm' value={grouping} onChange={(_event, newValue) => newValue && setGrouping(newValue)}>*/}
{/* <IconButton value='off'>*/}
{/* <AccessTimeIcon />*/}
{/* </IconButton>*/}
{/* <IconButton value='persona'>*/}
{/* <PersonIcon />*/}
{/* </IconButton>*/}
{/* </ToggleButtonGroup>*/}
{/*</ListItem>*/}
{filteredChatNavItems.map(item =>
<ChatDrawerItemMemo
key={'nav-' + item.conversationId}
item={item}
isLonely={singleChat}
showSymbols={showSymbols}
bottomBarBasis={(softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
onConversationActivate={handleConversationActivate}
onConversationDelete={handleConversationDelete}
onConversationExport={onConversationExportDialog}
onConversationFolderChange={handleConversationFolderChange}
/>)}
</Box>
<ListDivider sx={{ mt: 0 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ListItemButton onClick={props.onConversationImportDialog} sx={{ flex: 1 }}>
<ListItemDecorator>
<FileUploadIcon />
</ListItemDecorator>
Import
{/*<OpenAIIcon sx={{ ml: 'auto' }} />*/}
</ListItemButton>
<ListItemButton disabled={!nonEmptyChats} onClick={() => props.onConversationExportDialog(props.activeConversationId)} sx={{ flex: 1 }}>
<ListItemDecorator>
<FileDownloadIcon />
</ListItemDecorator>
Export
</ListItemButton>
</Box>
<ListItemButton disabled={!nonEmptyChats} onClick={props.onConversationsDeleteAll}>
<ListItemDecorator>
<DeleteOutlineIcon />
</ListItemDecorator>
Delete {selectConversationsCount >= 2 ? `all ${selectConversationsCount} chats` : 'chat'}
</ListItemButton>
</PageDrawerList>
{/* [Menu] Chat Item Folder Change */}
{!!folderChangeRequest?.anchorEl && (
<CloseableMenu
open anchorEl={folderChangeRequest.anchorEl} onClose={handleConversationFolderCancel}
placement='bottom-start'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
{/* Folder Assignment Buttons */}
{allFolders.map(folder => {
const isRequestFolder = folder === folderChangeRequest.currentFolder;
return (
<ListItem
key={folder.id}
variant={isRequestFolder ? 'soft' : 'plain'}
onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, folder.id)}
>
<ListItemButton>
<ListItemDecorator>
<FolderIcon sx={{ color: folder.color }} />
</ListItemDecorator>
{folder.title}
</ListItemButton>
</ListItem>
);
})}
{/* Remove Folder Assignment */}
{!!folderChangeRequest.currentFolder && (
<ListItem onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, null)}>
<ListItemButton>
{ClearFolderText}
</ListItemButton>
</ListItem>
)}
</CloseableMenu>
)}
</>;
}
@@ -1,109 +0,0 @@
import * as React from 'react';
import { Box, ListDivider, ListItemDecorator, MenuItem, Switch } from '@mui/joy';
import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlineBlankOutlined';
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
import ClearIcon from '@mui/icons-material/Clear';
import CompressIcon from '@mui/icons-material/Compress';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
import type { DConversationId } from '~/common/state/store-chats';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useChatShowSystemMessages } from '../../store-app-chat';
export function ChatMenuItems(props: {
conversationId: DConversationId | null,
hasConversations: boolean,
isConversationEmpty: boolean,
isMessageSelectionMode: boolean,
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationClear: (conversationId: DConversationId) => void,
onConversationFlatten: (conversationId: DConversationId) => void,
}) {
// external state
const { closePageMenu } = useOptimaDrawers();
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
// derived state
const disabled = !props.conversationId || props.isConversationEmpty;
const closeMenu = (event: React.MouseEvent) => {
event.stopPropagation();
closePageMenu();
};
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationClear(props.conversationId);
};
const handleConversationBranch = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationBranch(props.conversationId, null);
};
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationFlatten(props.conversationId);
};
const handleToggleMessageSelectionMode = (event: React.MouseEvent) => {
closeMenu(event);
props.setIsMessageSelectionMode(!props.isMessageSelectionMode);
};
const handleToggleSystemMessages = () => setShowSystemMessages(!showSystemMessages);
return <>
{/*<ListItem>*/}
{/* <Typography level='body-sm'>*/}
{/* Conversation*/}
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem onClick={handleToggleSystemMessages}>
<ListItemDecorator><SettingsSuggestIcon /></ListItemDecorator>
System message
<Switch checked={showSystemMessages} onChange={handleToggleSystemMessages} sx={{ ml: 'auto' }} />
</MenuItem>
<ListDivider inset='startContent' />
<MenuItem disabled={disabled} onClick={handleConversationBranch}>
<ListItemDecorator><ForkRightIcon /></ListItemDecorator>
Branch
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationFlatten}>
<ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>
Flatten
</MenuItem>
<ListDivider inset='startContent' />
<MenuItem disabled={disabled} onClick={handleToggleMessageSelectionMode}>
<ListItemDecorator>{props.isMessageSelectionMode ? <CheckBoxOutlinedIcon /> : <CheckBoxOutlineBlankOutlinedIcon />}</ListItemDecorator>
<span style={props.isMessageSelectionMode ? { fontWeight: 800 } : {}}>
Cleanup ...
</span>
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Reset Chat
{!disabled && <KeyStroke combo='Ctrl + Alt + X' />}
</Box>
</MenuItem>
</>;
}
@@ -1,115 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, ListItemButton, ListItemDecorator } from '@mui/joy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import SettingsIcon from '@mui/icons-material/Settings';
import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
import { PageBarDropdown, DropdownItems } from '~/common/layout/optima/components/PageBarDropdown';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
function AppBarLLMDropdown(props: {
llms: DLLM[],
chatLlmId: DLLMId | null,
setChatLlmId: (llmId: DLLMId | null) => void,
placeholder?: string,
}) {
// external state
const { openLlmOptions, openModelsSetup } = useOptimaLayout();
// build model menu items, filtering-out hidden models, and add Source separators
const llmItems: DropdownItems = {};
let prevSourceId: DModelSourceId | null = null;
for (const llm of props.llms) {
// filter-out hidden models
if (!(!llm.hidden || llm.id === props.chatLlmId))
continue;
// add separators when changing sources
if (!prevSourceId || llm.sId !== prevSourceId) {
if (prevSourceId)
llmItems[`sep-${llm.id}`] = {
type: 'separator',
title: llm.sId,
};
prevSourceId = llm.sId;
}
// add the model item
llmItems[llm.id] = {
title: llm.label,
// icon: llm.id.startsWith('some vendor') ? <VendorIcon /> : undefined,
};
}
const handleChatLLMChange = (_event: any, value: DLLMId | null) => value && props.setChatLlmId(value);
const handleOpenLLMOptions = () => props.chatLlmId && openLlmOptions(props.chatLlmId);
return (
<PageBarDropdown
items={llmItems}
value={props.chatLlmId} onChange={handleChatLLMChange}
placeholder={props.placeholder || 'Models …'}
appendOption={<>
{props.chatLlmId && (
<ListItemButton key='menu-opt' onClick={handleOpenLLMOptions}>
<ListItemDecorator><SettingsIcon color='success' /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Options
<KeyStroke combo='Ctrl + Shift + O' />
</Box>
</ListItemButton>
)}
<ListItemButton key='menu-llms' onClick={openModelsSetup}>
<ListItemDecorator><BuildCircleIcon color='success' /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Models
<KeyStroke combo='Ctrl + Shift + M' />
</Box>
</ListItemButton>
</>}
/>
);
}
export function useChatLLMDropdown() {
// external state
const { llms, chatLLMId, setChatLLMId } = useModelsStore(state => ({
llms: state.llms,
chatLLMId: state.chatLLMId,
setChatLLMId: state.setChatLLMId,
}), shallow);
const chatLLMDropdown = React.useMemo(
() => <AppBarLLMDropdown llms={llms} chatLlmId={chatLLMId} setChatLlmId={setChatLLMId} />,
[llms, chatLLMId, setChatLLMId],
);
return { chatLLMId, chatLLMDropdown };
}
/*export function useTempLLMDropdown(props: { initialLlmId: DLLMId | null }) {
// local state
const [llmId, setLlmId] = React.useState<DLLMId | null>(props.initialLlmId);
// external state
const llms = useModelsStore(state => state.llms, shallow);
const chatLLMDropdown = React.useMemo(
() => <AppBarLLMDropdown llms={llms} llmId={llmId} setLlmId={setLlmId} />,
[llms, llmId, setLlmId],
);
return { llmId, chatLLMDropdown };
}*/
@@ -1,60 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
import { useUIPreferencesStore } from '~/common/state/store-ui';
function AppBarPersonaDropdown(props: {
systemPurposeId: SystemPurposeId | null,
setSystemPurposeId: (systemPurposeId: SystemPurposeId | null) => void,
}) {
// external state
const { zenMode } = useUIPreferencesStore(state => ({
zenMode: state.zenMode,
}), shallow);
const handleSystemPurposeChange = (_event: any, value: SystemPurposeId | null) => props.setSystemPurposeId(value);
// options
// let appendOption: React.JSX.Element | undefined = undefined;
return (
<PageBarDropdown
items={SystemPurposes} showSymbols={zenMode !== 'cleaner'}
value={props.systemPurposeId} onChange={handleSystemPurposeChange}
// appendOption={appendOption}
/>
);
}
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
// external state
const { systemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
return {
systemPurposeId: conversation?.systemPurposeId ?? null,
};
}, shallow);
const personaDropdown = React.useMemo(() => systemPurposeId
? <AppBarPersonaDropdown
systemPurposeId={systemPurposeId}
setSystemPurposeId={(systemPurposeId) => {
if (conversationId && systemPurposeId)
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
}}
/> : null,
[conversationId, systemPurposeId],
);
return { personaDropdown };
}
+137
View File
@@ -0,0 +1,137 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Alert, Box, Sheet, Typography } from '@mui/joy';
import { ConversationHandler } from '~/common/chats/ConversationHandler';
import { useBeam } from '~/common/chats/BeamStore';
import { useLLMSelect } from '~/common/components/forms/useLLMSelect';
export function Beam(props: {
conversationHandler: ConversationHandler | null,
isMobile: boolean,
sx?: SxProps
}) {
// state
const { config, candidates } = useBeam(props.conversationHandler);
// external state
const [allChatLlm, allChatLlmComponent] = useLLMSelect(true, 'Beam LLM');
if (!config)
return null;
const lastMessage = config.history.slice(-1)[0] ?? null;
return (
<Box sx={{ ...props.sx, px: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Issues */}
{!!config.configError && (
<Alert>
{config.configError}
</Alert>
)}
{/* Models, [x] all same, */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'start', gap: 2 }}>
<Box sx={{ minWidth: 200 }}>
{allChatLlmComponent}
</Box>
{!!lastMessage && (
<Box sx={{
backgroundColor: 'background.surface',
boxShadow: 'xs',
borderRadius: 'lg',
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
py: 1,
px: 1,
mb: 'auto',
flex: 1,
}}>
{lastMessage.text}
</Box>
// <ChatMessageMemo
// message={lastMessage}
// fitScreen={props.isMobile}
// sx={{
// borderRadius: 'lg',
// borderBottomRightRadius: lastMessage.role === 'assistant' ? undefined : 0,
// borderBottomLeftRadius: lastMessage.role === 'user' ? undefined : 0,
// boxShadow: 'xs',
// my: 2,
// px: 0,
// py: 1,
// alignSelf: 'self-end',
// flex: 1,
// maxHeight: '5rem',
// overflow: 'hidden',
// }}
// />
)}
</Box>
{/* Grid */}
<Box sx={{
// my: 'auto',
// display: 'flex', flexDirection: 'column', alignItems: 'center',
border: '1px solid purple',
minHeight: '300px',
// layout
display: 'grid',
gridTemplateColumns: props.isMobile ? 'repeat(auto-fit, minmax(320px, 1fr))' : 'repeat(auto-fit, minmax(400px, 1fr))',
gap: { xs: 2, md: 2 },
}}>
<Sheet sx={{ minHeight: '50%' }}>
b
</Sheet>
<Sheet>
a
</Sheet>
<Sheet>
a
</Sheet>
<Sheet>
a
</Sheet>
</Box>
{/* Auto-Gatherer: All-in-one, Best-Of */}
<Box>
Gatherer
</Box>
<Box sx={{ flex: 1 }}>
<Typography level='body-sm' sx={{ whiteSpace: 'break-spaces' }}>
{/*{JSON.stringify(config, null, 2)}*/}
</Typography>
</Box>
<Box sx={{
height: '100%',
borderRadius: 'lg',
borderBottomLeftRadius: 0,
backgroundColor: 'background.surface',
boxShadow: 'lg',
m: 2,
p: '0.25rem 1rem',
}}>
</Box>
<Box>
a
</Box>
</Box>
);
}
@@ -7,61 +7,21 @@ import InfoIcon from '@mui/icons-material/Info';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { InlineError } from '~/common/components/InlineError';
import { downloadVideoFrameAsPNG, renderVideoFrameAsPNGFile } from '~/common/util/videoUtils';
import { useCameraCapture } from '~/common/components/useCameraCapture';
function prettyFileName(renderedFrame: HTMLCanvasElement) {
const prettyDate = new Date().toISOString().replace(/[:-]/g, '').replace('T', '-').replace('Z', '');
const prettyResolution = `${renderedFrame.width}x${renderedFrame.height}`;
return `camera-${prettyDate}-${prettyResolution}.png`;
}
function renderVideoFrameToCanvas(videoElement: HTMLVideoElement): HTMLCanvasElement {
// paint the video on a canvas, to save it
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth || 640;
canvas.height = videoElement.videoHeight || 480;
const ctx = canvas.getContext('2d');
ctx?.drawImage(videoElement, 0, 0);
return canvas;
}
function renderVideoFrameToFile(videoElement: HTMLVideoElement, callback: (file: File) => void) {
// video to canvas
const renderedFrame = renderVideoFrameToCanvas(videoElement);
// canvas to blob to file to callback
renderedFrame.toBlob((blob) => {
if (blob) {
const file = new File([blob], prettyFileName(renderedFrame), { type: blob.type });
callback(file);
}
}, 'image/png');
}
function downloadVideoFrameAsPNG(videoElement: HTMLVideoElement) {
// video to canvas to png
const renderedFrame = renderVideoFrameToCanvas(videoElement);
const imageDataURL = renderedFrame.toDataURL('image/png');
// auto-download
const link = document.createElement('a');
link.download = prettyFileName(renderedFrame);
link.href = imageDataURL;
link.click();
}
export function CameraCaptureModal(props: {
onCloseModal: () => void,
onAttachImage: (file: File) => void
// onOCR: (ocrText: string) => void }
}) {
// state
// const [ocrProgress/*, setOCRProgress*/] = React.useState<number | null>(null);
const [showInfo, setShowInfo] = React.useState(false);
// camera operations
// state
const [showInfo, setShowInfo] = React.useState(false);
// const [ocrProgress/*, setOCRProgress*/] = React.useState<number | null>(null);
// external state
const {
videoRef,
cameras, cameraIdx, setCameraIdx,
@@ -70,10 +30,14 @@ export function CameraCaptureModal(props: {
} = useCameraCapture();
const stopAndClose = () => {
// derived state
const { onCloseModal, onAttachImage } = props;
const stopAndClose = React.useCallback(() => {
resetVideo();
props.onCloseModal();
};
onCloseModal();
}, [onCloseModal, resetVideo]);
/*const handleVideoOCRClicked = async () => {
if (!videoRef.current) return;
@@ -94,18 +58,21 @@ export function CameraCaptureModal(props: {
props.onOCR(result.data.text);
};*/
const handleVideoSnapClicked = () => {
const handleVideoSnapClicked = React.useCallback(async () => {
if (!videoRef.current) return;
renderVideoFrameToFile(videoRef.current, (file) => {
props.onAttachImage(file);
try {
const file = await renderVideoFrameAsPNGFile(videoRef.current, 'camera');
onAttachImage(file);
stopAndClose();
});
};
} catch (error) {
console.error('Error capturing video frame:', error);
}
}, [onAttachImage, stopAndClose, videoRef]);
const handleVideoDownloadClicked = () => {
const handleVideoDownloadClicked = React.useCallback(() => {
if (!videoRef.current) return;
downloadVideoFrameAsPNG(videoRef.current);
};
downloadVideoFrameAsPNG(videoRef.current, 'camera');
}, [videoRef]);
return (
@@ -7,6 +7,7 @@ import { KeyStroke } from '~/common/components/KeyStroke';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ChatModeId } from '../../AppChat';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
interface ChatModeDescription {
@@ -31,8 +32,12 @@ const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
description: 'AI Image Generation',
requiresTTI: true,
},
'generate-text-beam': {
label: 'Best-Of', // Best of, Auto-Prime, Top Pick, Select Best
description: 'Smarter: best of multiple replies',
},
'generate-react': {
label: 'Reason + Act · α',
label: 'Reason + Act', // · α
description: 'Answers questions in multiple steps',
},
};
@@ -51,34 +56,39 @@ export function ChatModeMenu(props: {
}) {
// external state
const labsChatBeam = useUXLabsStore(state => state.labsChatBeam);
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
return <CloseableMenu
placement='top-end' sx={{ minWidth: 320 }}
open anchorEl={props.anchorEl} onClose={props.onClose}
>
return (
<CloseableMenu
placement='top-end'
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/* ChatMode items */}
{Object.entries(ChatModeItems)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio checked={key === props.chatModeId} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
{/* ChatMode items */}
{Object.entries(ChatModeItems)
.filter(([key, data]) => key !== 'generate-text-beam' || labsChatBeam)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio checked={key === props.chatModeId} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
</Box>
{(key === props.chatModeId || !!data.shortcut) && (
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
)}
</Box>
{(key === props.chatModeId || !!data.shortcut) && (
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
)}
</Box>
</MenuItem>)}
</MenuItem>)}
</CloseableMenu>;
</CloseableMenu>
);
}
+149 -91
View File
@@ -3,7 +3,7 @@ import { shallow } from 'zustand/shallow';
import { fileOpen, FileWithHandle } from 'browser-fs-access';
import { keyframes } from '@emotion/react';
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Tooltip, Typography } from '@mui/joy';
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import AttachFileIcon from '@mui/icons-material/AttachFile';
@@ -23,18 +23,19 @@ import type { DLLM } from '~/modules/llms/store-llms';
import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vendor';
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { countModelTokens } from '~/common/util/token-counter';
import { launchAppCall } from '~/common/app.routes';
import { lineHeightTextarea } from '~/common/app.theme';
import { lineHeightTextareaMd } from '~/common/app.theme';
import { playSoundUrl } from '~/common/util/audioUtils';
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
import { useDebouncer } from '~/common/components/useDebouncer';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ActileItem, ActileProvider } from './actile/ActileProvider';
@@ -50,9 +51,11 @@ import type { ComposerOutputMultiPart } from './composer.types';
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
import { ButtonCall } from './buttons/ButtonCall';
import { ButtonAttachScreenCaptureMemo } from './buttons/ButtonAttachScreenCapture';
import { ButtonCallMemo } from './buttons/ButtonCall';
import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
import { ButtonMicMemo } from './buttons/ButtonMic';
import { ButtonMultiChatMemo } from './buttons/ButtonMultiChat';
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
import { ChatModeMenu } from './ChatModeMenu';
import { TokenBadgeMemo } from './TokenBadge';
@@ -71,18 +74,36 @@ export const animationStopEnter = keyframes`
}
`;
const dropperCardSx: SxProps = {
display: 'none',
position: 'absolute', bottom: 0, left: 0, right: 0, top: 0,
alignItems: 'center', justifyContent: 'center', gap: 2,
border: '2px dashed',
borderRadius: 'xs',
boxShadow: 'none',
zIndex: 10,
} as const;
const dropppedCardDraggingSx: SxProps = {
...dropperCardSx,
display: 'flex',
} as const;
/**
* A React component for composing messages, with attachments and different modes.
*/
export function Composer(props: {
isMobile?: boolean;
chatLLM: DLLM | null;
composerTextAreaRef: React.RefObject<HTMLTextAreaElement>;
conversationId: DConversationId | null;
capabilityHasT2I: boolean;
isMulticast: boolean | null;
isDeveloperMode: boolean;
onAction: (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart) => boolean;
onTextImagine: (conversationId: DConversationId, text: string) => void;
setIsMulticast: (on: boolean) => void;
sx?: SxProps;
}) {
@@ -94,11 +115,12 @@ export function Composer(props: {
const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
// external state
const isMobile = useIsMobile();
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
const { labsCameraDesktop } = useUXLabsStore(state => ({
const { labsAttachScreenCapture, labsCameraDesktop } = useUXLabsStore(state => ({
labsAttachScreenCapture: state.labsAttachScreenCapture,
labsCameraDesktop: state.labsCameraDesktop,
}), shallow);
const { novel: explainShiftEnter, touch: touchShiftEnter } = useUICounter('composer-shift-enter');
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
const [startupText, setStartupText] = useComposerStartupText();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
@@ -116,9 +138,11 @@ export function Composer(props: {
const { attachAppendClipboardItems, attachAppendDataTransfer, attachAppendFile, attachments: _attachments, clearAttachments, removeAttachment } =
useAttachments(browsingInComposer && !composeText.startsWith('/'));
// derived state
const isDesktop = !isMobile;
const isMobile = !!props.isMobile;
const isDesktop = !props.isMobile;
const chatLLMId = props.chatLLM?.id || null;
// attachments derived state
@@ -172,36 +196,47 @@ export function Composer(props: {
return enqueued;
}, [clearAttachments, conversationId, llmAttachments, onAction, setComposeText]);
const handleSendClicked = () => handleSendAction(chatModeId, composeText);
const handleSendClicked = React.useCallback(() => {
handleSendAction(chatModeId, composeText);
}, [chatModeId, composeText, handleSendAction]);
const handleStopClicked = () => props.conversationId && stopTyping(props.conversationId);
const handleStopClicked = React.useCallback(() => {
!!props.conversationId && stopTyping(props.conversationId);
}, [props.conversationId, stopTyping]);
// Secondary buttons
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
const handleCallClicked = React.useCallback(() => {
props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
}, [props.conversationId, systemPurposeId]);
const handleDrawOptionsClicked = () => openPreferencesTab(PreferencesTab.Draw);
const handleDrawOptionsClicked = React.useCallback(() => {
openPreferencesTab(PreferencesTab.Draw);
}, [openPreferencesTab]);
const handleTextImagineClicked = () => {
const handleTextImagineClicked = React.useCallback(() => {
if (!composeText || !props.conversationId)
return;
props.onTextImagine(props.conversationId, composeText);
setComposeText('');
};
}, [composeText, props, setComposeText]);
// Mode menu
const handleModeSelectorHide = () => setChatModeMenuAnchor(null);
const handleModeSelectorHide = React.useCallback(() => {
setChatModeMenuAnchor(null);
}, []);
const handleModeSelectorShow = (event: React.MouseEvent<HTMLAnchorElement>) =>
const handleModeSelectorShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
}, []);
const handleModeChange = (_chatModeId: ChatModeId) => {
const handleModeChange = React.useCallback((_chatModeId: ChatModeId) => {
handleModeSelectorHide();
setChatModeId(_chatModeId);
};
}, [handleModeSelectorHide]);
// Actiles
@@ -256,6 +291,8 @@ export function Composer(props: {
}
// Shift: toggles the 'enter is newline'
if (e.shiftKey)
touchShiftEnter();
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (!assistantAbortible)
handleSendAction(chatModeId, composeText);
@@ -263,7 +300,7 @@ export function Composer(props: {
}
}
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, touchShiftEnter]);
// Focus mode
@@ -321,7 +358,9 @@ export function Composer(props: {
toggleRecording();
}, [micContinuation, micIsRunning, toggleRecording]);
const handleToggleMicContinuation = () => setMicContinuation(continued => !continued);
const handleToggleMicContinuation = React.useCallback(() => {
setMicContinuation(continued => !continued);
}, []);
React.useEffect(() => {
// autostart the microphone if the assistant stopped typing
@@ -341,6 +380,10 @@ export function Composer(props: {
void attachAppendFile('camera', file);
}, [attachAppendFile]);
const handleAttachScreenCapture = React.useCallback((file: File) => {
void attachAppendFile('screencapture', file);
}, [attachAppendFile]);
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
const handleAttachFilePicker = React.useCallback(async () => {
@@ -421,40 +464,62 @@ export function Composer(props: {
const isText = chatModeId === 'generate-text';
const isTextBeam = chatModeId === 'generate-text-beam';
const isAppend = chatModeId === 'append-user';
const isChat = isText || isAppend;
const isReAct = chatModeId === 'generate-react';
const isDraw = chatModeId === 'generate-image';
const buttonColor: ColorPaletteProp = assistantAbortible
? 'warning'
: isReAct ? 'success' : isDraw ? 'warning' : 'primary';
const textPlaceholder: string =
isDraw
? 'Describe an idea or a drawing...'
: isReAct
? 'Multi-step reasoning question...'
: props.isDeveloperMode
? 'Chat with me · drop source files · attach code...'
: props.capabilityHasT2I
? 'Chat · /react · /draw · drop files...'
: 'Chat · /react · drop files...';
const showCall = isText || isAppend;
const buttonColor: ColorPaletteProp =
assistantAbortible ? 'warning'
: isReAct ? 'success'
: isTextBeam ? 'success'
: isDraw ? 'warning'
: 'primary';
const buttonText =
isAppend ? 'Write'
: isReAct ? 'ReAct'
: isTextBeam ? 'Best-Of'
: isDraw ? 'Draw'
: 'Chat';
const buttonIcon =
micContinuation ? <AutoModeIcon />
: isAppend ? <SendIcon sx={{ fontSize: 18 }} />
: isReAct ? <PsychologyIcon />
: isTextBeam ? <ChatBeamIcon /> /* <GavelIcon /> */
: isDraw ? <FormatPaintIcon />
: <TelegramIcon />;
let textPlaceholder: string =
isDraw ? 'Describe an idea or a drawing...'
: isReAct ? 'Multi-step reasoning question...'
: isTextBeam ? 'Multi-chat with this persona...'
: props.isDeveloperMode ? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
: props.capabilityHasT2I ? 'Chat · /react · /draw · drop files...'
: 'Chat · /react · drop files...';
if (isDesktop && explainShiftEnter)
textPlaceholder += !enterIsNewline ? '\nShift+Enter to add a new line' : '\nShift+Enter to send';
return (
<Box aria-label='User Message' component='section' sx={props.sx}>
<Grid container spacing={{ xs: 1, md: 2 }}>
{/* Button column and composer Text (mobile: top, desktop: left and center) */}
<Grid xs={12} md={9}><Stack direction='row' spacing={{ xs: 1, md: 2 }}>
<Grid xs={12} md={9}><Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'flex-start' }}>
{/* Vertical (insert) buttons */}
{isMobile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Start buttons column */}
<Box sx={{
flexGrow: 0,
display: 'grid', gap: 1,
}}>
{isMobile ? <>
{/* [mobile] Mic button */}
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
{/* [mobile] [+] button */}
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddCircleOutlineIcon />
@@ -477,9 +542,10 @@ export function Composer(props: {
</Menu>
</Dropdown>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* [Mobile] MultiChat button */}
{props.isMulticast !== null && <ButtonMultiChatMemo isMobile multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
</> : <>
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
{/* Attach*/}
@@ -491,20 +557,24 @@ export function Composer(props: {
{/* Responsive Paste button */}
{supportsClipboardRead && <ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />}
{/* Responsive Screen Capture button */}
{labsAttachScreenCapture && supportsScreenCapture && <ButtonAttachScreenCaptureMemo onAttachScreenCapture={handleAttachScreenCapture} />}
{/* Responsive Camera OCR button */}
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
</Box>
)}
</>}
</Box>
{/* Vertically stacked [ Edit box + Overlays + Mic | Attachments ] */}
{/* [ Textarea + Overlays + Mic | Attachments ] */}
<Box sx={{
flexGrow: 1,
// layout
display: 'flex', flexDirection: 'column', gap: 1,
minWidth: 200, // enable X-scrolling (resetting any possible minWidth due to the attachments)
minWidth: 200, // flex: enable X-scrolling (resetting any possible minWidth due to the attachments)
}}>
{/* Edit box + Overlays + Mic buttons */}
{/* Textarea + Mic buttons + Mic/Drag overlay */}
<Box sx={{ position: 'relative' }}>
{/* Edit box with inner Token Progress bar */}
@@ -538,7 +608,7 @@ export function Composer(props: {
sx={{
backgroundColor: 'background.level1',
'&:focus-within': { backgroundColor: 'background.popup' },
lineHeight: lineHeightTextarea,
lineHeight: lineHeightTextareaMd,
}} />
{tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
@@ -595,16 +665,8 @@ export function Composer(props: {
{/* overlay: Drag & Drop*/}
{!isMobile && (
<Card
color='success' variant='soft' invertedColors
sx={{
display: isDragging ? 'flex' : 'none',
position: 'absolute', bottom: 0, left: 0, right: 0, top: 0,
alignItems: 'center', justifyContent: 'center', gap: 2,
border: '2px dashed',
borderRadius: 'xs',
boxShadow: 'none',
zIndex: 10,
}}
color={isDragging ? 'success' : undefined} variant={isDragging ? 'soft' : undefined} invertedColors={isDragging}
sx={isDragging ? dropppedCardDraggingSx : dropperCardSx}
onDragLeave={handleOverlayDragLeave}
onDragOver={handleOverlayDragOver}
onDrop={handleOverlayDrop}
@@ -628,18 +690,18 @@ export function Composer(props: {
</Box>
</Stack></Grid>
</Box></Grid>
{/* Send pane (mobile: bottom, desktop: right) */}
<Grid xs={12} md={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, height: '100%' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, height: '100%' } as const}>
{/* Send/Stop (and mobile corner buttons) */}
{/* This row is here only for the [mobile] bottom-start corner item */}
<Box sx={{ display: 'flex' }}>
{/* [mobile] bottom-corner secondary button */}
{isMobile && (isChat
? <ButtonCall isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
{isMobile && (showCall
? <ButtonCallMemo isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />
: isDraw
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
@@ -659,16 +721,10 @@ export function Composer(props: {
key='composer-act'
fullWidth disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
onClick={handleSendClicked}
endDecorator={
micContinuation ? <AutoModeIcon /> :
isAppend ? <SendIcon sx={{ fontSize: 18 }} /> :
isReAct ? <PsychologyIcon /> :
isDraw ? <FormatPaintIcon />
: <TelegramIcon />
}
endDecorator={buttonIcon}
sx={{ '--Button-gap': '1rem' }}
>
{micContinuation && 'Voice '}
{isAppend ? 'Write' : isReAct ? 'ReAct' : isDraw ? 'Draw' : 'Chat'}
{micContinuation && 'Voice '}{buttonText}
</Button>
) : (
<Button
@@ -701,12 +757,14 @@ export function Composer(props: {
</Box>
{/* [desktop] Multicast switch (under the Chat button) */}
{isDesktop && props.isMulticast !== null && <ButtonMultiChatMemo multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
{/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */}
{isDesktop && <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1, justifyContent: 'flex-end' }}>
{isDesktop && <Box sx={{ mt: 'auto', display: 'grid', gap: 1 }}>
{/* [desktop] Call secondary button */}
{isChat && <ButtonCall disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
{showCall && <ButtonCallMemo disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
{/* [desktop] Draw Options secondary button */}
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
@@ -716,23 +774,23 @@ export function Composer(props: {
</Box>
</Grid>
{/* Mode selector */}
{!!chatModeMenuAnchor && (
<ChatModeMenu
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
capabilityHasTTI={props.capabilityHasT2I}
/>
)}
{/* Camera */}
{cameraCaptureComponent}
{/* Actile */}
{actileComponent}
</Grid>
{/* Mode selector */}
{!!chatModeMenuAnchor && (
<ChatModeMenu
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
capabilityHasTTI={props.capabilityHasT2I}
/>
)}
{/* Camera */}
{cameraCaptureComponent}
{/* Actile */}
{actileComponent}
</Box>
);
}
@@ -21,7 +21,11 @@ export function ActilePopup(props: {
const hasAnyIcon = props.items.some(item => !!item.Icon);
return (
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding noBottomPadding sx={{ minWidth: 320 }}>
<CloseableMenu
noTopPadding noBottomPadding
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{!!props.title && (
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
@@ -50,7 +54,7 @@ export function ActilePopup(props: {
color={isActive ? 'primary' : undefined}
onClick={() => props.onItemClick(item)}
>
<ListItemButton>
<ListItemButton color='primary'>
{hasAnyIcon && (
<ListItemDecorator>
{item.Icon ? <item.Icon /> : null}
@@ -60,7 +64,7 @@ export function ActilePopup(props: {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
<span style={{ fontWeight: 600, textDecoration: 'underline' }}>{labelBold}</span>{labelNormal}
<span style={{ textDecoration: 'underline' }}><b>{labelBold}</b></span>{labelNormal}
</Typography>
{item.argument && <Typography level='body-sm'>
{item.argument}
@@ -184,7 +184,6 @@ export function AttachmentItem(props: {
border: variant === 'soft' ? '1px solid' : undefined,
borderColor: variant === 'soft' ? `${color}.solidBg` : undefined,
borderRadius: 'sm',
fontWeight: 'normal',
...ATTACHMENT_MIN_STYLE,
px: 1, py: 0.5,
display: 'flex', flexDirection: 'row', gap: 1,
@@ -96,9 +96,9 @@ export function AttachmentMenu(props: {
return (
<CloseableMenu
dense placement='top' sx={{ minWidth: 200 }}
dense placement='top'
open anchorEl={props.menuAnchor} onClose={props.onClose}
noTopPadding noBottomPadding
sx={{ minWidth: 200 }}
>
{/* Move Arrows */}
@@ -141,9 +141,8 @@ export function Attachments(props: {
{/* Overall Menu */}
{!!overallMenuAnchor && (
<CloseableMenu
placement='top-start'
dense placement='top-start'
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
noTopPadding noBottomPadding
>
<MenuItem onClick={handleAttachmentsInlineText} disabled={!isOutputTextInlineable}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
@@ -18,6 +18,7 @@ const PLAIN_TEXT_MIMETYPES: string[] = [
'text/markdown',
'text/csv',
'text/css',
'text/javascript',
'application/json',
];
@@ -2,13 +2,13 @@ import { create } from 'zustand';
import type { FileWithHandle } from 'browser-fs-access';
import type { ComposerOutputMultiPart } from '../composer.types';
import { attachmentPerformConversion, attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync } from './pipeline';
import { attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync, attachmentPerformConversion } from './pipeline';
// Attachment Types
export type AttachmentSourceOriginDTO = 'drop' | 'paste';
export type AttachmentSourceOriginFile = 'camera' | 'file-open' | 'clipboard-read' | AttachmentSourceOriginDTO;
export type AttachmentSourceOriginFile = 'camera' | 'screencapture' | 'file-open' | 'clipboard-read' | AttachmentSourceOriginDTO;
export type AttachmentSource = {
media: 'url';
@@ -0,0 +1,62 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import ScreenshotMonitorIcon from '@mui/icons-material/ScreenshotMonitor';
import { takeScreenCapture } from '~/common/util/screenCaptureUtils';
export const ButtonAttachScreenCaptureMemo = React.memo(ButtonAttachScreenCapture);
function ButtonAttachScreenCapture(props: { isMobile?: boolean, onAttachScreenCapture: (file: File) => void }) {
// state
const [capturing, setCapturing] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// derived state
const { onAttachScreenCapture } = props;
const handleTakeScreenCapture = React.useCallback(async () => {
setError(null);
setCapturing(true);
try {
const file = await takeScreenCapture();
file && onAttachScreenCapture(file);
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error);
setError(`Screen capture issue: ${message}`);
}
setCapturing(false);
}, [onAttachScreenCapture]);
return props.isMobile ? (
<IconButton onClick={handleTakeScreenCapture}>
<ScreenshotMonitorIcon />
</IconButton>
) : (
<Tooltip
arrow disableInteractive variant='solid' placement='top-start'
title={
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
<b>Attach screen capture</b><br />
{error || 'Attach the image of a window, a browser tab, or a screen'}
</Box>
}
>
<Button
fullWidth
variant={capturing ? 'solid' : 'plain'}
color={!!error ? 'danger' : 'neutral'}
onClick={handleTakeScreenCapture}
loading={capturing}
startDecorator={<ScreenshotMonitorIcon />}
sx={{ justifyContent: 'flex-start' }}
>
Screen
</Button>
</Tooltip>
);
}
@@ -10,14 +10,25 @@ const callConversationLegend =
Quick call regarding this chat
</Box>;
export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void, sx?: SxProps }) {
const mobileSx: SxProps = {
mr: { xs: 1, md: 2 },
} as const;
const desktopSx: SxProps = {
'--Button-gap': '1rem',
} as const;
export const ButtonCallMemo = React.memo(ButtonCall);
export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void }) {
return props.isMobile ? (
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={props.sx}>
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={mobileSx}>
<CallIcon />
</IconButton>
) : (
<Tooltip disableInteractive variant='solid' arrow placement='right' title={callConversationLegend}>
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={props.sx}>
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={desktopSx}>
Call
</Button>
</Tooltip>
@@ -0,0 +1,32 @@
import * as React from 'react';
import { Box, FormControl, FormLabel, IconButton, Switch } from '@mui/joy';
import { ChatMulticastOnIcon } from '~/common/components/icons/ChatMulticastOnIcon';
import { ChatMulticastOffIcon } from '~/common/components/icons/ChatMulticastOffIcon';
export const ButtonMultiChatMemo = React.memo(ButtonMultiChat);
export function ButtonMultiChat(props: { isMobile?: boolean, multiChat: boolean, onSetMultiChat: (multiChat: boolean) => void }) {
const { multiChat } = props;
return props.isMobile ? (
<IconButton
variant={multiChat ? 'solid' : 'outlined'}
color={multiChat ? 'warning' : undefined}
onClick={() => props.onSetMultiChat(!multiChat)}
>
{multiChat ? <ChatMulticastOnIcon /> : <ChatMulticastOffIcon />}
</IconButton>
) : (
<FormControl orientation='horizontal' sx={{ minHeight: '2.25rem', justifyContent: 'space-between' }}>
<FormLabel sx={{ gap: 1, flexFlow: 'row nowrap' }}>
<Box sx={{ display: { xs: 'none', lg: 'inline-block' } }}>
{multiChat ? <ChatMulticastOnIcon sx={{ color: 'warning.solidBg' }} /> : <ChatMulticastOffIcon />}
</Box>
{multiChat ? 'Multichat · On' : 'Multichat'}
</FormLabel>
<Switch color={multiChat ? 'primary' : undefined} checked={multiChat} onChange={(e) => props.onSetMultiChat(e.target.checked)} />
</FormControl>
);
}
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Button, ListItem, ListItemDecorator } from '@mui/joy';
import { ListItem, ListItemButton, ListItemDecorator } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import FolderIcon from '@mui/icons-material/Folder';
@@ -31,41 +31,37 @@ export function AddFolderButton() {
};
return isAddingFolder ? (
<ListItem sx={{
'--ListItem-paddingLeft': '0.75rem',
'--ListItem-minHeight': '3rem', // --Folder-ListItem-height
display: 'flex', alignItems: 'center', gap: 1,
}}>
<ListItem>
<ListItemDecorator>
<FolderIcon style={{ color: newFolderColor || 'inherit' }} />
</ListItemDecorator>
<InlineTextarea
initialText='' placeholder='Folder Name'
initialText=''
placeholder='Folder Name'
onEdit={handleCreateFolder}
onCancel={handleCancelAddFolder}
sx={{
flexGrow: 1,
}} />
sx={{ ml: -1.5, mr: -0.5, flexGrow: 1, minWidth: 100 }}
/>
{/*<IconButton color='danger' onClick={handleCancelAddFolder}>*/}
{/* <CloseIcon />*/}
{/* <CloseRoundedIcon />*/}
{/*</IconButton>*/}
</ListItem>
) : (
<Button
color='neutral'
variant='plain'
startDecorator={<AddIcon />}
onClick={handleAddFolder}
sx={{
// display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
// minHeight: '3rem', // --Folder-ListItem-height
// match the forder elements
paddingInline: '1.2rem',
gap: '0.75rem',
// fontWeight: 400,
}}
>
New folder
</Button>
<ListItem>
<ListItemButton
onClick={handleAddFolder}
sx={{
// equal to the 'new chat' button
fontSize: 'sm',
fontWeight: 'lg',
color: 'neutral.outlinedColor',
}}
>
<ListItemDecorator>
<AddIcon sx={{ '--Icon-fontSize': 'var(--joy-fontSize-xl)', pl: '0.125rem' }} />
</ListItemDecorator>
New folder
</ListItemButton>
</ListItem>
);
}
@@ -1,9 +1,10 @@
import * as React from 'react';
import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd';
import { List, ListItem, ListItemButton, ListItemContent, ListItemDecorator, Sheet, Typography } from '@mui/joy';
import { List, ListItem, ListItemButton, ListItemDecorator, Sheet } from '@mui/joy';
import FolderIcon from '@mui/icons-material/Folder';
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { AddFolderButton } from './AddFolderButton';
@@ -13,6 +14,7 @@ import { StrictModeDroppable } from './StrictModeDroppable';
export function ChatFolderList(props: {
folders: DFolder[];
contentScaling: ContentScaling;
activeFolderId: string | null;
onFolderSelect: (folderId: string | null) => void;
}) {
@@ -47,8 +49,11 @@ export function ChatFolderList(props: {
},
// copied from the former PageDrawerList as this was contained
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
'--ListItemDecorator-size': '2.75rem',
'--ListItem-minHeight': '3rem', // --Folder-ListItem-height
// dynamic sizing
...themeScalingMap[props.contentScaling].chatDrawerItemFolderSx,
// '--ListItemDecorator-size': '2.75rem',
// '--ListItem-minHeight': '2.75rem',
'--List-radius': '8px',
'--List-gap': '1rem',
@@ -64,6 +69,7 @@ export function ChatFolderList(props: {
'--joy-palette-neutral-plainHoverBg': 'rgba(255 255 255 / 0.1)',
'--joy-palette-neutral-plainActiveBg': 'rgba(255 255 255 / 0.16)',
},
boxShadow: 'sm',
})}
>
<ListItem nested>
@@ -92,21 +98,12 @@ export function ChatFolderList(props: {
onFolderSelect(null);
}}
selected={!activeFolderId}
sx={{
border: 0,
justifyContent: 'space-between',
'&:hover .menu-icon': {
visibility: 'visible', // Hide delete icon for default folder
},
}}
sx={{ border: 0 }}
>
<ListItemDecorator>
<FolderIcon />
</ListItemDecorator>
<ListItemContent>
<Typography>All</Typography>
</ListItemContent>
All
</ListItemButton>
</ListItem>
@@ -123,7 +120,10 @@ export function ChatFolderList(props: {
)}
</Draggable>
))}
{provided.placeholder}
<AddFolderButton />
</List>
)}
</StrictModeDroppable>
@@ -131,7 +131,6 @@ export function ChatFolderList(props: {
</ListItem>
</List>
<AddFolderButton />
</Sheet>
);
}
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import type { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuItem, Radio, radioClasses, RadioGroup, Sheet, Typography } from '@mui/joy';
import CloseIcon from '@mui/icons-material/Close';
import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuItem, Radio, radioClasses, RadioGroup, Sheet } from '@mui/joy';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import Done from '@mui/icons-material/Done';
import EditIcon from '@mui/icons-material/Edit';
@@ -12,6 +12,7 @@ import MoreVertIcon from '@mui/icons-material/MoreVert';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DFolder, FOLDERS_COLOR_PALETTE, useFolderStore } from '~/common/state/store-folders';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
export function FolderListItem(props: {
@@ -181,7 +182,7 @@ export function FolderListItem(props: {
userSelect: 'none',
}}
>
<Typography>{folder.title}</Typography>
{folder.title}
</ListItemContent>
)}
@@ -192,6 +193,7 @@ export function FolderListItem(props: {
onClick={handleMenuOpen}
sx={{
visibility: 'hidden',
my: '-0.25rem', /* absorb the button padding */
}}
>
<MoreVertIcon />
@@ -199,9 +201,9 @@ export function FolderListItem(props: {
{!!menuAnchorEl && (
<CloseableMenu
dense placement='top'
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
placement='top'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
zIndex={themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
@@ -228,7 +230,7 @@ export function FolderListItem(props: {
<>
<MenuItem onClick={handleDeleteCanceled}>
<ListItemDecorator>
<CloseIcon />
<CloseRoundedIcon />
</ListItemDecorator>
Cancel
</MenuItem>
@@ -254,10 +256,10 @@ export function FolderListItem(props: {
id='folder-color'
sx={{
mb: 1.5,
fontWeight: 'xl',
textTransform: 'uppercase',
fontSize: 'xs',
fontWeight: 'xl', /* 700: this COLOR labels stands out positively */
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
Color
@@ -1,13 +1,14 @@
import * as React from 'react';
import ClearIcon from '@mui/icons-material/Clear';
import FolderIcon from '@mui/icons-material/Folder';
import type { DConversationId } from '~/common/state/store-chats';
import { DropdownItems, PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useFolderStore } from '~/common/state/store-folders';
export const ClearFolderText = 'Clear Folder';
export const ClearFolderText = 'No Folder';
const SPECIAL_ID_CLEAR_FOLDER = '_REMOVE_';
@@ -18,7 +19,10 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
// Prepare items for the dropdown
const folderItems: DropdownItems = React.useMemo(() => {
const folderItems: DropdownItems | null = React.useMemo(() => {
if (!folders.length)
return null;
// add one item per folder
const items = folders.reduce((items, folder) => {
items[folder.id] = {
@@ -31,6 +35,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
// add one item representing no folder
items[SPECIAL_ID_CLEAR_FOLDER] = {
title: ClearFolderText,
icon: <ClearIcon />,
};
return items;
@@ -38,7 +43,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
// Handle dropdown folder change
const handleFolderChange = React.useCallback((_event: any, folderId: string | null) => {
const handleFolderChange = React.useCallback((folderId: string | null) => {
if (conversationId && folderId) {
// Remove conversation from all folders
folders.forEach(folder => {
@@ -59,15 +64,15 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
const folderDropdown = React.useMemo(() => {
// don't show the dropdown if folders are not enabled
if (!enableFolders)
if (!enableFolders || !folderItems)
return null;
return (
<PageBarDropdown
<PageBarDropdownMemo
items={folderItems}
value={currentFolderId}
onChange={handleFolderChange}
placeholder='Select a folder'
placeholder='Assign to folder'
showSymbols
/>
);
+175 -218
View File
@@ -1,10 +1,8 @@
import * as React from 'react';
import TimeAgo from 'react-timeago';
import { shallow } from 'zustand/shallow';
import { cleanupEfficiency, Diff as TextDiff, makeDiff } from '@sanity/diff-match-patch';
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import ClearIcon from '@mui/icons-material/Clear';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
@@ -21,32 +19,25 @@ import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import TelegramIcon from '@mui/icons-material/Telegram';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { BlocksRenderer, editBlocksSx } from '~/modules/blocks/BlocksRenderer';
import { useSanityTextDiffs } from '~/modules/blocks/RenderTextDiff';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DMessage } from '~/common/state/store-chats';
import { InlineError } from '~/common/components/InlineError';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { KeyStroke } from '~/common/components/KeyStroke';
import { Link } from '~/common/components/Link';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { cssRainbowColorKeyframes, lineHeightChatText } from '~/common/app.theme';
import { cssRainbowColorKeyframes, themeScalingMap } from '~/common/app.theme';
import { prettyBaseModel } from '~/common/util/modelUtils';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { useChatShowTextDiff } from '../../store-app-chat';
import { RenderCode } from './RenderCode';
import { RenderHtml } from './RenderHtml';
import { RenderImage } from './RenderImage';
import { RenderLatex } from './RenderLatex';
import { RenderMarkdown } from './RenderMarkdown';
import { RenderText } from './RenderText';
import { RenderTextDiff } from './RenderTextDiff';
import { parseBlocks } from './blocks';
// How long is the user collapsed message
const USER_COLLAPSED_LINES: number = 8;
// Enable the menu on text selection
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
@@ -62,7 +53,7 @@ export function messageBackground(messageRole: DMessage['role'] | string, wasEdi
case 'assistant':
return unknownAssistantIssue ? 'danger.softBg' : 'background.surface';
case 'system':
return wasEdited ? 'warning.softHoverBg' : 'background.surface';
return wasEdited ? 'warning.softHoverBg' : 'neutral.softBg';
default:
return '#ff0000';
}
@@ -73,6 +64,7 @@ const avatarIconSx = { width: 36, height: 36 };
export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['role'] | string, messageOriginLLM: string | undefined, messagePurposeId: SystemPurposeId | undefined, messageSender: string, messageTyping: boolean, size: 'sm' | undefined = undefined): React.JSX.Element {
if (typeof messageAvatar === 'string' && messageAvatar)
return <Avatar alt={messageSender} src={messageAvatar} />;
const mascotSx = size === 'sm' ? avatarIconSx : { width: 64, height: 64 };
switch (messageRole) {
case 'system':
@@ -85,17 +77,18 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
// typing gif (people seem to love this, so keeping it after april fools')
const isTextToImage = messageOriginLLM === 'DALL·E' || messageOriginLLM === 'Prodia';
const isReact = messageOriginLLM?.startsWith('react-');
if (messageTyping) {
// animation: message typing
if (messageTyping)
return <Avatar
alt={messageSender} variant='plain'
src={isTextToImage ? 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp'
: isReact ? 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp'
: 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'}
src={isTextToImage ? 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp' // brush
: isReact ? 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp' // mind
: 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'} // typing
sx={{ ...mascotSx, borderRadius: 'sm' }}
/>;
}
// text-to-image: icon
// icon: text-to-image
if (isTextToImage)
return <FormatPaintIcon sx={{
...avatarIconSx,
@@ -104,15 +97,16 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
// purpose symbol (if present)
const symbol = SystemPurposes[messagePurposeId!]?.symbol;
if (symbol) return <Box sx={{
fontSize: '24px',
textAlign: 'center',
width: '100%',
minWidth: `${avatarIconSx.width}px`,
lineHeight: `${avatarIconSx.height}px`,
}}>
{symbol}
</Box>;
if (symbol)
return <Box sx={{
fontSize: '24px',
textAlign: 'center',
width: '100%',
minWidth: `${avatarIconSx.width}px`,
lineHeight: `${avatarIconSx.height}px`,
}}>
{symbol}
</Box>;
// default assistant avatar
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
@@ -180,21 +174,6 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
return { errorMessage, isAssistantError };
}
function useSanityTextDiffs(text: string, diffText: string | undefined, enabled: boolean) {
const [diffs, setDiffs] = React.useState<TextDiff[] | null>(null);
React.useEffect(() => {
if (!diffText || !enabled)
return setDiffs(null);
setDiffs(
cleanupEfficiency(makeDiff(diffText, text, {
timeout: 1,
checkLines: true,
}), 4),
);
}, [text, diffText, enabled]);
return diffs;
}
export const ChatMessageMemo = React.memo(ChatMessage);
@@ -208,13 +187,14 @@ export const ChatMessageMemo = React.memo(ChatMessage);
*/
export function ChatMessage(props: {
message: DMessage,
showDate?: boolean, diffPreviousText?: string,
hideAvatars?: boolean, codeBackground?: string,
noMarkdown?: boolean, diagramMode?: boolean,
isBottom?: boolean, noBottomBorder?: boolean,
isImagining?: boolean, isSpeaking?: boolean,
diffPreviousText?: string,
fitScreen: boolean,
isBottom?: boolean,
isImagining?: boolean,
isSpeaking?: boolean,
blocksShowDate?: boolean,
onConversationBranch?: (messageId: string) => void,
onConversationRestartFrom?: (messageId: string, offset: number) => Promise<void>,
onConversationRestartFrom?: (messageId: string, offset: number, chatEffectBeam: boolean) => Promise<void>,
onConversationTruncate?: (messageId: string) => void,
onMessageDelete?: (messageId: string) => void,
onMessageEdit?: (messageId: string, text: string) => void,
@@ -225,7 +205,6 @@ export function ChatMessage(props: {
}) {
// state
const [forceUserExpanded, setForceUserExpanded] = React.useState(false);
const [isHovering, setIsHovering] = React.useState(false);
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
const [selMenuAnchor, setSelMenuAnchor] = React.useState<HTMLElement | null>(null);
@@ -233,10 +212,12 @@ export function ChatMessage(props: {
const [isEditing, setIsEditing] = React.useState(false);
// external state
const { cleanerLooks, renderMarkdown, doubleClickToEdit } = useUIPreferencesStore(state => ({
const labsChatBeam = useUXLabsStore(state => state.labsChatBeam);
const { cleanerLooks, contentScaling, doubleClickToEdit, renderMarkdown } = useUIPreferencesStore(state => ({
cleanerLooks: state.zenMode === 'cleaner',
renderMarkdown: state.renderMarkdown,
contentScaling: state.contentScaling,
doubleClickToEdit: state.doubleClickToEdit,
renderMarkdown: state.renderMarkdown,
}), shallow);
const [showDiff, setShowDiff] = useChatShowTextDiff();
const textDiffs = useSanityTextDiffs(props.message.text, props.diffPreviousText, showDiff);
@@ -257,10 +238,9 @@ export function ChatMessage(props: {
const fromAssistant = messageRole === 'assistant';
const fromSystem = messageRole === 'system';
const fromUser = messageRole === 'user';
const wasEdited = !!messageUpdated;
const showAvatars = props.hideAvatars !== true && !cleanerLooks;
const showAvatars = !cleanerLooks;
const textSel = selMenuText ? selMenuText : messageText;
const isSpecialT2I = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/draw ') || textSel.startsWith('/imagine ') || textSel.startsWith('/img ');
@@ -275,37 +255,42 @@ export function ChatMessage(props: {
props.onMessageEdit(messageId, editedText);
};
const handleUncollapse = () => setForceUserExpanded(true);
// Operations Menu
const closeOperationsMenu = () => setOpsMenuAnchor(null);
const closeOpsMenu = () => setOpsMenuAnchor(null);
const handleOpsCopy = (e: React.MouseEvent) => {
copyToClipboard(textSel, 'Text');
e.preventDefault();
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
};
const handleOpsEdit = (e: React.MouseEvent) => {
const handleOpsEdit = React.useCallback((e: React.MouseEvent) => {
if (messageTyping && !isEditing) return; // don't allow editing while typing
setIsEditing(!isEditing);
e.preventDefault();
closeOperationsMenu();
};
closeOpsMenu();
}, [isEditing, messageTyping]);
const handleOpsConversationBranch = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // to try to not steal the focus from the banched conversation
props.onConversationBranch && props.onConversationBranch(messageId);
closeOperationsMenu();
closeOpsMenu();
};
const handleOpsConversationRestartFrom = async (e: React.MouseEvent) => {
e.preventDefault();
closeOperationsMenu();
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
closeOpsMenu();
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0, false);
};
const handleOpsConversationRestartFromBeam = async (e: React.MouseEvent) => {
e.stopPropagation();
closeOpsMenu();
props.onConversationRestartFrom && labsChatBeam && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0, true);
};
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
@@ -314,7 +299,7 @@ export function ChatMessage(props: {
e.preventDefault();
if (props.onTextDiagram) {
await props.onTextDiagram(messageId, textSel);
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
}
};
@@ -323,7 +308,7 @@ export function ChatMessage(props: {
e.preventDefault();
if (props.onTextImagine) {
await props.onTextImagine(textSel);
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
}
};
@@ -332,14 +317,14 @@ export function ChatMessage(props: {
e.preventDefault();
if (props.onTextSpeak) {
await props.onTextSpeak(textSel);
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
}
};
const handleOpsTruncate = (_e: React.MouseEvent) => {
props.onConversationTruncate && props.onConversationTruncate(messageId);
closeOperationsMenu();
closeOpsMenu();
};
const handleOpsDelete = (_e: React.MouseEvent) => {
@@ -395,6 +380,17 @@ export function ChatMessage(props: {
}, [openSelectionMenu]);
// Blocks renderer
const handleBlocksContextMenu = React.useCallback((event: React.MouseEvent) => {
handleMouseUp(event.nativeEvent);
}, [handleMouseUp]);
const handleBlocksDoubleClick = React.useCallback((event: React.MouseEvent) => {
doubleClickToEdit && props.onMessageEdit && handleOpsEdit(event);
}, [doubleClickToEdit, handleOpsEdit, props.onMessageEdit]);
// prettier upstream errors
const { isAssistantError, errorMessage } = React.useMemo(
() => explainErrorInMessage(messageText, fromAssistant, messageOriginLLM),
@@ -410,47 +406,17 @@ export function ChatMessage(props: {
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping, showAvatars],
);
// per-blocks css
const blockSx: SxProps = {
my: 'auto',
lineHeight: lineHeightChatText,
};
const typographySx: SxProps = {
lineHeight: lineHeightChatText,
};
const codeSx: SxProps = {
// backgroundColor: fromAssistant ? 'background.level1' : 'background.level1',
backgroundColor: props.codeBackground ? props.codeBackground : fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
boxShadow: 'xs',
fontFamily: 'code',
fontSize: '0.875rem',
fontVariantLigatures: 'none',
lineHeight: lineHeightChatText,
borderRadius: 'var(--joy-radius-sm)',
};
// user message truncation
let collapsedText = messageText;
let isCollapsed = false;
if (fromUser && !forceUserExpanded) {
const lines = messageText.split('\n');
if (lines.length > USER_COLLAPSED_LINES) {
collapsedText = lines.slice(0, USER_COLLAPSED_LINES).join('\n');
isCollapsed = true;
}
}
return (
<ListItem
sx={{
display: 'flex', flexDirection: !fromAssistant ? 'row-reverse' : 'row', alignItems: 'flex-start',
gap: { xs: 0, md: 1 }, px: { xs: 1, md: 2 }, py: 2,
gap: { xs: 0, md: 1 },
px: { xs: 1, md: themeScalingMap[contentScaling]?.chatMessagePadding ?? 2 },
py: themeScalingMap[contentScaling]?.chatMessagePadding ?? 2,
backgroundColor,
...(props.noBottomBorder !== true && {
borderBottom: '1px solid',
borderBottomColor: 'divider',
}),
borderBottom: '1px solid',
borderBottomColor: 'divider',
...(ENABLE_COPY_MESSAGE_OVERLAY && { position: 'relative' }),
'&:hover > button': { opacity: 1 },
...props.sx,
@@ -465,13 +431,14 @@ export function ChatMessage(props: {
sx={{
// flexBasis: 0, // this won't let the item grow
display: 'flex', flexDirection: 'column', alignItems: 'center',
minWidth: { xs: 50, md: 64 }, maxWidth: 80,
minWidth: { xs: 50, md: 64 },
maxWidth: 80,
textAlign: 'center',
}}
>
{isHovering ? (
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'} sx={avatarIconSx}>
<IconButton variant='soft' color={(fromAssistant || fromSystem) ? 'neutral' : 'primary'} sx={avatarIconSx}>
<MoreVertIcon />
</IconButton>
) : (
@@ -480,7 +447,7 @@ export function ChatMessage(props: {
{/* Assistant model name */}
{fromAssistant && (
<Tooltip title={messageOriginLLM || 'unk-model'} variant='solid'>
<Tooltip title={messageTyping ? null : (messageOriginLLM || 'unk-model')} variant='solid'>
<Typography level='body-xs' sx={{
overflowWrap: 'anywhere',
...(messageTyping ? { animation: `${cssRainbowColorKeyframes} 5s linear infinite` } : {}),
@@ -499,79 +466,33 @@ export function ChatMessage(props: {
<InlineTextarea
initialText={messageText} onEdit={handleTextEdited}
sx={{
...blockSx,
flexGrow: 1,
}} />
sx={editBlocksSx}
/>
) : (
<Box
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
sx={{
...blockSx,
flexGrow: 0,
overflowX: 'auto',
...(!!props.diagramMode && {
// width: '100%',
boxShadow: 'md',
}),
}}>
<BlocksRenderer
text={messageText}
fromRole={messageRole}
contentScaling={contentScaling}
errorMessage={errorMessage}
fitScreen={props.fitScreen}
isBottom={props.isBottom}
renderTextAsMarkdown={renderMarkdown}
renderTextDiff={textDiffs || undefined}
showDate={props.blocksShowDate === true ? messageUpdated || messageCreated || undefined : undefined}
wasUserEdited={wasEdited}
onContextMenu={(props.onMessageEdit && ENABLE_SELECTION_RIGHT_CLICK_MENU) ? handleBlocksContextMenu : undefined}
onDoubleClick={(props.onMessageEdit && doubleClickToEdit) ? handleBlocksDoubleClick : undefined}
optiAllowMemo={messageTyping}
/>
{props.showDate === true && (
<Typography level='body-sm' sx={{ mx: 1.5, textAlign: fromAssistant ? 'left' : 'right' }}>
<TimeAgo date={messageUpdated || messageCreated} />
</Typography>
)}
{/* Warn about user-edited system message */}
{fromSystem && wasEdited && (
<Typography level='body-sm' color='warning' sx={{ mt: 1, mx: 1.5 }}>modified by user - auto-update disabled</Typography>
)}
{errorMessage && (
<Tooltip title={<Typography sx={{ maxWidth: 800 }}>{collapsedText}</Typography>} variant='soft'>
<InlineError error={errorMessage} />
</Tooltip>
)}
{/* sequence of render components, for each Block */}
{!errorMessage && parseBlocks(collapsedText, fromSystem, textDiffs)
.filter((block, _, blocks) => !props.diagramMode || block.type === 'code' || blocks.length === 1)
.map(
(block, index) =>
block.type === 'html'
? <RenderHtml key={'html-' + index} htmlBlock={block} sx={codeSx} />
: block.type === 'code'
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} noCopyButton={props.diagramMode} />
: block.type === 'image'
? <RenderImage key={'image-' + index} imageBlock={block} isFirst={!index} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsConversationRestartFrom} />
: block.type === 'latex'
? <RenderLatex key={'latex-' + index} latexBlock={block} sx={typographySx} />
: block.type === 'diff'
? <RenderTextDiff key={'latex-' + index} diffBlock={block} sx={typographySx} />
: (renderMarkdown && props.noMarkdown !== true && !fromSystem && !(fromUser && block.content.startsWith('/')))
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
: <RenderText key={'text-' + index} textBlock={block} sx={typographySx} />)}
{isCollapsed && (
<Button variant='plain' color='neutral' onClick={handleUncollapse}>... expand ...</Button>
)}
{/* import VisibilityIcon from '@mui/icons-material/Visibility'; */}
{/*<br />*/}
{/*<Chip variant='outlined' color='warning' sx={{ mt: 1, fontSize: '0.75em' }} startDecorator={<VisibilityIcon />}>*/}
{/* BlockAction*/}
{/*</Chip>*/}
</Box>
)}
{/* Overlay copy icon */}
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
<Tooltip title={fromAssistant ? 'Copy message' : 'Copy input'} variant='solid'>
<Tooltip title={messageTyping ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>
<IconButton
variant='outlined' onClick={handleOpsCopy}
sx={{
@@ -587,9 +508,20 @@ export function ChatMessage(props: {
{/* Operations Menu (3 dots) */}
{!!opsMenuAnchor && (
<CloseableMenu
dense placement='bottom-end' sx={{ minWidth: 280 }}
open anchorEl={opsMenuAnchor} onClose={closeOperationsMenu}
dense placement='bottom-end'
open anchorEl={opsMenuAnchor} onClose={closeOpsMenu}
sx={{ minWidth: 280 }}
>
{fromSystem && (
<ListItem>
<Typography level='body-sm'>
System message
</Typography>
</ListItem>
)}
{/* Edit / Copy */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{!!props.onMessageEdit && (
<MenuItem variant='plain' disabled={messageTyping} onClick={handleOpsEdit} sx={{ flex: 1 }}>
@@ -603,6 +535,32 @@ export function ChatMessage(props: {
Copy
</MenuItem>
</Box>
{/* Delete / Branch / Truncate */}
{!!props.onMessageDelete && <ListDivider />}
{!!props.onMessageDelete && (
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Delete
<span style={{ opacity: 0.5 }}>message</span>
</MenuItem>
)}
{!!props.onConversationBranch && (
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
<ListItemDecorator>
<ForkRightIcon />
</ListItemDecorator>
Branch
{!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
</MenuItem>
)}
{!!props.onConversationTruncate && (
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Truncate
<span style={{ opacity: 0.5 }}>after this</span>
</MenuItem>
)}
{/* Diff Viewer */}
{!!props.diffPreviousText && <ListDivider />}
{!!props.diffPreviousText && (
<MenuItem onClick={handleOpsToggleShowDiff}>
@@ -611,10 +569,31 @@ export function ChatMessage(props: {
<Switch checked={showDiff} onChange={handleOpsToggleShowDiff} sx={{ ml: 'auto' }} />
</MenuItem>
)}
<ListDivider />
{/* Diagram / Draw / Speak */}
{!!props.onTextDiagram && <ListDivider />}
{!!props.onTextDiagram && (
<MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Diagram ...
</MenuItem>
)}
{!!props.onTextImagine && (
<MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
Draw ...
</MenuItem>
)}
{!!props.onTextSpeak && (
<MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
</MenuItem>
)}
{/* Restart/try */}
{!!props.onConversationRestartFrom && <ListDivider />}
{!!props.onConversationRestartFrom && (
<MenuItem onClick={handleOpsConversationRestartFrom}>
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <TelegramIcon />}</ListItemDecorator>
<ListItemDecorator>{fromAssistant ? <ReplayIcon color='primary' /> : <TelegramIcon color='primary' />}</ListItemDecorator>
{!fromAssistant
? <>Restart <span style={{ opacity: 0.5 }}>from here</span></>
: !props.isBottom
@@ -622,42 +601,19 @@ export function ChatMessage(props: {
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Retry
<KeyStroke combo='Ctrl + Shift + R' />
</Box>
}
</MenuItem>
)}
{!!props.onConversationBranch && (
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
<ListItemDecorator>
<ForkRightIcon />
</ListItemDecorator>
Branch {!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
</MenuItem>
)}
{!!props.onConversationBranch && <ListDivider />}
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Diagram ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
Draw ...
</MenuItem>}
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
</MenuItem>}
{!!props.onConversationRestartFrom && <ListDivider />}
{!!props.onConversationTruncate && (
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Truncate <span style={{ opacity: 0.5 }}>after</span>
</MenuItem>
)}
{!!props.onMessageDelete && (
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Delete <span style={{ opacity: 0.5 }}>message</span>
</Box>}
{labsChatBeam && (
<Tooltip title={messageTyping ? null : 'Best-Of'}>
<IconButton
size='sm'
variant='outlined' color='primary'
onClick={handleOpsConversationRestartFromBeam}
sx={{ ml: 'auto', my: '-0.25rem' /* absorb the menuItem padding */ }}
>
<ChatBeamIcon /> {/*<GavelIcon />*/}
</IconButton>
</Tooltip>
)}
</MenuItem>
)}
</CloseableMenu>
@@ -666,8 +622,9 @@ export function ChatMessage(props: {
{/* Selection (Contextual) Menu */}
{!!selMenuAnchor && (
<CloseableMenu
dense placement='bottom-start' sx={{ minWidth: 220 }}
dense placement='bottom-start'
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
sx={{ minWidth: 220 }}
>
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
@@ -1,50 +0,0 @@
import * as React from 'react';
import { Button, Tooltip } from '@mui/joy';
interface CodeBlockProps {
codeBlock: {
code: string;
language?: string;
};
}
export function OpenInCodepen({ codeBlock }: CodeBlockProps): React.JSX.Element {
const { code, language } = codeBlock;
const hasCSS = language === 'css';
const hasJS = ['javascript', 'json', 'typescript'].includes(language || '');
const hasHTML = !hasCSS && !hasJS; // use HTML as fallback if an unanticipated frontend language is used
const handleOpenInCodepen = () => {
const data = {
title: `GPT ${new Date().toISOString()}`, // eg "GPT 2021-08-31T15:00:00.000Z"
css: hasCSS ? code : '',
html: hasHTML ? code : '',
js: hasJS ? code : '',
editors: `${hasHTML ? 1 : 0}${hasCSS ? 1 : 0}${hasJS ? 1 : 0}` // eg '101' for HTML, JS
};
const form = document.createElement('form');
form.method = 'POST';
form.action = 'https://codepen.io/pen/define';
form.target = '_blank';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'data';
input.value = JSON.stringify(data);
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
};
return (
<Tooltip title='Open in Codepen' variant='solid'>
<Button variant='outlined' color='neutral' onClick={handleOpenInCodepen}>
Codepen
</Button>
</Tooltip>
);
}
@@ -1,34 +0,0 @@
import * as React from 'react';
import { Button, Tooltip } from '@mui/joy';
interface CodeBlockProps {
codeBlock: {
code: string;
language?: string;
};
}
export function OpenInReplit({ codeBlock }: CodeBlockProps): React.JSX.Element {
const { language } = codeBlock;
const replitLanguageMap: Record<string, string> = {
python: 'python3',
csharp: 'csharp',
java: 'java',
};
const handleOpenInReplit = () => {
const replitLanguage = replitLanguageMap[language || 'python'];
const url = new URL(`https://replit.com/languages/${replitLanguage}`);
window.open(url.toString(), '_blank');
};
return (
<Tooltip title={`Open in Replit (${codeBlock.language})`} variant='solid'>
<Button variant='outlined' color='neutral' onClick={handleOpenInReplit}>
Replit
</Button>
</Tooltip>
);
}
@@ -1,224 +0,0 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Box, IconButton, Sheet, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import HtmlIcon from '@mui/icons-material/Html';
import SchemaIcon from '@mui/icons-material/Schema';
import ShapeLineOutlinedIcon from '@mui/icons-material/ShapeLineOutlined';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { CodeBlock } from './blocks';
import { OpenInCodepen } from './OpenInCodepen';
import { OpenInReplit } from './OpenInReplit';
import { RenderCodeMermaid } from './RenderCodeMermaid';
import { heuristicIsHtml, IFrameComponent } from './RenderHtml';
export const overlayButtonsSx: SxProps = {
position: 'absolute', top: 0, right: 0, zIndex: 10,
display: 'flex', flexDirection: 'row', gap: 1,
opacity: 0, transition: 'opacity 0.2s',
'& > button': { backdropFilter: 'blur(12px)' },
};
function RenderCodeImpl(props: {
codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps,
highlightCode: (inferredCodeLanguage: string | null, blockCode: string) => string,
inferCodeLanguage: (blockTitle: string, code: string) => string | null,
}) {
// state
const [showHTML, setShowHTML] = React.useState(false);
const [showMermaid, setShowMermaid] = React.useState(true);
const [showPlantUML, setShowPlantUML] = React.useState(true);
const [showSVG, setShowSVG] = React.useState(true);
// derived props
const {
codeBlock: { blockTitle, blockCode, complete: blockComplete },
highlightCode, inferCodeLanguage,
} = props;
// heuristic for language, and syntax highlight
const { highlightedCode, inferredCodeLanguage } = React.useMemo(
() => {
const inferredCodeLanguage = inferCodeLanguage(blockTitle, blockCode);
const highlightedCode = highlightCode(inferredCodeLanguage, blockCode);
return { highlightedCode, inferredCodeLanguage };
}, [inferCodeLanguage, blockTitle, blockCode, highlightCode]);
// heuristics for specialized rendering
const isHTML = heuristicIsHtml(blockCode);
const renderHTML = isHTML && showHTML;
const isMermaid = blockTitle === 'mermaid' && blockComplete;
const renderMermaid = isMermaid && showMermaid;
const isPlantUML =
(blockCode.startsWith('@startuml') && blockCode.endsWith('@enduml'))
|| (blockCode.startsWith('@startmindmap') && blockCode.endsWith('@endmindmap'))
|| (blockCode.startsWith('@startsalt') && blockCode.endsWith('@endsalt'))
|| (blockCode.startsWith('@startwbs') && blockCode.endsWith('@endwbs'))
|| (blockCode.startsWith('@startgantt') && blockCode.endsWith('@endgantt'));
let renderPlantUML = isPlantUML && showPlantUML;
const { data: plantUmlHtmlData, error: plantUmlError } = useQuery({
enabled: renderPlantUML,
queryKey: ['plantuml', blockCode],
queryFn: async () => {
// fetch the PlantUML SVG
let text: string = '';
try {
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
const { encode: plantUmlEncode } = await import('plantuml-encoder');
// retrieve and manually adapt the SVG, to remove the background
const encodedPlantUML: string = plantUmlEncode(blockCode);
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
text = await response.text();
} catch (e) {
return null;
}
// validate/extract the SVG
const start = text.indexOf('<svg ');
const end = text.indexOf('</svg>');
if (start < 0 || end <= start)
throw new Error('Could not render PlantUML');
const svg = text
.slice(start, end + 6) // <svg ... </svg>
.replace('background:#FFFFFF;', ''); // transparent background
// check for syntax errors
if (svg.includes('>Syntax Error?</text>'))
throw new Error('syntax issue (it happens!). Please regenerate or change generator model.');
return svg;
},
staleTime: 24 * 60 * 60 * 1000, // 1 day
});
renderPlantUML = renderPlantUML && (!!plantUmlHtmlData || !!plantUmlError);
const isSVG = blockCode.startsWith('<svg') && blockCode.endsWith('</svg>');
const renderSVG = isSVG && showSVG;
const languagesCodepen = ['html', 'css', 'javascript', 'json', 'typescript'];
const canCodepen = isSVG || (!!inferredCodeLanguage && languagesCodepen.includes(inferredCodeLanguage));
const languagesReplit = ['python', 'java', 'csharp'];
const canReplit = !!inferredCodeLanguage && languagesReplit.includes(inferredCodeLanguage);
const handleCopyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation();
copyToClipboard(blockCode, 'Code');
};
return (
<Box sx={{ position: 'relative' /* for overlay buttons to stick properly */ }}>
<Box
component='code'
className={`language-${inferredCodeLanguage || 'unknown'}`}
sx={{
fontWeight: 500, whiteSpace: 'pre', // was 'break-spaces' before we implemented per-block scrolling
mx: 0, p: 1.5, // this block gets a thicker border
display: 'block',
overflowX: 'auto',
'&:hover > .overlay-buttons': { opacity: 1 },
...(props.sx || {}),
}}>
{/* Markdown Title (File/Type) */}
{blockTitle != inferredCodeLanguage && blockTitle.includes('.') && (
<Sheet sx={{ boxShadow: 'sm', borderRadius: 'sm', mb: 1 }}>
<Typography level='title-sm' sx={{ px: 1, py: 0.5 }}>
{blockTitle}
{/*{inferredCodeLanguage}*/}
</Typography>
</Sheet>
)}
{/* Renders HTML, or inline SVG, inline plantUML rendered, or highlighted code */}
{renderHTML
? <IFrameComponent htmlString={blockCode} />
: renderMermaid
? <RenderCodeMermaid mermaidCode={blockCode} />
: <Box component='div'
dangerouslySetInnerHTML={{
__html:
renderSVG
? blockCode
: renderPlantUML
? (plantUmlHtmlData || (plantUmlError as string) || 'No PlantUML rendering.')
: highlightedCode,
}}
sx={{
...(renderSVG ? { lineHeight: 0 } : {}),
...(renderPlantUML ? { textAlign: 'center' } : {}),
}}
/>}
{/* Code Buttons */}
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, p: 0.5 }}>
{isHTML && (
<Tooltip title={renderHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
<IconButton variant={renderHTML ? 'solid' : 'outlined'} color='danger' onClick={() => setShowHTML(!showHTML)}>
<HtmlIcon />
</IconButton>
</Tooltip>
)}
{isMermaid && (
<Tooltip title={renderMermaid ? 'Show Code' : 'Render Mermaid'} variant='solid'>
<IconButton variant={renderMermaid ? 'solid' : 'outlined'} onClick={() => setShowMermaid(!showMermaid)}>
<SchemaIcon />
</IconButton>
</Tooltip>
)}
{isPlantUML && (
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'} variant='solid'>
<IconButton variant={renderPlantUML ? 'solid' : 'outlined'} onClick={() => setShowPlantUML(!showPlantUML)}>
<SchemaIcon />
</IconButton>
</Tooltip>
)}
{isSVG && (
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'} variant='solid'>
<IconButton variant={renderSVG ? 'solid' : 'outlined'} onClick={() => setShowSVG(!showSVG)}>
<ShapeLineOutlinedIcon />
</IconButton>
</Tooltip>
)}
{canCodepen && <OpenInCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{canReplit && <OpenInReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{props.noCopyButton !== true && <Tooltip title='Copy Code' variant='solid'>
<IconButton variant='outlined' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>}
</Box>
</Box>
</Box>
);
}
// Dynamically import the heavy prism functions
const RenderCodeDynamic = React.lazy(async () => {
// Dynamically import the code highlight functions
const { highlightCode, inferCodeLanguage } = await import('./codePrism');
return {
default: (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
<RenderCodeImpl highlightCode={highlightCode} inferCodeLanguage={inferCodeLanguage} {...props} />,
};
});
export const RenderCode = (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
<React.Suspense fallback={<Box component='code' sx={{ p: 1.5, display: 'block', ...(props.sx || {}) }} />}>
<RenderCodeDynamic {...props} />
</React.Suspense>;
@@ -1,141 +0,0 @@
import * as React from 'react';
import { Alert, Box, IconButton, Tooltip, Typography } from '@mui/joy';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ReplayIcon from '@mui/icons-material/Replay';
import { Link } from '~/common/components/Link';
import { ImageBlock } from './blocks';
import { overlayButtonsSx } from './RenderCode';
const mdImageReferenceRegex = /^!\[([^\]]*)]\(([^)]+)\)$/;
const imageExtensions = /\.(jpg|jpeg|png|gif|bmp|svg)/i;
/**
* Checks if the entire content consists solely of Markdown image references.
* If so, returns an array of ImageBlock objects for each image reference.
* If any non-image content is present or if there are no image references, returns null.
*/
export function heuristicMarkdownImageReferenceBlocks(fullText: string) {
// Check if all lines are valid Markdown image references with image URLs
const imageBlocks: ImageBlock[] = [];
for (const line of fullText.split('\n')) {
if (line.trim() === '') continue; // skip empty lines
const match = mdImageReferenceRegex.exec(line);
if (match && imageExtensions.test(match[2])) {
const alt = match[1];
const url = match[2];
imageBlocks.push({ type: 'image', url, alt });
} else {
// if there is any outlier line, return null
return null;
}
}
// Return the image blocks if all lines are image references with valid image URLs
return imageBlocks.length > 0 ? imageBlocks : null;
}
const prodiaUrlRegex = /^(https?:\/\/images\.prodia\.\S+)$/i;
/**
* Legacy heuristic for detecting images from "images.prodia." URLs.
*/
export function heuristicLegacyImageBlocks(fullText: string): ImageBlock[] | null {
// Check if all lines are URLs starting with "http://images.prodia." or "https://images.prodia."
const imageBlocks: ImageBlock[] = [];
for (const line of fullText.split('\n')) {
const match = prodiaUrlRegex.exec(line);
if (match) {
const url = match[1];
imageBlocks.push({ type: 'image', url });
} else {
// if there is any outlier line, return null
return null;
}
}
// Return the image blocks if all lines are URLs from "images.prodia."
return imageBlocks.length > 0 ? imageBlocks : null;
}
export const RenderImage = (props: { imageBlock: ImageBlock, isFirst: boolean, allowRunAgain: boolean, onRunAgain?: (e: React.MouseEvent) => void }) => {
const { url, alt } = props.imageBlock;
const imageUrls = url.split('\n');
return imageUrls.map((url, index) => {
// display a notice for temporary images DallE
const isTempDalleUrl = url.startsWith('https://oaidalle');
return <Box
key={'gen-img-' + index}
sx={{
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
mx: 1.5, mb: 1.5, // mt: (index > 0 || !props.isFirst) ? 1.5 : 0,
// p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1,
minWidth: 128, minHeight: 128,
boxShadow: 'md',
backgroundColor: 'neutral.solidBg',
'& picture': { display: 'flex' },
'& img': { maxWidth: '100%', maxHeight: '100%' },
'&:hover > .overlay-buttons': { opacity: 1 },
}}
>
{/* External Image */}
{alt ? (
<Tooltip
variant='outlined' color='neutral'
title={
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{isTempDalleUrl && <Alert variant='soft' color='warning' sx={{ flexDirection: 'column', alignItems: 'start' }}>
<Typography level='title-sm'> Temporary Image</Typography>
<Typography level='body-sm'>
This image will be deleted from the OpenAI servers in one hour. <b>Please save it to your device</b>.
</Typography>
{/*<Typography level='body-xs'>*/}
{/* The following is the re-written DALL·E prompt that generated this image.*/}
{/*</Typography>*/}
</Alert>}
<Typography level='title-sm' sx={{ p: 2 }}>
{alt}
</Typography>
</Box>
}
placement='top-start'
sx={{
maxWidth: { sm: '90vw', md: '70vw' },
boxShadow: 'md',
}}
>
<picture><img src={url} alt={`Generated Image: ${alt}`} /></picture>
</Tooltip>
) : (
<picture><img src={url} alt='Generated Image' /></picture>
)}
{/* Image Buttons */}
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, pt: 0.5, px: 0.5, gap: 0.5 }}>
{props.allowRunAgain && !!props.onRunAgain && (
<Tooltip title='Draw again' variant='solid'>
<IconButton variant='solid' onClick={props.onRunAgain}>
<ReplayIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title='Open in new tab'>
<IconButton component={Link} href={url} download={alt || 'image'} target='_blank' variant='solid'>
<OpenInNewIcon />
</IconButton>
</Tooltip>
</Box>
</Box>;
});
};
@@ -1,53 +0,0 @@
import * as React from 'react';
import { Box, styled } from '@mui/joy';
import { lineHeightChatText } from '~/common/app.theme';
import type { TextBlock } from './blocks';
/*
* For performance reasons, we style this component here and copy the equivalent of 'props.sx' (the lineHeight) locally.
*/
const RenderMarkdownBox = styled(Box)({
// same look as the other RenderComponents
marginInline: '0.75rem !important', // margin: 1.5 like other blocks
lineHeight: lineHeightChatText,
// patch the CSS
// fontFamily: `inherit !important`, // (not needed anymore, as CSS is under our control) use the default font family
// '--color-canvas-default': 'transparent !important', // (not needed anymore) remove the default background color
'& table': { width: 'inherit !important' }, // un-break auto-width (tables have 'max-content', which overflows)
});
// Dynamically import ReactMarkdown using React.lazy
const DynamicReactGFM = React.lazy(async () => {
const [markdownModule, remarkGfmModule] = await Promise.all([
import('react-markdown'),
import('remark-gfm'),
]);
// NOTE: extracted here instead of inline as a large performance optimization
const remarkPlugins = [remarkGfmModule.default];
// Pass the dynamically imported remarkGfm as children
const ReactMarkdownWithRemarkGfm = (props: any) =>
<markdownModule.default remarkPlugins={remarkPlugins} {...props} />;
return { default: ReactMarkdownWithRemarkGfm };
});
export const RenderMarkdown = (props: { textBlock: TextBlock }) => {
return (
<RenderMarkdownBox className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */ >
<React.Suspense fallback={<div>Loading...</div>}>
<DynamicReactGFM>
{props.textBlock.content}
</DynamicReactGFM>
</React.Suspense>
</RenderMarkdownBox>
);
};
@@ -27,13 +27,13 @@ interface AppChatPanesStore {
// state
chatPanes: ChatPane[];
chatPaneFocusIndex: number | null;
chatPaneInputMode: 'focused' | 'broadcast';
// actions
openConversationInFocusedPane: (conversationId: DConversationId) => void;
openConversationInSplitPane: (conversationId: DConversationId) => void;
navigateHistoryInFocusedPane: (direction: 'back' | 'forward') => boolean;
duplicatePane: (paneIndex: number) => void;
duplicateFocusedPane: (/*paneIndex: number*/) => void;
removeOtherPanes: () => void;
removePane: (paneIndex: number) => void;
setFocusedPane: (paneIndex: number) => void;
onConversationsChanged: (conversationIds: DConversationId[]) => void;
@@ -54,7 +54,6 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
// Initial state: no panes
chatPanes: [] as ChatPane[],
chatPaneFocusIndex: null as number | null,
chatPaneInputMode: 'focused' as 'focused' | 'broadcast',
openConversationInFocusedPane: (conversationId: DConversationId) => {
_set((state) => {
@@ -160,18 +159,18 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
return true;
},
duplicatePane: (paneIndex: number) =>
duplicateFocusedPane: (/*paneIndex: number*/) =>
_set(state => {
const { chatPanes } = state;
const { chatPanes, chatPaneFocusIndex: _srcIndex } = state;
// Validate index
if (paneIndex < 0 || paneIndex >= chatPanes.length) {
console.warn('Attempted to duplicate a pane with an out-of-range index:', paneIndex);
if (_srcIndex === null || _srcIndex < 0 || _srcIndex >= chatPanes.length) {
console.warn('Attempted to duplicate a pane with an out-of-range index:', _srcIndex);
return state; // Return the existing state without changes
}
// Clone the pane at the specified index, including a deep copy of the history array
const paneToDuplicate = chatPanes[paneIndex];
const paneToDuplicate = chatPanes[_srcIndex];
const duplicatedPane = {
...paneToDuplicate,
history: [...paneToDuplicate.history], // Deep copy of the history array
@@ -179,14 +178,27 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
// Insert the duplicated pane into the array, right after the original pane
const newPanes = [
...chatPanes.slice(0, paneIndex + 1),
...chatPanes.slice(0, _srcIndex + 1),
duplicatedPane,
...chatPanes.slice(paneIndex + 1),
...chatPanes.slice(_srcIndex + 1),
];
return {
chatPanes: newPanes,
chatPaneFocusIndex: paneIndex + 1,
chatPaneFocusIndex: _srcIndex + 1,
};
}),
removeOtherPanes: () =>
_set(state => {
const { chatPanes, chatPaneFocusIndex } = state;
if (chatPanes.length < 2)
return state;
const newPanes = [chatPanes[chatPaneFocusIndex ?? 0]];
return {
chatPanes: newPanes,
chatPaneFocusIndex: 0,
};
}),
@@ -267,7 +279,7 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
// play it safe, and make sure a pane exists, and is focused
return {
chatPanes: newPanes.length ? newPanes : [createPane(conversationIds[0] ?? null)],
chatPaneFocusIndex: (newPanes.length && chatPaneFocusIndex !== null && chatPaneFocusIndex < newPanes.length) ? state.chatPaneFocusIndex : 0,
chatPaneFocusIndex: (newPanes.length && chatPaneFocusIndex !== null && chatPaneFocusIndex < newPanes.length) ? chatPaneFocusIndex : 0,
};
}),
@@ -276,6 +288,9 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
},
));
export function getInstantAppChatPanesCount() {
return useAppChatPanesStore.getState().chatPanes.length;
}
export function usePanesManager() {
// use Panes
@@ -287,7 +302,6 @@ export function usePanesManager() {
onConversationsChanged,
openConversationInFocusedPane,
openConversationInSplitPane,
duplicatePane,
removePane,
setFocusedPane,
} = state;
@@ -299,8 +313,7 @@ export function usePanesManager() {
onConversationsChanged,
openConversationInFocusedPane,
openConversationInSplitPane,
paneIndex: chatPaneFocusIndex,
duplicatePane,
focusedPaneIndex: chatPaneFocusIndex,
removePane,
setFocusedPane,
};
@@ -319,4 +332,13 @@ export function usePanesManager() {
return {
...panesFunctions,
};
}
export function usePaneDuplicateOrClose() {
return useAppChatPanesStore(state => ({
canAddPane: state.chatPanes.length < 4,
isMultiPane: state.chatPanes.length > 1,
duplicateFocusedPane: state.duplicateFocusedPane,
removeOtherPanes: state.removeOtherPanes,
}), shallow);
}
@@ -1,56 +1,121 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
import type { SxProps } from '@mui/joy/styles/types';
import { Alert, Avatar, Box, Button, Card, CardContent, Checkbox, IconButton, Input, List, ListItem, ListItemButton, Textarea, Tooltip, Typography } from '@mui/joy';
import ClearIcon from '@mui/icons-material/Clear';
import DoneIcon from '@mui/icons-material/Done';
import EditIcon from '@mui/icons-material/Edit';
import EditNoteIcon from '@mui/icons-material/EditNote';
import SearchIcon from '@mui/icons-material/Search';
import TelegramIcon from '@mui/icons-material/Telegram';
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { useChatLLM } from '~/modules/llms/store-llms';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { lineHeightTextarea } from '~/common/app.theme';
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
import { lineHeightTextareaMd } from '~/common/app.theme';
import { navigateToPersonas } from '~/common/app.routes';
import { useChipBoolean } from '~/common/components/useChipBoolean';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../data';
import { usePurposeStore } from './store-purposes';
// 'special' purpose IDs, for tile hiding purposes
const PURPOSE_ID_PERSONA_CREATOR = '__persona-creator__';
// Constants for tile sizes / grid width - breakpoints need to be computed here to work around
// the "flex box cannot shrink over wrapped content" issue
//
// Absolutely dislike this workaround, but it's the only way I found to make it work
const bpTileSize = { xs: 116, md: 125, xl: 130 };
const tileCols = [3, 4, 6];
const tileSpacing = 1;
const bpMaxWidth = Object.entries(bpTileSize).reduce((acc, [key, value], index) => {
acc[key] = tileCols[index] * (value + 8 * tileSpacing) - 8 * tileSpacing;
return acc;
}, {} as Record<string, number>);
const bpTileGap = { xs: 0.5, md: 1 };
// defined looks
const tileSize = 7.5; // rem
const tileGap = 0.5; // rem
// Add this utility function to get a random array element
const getRandomElement = <T, >(array: T[]): T | undefined =>
array.length > 0 ? array[Math.floor(Math.random() * array.length)] : undefined;
function Tile(props: {
text?: string,
imageUrl?: string,
symbol?: string,
isActive: boolean,
isEditMode: boolean,
isHidden?: boolean,
isHighlighted?: boolean,
onClick: () => void,
sx?: SxProps,
}) {
return (
<Button
variant={(!props.isEditMode && props.isActive) ? 'solid' : props.isHighlighted ? 'soft' : 'soft'}
color={(!props.isEditMode && props.isActive) ? 'primary' : props.isHighlighted ? 'primary' : 'neutral'}
onClick={props.onClick}
sx={{
aspectRatio: 1,
height: `${tileSize}rem`,
fontWeight: 'md',
...((props.isEditMode || !props.isActive) ? {
boxShadow: props.isHighlighted ? '0 2px 8px -2px rgb(var(--joy-palette-primary-mainChannel) / 50%)' : 'sm',
backgroundColor: props.isHighlighted ? undefined : 'background.surface',
...(props.imageUrl && {
backgroundImage: `linear-gradient(rgba(255 255 255 /0.85), rgba(255 255 255 /1)), url(${props.imageUrl})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
'&:hover': {
backgroundImage: 'none',
},
}),
} : {}),
flexDirection: 'column', gap: 1,
...props.sx,
}}
>
{/* [Edit mode checkbox] */}
{props.isEditMode && (
<Checkbox
variant='soft' color='neutral'
checked={!props.isHidden}
// label={<Typography level='body-xs'>show</Typography>}
sx={{ position: 'absolute', left: `${tileGap}rem`, top: `${tileGap}rem` }}
/>
)}
{/* Icon and Text */}
{/*<Box sx={{ fontSize: '2rem' }}>*/}
{/* {props.symbol}*/}
{/*</Box>*/}
<Avatar
variant='plain'
src={props.imageUrl}
sx={{
'--Avatar-size': '3rem',
fontSize: '2rem',
borderRadius: props.imageUrl ? 'sm' : 0,
boxShadow: (props.imageUrl && !props.isActive) ? 'sm' : undefined,
}}
>
{props.symbol}
</Avatar>
<div>
{props.text}
</div>
</Button>
);
}
/**
* Purpose selector for the current chat. Clicking on any item activates it for the current chat.
*/
export function PersonaSelector(props: { conversationId: DConversationId, runExample: (example: string) => void }) {
// state
const [searchQuery, setSearchQuery] = React.useState('');
const [filteredIDs, setFilteredIDs] = React.useState<SystemPurposeId[] | null>(null);
const [editMode, setEditMode] = React.useState(false);
// external state
const showFinder = useUIPreferencesStore(state => state.showPurposeFinder);
const showFinder = useUIPreferencesStore(state => state.showPersonaFinder);
const [showExamples, showExamplescomponent] = useChipBoolean('Examples', false);
const [showPrompt, showPromptComponent] = useChipBoolean('Prompt', false);
const { systemPurposeId, setSystemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
@@ -59,228 +124,296 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
};
}, shallow);
const { hiddenPurposeIDs, toggleHiddenPurposeId } = usePurposeStore(state => ({ hiddenPurposeIDs: state.hiddenPurposeIDs, toggleHiddenPurposeId: state.toggleHiddenPurposeId }), shallow);
// safety check - shouldn't happen
if (!systemPurposeId || !setSystemPurposeId)
return null;
const { chatLLM } = useChatLLM();
const handleSearchClear = () => {
setSearchQuery('');
setFilteredIDs(null);
};
// derived state
const handleSearchOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
if (!query)
return handleSearchClear();
setSearchQuery(query);
const isCustomPurpose = systemPurposeId === 'Custom';
// Filter results based on search term
const ids = Object.keys(SystemPurposes)
.filter(key => SystemPurposes.hasOwnProperty(key))
.filter(key => {
const purpose = SystemPurposes[key as SystemPurposeId];
return purpose.title.toLowerCase().includes(query.toLowerCase())
|| (typeof purpose.description === 'string' && purpose.description.toLowerCase().includes(query.toLowerCase()));
});
setFilteredIDs(ids as SystemPurposeId[]);
// If there's a search term, activate the first item
if (ids.length && !ids.includes(systemPurposeId))
handlePurposeChanged(ids[0] as SystemPurposeId);
};
const handleSearchOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key == 'Escape')
handleSearchClear();
};
const { selectedPurpose, fourExamples } = React.useMemo(() => {
const selectedPurpose: SystemPurposeData | null = systemPurposeId ? (SystemPurposes[systemPurposeId] ?? null) : null;
// const selectedExample = selectedPurpose?.examples?.length
// ? selectedPurpose.examples[Math.floor(Math.random() * selectedPurpose.examples.length)]
// : null;
const fourExamples = selectedPurpose?.examples?.slice(0, 4) ?? null;
return { selectedPurpose, fourExamples };
}, [systemPurposeId]);
const toggleEditMode = () => setEditMode(!editMode);
const unfilteredPurposeIDs = (filteredIDs && showFinder) ? filteredIDs : Object.keys(SystemPurposes) as SystemPurposeId[];
const visiblePurposeIDs = editMode ? unfilteredPurposeIDs : unfilteredPurposeIDs.filter(id => !hiddenPurposeIDs.includes(id));
const hidePersonaCreator = hiddenPurposeIDs.includes(PURPOSE_ID_PERSONA_CREATOR);
const handlePurposeChanged = (purposeId: SystemPurposeId | null) => {
if (purposeId)
// Handlers
const handlePurposeChanged = React.useCallback((purposeId: SystemPurposeId | null) => {
if (purposeId && setSystemPurposeId)
setSystemPurposeId(props.conversationId, purposeId);
};
}, [props.conversationId, setSystemPurposeId]);
const handleCustomSystemMessageChange = (v: React.ChangeEvent<HTMLTextAreaElement>): void => {
const handleCustomSystemMessageChange = React.useCallback((v: React.ChangeEvent<HTMLTextAreaElement>): void => {
// TODO: persist this change? Right now it's reset every time.
// maybe we shall have a "save" button just save on a state to persist between sessions
SystemPurposes['Custom'].systemMessage = v.target.value;
};
}, []);
const handleSwitchToCustom = React.useCallback((customText: string) => {
if (setSystemPurposeId) {
SystemPurposes['Custom'].systemMessage = customText;
setSystemPurposeId(props.conversationId, 'Custom');
}
}, [props.conversationId, setSystemPurposeId]);
const toggleEditMode = React.useCallback(() => setEditMode(on => !on), []);
// we show them all if the filter is clear (null)
const unfilteredPurposeIDs = (filteredIDs && showFinder) ? filteredIDs : Object.keys(SystemPurposes);
const purposeIDs = editMode ? unfilteredPurposeIDs : unfilteredPurposeIDs.filter(id => !hiddenPurposeIDs.includes(id));
// Search (filtering)
const hidePersonaCreator = hiddenPurposeIDs.includes(PURPOSE_ID_PERSONA_CREATOR);
const handleSearchClear = React.useCallback(() => {
setSearchQuery('');
setFilteredIDs(null);
}, []);
const selectedPurpose = purposeIDs.length ? (SystemPurposes[systemPurposeId] ?? null) : null;
const selectedExample = selectedPurpose?.examples && getRandomElement(selectedPurpose.examples) || null;
const handleSearchOnChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
if (!query)
return handleSearchClear();
return <>
// Filter results based on search term (title and description)
const lcQuery = query.toLowerCase();
const ids = (Object.keys(SystemPurposes) as SystemPurposeId[])
.filter(key => SystemPurposes.hasOwnProperty(key))
.filter(key => {
const purpose = SystemPurposes[key as SystemPurposeId];
return purpose.title.toLowerCase().includes(lcQuery)
|| (typeof purpose.description === 'string' && purpose.description.toLowerCase().includes(lcQuery));
});
{showFinder && <Box sx={{ p: 2 * tileSpacing }}>
<Input
fullWidth
variant='outlined' color='neutral'
value={searchQuery} onChange={handleSearchOnChange}
onKeyDown={handleSearchOnKeyDown}
placeholder='Search for purpose…'
startDecorator={<SearchIcon />}
endDecorator={searchQuery && (
<IconButton onClick={handleSearchClear}>
<ClearIcon />
</IconButton>
)}
sx={{
boxShadow: 'sm',
}}
/>
</Box>}
setSearchQuery(query);
setFilteredIDs(ids);
<Stack direction='column' sx={{ minHeight: '60vh', justifyContent: 'center', alignItems: 'center' }}>
// If there's a search term, activate the first item
// if (ids.length && systemPurposeId && !ids.includes(systemPurposeId))
// handlePurposeChanged(ids[0] as SystemPurposeId);
}, [handleSearchClear]);
<Box sx={{ maxWidth: bpMaxWidth }}>
const handleSearchOnKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key == 'Escape')
handleSearchClear();
}, [handleSearchClear]);
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 1 }}>
// safety check - shouldn't happen - this is set to null when the conversation is not found
if (!setSystemPurposeId)
return null;
return (
<Box sx={{
maxWidth: 'md',
minWidth: `${2 + 1 + tileSize * 2}rem`, // accomodate at least 2 columns (scroll-x in case)
mx: 'auto',
minHeight: '60svh',
display: 'grid',
px: { xs: 0.5, sm: 1, md: 2 },
py: 2,
}}>
{showFinder && <Box>
<Input
fullWidth
variant='outlined' color='neutral'
value={searchQuery} onChange={handleSearchOnChange}
onKeyDown={handleSearchOnKeyDown}
placeholder='Search for purpose…'
startDecorator={<SearchIcon />}
endDecorator={searchQuery && (
<IconButton onClick={handleSearchClear}>
<ClearIcon />
</IconButton>
)}
sx={{
boxShadow: 'sm',
}}
/>
</Box>}
<Box sx={{
my: 'auto',
// layout
display: 'grid',
gridTemplateColumns: `repeat(auto-fit, minmax(${tileSize}rem, ${tileSize}rem))`,
justifyContent: 'center', gap: `${tileGap}rem`,
}}>
{/* [row 0] ... Edit mode [ ] */}
<Box sx={{
gridColumn: '1 / -1',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<Typography level='title-sm'>
AI Persona
</Typography>
<Tooltip disableInteractive title={editMode ? 'Done Editing' : 'Edit Tiles'}>
<IconButton size='sm' onClick={toggleEditMode}>
<IconButton size='sm' onClick={toggleEditMode} sx={{ my: '-0.25rem' /* absorb the button padding */ }}>
{editMode ? <DoneIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={tileSpacing} sx={{ justifyContent: 'flex-start' }}>
{purposeIDs.map((spId) => (
<Grid key={spId}>
<Button
variant={(!editMode && systemPurposeId === spId) ? 'solid' : 'soft'}
color={(!editMode && systemPurposeId === spId) ? 'primary' : SystemPurposes[spId as SystemPurposeId]?.highlighted ? 'warning' : 'neutral'}
onClick={() => editMode
? toggleHiddenPurposeId(spId)
: handlePurposeChanged(spId as SystemPurposeId)
}
{/* Personas Tiles */}
{visiblePurposeIDs.map((spId: SystemPurposeId) => {
const isActive = systemPurposeId === spId;
const systemPurpose = SystemPurposes[spId];
return (
<Tile
key={'tile-' + spId}
text={systemPurpose?.title}
imageUrl={systemPurpose?.imageUri}
symbol={systemPurpose?.symbol}
isActive={isActive}
isEditMode={editMode}
isHidden={hiddenPurposeIDs.includes(spId)}
isHighlighted={systemPurpose?.highlighted}
onClick={() => editMode ? toggleHiddenPurposeId(spId) : handlePurposeChanged(spId)}
/>
);
})}
{/* Persona Creator Tile */}
{(editMode || !hidePersonaCreator) && (
<Tile
text='Persona Creator'
symbol='🎭'
isActive={false}
isEditMode={editMode}
isHidden={hidePersonaCreator}
onClick={() => editMode ? toggleHiddenPurposeId(PURPOSE_ID_PERSONA_CREATOR) : void navigateToPersonas()}
sx={{
boxShadow: 'xs',
backgroundColor: 'neutral.softDisabledBg',
}}
/>
)}
{/* [row -3] Description */}
<Box sx={{ gridColumn: '1 / -1', mt: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Description*/}
<Typography level='body-sm' sx={{ color: 'text.primary' }}>
{!selectedPurpose
? 'Cannot find the former persona' + (systemPurposeId ? ` "${systemPurposeId}"` : '')
: selectedPurpose?.description || 'No description available'}
</Typography>
{/* Examples Toggle */}
{/*<Box sx={{ display: 'flex', flexFlow: 'row wrap', flexShrink: 1 }}>*/}
{fourExamples && showExamplescomponent}
{!isCustomPurpose && showPromptComponent}
{/*</Box>*/}
</Box>
{/* [row -3] Example incipits */}
{systemPurposeId !== 'Custom' && (
<ExpanderControlledBox expanded={showExamples || (!isCustomPurpose && showPrompt)} sx={{ gridColumn: '1 / -1', pt: 1 }}>
{showExamples && (
<List
aria-label='Persona Conversation Starters'
sx={{
flexDirection: 'column',
fontWeight: 500,
// paddingInline: 1,
gap: bpTileGap,
height: bpTileSize,
width: bpTileSize,
...((editMode || systemPurposeId !== spId) ? {
boxShadow: 'md',
...(SystemPurposes[spId as SystemPurposeId]?.highlighted ? {} : { backgroundColor: 'background.surface' }),
} : {}),
// example items 2-col layout
display: 'grid',
gridTemplateColumns: `repeat(auto-fit, minmax(${tileSize * 2 + 1}rem, 1fr))`,
gap: 1,
}}
>
{editMode && (
<Checkbox
color='neutral'
checked={!hiddenPurposeIDs.includes(spId)}
// label={<Typography level='body-xs'>show</Typography>}
sx={{ position: 'absolute', left: 8, top: 8 }}
/>
)}
<div style={{ fontSize: '2rem' }}>
{SystemPurposes[spId as SystemPurposeId]?.symbol}
</div>
<div>
{SystemPurposes[spId as SystemPurposeId]?.title}
</div>
</Button>
</Grid>
))}
{/* Button to start the Persona Creator */}
{(editMode || !hidePersonaCreator) && <Grid>
<Button
variant='soft' color='neutral'
onClick={() => editMode
? toggleHiddenPurposeId(PURPOSE_ID_PERSONA_CREATOR)
: void navigateToPersonas()
}
sx={{
flexDirection: 'column',
fontWeight: 500,
// paddingInline: 1,
gap: bpTileGap,
height: bpTileSize,
width: bpTileSize,
// border: `1px dashed`,
// borderColor: 'neutral.softActiveBg',
boxShadow: 'xs',
backgroundColor: 'neutral.softDisabledBg',
}}
>
{editMode && (
<Checkbox
color='neutral'
checked={!hidePersonaCreator}
// label={<Typography level='body-xs'>show</Typography>}
sx={{ position: 'absolute', left: 8, top: 8 }}
/>
)}
<div>
<div style={{ fontSize: '2rem' }}>
🎭
</div>
{/*<SettingsAccessibilityIcon style={{ opacity: 0.5 }} />*/}
</div>
<div style={{ textAlign: 'center' }}>
Persona Creator
</div>
</Button>
</Grid>}
</Grid>
<Typography
level='body-sm'
sx={{
mt: selectedExample ? 1 : 3,
display: 'flex', alignItems: 'center', gap: 1,
// justifyContent: 'center',
'&:hover > button': { opacity: 1 },
}}>
{!selectedPurpose
? 'Oops! No AI persona found for your search.'
: (selectedExample
? <>
Example: {selectedExample}
<IconButton
color='primary'
onClick={() => props.runExample(selectedExample)}
sx={{ opacity: 0, transition: 'opacity 0.3s' }}
{fourExamples?.map((example, idx) => (
<ListItem
key={idx}
variant='soft'
sx={{
borderRadius: 'md',
// boxShadow: 'xs',
padding: '0.25rem 0.5rem',
backgroundColor: 'background.surface',
'& svg': { opacity: 0.1, transition: 'opacity 0.2s' },
'&:hover svg': { opacity: 1 },
}}
>
<TelegramIcon />
</IconButton>
</>
: selectedPurpose.description
<ListItemButton onClick={() => props.runExample(example)} sx={{ justifyContent: 'space-between' }}>
<Typography level='body-sm'>
{example}
</Typography>
<TelegramIcon color='primary' sx={{}} />
</ListItemButton>
</ListItem>
))}
</List>
)}
</Typography>
{(!isCustomPurpose && showPrompt) && (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography level='title-sm'>
System Prompt
</Typography>
<Button
variant='plain' color='neutral' size='sm'
endDecorator={<EditNoteIcon />}
onClick={() => handleSwitchToCustom(bareBonesPromptMixer(selectedPurpose?.systemMessage || 'No system message available', chatLLM?.id))}
sx={{ ml: 'auto', my: '-0.25rem' /* absorb the button padding */ }}
>
Custom
</Button>
</Box>
<Typography level='body-sm' sx={{ whiteSpace: 'break-spaces' }}>
{bareBonesPromptMixer(selectedPurpose?.systemMessage || 'No system message available', chatLLM?.id)}
</Typography>
{!!selectedPurpose?.systemMessageNotes && (
<Alert sx={{ m: -1, mt: 1, p: 1 }}>
<Typography level='body-xs'>
Prompt notes: {selectedPurpose.systemMessageNotes}
</Typography>
</Alert>
)}
</CardContent>
</Card>
)}
</ExpanderControlledBox>
)}
{/* [row -1] Custom Prompt box */}
{systemPurposeId === 'Custom' && (
<Textarea
variant='outlined' autoFocus placeholder={'Craft your custom system message here…'}
autoFocus
variant='outlined'
placeholder='Craft your custom system message here…'
minRows={3}
defaultValue={SystemPurposes['Custom']?.systemMessage} onChange={handleCustomSystemMessageChange}
defaultValue={SystemPurposes['Custom']?.systemMessage}
onChange={handleCustomSystemMessageChange}
endDecorator={
<Alert sx={{ flex: 1, p: 1 }}>
<Typography level='body-xs'>
Just start chatting when done.
</Typography>
</Alert>
}
sx={{
backgroundColor: 'background.level1',
gridColumn: '1 / -1',
backgroundColor: 'background.surface',
'&:focus-within': {
backgroundColor: 'background.popup',
},
lineHeight: lineHeightTextarea,
mt: 1,
}} />
lineHeight: lineHeightTextareaMd,
}}
/>
)}
</Box>
</Stack>
</>;
</Box>
);
}
@@ -18,7 +18,7 @@ export const usePurposeStore = create<PurposeStore>()(
(set) => ({
// default state
hiddenPurposeIDs: ['Designer'],
hiddenPurposeIDs: ['Developer', 'Designer'],
toggleHiddenPurposeId: (purposeId: string) => {
set(state => {
@@ -34,5 +34,18 @@ export const usePurposeStore = create<PurposeStore>()(
}),
{
name: 'app-purpose',
/* versioning:
* 1: hide 'Developer' as 'DeveloperPreview' is best
*/
version: 1,
migrate: (state: any, fromVersion: number): PurposeStore => {
// 0 -> 1: rename 'enterToSend' to 'enterIsNewline' (flip the meaning)
if (state && fromVersion === 0)
if (!state.hiddenPurposeIDs.includes('Developer'))
state.hiddenPurposeIDs.push('Developer');
return state;
},
}),
);
@@ -1,10 +1,33 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { IconButton } from '@mui/joy';
import KeyboardDoubleArrowDownIcon from '@mui/icons-material/KeyboardDoubleArrowDown';
import { useScrollToBottom } from './useScrollToBottom';
// const object
const buttonSx: SxProps = {
// place this on the bottom-right corner (FAB-like)
position: 'absolute',
bottom: '2rem',
right: {
xs: '1rem',
md: '2rem',
},
// style it
backgroundColor: 'background.surface',
borderRadius: '50%',
boxShadow: 'md',
// fade it in when hovering
// transition: 'all 0.15s',
// '&:hover': {
// transform: 'scale(1.1)',
// },
} as const;
export function ScrollToBottomButton() {
@@ -20,37 +43,8 @@ export function ScrollToBottomButton() {
return null;
return (
// <Tooltip title={
// <Typography variant='solid' level='title-sm' sx={{ px: 1 }}>
// Scroll to bottom
// </Typography>
// }>
<IconButton
variant='outlined'
onClick={handleStickToBottom}
sx={{
// place this on the bottom-right corner (FAB-like)
position: 'absolute',
bottom: '2rem',
right: {
xs: '1rem',
md: '2rem',
},
// style it
backgroundColor: 'background.surface',
borderRadius: '50%',
boxShadow: 'md',
// fade it in when hovering
// transition: 'all 0.15s',
// '&:hover': {
// transform: 'scale(1.1)',
// },
}}
>
<IconButton aria-label='Scroll To Bottom' variant='outlined' onClick={handleStickToBottom} sx={buttonSx}>
<KeyboardDoubleArrowDownIcon />
</IconButton>
// </Tooltip>
);
}
@@ -0,0 +1,210 @@
import { shallow } from 'zustand/shallow';
import type { DFolder } from '~/common/state/store-folders';
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
import type { ChatNavigationItemData } from './ChatDrawerItem';
// configuration
const SEARCH_MIN_CHARS = 3;
export type ChatNavGrouping = false | 'date' | 'persona';
interface ChatNavigationGroupData {
type: 'nav-item-group',
title: string,
}
interface ChatNavigationInfoMessage {
type: 'nav-item-info-message',
message: string,
}
type ChatRenderItemData = ChatNavigationItemData | ChatNavigationGroupData | ChatNavigationInfoMessage;
// Returns a string with the pane indices where the conversation is also open, or false if it's not
function findOpenInViewNumbers(chatPanesConversationIds: DConversationId[], ourId: DConversationId): string | false {
if (chatPanesConversationIds.length <= 1) return false;
return chatPanesConversationIds.reduce((acc: string[], id, idx) => {
if (id === ourId)
acc.push((idx + 1).toString());
return acc;
}, []).join(', ') || false;
}
function getNextMidnightTime(): number {
const midnight = new Date();
// midnight.setDate(midnight.getDate() - 1);
midnight.setHours(24, 0, 0, 0);
return midnight.getTime();
}
function getTimeBucketEn(currentTime: number, midnightTime: number): string {
const oneDay = 24 * 60 * 60 * 1000;
const oneWeek = oneDay * 7;
const oneMonth = oneDay * 30; // approximation
const diff = midnightTime - currentTime;
if (diff < oneDay) {
return 'Today';
} else if (diff < oneDay * 2) {
return 'Yesterday';
} else if (diff < oneWeek) {
return 'This Week';
} else if (diff < oneWeek * 2) {
return 'Last Week';
} else if (diff < oneMonth) {
return 'This Month';
} else if (diff < oneMonth * 2) {
return 'Last Month';
} else {
return 'Older';
}
}
/*
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
* to avoid unnecessary re-renders on each new character typed by the assistant
*/
export function useChatNavRenderItems(
activeConversationId: DConversationId | null,
chatPanesConversationIds: DConversationId[],
filterByQuery: string,
activeFolder: DFolder | null,
allFolders: DFolder[],
grouping: ChatNavGrouping,
showRelativeSize: boolean,
): {
renderNavItems: ChatRenderItemData[],
filteredChatIDs: DConversationId[],
filteredChatsCount: number,
filteredChatsAreEmpty: boolean,
filteredChatsBarBasis: number,
filteredChatsIncludeActive: boolean,
} {
return useChatStore(({ conversations }) => {
// filter 1: select all conversations or just the ones in the active folder
const selectedConversations = !activeFolder ? conversations : conversations.filter(_c => activeFolder.conversationIds.includes(_c.id));
// filter 2: preparation: lowercase the query
const lcTextQuery = filterByQuery.trim().toLowerCase();
const isSearching = lcTextQuery.length >= SEARCH_MIN_CHARS;
// transform (the conversations into ChatNavigationItemData) + filter2 (if searching)
const chatNavItems = selectedConversations.map((_c): ChatNavigationItemData => {
// rich properties
const title = conversationTitle(_c);
const isAlsoOpen = findOpenInViewNumbers(chatPanesConversationIds, _c.id);
// set the frequency counters if filtering is enabled
let searchFrequency: number = 0;
if (isSearching) {
const titleFrequency = title.toLowerCase().split(lcTextQuery).length - 1;
const messageFrequency = _c.messages.reduce((count, message) => count + (message.text.toLowerCase().split(lcTextQuery).length - 1), 0);
searchFrequency = titleFrequency + messageFrequency;
}
// create the ChatNavigationData
return {
type: 'nav-item-chat-data',
conversationId: _c.id,
isActive: _c.id === activeConversationId,
isAlsoOpen,
isEmpty: !_c.messages.length && !_c.userTitle,
title,
folder: !allFolders.length
? undefined // don't show folder select if folders are disabled
: _c.id === activeConversationId // only show the folder for active conversation(s)
? allFolders.find(folder => folder.conversationIds.includes(_c.id)) ?? null
: null,
updatedAt: _c.updated || _c.created || 0,
messageCount: _c.messages.length,
assistantTyping: !!_c.abortController,
systemPurposeId: _c.systemPurposeId,
searchFrequency,
};
}).filter(item => !isSearching || item.searchFrequency > 0);
// check if the active conversation has an item in the list
const filteredChatsIncludeActive = chatNavItems.some(_c => _c.conversationId === activeConversationId);
// [sort by frequency, don't group] if there's a search query
chatNavItems.sort((a, b) => b.searchFrequency - a.searchFrequency);
// Render List
let renderNavItems: ChatRenderItemData[] = chatNavItems;
// [search] add a header if searching
if (isSearching) {
// only prepend a 'Results' group if there are results
if (chatNavItems.length)
renderNavItems = [{ type: 'nav-item-group', title: 'Search results' }, ...chatNavItems];
}
// [grouping] group by date or persona
else if (grouping) {
// [grouping/date]: sort by update time
const midnightTime = getNextMidnightTime();
if (grouping === 'date')
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
// Array.groupBy(...)
const grouped = chatNavItems.reduce((acc, item) => {
const groupName = grouping === 'date'
? getTimeBucketEn(item.updatedAt || midnightTime, midnightTime)
: item.systemPurposeId;
if (!acc[groupName])
acc[groupName] = [];
acc[groupName].push(item);
return acc;
}, {} as { [groupName: string]: ChatNavigationItemData[] });
// prepend groups
renderNavItems = Object.entries(grouped).flatMap(([groupName, items]) => [
{ type: 'nav-item-group', title: groupName },
...items,
]);
}
// [empty message] if there are no items
if (!renderNavItems.length)
renderNavItems.push({ type: 'nav-item-info-message', message: isSearching ? 'No results found' : 'No conversations in folder' });
// other derived state
const filteredChatIDs = chatNavItems.map(_c => _c.conversationId);
const filteredChatsCount = chatNavItems.length;
const filteredChatsAreEmpty = !filteredChatsCount || (filteredChatsCount === 1 && chatNavItems[0].isEmpty);
const filteredChatsBarBasis = ((showRelativeSize && filteredChatsCount >= 2) || isSearching)
? chatNavItems.reduce((longest, _c) => Math.max(longest, isSearching ? _c.searchFrequency : _c.messageCount), 1)
: 0;
return {
renderNavItems,
filteredChatIDs,
filteredChatsCount,
filteredChatsAreEmpty,
filteredChatsBarBasis,
filteredChatsIncludeActive,
};
},
(a, b) => {
// we only compare the renderNavItems array, which shall be changed if the rest changes
return a.renderNavItems.length === b.renderNavItems.length
&& a.renderNavItems.every((_a, i) => shallow(_a, b.renderNavItems[i]))
&& shallow(a.filteredChatIDs, b.filteredChatIDs)
// we also compare this, as it changes with a parameter
&& a.filteredChatsBarBasis === b.filteredChatsBarBasis;
},
);
}
+172
View File
@@ -0,0 +1,172 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, IconButton, ListItemButton, ListItemDecorator } from '@mui/joy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import SettingsIcon from '@mui/icons-material/Settings';
import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
import { findVendorById } from '~/modules/llms/vendors/vendors.registry';
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
function LLMDropdown(props: {
llms: DLLM[],
chatLlmId: DLLMId | null,
setChatLlmId: (llmId: DLLMId | null) => void,
placeholder?: string,
}) {
// external state
const { openLlmOptions, openModelsSetup } = useOptimaLayout();
// derived state
const { chatLlmId, llms, setChatLlmId } = props;
const handleChatLLMChange = React.useCallback((value: DLLMId | null) => {
value && setChatLlmId(value);
}, [setChatLlmId]);
const handleOpenLLMOptions = React.useCallback(() => {
return chatLlmId && openLlmOptions(chatLlmId);
}, [chatLlmId, openLlmOptions]);
const llmDropdownItems: DropdownItems = React.useMemo(() => {
const llmItems: DropdownItems = {};
let prevSourceId: DModelSourceId | null = null;
let sepCount = 0;
for (const llm of llms) {
// filter-out hidden models from the dropdown
if (!(!llm.hidden || llm.id === chatLlmId))
continue;
// add separators when changing sources
if (!prevSourceId || llm.sId !== prevSourceId) {
const llmVendor = findVendorById(llm._source?.vId ?? undefined);
const sourceName = llmVendor?.name || llm.sId;
llmItems[`sep-${llm.id}`] = {
type: 'separator',
title: sourceName,
icon: llmVendor?.Icon ? <llmVendor.Icon /> : undefined,
};
prevSourceId = llm.sId;
sepCount++;
}
// add the model item
llmItems[llm.id] = {
title: llm.label,
// icon: llm.id.startsWith('some vendor') ? <VendorIcon /> : undefined,
};
}
// if there's a single separator (i.e. only one source), remove it
if (sepCount === 1) {
for (const key in llmItems) {
if (key.startsWith('sep-')) {
delete llmItems[key];
break;
}
}
}
return llmItems;
}, [chatLlmId, llms]);
// "Model Options" button (only on the active item)
const llmDropdownButton = React.useMemo(() => (
<GoodTooltip title={
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Model Options
<KeyStroke combo='Ctrl + Shift + O' sx={{ my: 0.5 }} />
</Box>
}>
<IconButton
variant='outlined' color='neutral'
onClick={handleOpenLLMOptions}
sx={{
ml: 'auto',
// mr: -0.5,
my: '-0.25rem' /* absorb the menuItem padding */,
backgroundColor: 'background.surface',
boxShadow: 'xs',
}}
>
<SettingsIcon sx={{ fontSize: 'xl' }} />
</IconButton>
</GoodTooltip>
), [handleOpenLLMOptions]);
// "Models Setup" button
const llmDropdownAppendOptions = React.useMemo(() => <>
{/*{chatLlmId && (*/}
{/* <ListItemButton key='menu-opt' onClick={handleOpenLLMOptions}>*/}
{/* <ListItemDecorator><SettingsIcon color='success' /></ListItemDecorator>*/}
{/* <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>*/}
{/* Options*/}
{/* <KeyStroke combo='Ctrl + Shift + O' />*/}
{/* </Box>*/}
{/* </ListItemButton>*/}
{/*)}*/}
<ListItemButton key='menu-llms' onClick={openModelsSetup}>
<ListItemDecorator><BuildCircleIcon color='success' /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Models
<KeyStroke combo='Ctrl + Shift + M' sx={{ ml: 2 }} />
</Box>
</ListItemButton>
</>, [openModelsSetup]);
return (
<PageBarDropdownMemo
items={llmDropdownItems}
value={chatLlmId}
onChange={handleChatLLMChange}
placeholder={props.placeholder || 'Models …'}
appendOption={llmDropdownAppendOptions}
activeEndDecorator={llmDropdownButton}
/>
);
}
export function useChatLLMDropdown() {
// external state
const { llms, chatLLMId, setChatLLMId } = useModelsStore(state => ({
llms: state.llms, // NOTE: we don't need a deep comparison as we reference the same array
chatLLMId: state.chatLLMId,
setChatLLMId: state.setChatLLMId,
}), shallow);
const chatLLMDropdown = React.useMemo(
() => <LLMDropdown llms={llms} chatLlmId={chatLLMId} setChatLlmId={setChatLLMId} />,
[llms, chatLLMId, setChatLLMId],
);
return { chatLLMId, chatLLMDropdown };
}
/*export function useTempLLMDropdown(props: { initialLlmId: DLLMId | null }) {
// local state
const [llmId, setLlmId] = React.useState<DLLMId | null>(props.initialLlmId);
// external state
const llms = useModelsStore(state => state.llms, shallow);
const chatLLMDropdown = React.useMemo(
() => <LLMDropdown llms={llms} llmId={llmId} setLlmId={setLlmId} />,
[llms, llmId, setLlmId],
);
return { llmId, chatLLMDropdown };
}*/
@@ -0,0 +1,83 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { usePurposeStore } from './persona-selector/store-purposes';
function PersonaDropdown(props: {
systemPurposeId: SystemPurposeId | null,
setSystemPurposeId: (systemPurposeId: SystemPurposeId | null) => void,
}) {
// external state
const hiddenPurposeIDs = usePurposeStore(state => state.hiddenPurposeIDs);
const { zenMode } = useUIPreferencesStore(state => ({
zenMode: state.zenMode,
}), shallow);
// filter by key in the object - must be missing the system purpose ids hidden by the user, or be the currently active one
const visibleSystemPurposes = React.useMemo(() => {
return Object.keys(SystemPurposes)
.filter(key => !hiddenPurposeIDs.includes(key as SystemPurposeId) || key === props.systemPurposeId)
.reduce((obj, key) => {
obj[key as SystemPurposeId] = SystemPurposes[key as SystemPurposeId];
return obj;
}, {} as typeof SystemPurposes);
}, [hiddenPurposeIDs, props.systemPurposeId]);
const { setSystemPurposeId } = props;
const handleSystemPurposeChange = React.useCallback((value: string | null) => {
setSystemPurposeId(value as (SystemPurposeId | null));
}, [setSystemPurposeId]);
return (
<PageBarDropdownMemo
items={visibleSystemPurposes}
value={props.systemPurposeId}
onChange={handleSystemPurposeChange}
showSymbols={zenMode !== 'cleaner'}
/>
);
}
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
// external state
const { systemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
return {
systemPurposeId: conversation?.systemPurposeId ?? null,
};
}, shallow);
const handleSetSystemPurposeId = React.useCallback((systemPurposeId: SystemPurposeId | null) => {
if (conversationId && systemPurposeId)
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
}, [conversationId]);
const personaDropdown = React.useMemo(() => {
if (!systemPurposeId) return null;
return (
<PersonaDropdown
systemPurposeId={systemPurposeId}
setSystemPurposeId={handleSetSystemPurposeId}
/>
);
},
[handleSetSystemPurposeId, systemPurposeId],
);
return { personaDropdown };
}
+6 -24
View File
@@ -1,38 +1,20 @@
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { DMessage, useChatStore } from '~/common/state/store-chats';
import { createAssistantTypingMessage } from './editors';
import { ConversationManager } from '~/common/chats/ConversationHandler';
export const runBrowseUpdatingState = async (conversationId: string, url: string) => {
export const runBrowseGetPageUpdatingState = async (conversationId: string, url: string) => {
const cHandler = ConversationManager.getHandler(conversationId);
const { editMessage } = useChatStore.getState();
// create a blank and 'typing' message for the assistant - to be filled when we're done
// const assistantModelStr = 'react-' + assistantModelId.slice(4, 7); // HACK: this is used to change the Avatar animation
// noinspection HttpUrlsUsage
const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', '');
const assistantMessageId = createAssistantTypingMessage(conversationId, 'web', undefined, `Loading page at ${shortUrl}...`);
const updateAssistantMessage = (update: Partial<DMessage>) => editMessage(conversationId, assistantMessageId, update, false);
const assistantMessageId = cHandler.messageAppendAssistant(`Loading page at ${shortUrl}...`, 'web', undefined);
try {
const page = await callBrowseFetchPage(url);
if (!page.content) {
// noinspection ExceptionCaughtLocallyJS
throw new Error('No text found.');
}
updateAssistantMessage({
text: page.content,
typing: false,
});
cHandler.messageEdit(assistantMessageId, { text: page.content || 'Issue: page load did not produce an answer: no text found', typing: false }, true);
} catch (error: any) {
console.error(error);
updateAssistantMessage({
text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').',
typing: false,
});
cHandler.messageEdit(assistantMessageId, { text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').', typing: false }, true);
}
};
+77 -49
View File
@@ -1,48 +1,53 @@
import { DLLMId } from '~/modules/llms/store-llms';
import type { DLLMId } from '~/modules/llms/store-llms';
import type { StreamingClientUpdate } from '~/modules/llms/vendors/unifiedStreamingClient';
import { SystemPurposeId } from '../../../data';
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { llmStreamingChatGenerate } from '~/modules/llms/llm.client';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import { DMessage, useChatStore } from '~/common/state/store-chats';
import type { DMessage } from '~/common/state/store-chats';
import { ConversationManager } from '~/common/chats/ConversationHandler';
import { ChatAutoSpeakType, getChatAutoAI } from '../store-app-chat';
import { createAssistantTypingMessage, updatePurposeInHistory } from './editors';
/**
* The main "chat" function. TODO: this is here so we can soon move it to the data model.
*/
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, systemPurpose: SystemPurposeId) {
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, systemPurpose: SystemPurposeId, parallelViewCount: number) {
const cHandler = ConversationManager.getHandler(conversationId);
// ai follow-up operations (fire/forget)
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
// update the system message from the active Purpose, if not manually edited
history = updatePurposeInHistory(conversationId, history, assistantLlmId, systemPurpose);
history = cHandler.resyncPurposeInHistory(history, assistantLlmId, systemPurpose);
// create a blank and 'typing' message for the assistant
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantLlmId, history[0].purposeId, '...');
const assistantMessageId = cHandler.messageAppendAssistant('...', assistantLlmId, history[0].purposeId);
// when an abort controller is set, the UI switches to the "stop" mode
const controller = new AbortController();
const { startTyping, editMessage } = useChatStore.getState();
startTyping(conversationId, controller);
const abortController = new AbortController();
cHandler.setAbortController(abortController);
// stream the assistant's messages
await streamAssistantMessage(
assistantLlmId, history,
assistantLlmId,
history,
parallelViewCount,
autoSpeak,
(updatedMessage) => editMessage(conversationId, assistantMessageId, updatedMessage, false),
controller.signal,
(update) => cHandler.messageEdit(assistantMessageId, update, false),
abortController.signal,
);
// clear to send, again
startTyping(conversationId, null);
cHandler.setAbortController(null);
if (autoTitleChat)
conversationAutoTitle(conversationId, false);
if (autoTitleChat) {
// fire/forget, this will only set the title if it's not already set
void conversationAutoTitle(conversationId, false);
}
if (autoSuggestDiagrams || autoSuggestQuestions)
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
@@ -50,53 +55,76 @@ export async function runAssistantUpdatingState(conversationId: string, history:
async function streamAssistantMessage(
llmId: DLLMId, history: DMessage[],
llmId: DLLMId,
history: DMessage[],
throttleUnits: number, // 0: disable, 1: default throttle (12Hz), 2+ reduce the message frequency with the square root
autoSpeak: ChatAutoSpeakType,
editMessage: (updatedMessage: Partial<DMessage>) => void,
editMessage: (update: Partial<DMessage>) => void,
abortSignal: AbortSignal,
) {
// speak once
let spokenText = '';
let spokenLine = false;
const messages = history.map(({ role, text }) => ({ role, content: text }));
try {
await llmStreamingChatGenerate(llmId, messages, null, null, abortSignal,
(updatedMessage: Partial<DMessage>) => {
// update the message in the store (and thus schedule a re-render)
editMessage(updatedMessage);
// 📢 TTS: first-line
if (updatedMessage?.text) {
spokenText = updatedMessage.text;
if (autoSpeak === 'firstLine' && !spokenLine) {
let cutPoint = spokenText.lastIndexOf('\n');
if (cutPoint < 0)
cutPoint = spokenText.lastIndexOf('. ');
if (cutPoint > 100 && cutPoint < 400) {
spokenLine = true;
const firstParagraph = spokenText.substring(0, cutPoint);
// Throttling setup
let lastCallTime = 0;
let throttleDelay = 1000 / 12; // 12 messages per second works well for 60Hz displays (single chat, and 24 in 4 chats, see the square root below)
if (throttleUnits > 1)
throttleDelay = Math.round(throttleDelay * Math.sqrt(throttleUnits));
// fire/forget: we don't want to stall this loop
void speakText(firstParagraph);
}
}
}
},
);
} catch (error: any) {
if (error?.name !== 'AbortError') {
console.error('Fetch request error:', error);
// TODO: show an error to the UI?
function throttledEditMessage(updatedMessage: Partial<DMessage>) {
const now = Date.now();
if (throttleUnits === 0 || now - lastCallTime >= throttleDelay) {
editMessage(updatedMessage);
lastCallTime = now;
}
}
// 📢 TTS: all
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && spokenText && !spokenLine && !abortSignal.aborted)
void speakText(spokenText);
const incrementalAnswer: Partial<DMessage> = { text: '' };
// finally, stop the typing animation
editMessage({ typing: false });
try {
await llmStreamingChatGenerate(llmId, messages, null, null, abortSignal, (update: StreamingClientUpdate) => {
const textSoFar = update.textSoFar;
// grow the incremental message
if (update.originLLM) incrementalAnswer.originLLM = update.originLLM;
if (textSoFar) incrementalAnswer.text = textSoFar;
if (update.typing !== undefined) incrementalAnswer.typing = update.typing;
// Update the data store, with optional max-frequency throttling (e.g. OpenAI is downsamped 50 -> 12Hz)
// This can be toggled from the settings
throttledEditMessage(incrementalAnswer);
// 📢 TTS: first-line
if (textSoFar && autoSpeak === 'firstLine' && !spokenLine) {
let cutPoint = textSoFar.lastIndexOf('\n');
if (cutPoint < 0)
cutPoint = textSoFar.lastIndexOf('. ');
if (cutPoint > 100 && cutPoint < 400) {
spokenLine = true;
const firstParagraph = textSoFar.substring(0, cutPoint);
// fire/forget: we don't want to stall this loop
void speakText(firstParagraph);
}
}
});
} catch (error: any) {
if (error?.name !== 'AbortError') {
console.error('Fetch request error:', error);
const errorText = ` [Issue: ${error.message || (typeof error === 'string' ? error : 'Chat stopped.')}]`;
incrementalAnswer.text = (incrementalAnswer.text || '') + errorText;
}
}
// Optimized:
// 1 - stop the typing animation
// 2 - ensure the last content is flushed out
editMessage({ ...incrementalAnswer, typing: false });
// 📢 TTS: all
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && incrementalAnswer.text && !spokenLine && !abortSignal.aborted)
void speakText(incrementalAnswer.text);
}
-33
View File
@@ -1,33 +0,0 @@
import { DLLMId } from '~/modules/llms/store-llms';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | string /* 'DALL·E' | 'Prodia' | 'react-...' | 'web' */, assistantPurposeId: SystemPurposeId | undefined, text: string): string {
const assistantMessage: DMessage = createDMessage('assistant', text);
assistantMessage.typing = true;
assistantMessage.purposeId = assistantPurposeId;
assistantMessage.originLLM = assistantLlmLabel;
useChatStore.getState().appendMessage(conversationId, assistantMessage);
return assistantMessage.id;
}
export function updatePurposeInHistory(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, purposeId: SystemPurposeId): DMessage[] {
const systemMessageIndex = history.findIndex(m => m.role === 'system');
const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', '');
if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) {
systemMessage.purposeId = purposeId;
systemMessage.text = SystemPurposes[purposeId].systemMessage
.replaceAll('{{Cutoff}}', assistantLlmId.includes('1106') ? '2023-04' : '2021-09')
.replaceAll('{{Today}}', new Date().toISOString().split('T')[0]);
// HACK: this is a special case for the "Custom" persona, to set the message in stone (so it doesn't get updated when switching to another persona)
if (purposeId === 'Custom')
systemMessage.updated = Date.now();
}
history.unshift(systemMessage);
useChatStore.getState().setMessages(conversationId, history);
return history;
}
+23 -20
View File
@@ -1,39 +1,42 @@
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageOrThrow } from '~/modules/t2i/t2i.client';
import { useChatStore } from '~/common/state/store-chats';
import { createAssistantTypingMessage } from './editors';
import { ConversationManager } from '~/common/chats/ConversationHandler';
import { TextToImageProvider } from '~/common/components/useCapabilities';
/**
* Text to image, appended as an 'assistant' message
*/
export async function runImageGenerationUpdatingState(conversationId: string, imageText: string) {
const handler = ConversationManager.getHandler(conversationId);
// Acquire the active TextToImageProvider
let t2iProvider: TextToImageProvider | undefined = undefined;
try {
t2iProvider = getActiveTextToImageProviderOrThrow();
} catch (error: any) {
const assistantErrorMessageId = handler.messageAppendAssistant(`[Issue] Sorry, I can't generate images right now. ${error?.message || error?.toString() || 'Unknown error'}.`, 'issue', undefined);
handler.messageEdit(assistantErrorMessageId, { typing: false }, true);
return;
}
// if the imageText ends with " xN" or " [N]" (where N is a number), then we'll generate N images
const match = imageText.match(/\sx(\d+)$|\s\[(\d+)]$/);
const count = match ? parseInt(match[1] || match[2], 10) : 1;
if (count > 1)
const repeat = match ? parseInt(match[1] || match[2], 10) : 1;
if (repeat > 1)
imageText = imageText.replace(/x(\d+)$|\[(\d+)]$/, '').trim(); // Remove the "xN" or "[N]" part from the imageText
// create a blank and 'typing' message for the assistant
const assistantMessageId = createAssistantTypingMessage(conversationId, '', undefined,
`Give me a few seconds while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'}...`);
// reference the state editing functions
const { editMessage } = useChatStore.getState();
const assistantMessageId = handler.messageAppendAssistant(
`Give me ${t2iProvider.vendor === 'openai' ? 'a dozen' : 'a few'} seconds while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'}...`,
'', undefined,
);
handler.messageEdit(assistantMessageId, { originLLM: t2iProvider.painter }, false);
try {
const t2iProvider = getActiveTextToImageProviderOrThrow();
editMessage(conversationId, assistantMessageId, { originLLM: t2iProvider.painter }, false);
const imageUrls = await t2iGenerateImageOrThrow(t2iProvider, imageText, count);
editMessage(conversationId, assistantMessageId, { text: imageUrls.join('\n'), typing: false }, true);
const imageUrls = await t2iGenerateImageOrThrow(t2iProvider, imageText, repeat);
handler.messageEdit(assistantMessageId, { text: imageUrls.join('\n'), typing: false }, true);
} catch (error: any) {
const errorMessage = error?.message || error?.toString() || 'Unknown error';
if (assistantMessageId)
editMessage(conversationId, assistantMessageId, { text: `[Issue] Sorry, I couldn't create an image for you. ${errorMessage}`, typing: false }, false);
handler.messageEdit(assistantMessageId, { text: `[Issue] Sorry, I couldn't create an image for you. ${errorMessage}`, typing: false }, false);
}
}
+11 -17
View File
@@ -2,37 +2,31 @@ import { Agent } from '~/modules/aifn/react/react';
import { DLLMId } from '~/modules/llms/store-llms';
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
import { createDEphemeral, DMessage, useChatStore } from '~/common/state/store-chats';
import { ConversationManager } from '~/common/chats/ConversationHandler';
import { createAssistantTypingMessage } from './editors';
const EPHEMERAL_DELETION_DELAY = 5 * 1000;
/**
* Synchronous ReAct chat function - TODO: event loop, auto-ui, cleanups, etc.
*/
export async function runReActUpdatingState(conversationId: string, question: string, assistantLlmId: DLLMId) {
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
const { appendEphemeral, updateEphemeralText, updateEphemeralState, deleteEphemeral, editMessage } = useChatStore.getState();
const cHandler = ConversationManager.getHandler(conversationId);
// create a blank and 'typing' message for the assistant - to be filled when we're done
const assistantModelLabel = 'react-' + assistantLlmId.slice(4, 7); // HACK: this is used to change the Avatar animation
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantModelLabel, undefined, '...');
const updateAssistantMessage = (update: Partial<DMessage>) =>
editMessage(conversationId, assistantMessageId, update, false);
const assistantMessageId = cHandler.messageAppendAssistant('...', assistantModelLabel, undefined);
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
// create an ephemeral space
const ephemeral = createDEphemeral(`Reason+Act`, 'Initializing ReAct..');
appendEphemeral(conversationId, ephemeral);
const eHandler = cHandler.createEphemeral(`Reason+Act`, 'Initializing ReAct..');
let ephemeralText = '';
const logToEphemeral = (text: string) => {
console.log(text);
ephemeralText += (text.length > 300 ? text.slice(0, 300) + '...' : text) + '\n';
updateEphemeralText(conversationId, ephemeral.id, ephemeralText);
eHandler.updateText(ephemeralText);
};
const showStateInEphemeral = (state: object) => updateEphemeralState(conversationId, ephemeral.id, state);
const showStateInEphemeral = (state: object) => eHandler.updateState(state);
try {
@@ -40,12 +34,12 @@ export async function runReActUpdatingState(conversationId: string, question: st
const agent = new Agent();
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 4 * 1000);
updateAssistantMessage({ text: reactResult, typing: false });
cHandler.messageEdit(assistantMessageId, { text: reactResult, typing: false }, false);
setTimeout(() => eHandler.delete(), EPHEMERAL_DELETION_DELAY);
} catch (error: any) {
console.error(error);
logToEphemeral(ephemeralText + `\nIssue: ${error || 'unknown'}`);
updateAssistantMessage({ text: 'Issue: ReAct did not produce an answer.', typing: false });
cHandler.messageEdit(assistantMessageId, { text: 'Issue: ReAct did not produce an answer.', typing: false }, false);
}
}
+16
View File
@@ -10,6 +10,8 @@ export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
interface AppChatStore {
// chat AI
autoSpeak: ChatAutoSpeakType;
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void;
@@ -22,9 +24,14 @@ interface AppChatStore {
autoTitleChat: boolean;
setAutoTitleChat: (autoTitleChat: boolean) => void;
// chat UI
micTimeoutMs: number;
setMicTimeoutMs: (micTimeoutMs: number) => void;
showRelativeSize: boolean;
setShowRelativeSize: (showRelativeSize: boolean) => void;
showTextDiff: boolean;
setShowTextDiff: (showTextDiff: boolean) => void;
@@ -52,6 +59,9 @@ const useAppChatStore = create<AppChatStore>()(persist(
micTimeoutMs: 2000,
setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }),
showRelativeSize: false,
setShowRelativeSize: (showRelativeSize: boolean) => _set({ showRelativeSize }),
showTextDiff: false,
setShowTextDiff: (showTextDiff: boolean) => _set({ showTextDiff }),
@@ -103,6 +113,12 @@ export const useChatMicTimeoutMsValue = (): number =>
export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void] =>
useAppChatStore(state => [state.micTimeoutMs, state.setMicTimeoutMs], shallow);
export const useChatShowRelativeSize = (): { showRelativeSize: boolean, toggleRelativeSize: () => void } => {
const showRelativeSize = useAppChatStore(state => state.showRelativeSize);
const toggleRelativeSize = () => useAppChatStore.getState().setShowRelativeSize(!showRelativeSize);
return { showRelativeSize, toggleRelativeSize };
};
export const useChatShowTextDiff = (): [boolean, (showDiff: boolean) => void] =>
useAppChatStore(state => [state.showTextDiff, state.setShowTextDiff], shallow);
+5 -4
View File
@@ -6,7 +6,6 @@ import { useRouterQuery } from '~/common/app.routes';
import { DrawHeading } from './components/DrawHeading';
import { DrawUnconfigured } from './components/DrawUnconfigured';
import { Gallery } from './Gallery';
import { TextToImage } from './TextToImage';
@@ -18,6 +17,7 @@ export interface AppDrawIntent {
export function AppDraw() {
// state
const [showHeading, setShowHeading] = React.useState<boolean>(true);
const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
const [section, setSection] = React.useState<number>(0);
@@ -45,19 +45,20 @@ export function AppDraw() {
{/* The container is a 100dvh, flex column with App bg (see `pageCoreSx`) */}
<DrawHeading
{showHeading && <DrawHeading
section={section}
setSection={setSection}
showSections
onRemoveHeading={() => setShowHeading(false)}
sx={{
px: { xs: 1, md: 2 },
py: { xs: 1, md: 6 },
}}
/>
/>}
{!mayWork && <DrawUnconfigured />}
{mayWork && <Gallery />}
{/*{mayWork && <Gallery />}*/}
{mayWork && (
<TextToImage
+1 -1
View File
@@ -4,7 +4,7 @@ import * as React from 'react';
export function Gallery() {
return (
<AppPlaceholder text='Drawing App is under development. v1.12 or v1.13.' />
<AppPlaceholder text='Drawing App is under development. v1.13 or v1.14.' />
);
}
+107 -16
View File
@@ -1,13 +1,92 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Box } from '@mui/joy';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Card, Skeleton } from '@mui/joy';
import type { ImageBlock } from '~/modules/blocks/blocks';
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageOrThrow } from '~/modules/t2i/t2i.client';
import { heuristicMarkdownImageReferenceBlocks } from '~/modules/blocks/RenderImage';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { InlineError } from '~/common/components/InlineError';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { DesignerPrompt, PromptDesigner } from './components/PromptDesigner';
import { ProviderConfigure } from './components/ProviderConfigure';
const STILL_LAYOUTING = false;
/**
* @returns up-to `vectorSize` image URLs
*/
async function queryActiveGenerateImageVector(singlePrompt: string, vectorSize: number = 1) {
const t2iProvider = getActiveTextToImageProviderOrThrow();
const mdStringsVector = await t2iGenerateImageOrThrow(t2iProvider, singlePrompt, vectorSize);
if (!mdStringsVector?.length)
throw new Error('No image generated');
const block = heuristicMarkdownImageReferenceBlocks(mdStringsVector.join('\n'));
if (!block?.length)
throw new Error('No URLs in the generated images');
return block;
}
function TempPromptImageGen(props: { prompt: DesignerPrompt, sx?: SxProps }) {
// NOTE: we shall consider a multidimensional shape-based design
// derived state
const { prompt: dp } = props;
// external state
const { data: imageBlocks, error, isLoading } = useQuery<ImageBlock[], Error>({
enabled: !!dp.prompt,
queryKey: ['draw-uuid', dp.uuid],
queryFn: () => queryActiveGenerateImageVector(dp.prompt, dp._repeatCount),
refetchOnReconnect: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
staleTime: Infinity,
});
return <>
{error && <InlineError error={error} />}
{Array.from({ length: dp._repeatCount }).map((_, index) => {
const imgUid = `gen-img-${index}`;
const imageBlock = imageBlocks?.[index] || null;
return imageBlock
// ? <RenderImage key={imgUid} imageBlock={imageBlock} noTooltip />
? <Box sx={{
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
mx: 'auto', my: 'auto', // mt: (index > 0 || !props.isFirst) ? 1.5 : 0,
boxShadow: 'lg',
backgroundColor: 'neutral.solidBg',
'& picture': { display: 'flex' },
'& img': { maxWidth: '100%', maxHeight: '100%' },
}}>
<picture><img src={imageBlock.url} alt={imageBlock.alt} /></picture>
</Box>
: <Card key={imgUid} sx={{ mb: 'auto' }}>
<Skeleton animation='wave' variant='rectangular' sx={{ minWidth: 128, width: '100%', aspectRatio: 1 }} />
</Card>;
})}
</>;
};
export function TextToImage(props: {
isMobile: boolean,
providers: TextToImageProvider[],
@@ -23,8 +102,8 @@ export function TextToImage(props: {
setPrompts([]);
}, []);
const handlePromptEnqueue = React.useCallback((prompt: DesignerPrompt) => {
setPrompts(prompts => [...prompts, prompt]);
const handlePromptEnqueue = React.useCallback((prompts: DesignerPrompt[]) => {
setPrompts((prevPrompts) => [...prompts, ...prevPrompts]);
}, []);
@@ -39,28 +118,39 @@ export function TextToImage(props: {
}}
/>
{/* Placeholder */}
{/* TMP Body */}
<Box sx={{
flexGrow: 1,
overflowY: 'auto',
// style
backgroundColor: 'background.level2',
// border: '1px solid blue',
border: STILL_LAYOUTING ? '1px solid blue' : undefined,
p: { xs: 1, md: 2 },
}}>
<Box sx={{
my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
border: '1px solid red',
// my: 'auto',
// display: 'flex', flexDirection: 'column', alignItems: 'center',
border: STILL_LAYOUTING ? '1px solid purple' : undefined,
minHeight: '300px',
// layout
display: 'grid',
gridTemplateColumns: props.isMobile ? 'repeat(auto-fit, minmax(320px, 1fr))' : 'repeat(auto-fit, minmax(400px, 1fr))',
gap: { xs: 2, md: 2 },
}}>
{prompts.map((prompt, index) => (
<Box key={index} sx={{
border: '1px solid green',
width: '100%',
}}>
{prompt.prompt}
</Box>
))}
{prompts.map((prompt, index) => {
return (
<TempPromptImageGen
key={prompt.uuid}
prompt={prompt}
sx={{
border: STILL_LAYOUTING ? '1px solid green' : undefined,
}}
/>
);
})}
</Box>
</Box>
@@ -70,6 +160,7 @@ export function TextToImage(props: {
onDrawingStop={handleStopDrawing}
onPromptEnqueue={handlePromptEnqueue}
sx={{
backgroundColor: themeBgAppChatComposer,
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },

Some files were not shown because too many files have changed in this diff Show More