Compare commits

...

379 Commits

Author SHA1 Message Date
Enrico Ros 4c79b95ddc Merge remote-tracking branch 'opensource/main-stable'
# Conflicts:
#	package-lock.json
2024-01-26 05:00:38 -08:00
Enrico Ros 720945f903 Maintainers template update 2024-01-26 04:41:41 -08:00
Enrico Ros 7ee8f218f6 Maintainers command update 2024-01-26 04:36:24 -08:00
Enrico Ros 72f9e01e60 Merge branch 'release-1.12.0' 2024-01-26 04:35:39 -08:00
Enrico Ros b4bae3ba20 1.12.0: Changelog 2024-01-26 04:35:23 -08:00
Enrico Ros 7c67dbd1f2 1.12.0: Readme Video 2024-01-26 04:33:01 -08:00
Enrico Ros ac8da8dfbf 1.12.0: Readme Update 2024-01-26 04:11:06 -08:00
Enrico Ros 1d778a699a Release Template update 2024-01-26 04:10:45 -08:00
Enrico Ros 0ac3033320 1.12.0: news.data.tsx 2024-01-26 04:10:33 -08:00
Enrico Ros c65aa99f9e 1.12.0: Version 2024-01-26 02:32:42 -08:00
Enrico Ros b22d54254a Nav: move back from the bottom to the menu 2024-01-26 02:14:00 -08:00
Enrico Ros 3eeb4aa157 Rounder numbers 2024-01-26 02:13:40 -08:00
Enrico Ros fac237638f Focus Mode: disable 2024-01-26 02:05:00 -08:00
Enrico Ros 7b617c5d03 Lints 2024-01-26 01:47:53 -08:00
Enrico Ros 3a579f3468 Roll deps 2024-01-26 01:47:41 -08:00
Enrico Ros 2bf407a989 Ollama: update available upstream models
Can't wait for https://github.com/ollama/ollama/issues/1473 enough.
2024-01-26 01:43:09 -08:00
Enrico Ros 18a16294bc Ollama: extract contextWindow from num_ctx. Closes #309
Note that from testing, only yarn-mistral has a number set that's not 4096,
while some models don't have parameters, don't have a 'num_ctx' value to parse
within, or have it set to 4096.
2024-01-26 01:04:55 -08:00
Enrico Ros db1346fe3e Ollama: extract Zod parsers 2024-01-26 00:01:35 -08:00
Enrico Ros 2b3477feb0 Drawing: disable by default, add option, and disable that too 2024-01-25 23:52:19 -08:00
Enrico Ros b7bc715b36 OpenAI models: 1106 visible 2024-01-25 17:18:54 -08:00
Enrico Ros bc237dee1c OpenAI models: sync with today's released/announced models 2024-01-25 17:12:12 -08:00
Enrico Ros 6131556bab OpenAI models: improve sorting 2024-01-25 17:11:19 -08:00
Enrico Ros 3d42bc51f3 Roll it 2024-01-25 14:49:13 -08:00
Enrico Ros 3f3f3c67bf Draw Mode: Begin some wiring 2024-01-25 04:31:26 -08:00
Enrico Ros eeaa87bde3 Draw Mode: Improve Layout 2024-01-25 03:58:52 -08:00
Enrico Ros f854f0182f Draw Mode: Extract ProviderConfigure 2024-01-25 03:52:37 -08:00
Enrico Ros 302e327d2d Remove hook dependency 2024-01-25 02:54:01 -08:00
Enrico Ros 2d18a81654 Draw Mode: Extract Prompt Designer 2024-01-25 02:40:52 -08:00
Enrico Ros 71a97e1c4e Draw Mode: Prompt Designer layout 2024-01-25 00:27:57 -08:00
Enrico Ros 542b47ba78 Draw Mode: propagate isMobile 2024-01-25 00:07:13 -08:00
Enrico Ros d27f269abc Draw Mode: ideas 2024-01-25 00:06:59 -08:00
Enrico Ros b0484e24af Style: adjust dividers 2024-01-24 23:06:18 -08:00
Enrico Ros fa8c4a30d8 Export: remove from the Chat Menu 2024-01-24 23:00:58 -08:00
Enrico Ros f6163b5a22 Export: to Drawer Item 2024-01-24 22:56:38 -08:00
Enrico Ros 8f945f11e7 Sharing: change a couple of strings 2024-01-24 21:53:07 -08:00
Enrico Ros fa7a7bdf1d Edit: use accessible Icons, no Text 2024-01-24 21:45:42 -08:00
Enrico Ros fe7a2caf2c Mobile Nav: land. 2024-01-24 16:45:19 -08:00
Enrico Ros 6ae11d07eb Nav: improvements 2024-01-24 15:49:33 -08:00
Enrico Ros 58896a7052 Export improvements and Export to Markdown, Closes #337 2024-01-24 15:26:00 -08:00
Enrico Ros 1f83210792 Ollama: track upstream ticket - https://github.com/ollama/ollama/issues/1473 2024-01-24 14:47:41 -08:00
Enrico Ros 0a4a452bee Optimize GithubMarkdown dark/light 2024-01-24 06:31:54 -08:00
Enrico Ros 8063ee34b3 GithubMarkdown: update from upstream 2024-01-24 06:05:46 -08:00
Enrico Ros 72e2fa41aa Accessibility: finish with some good improvements, #358 2024-01-24 04:59:01 -08:00
Enrico Ros 1c3f8ba8ec Accessibility: improve social links (was 2 tabIndexes), #358 2024-01-24 04:38:03 -08:00
Enrico Ros e1802cb0f8 Misc UX cleanups 2024-01-24 04:32:41 -08:00
Enrico Ros afeab71da1 Actiles: shall support Mobile now 2024-01-24 04:06:00 -08:00
Enrico Ros 8d492702f2 Cleanups 2024-01-24 03:20:49 -08:00
Enrico Ros 64e8cfcb03 Accessibility: Call: fix buttons, #358 2024-01-24 02:55:48 -08:00
Enrico Ros 2167d0ef1e Accessibility: improve HTML elements for manage models, preferences, closing the dialogs, model list, model selection, model unhide, #358 2024-01-24 02:33:16 -08:00
Enrico Ros 977b14494b Lint 2024-01-24 01:23:10 -08:00
Enrico Ros 3b408c8173 Optimization: sx stability 2024-01-24 00:49:55 -08:00
Enrico Ros 9547b25835 Debouncer: min 2 chars (no single-letter searches, as there are too many positives) 2024-01-24 00:49:45 -08:00
Enrico Ros 9c53557183 Chat Item Folder: fix button size 2024-01-24 00:29:45 -08:00
Enrico Ros 3cc8d48b75 Roll packages 2024-01-24 00:22:45 -08:00
Enrico Ros 71dbc653a9 Accessibility: use nav/aside/main/header. #358 2024-01-24 00:19:18 -08:00
Enrico Ros f1e8bf3d1f Wording 2024-01-23 22:16:58 -08:00
Enrico Ros 0c8dd4a4d9 Large Optimizations 2024-01-23 22:16:43 -08:00
Enrico Ros 4911f39793 Optimizations (more) 2024-01-23 21:40:44 -08:00
Enrico Ros daaf33a69e Optimizations 2024-01-23 21:14:41 -08:00
Enrico Ros 8b04d38ce3 Folder selection: remove fading - Closes #360 2024-01-23 20:45:22 -08:00
Enrico Ros 4a35701def Folders: change Folder from the Drawer Chat Item as well, #321 2024-01-23 20:36:54 -08:00
Enrico Ros 8800cae62f Remove unused setting 2024-01-23 18:56:45 -08:00
Enrico Ros aebf7b99f4 Folders: drawer cleanup code 2024-01-23 18:54:19 -08:00
Enrico Ros a9ea4070ff Folders: uniform 'active' name for folders and chats 2024-01-23 16:49:23 -08:00
Enrico Ros 3fb8d91ab1 Folders: minutia 2024-01-23 16:31:48 -08:00
Enrico Ros a9943e26af Folders: de-confuse variable 2024-01-23 16:23:24 -08:00
Enrico Ros 514ecedf1c Prompts store: improve deletion 2024-01-23 16:00:53 -08:00
Enrico Ros 74a277a6f3 Prompts store: stay (significantly)below localStorage quota 2024-01-23 15:56:03 -08:00
Enrico Ros b14cd47a7b Tiktoken [x4, port]: successfully defer the library load, with large interactivity improvements
The rationals that TTFP would be a more important metric than
awaiting the 1.2MB dependency at every page load

(cherry picked from commit 3a8195a02b)
(cherry picked from commit 808077bc2b)
(cherry picked from commit 76f6c7917c)
(cherry picked from commit fc1fc91845)
2024-01-23 05:03:20 -08:00
Enrico Ros c1a29d76d5 Roll 'tiktoken' (fka. @dqbd/tiktoken) 2024-01-23 05:01:17 -08:00
Enrico Ros 3a8195a02b TikToken delayed loading: debug removal 2024-01-23 04:56:27 -08:00
Enrico Ros f70b0474ad Disabled debug(prod) code 2024-01-23 04:53:15 -08:00
Enrico Ros 808077bc2b Tiktoken: successfully defer the library load, with large interactivity improvements 2024-01-23 04:52:44 -08:00
Enrico Ros 76f6c7917c Tiktoken: do not warm-up the encoding for the current model
The rationals that TTFP would be a more important metric than
awaiting the 1.2MB dependency at every page load
2024-01-23 03:04:47 -08:00
Enrico Ros fc1fc91845 Tiktoken: make sharing of the 100k explicit 2024-01-23 03:00:18 -08:00
Enrico Ros 72d5a8f5f0 Roll 'tiktoken' (fka. @dqbd/tiktoken) 2024-01-23 03:00:04 -08:00
Enrico Ros 53226da794 Roll Packages 2024-01-23 02:47:57 -08:00
Enrico Ros 638bd1e780 Roll NextJS 2024-01-23 02:10:59 -08:00
Enrico Ros 046d193af8 Chat Drawer: rename 2024-01-23 01:48:32 -08:00
Enrico Ros ff0cc09505 DALL·E: salvage successful requests, and throw a more descriptive errors. #353 2024-01-23 01:39:02 -08:00
Enrico Ros b52468dd54 Link: fix size 2024-01-22 19:03:10 -08:00
Enrico Ros 76cadaed18 Docs: fix npm start vs next start (thanks @motocycle) 2024-01-22 18:54:32 -08:00
Enrico Ros 2e68172fa9 Nav: bring back hide on mobile 2024-01-22 18:54:00 -08:00
Enrico Ros 4bbed2adb1 Link: option to show the deletion keys in the drawer 2024-01-22 18:12:52 -08:00
Enrico Ros fb4a62be16 Merge branch 'issue-356'. Fixes #356 2024-01-22 18:05:12 -08:00
Enrico Ros 5da3a887c4 Link: enable delete. Fixes #356 2024-01-22 18:04:13 -08:00
Enrico Ros 2df49977c2 Link: improve deletion 2024-01-22 18:03:40 -08:00
Enrico Ros d275ee0f7d LinkChat: improve appearance 2024-01-22 17:11:18 -08:00
Enrico Ros 19ec67bf3c Links: more uniform route names 2024-01-22 17:10:22 -08:00
Enrico Ros 9dc8aaa9aa Links: renames 2024-01-22 17:09:59 -08:00
Enrico Ros 15cfef0f8b Links: land to former conversations #356 2024-01-22 16:03:59 -08:00
Enrico Ros 695af02cee Nav: functions for nav/icon visibility #356 2024-01-22 15:53:42 -08:00
Enrico Ros 1ed86b6ebc Disable Nav items debug code 2024-01-22 15:41:25 -08:00
Enrico Ros e18ac02af9 Links: update text 2024-01-22 14:44:57 -08:00
Enrico Ros a4d89c9e2c Links: Import as New/Over #356 2024-01-22 14:43:28 -08:00
Enrico Ros 911c46ebe2 Bring back debug conv IDs. 2024-01-22 14:42:30 -08:00
Enrico Ros f0073133c3 Trade: update deletion key #356 2024-01-22 14:35:09 -08:00
Enrico Ros db3a435027 Trade: cleanups 2024-01-22 14:00:44 -08:00
Enrico Ros a94f2c6df3 Trade: move the store 2024-01-22 13:50:26 -08:00
Enrico Ros 0b7eaf69ba Trade: extract Publish (paste.gg) 2024-01-22 13:47:52 -08:00
Enrico Ros 326f49bafc Trade: extract ChatLinkManager 2024-01-22 13:32:58 -08:00
Enrico Ros 6195c8954d Draw: improve layout 2024-01-21 22:09:02 -08:00
Enrico Ros 1586377ead Draw: (used) selector 2024-01-21 20:12:45 -08:00
Enrico Ros 97b1f15121 Draw: TextToImage: layout 2024-01-21 19:23:34 -08:00
Enrico Ros 6d185119ac useToggleableBoolean: remember last state in-mem 2024-01-21 19:07:34 -08:00
Enrico Ros 296eff7278 Dall-E: disable advanced by default 2024-01-21 18:56:13 -08:00
Enrico Ros 84b1825895 Draw: Service Configuration 2024-01-21 18:52:27 -08:00
Enrico Ros a69c067530 Draw: select Service Provider 2024-01-21 18:35:43 -08:00
Enrico Ros 0043b39293 Preferences: remove hardcodings 2024-01-21 18:03:57 -08:00
Enrico Ros 8123c237e3 Draw: begin breakdown 2024-01-21 17:47:00 -08:00
Enrico Ros 5a0fb1bb63 Draw: t2i settings 2024-01-20 20:05:50 -08:00
Enrico Ros a507d53d34 Draw: keep the placeholder 2024-01-20 20:03:09 -08:00
Enrico Ros 60cbcdaedb Draw: header 2024-01-20 20:02:30 -08:00
Enrico Ros 96b4f502f1 Draw: placeholder. 2024-01-20 19:47:24 -08:00
Enrico Ros 846b3cddaf Fix possible exception when fetching from ElevenLabs 2024-01-20 19:41:28 -08:00
Enrico Ros 1b66dce9f0 Desktop Layout: show App separator + bits 2024-01-20 19:37:02 -08:00
Enrico Ros c7952ae974 Call: hide hidden personas (fixed) 2024-01-20 19:11:09 -08:00
Enrico Ros ed2284716b Call: hide hidden personas 2024-01-20 19:09:21 -08:00
Enrico Ros d64ed69371 Change hint text 2024-01-20 19:05:55 -08:00
Enrico Ros e73bf2ddec Call: persist all 3 settings 2024-01-20 18:59:12 -08:00
Enrico Ros 19609e5ccd Call: simplify~1 2024-01-20 18:41:38 -08:00
Enrico Ros 3adc2f4654 Call: allow for Gray UI, and cleanup CSS 2024-01-20 18:25:15 -08:00
Enrico Ros 2b95b6ace1 Calls: customizable Contacts page 2024-01-20 17:35:23 -08:00
Enrico Ros 5720de1224 Page Bar: Custom Title and big-AGI (=back) button 2024-01-20 17:21:49 -08:00
Enrico Ros 1b110f5a38 Remove Shared Page Drawer 2024-01-20 17:19:16 -08:00
Enrico Ros 0785961581 Call: add support link 2024-01-20 17:17:38 -08:00
Enrico Ros f1cc92727c Call: fix looping on missing Conversation 2024-01-19 22:40:39 -08:00
Enrico Ros b36197ffad Call: fix double-message on error 2024-01-19 22:35:12 -08:00
Enrico Ros eae3d78911 Call: take out of Beta
Also remove the option to call from the Dropdown menu, which
was an initial workaround anyway.
2024-01-19 22:04:41 -08:00
Enrico Ros 12a93fdcb7 Update Prodia Default 2024-01-19 21:55:07 -08:00
Enrico Ros c98ab8cb9d Call: do not display "Re:" if no call 2024-01-19 21:50:13 -08:00
Enrico Ros 8619a9ca1d Call App: style++. 2024-01-19 20:19:47 -08:00
Enrico Ros 2b182a4209 When the bar is shown, show the menu (for Dark/Light mode) 2024-01-19 15:19:32 -08:00
Enrico Ros ddc7d571d2 Call App: Done. Beauty & Function. #175. Closes #354. 2024-01-19 15:19:07 -08:00
Enrico Ros 3de693e5e3 Enabled the Call app 2024-01-18 18:41:01 -08:00
Enrico Ros 770fbdef72 Bits 2024-01-18 18:40:28 -08:00
Enrico Ros 80d9f458bb Style: fill active icons 2024-01-18 17:14:01 -08:00
Enrico Ros 52f91dd328 Style: fill active icons 2024-01-18 17:13:35 -08:00
Enrico Ros 22550f7efb Style: update icons 2024-01-18 16:59:54 -08:00
Enrico Ros f811b59919 Style: bring back the former behavior of the Links 2024-01-18 16:51:46 -08:00
Enrico Ros d2344e5010 Style the Desktop Nav Panel 2024-01-18 16:33:26 -08:00
Enrico Ros 6fee9a6238 Call: improve styling, honor dark/bright mode - #175 2024-01-17 16:58:53 -08:00
Enrico Ros 08730002a4 Call: some top-level structure 2024-01-17 16:01:16 -08:00
Enrico Ros 20adb796c0 Call: update strings 2024-01-17 15:36:13 -08:00
Enrico Ros 0e7cbfe0e4 Improve Mobile Insert - save space - looks better. Fixes #315 2024-01-17 14:48:21 -08:00
Enrico Ros 46ef5d9b45 Update roadmap request 2024-01-17 14:15:19 -08:00
Enrico Ros f249b39db5 Drawer: radius on mobile, and optimize 2024-01-17 14:01:39 -08:00
Enrico Ros 280bb2e424 Attachments: when pasted from cliboard (no ref), add a "<!DOCTYPE html>" to render as HTML block - Fixes #348. 2024-01-17 13:15:42 -08:00
Enrico Ros 8c206aedb9 Simplify Structural BgColors 2024-01-17 13:09:34 -08:00
Enrico Ros d74b7df41d Select All -> Select #. 2024-01-16 05:12:33 -08:00
Enrico Ros 571a04cf6c Rename/Auto-Name conversations and New UI Conversation Item. Fixes #222, Fixes #297. 2024-01-16 04:49:44 -08:00
Enrico Ros 216dae9423 InlineTextarea: support inverted soft colors 2024-01-16 04:49:02 -08:00
Enrico Ros ef09d50715 AutoTitle: make it force-able 2024-01-16 04:11:43 -08:00
Enrico Ros 1e851bbb6c Style: more consistent gaps and paddings across settings 2024-01-16 02:10:46 -08:00
Enrico Ros 3c63593141 Models list: stop the never ending scrolling by absorbing it on the LLMs list 2024-01-16 01:41:35 -08:00
Enrico Ros 6ef32e52ba Mic Continuation: stop if error. Also reset the error state on external manual stop. Fixes #302 2024-01-16 01:12:32 -08:00
Enrico Ros 682c168372 Attachments: further improve heuristics, mainly for powerpoint. #286 2024-01-16 01:02:28 -08:00
Enrico Ros 48f039517d Attachments: show when attaching a rich table or rich html. #286 2024-01-16 00:48:21 -08:00
Enrico Ros 7ebeea3550 Attachments: Excel: paste as Table/HTML/Text rather than image. Fixes #286 2024-01-16 00:38:17 -08:00
Enrico Ros a7a234ecca Attachments: debug option 2024-01-16 00:11:02 -08:00
Enrico Ros a237e53580 Roll pdfjs 2024-01-15 23:51:44 -08:00
Enrico Ros 584544d037 Roll packages 2024-01-15 23:37:18 -08:00
Enrico Ros a601dfa4cf VercelSpeedInsights: 10% sampling rate, to reduce Speed Insights volume 2024-01-15 23:36:57 -08:00
Enrico Ros dbee0d7b87 Update README.md 2024-01-15 22:30:07 -08:00
Enrico Ros ff4857b9ac Merge branch 'release-1.11.0' 2024-01-15 22:11:12 -08:00
Enrico Ros 5b557705e7 1.11.0: Readme and Changelog 2024-01-15 22:10:39 -08:00
Enrico Ros cd70c4dd84 1.11.0: news.data.tsx 2024-01-15 21:39:14 -08:00
Enrico Ros 9eb2ef05de 1.11.0: Version 2024-01-15 17:07:32 -08:00
Enrico Ros 8fae15d343 Together AI: improve icon 2024-01-15 16:42:13 -08:00
Enrico Ros bca5a1ac78 Update vendors count 2024-01-15 16:02:03 -08:00
Enrico Ros d899fb7e3b Persona Creator Drawer: selection mode 2024-01-15 16:00:29 -08:00
Enrico Ros 0f05b70e3b Bits 2024-01-15 15:01:22 -08:00
Enrico Ros 7b121a3a95 Together AI: implement free-tier rate limiting 2024-01-15 14:49:45 -08:00
Enrico Ros d4e414f99c Together AI: add popular models (with context window sizes) 2024-01-15 14:23:43 -08:00
Enrico Ros a7f322ef38 Together AI Vendor support 2024-01-15 14:10:21 -08:00
Enrico Ros d4494bf2e0 OpenAI transports: do not include n=1 in the payload 2024-01-11 09:35:09 -08:00
Enrico Ros 78cf74e3f2 Persona Creator: Drawer/Drawer Items - storage OK. Closes #301 2024-01-10 02:57:03 -08:00
Enrico Ros cfaed03603 PageDrawerList: add onClick for list callbacks 2024-01-10 02:52:29 -08:00
Enrico Ros a8e3183733 Persona Creator: store 2024-01-10 01:48:34 -08:00
Enrico Ros 9395db0fd5 Persona Creator: move Creator stuff to ./creator 2024-01-10 00:26:03 -08:00
Enrico Ros 8c75061178 Move useFormEditTextArray 2024-01-10 00:23:24 -08:00
Enrico Ros de0cdded87 Persona Creator: move the YouTube module 2024-01-10 00:23:11 -08:00
Enrico Ros d225541da2 bits 2024-01-09 23:13:06 -08:00
Enrico Ros 7a0008de5a Move useLLMChain 2024-01-09 22:57:46 -08:00
Enrico Ros 0bdd817d6d Persona Creator: bits 2024-01-09 21:48:19 -08:00
Enrico Ros d606975584 Persona Creator: improve LLM selection 2024-01-09 21:22:05 -08:00
Enrico Ros af56c2c1af GoodDropdown -> PageBarDropdown 2024-01-09 20:14:23 -08:00
Enrico Ros 73de7df0fb Mobile Nav: add Personas 2024-01-09 19:56:07 -08:00
Enrico Ros 3ca80d6a6e This is much better 2024-01-09 19:43:36 -08:00
Enrico Ros eb9e5362fe Begin reducing LLMs dependencies 2024-01-09 19:42:07 -08:00
Enrico Ros 45d1ca7437 PersonaCreator: debug (find issues) 2024-01-09 15:22:01 -08:00
Enrico Ros e492ccfb04 Improve the useLLMChain hook 2024-01-09 15:20:39 -08:00
Enrico Ros d01b6acd51 Persona Creation: enable user prompts, fixes #336 2024-01-09 04:35:32 -08:00
Enrico Ros eec81d5d73 Persona Creation: improve layout 2024-01-09 03:28:08 -08:00
Enrico Ros 03423ce58c Persona Creation: improve progress 2024-01-09 02:36:55 -08:00
Enrico Ros e2e7ea972d Persona Creation: use cancelable streaming, - Fixes #316, #328. 2024-01-09 02:31:04 -08:00
Enrico Ros 91b770d2c8 Persona Creation: extract the Tabs 2024-01-09 00:56:48 -08:00
Enrico Ros 79500e6d8b Persona Creation: extract YouTube Transcript downloader 2024-01-09 00:30:18 -08:00
Enrico Ros 4ede66cf2b Improve OpenAI API Endpoint Tooltip #323 2024-01-08 21:00:59 -08:00
Enrico Ros 40bff32442 Allow up to 5 OpenAI Endpoints. Fixes #323 2024-01-08 20:49:30 -08:00
Enrico Ros 3fc8e8efa0 LLM Source re-numbering, #323 2024-01-08 20:30:45 -08:00
Enrico Ros 12ea5f218d LLM auto-selection: ignore hidden, unless there's nothing else 2024-01-08 19:47:04 -08:00
Enrico Ros d47c0e45af AutoTitle: fix exception when an immediate call to chat-gen fails 2024-01-08 19:44:06 -08:00
Enrico Ros 298d0201d2 (disabled) Folder reveal animation 2024-01-08 19:24:53 -08:00
Enrico Ros a6bde2377e Reduce MenuList usage 2024-01-08 19:24:53 -08:00
Enrico Ros 76778c5ab7 Action Tiles framework - for commands and attachments 2024-01-08 00:42:54 -08:00
Enrico Ros 11565f5ac8 Commands: add arguments 2024-01-08 00:42:49 -08:00
Enrico Ros 6c5131996b Drawer width: less than half a percent skinnier 2024-01-07 22:14:33 -08:00
Enrico Ros 9b4301cd90 Export: undo the flip 2024-01-07 22:05:46 -08:00
Enrico Ros c73bbaf0d4 Chat Drawer Item: frequency as bar basis, and move the frequency count at the env (stable items) 2024-01-07 22:01:37 -08:00
Enrico Ros 163257e052 Bits 2024-01-07 21:49:30 -08:00
Enrico Ros cf689ca9a9 Chat Titles fixes 2024-01-07 21:41:15 -08:00
Enrico Ros 4a65389b71 Mobile Chat Drawer: do not close when clicking the active item 2024-01-07 21:22:59 -08:00
Enrico Ros 5de7762238 Fix a layout bug introduced moving away from MenuList to List 2024-01-07 21:10:58 -08:00
Enrico Ros 06655ced46 Title Edit - cancellation 2024-01-07 13:39:35 -08:00
Enrico Ros 60a775b869 Fix keyboard de-focus on Search chats.
Move away from MenuList to List - as the Menu does some focus stealing behind the scenes.
Will minimize or remove MenuList usage going forward.
2024-01-07 13:29:45 -08:00
Enrico Ros 5a3645bd43 Merge pull request #330 from joriskalz/dev-fixes
Refactored DebounceInput as external component, added clear input functionality with keyboard navigation support
2024-01-07 12:37:05 -08:00
Joris Kalz 54d37e663a Create component and add clear icon for search input 2024-01-07 12:26:42 +01:00
Enrico Ros f4c056fa9f Update README.md 2024-01-06 10:18:37 -08:00
Enrico Ros 8f53fa7407 Update README.md 2024-01-06 10:17:22 -08:00
Enrico Ros 2f9a4ea00f Merge pull request #329 from joriskalz/main
Enhanced Search: Frequency Ranking and In-Message Querying #324
2024-01-06 03:33:07 -08:00
Joris Kalz ee7dae827e Merge branch 'enricoros:main' into main 2024-01-06 12:23:24 +01:00
Joris Kalz 6fe94e344a Show number of results 2024-01-06 12:20:42 +01:00
Joris Kalz 3376867966 Debounced Input field 300ms 2024-01-06 12:05:31 +01:00
Enrico Ros 4a8a2b9c5d First user experience - highlight the need to configure models 2024-01-06 02:57:05 -08:00
Joris Kalz 7f84160a62 Enable Search 2024-01-06 11:55:26 +01:00
Enrico Ros fb5b349866 News: improve 2024-01-06 02:22:10 -08:00
Enrico Ros f5c7b96ff6 Chat Drawer: Import and Export 2024-01-06 01:37:40 -08:00
Enrico Ros 7c430cc5c8 Export: improve dialog 2024-01-06 01:29:45 -08:00
Enrico Ros 8c7d069189 Style: Chat List: soft instead of solid - for now 2024-01-06 00:57:44 -08:00
Enrico Ros f50d040d8a Update maintainers/release 2024-01-06 00:39:54 -08:00
Enrico Ros aa10f87c7d Update maintainers/release 2024-01-06 00:39:06 -08:00
Enrico Ros 4e96a5b5e5 Merge branch 'release-1.10.0' 2024-01-05 23:08:59 -08:00
Enrico Ros 329456f287 1.10.0: README/Changelog 2024-01-05 23:08:30 -08:00
Enrico Ros 6f8368d7cb 1.10.0: news.data.tsx 2024-01-05 23:00:43 -08:00
Enrico Ros 9c2b0cb7ca 1.10.0: version 2024-01-05 22:18:16 -08:00
Enrico Ros 1e15c4c4d1 Auto-Scrolling to the bottom on /link/chat. Fixes #319 2024-01-05 22:00:42 -08:00
Enrico Ros 9f209526a0 Update bug template 2024-01-05 21:50:42 -08:00
Enrico Ros 60ab9bd239 Update bug template 2024-01-05 21:50:08 -08:00
Enrico Ros 70e51b2e71 Trying out the Vercel Speed Insights functionality on Vercel deployments. +3kb 2024-01-05 21:47:36 -08:00
Enrico Ros 2d6edde12c Ani: Revert Bits 2024-01-04 11:48:55 -08:00
Enrico Ros d2fb0c2425 Ani: Bits 2024-01-04 11:47:48 -08:00
Enrico Ros 122bbf0034 LLMs: make maxTokens optional 2024-01-04 03:38:40 -08:00
Enrico Ros e79449b38c OpenAI Transport: make maxTokens optional 2024-01-04 03:38:22 -08:00
Enrico Ros fcad6495e1 Anthropic: relax max tokens 2024-01-04 03:38:06 -08:00
Enrico Ros 330d35a24c LM Studio: improve model name 2024-01-04 03:36:38 -08:00
Enrico Ros a8ec58c732 Fix mouse jumpiness on avatar icon and improve spacing 2024-01-04 02:14:37 -08:00
Enrico Ros 8054c8b328 Cleanup 2024-01-04 02:07:40 -08:00
Enrico Ros 7d6f2317e4 Clenup mobile Nav, and social links 2024-01-04 02:07:25 -08:00
Enrico Ros 10dd83bb2b Mobile Nav: make it dynamic 2024-01-04 01:43:04 -08:00
Enrico Ros 7bf285f26a Bits 2024-01-04 01:07:37 -08:00
Enrico Ros fde7a8cd9b Style: improve dark color scheme, with consistent shading 2024-01-04 01:07:32 -08:00
Enrico Ros 49ae5abba5 Dark theme improvement. Much better bars. 2024-01-04 00:53:18 -08:00
Enrico Ros f50ae4e7e2 Persona Creator: slight cleanup 2024-01-04 00:34:22 -08:00
Enrico Ros 99ff5cd7ad Persona Creator: render as markdown 2024-01-03 23:57:22 -08:00
Enrico Ros f80facb191 LLMs: support context window/max tokens not provided, and handle 'fallbacks' more explicitly 2024-01-03 23:38:01 -08:00
Enrico Ros ea8d2fff3e Fix parsing of OpenAI tokens message 2024-01-03 22:55:11 -08:00
Enrico Ros e3f1a5c54d LM Studio: actually, don't replace the hyphen 2024-01-03 22:22:57 -08:00
Enrico Ros fdafc1207b Re-rank local model providers 2024-01-03 22:21:43 -08:00
Enrico Ros 5d3971c21f Support LM Studio 2024-01-03 22:21:31 -08:00
Enrico Ros f8a4002a41 Fix Folder options on Mobile, #321 2024-01-03 16:16:39 -08:00
Enrico Ros 38a3eeef21 Folders: Toggle support.
This makes sure the folders can be disabled with a single button press in
case there are unexpected issues. Will get user testing and feedback.
Also very important on mobile, where the "select folder" UX
component makes the toolbar wrap.
2024-01-03 15:32:40 -08:00
Enrico Ros bf54807fb2 New UI: Improve the 'new title' 2024-01-03 15:15:23 -08:00
Enrico Ros 1aaabec28f New UI: Improve the 'new chat' button 2024-01-03 15:15:14 -08:00
Enrico Ros 8ec3927f02 New UI: Drawer: extract the PageDrawerHeader 2024-01-03 15:10:22 -08:00
Enrico Ros 73f201b8ac Hand-optimize the Chat items, for faster display and avoid refresh-while-type 2024-01-03 06:38:05 -08:00
Enrico Ros 0b61c9a49e Folders: use our Closeable menus instead of the Dropdown 2024-01-03 05:31:01 -08:00
Enrico Ros ee82911d8f Merge branch 'joriskalz-folders'
# Conflicts:
#	src/common/layout/optima/PageDrawer.tsx
2024-01-03 05:08:42 -08:00
Enrico Ros 89fa3fe633 New UI: Improve Drawer Names 2024-01-03 05:03:03 -08:00
Enrico Ros da56db7502 New UI: show the back arrow on desktop/no-nav 2024-01-03 05:03:01 -08:00
Enrico Ros 1d0f99a9a5 New UI: fix bug with desktop h-layout 2024-01-03 05:02:58 -08:00
Enrico Ros 8254443d29 New UI: Popups: denser 'dense' looks 2024-01-03 05:02:54 -08:00
Enrico Ros e1d6536102 New UI: Nav: support /link/chat 2024-01-03 05:02:50 -08:00
Enrico Ros c9fbbc1ab1 Folders: Complete review 2024-01-03 04:59:22 -08:00
Enrico Ros ae2e9b8f56 Folders: AppChat - review, simplify 2024-01-03 04:13:52 -08:00
Enrico Ros 64ca896ea7 Folders: store: cleanup, looks good 2024-01-03 03:15:04 -08:00
Enrico Ros 9bed685fe2 Folders: dropdown: ability to remove a folder association 2024-01-03 03:14:29 -08:00
Enrico Ros 9432084342 Folders: pre-select a folder color 2024-01-03 02:42:19 -08:00
Enrico Ros 0b7ffd16ab Folders: Reuse InlineTextArea both in New Folder & Edit Title. 2024-01-03 02:18:28 -08:00
Enrico Ros 3437888bf4 Folders: Style More: AddFolderButton 2024-01-03 02:14:41 -08:00
Enrico Ros 9b02be8861 Folders: Style: AddFolderButton and ChatFolderList 2024-01-03 01:40:28 -08:00
Enrico Ros 953d8434c3 Folders: Style: auto-size 2024-01-03 00:19:19 -08:00
Enrico Ros f9484ee3e9 Folders: Style: re-z-order 2024-01-03 00:11:34 -08:00
Enrico Ros 4a3956d743 Folders: Style: transfer shadow 2024-01-03 00:09:50 -08:00
Enrico Ros 785139e7bc New UI: Improve Drawer Names 2024-01-03 00:06:40 -08:00
Enrico Ros d45fbff28d New UI: show the back arrow on desktop/no-nav 2024-01-03 00:06:17 -08:00
Enrico Ros fce6ecaf5f New UI: fix bug with desktop h-layout 2024-01-02 23:58:34 -08:00
Enrico Ros 847d199dd8 New UI: Popups: denser 'dense' looks 2024-01-02 23:53:03 -08:00
Enrico Ros 274525a727 New UI: Nav: support /link/chat 2024-01-02 23:52:25 -08:00
Enrico Ros 4d807ecf5c New UI: transfer App Drawer lists into the Plugged 2024-01-02 23:49:21 -08:00
Enrico Ros 37a25f0117 Preferences 2024-01-02 18:14:18 -08:00
Enrico Ros 7d5ab95c20 Merge branch 'folders' of https://github.com/joriskalz/big-AGI-dev into joriskalz-folders 2024-01-02 18:11:10 -08:00
Enrico Ros 7fe8dd776f ScrollToBottom: fix edge case of the edge case fix 2024-01-02 18:09:58 -08:00
Enrico Ros 0a85d8d104 Desktop: back to 5 rows, we have the space 2024-01-02 17:57:19 -08:00
Enrico Ros cfd563b200 Apps: override fullWidth (for Call only, for now) 2024-01-02 17:56:32 -08:00
Enrico Ros 311a8d0ba0 Use Anybburger menu 2024-01-02 17:56:14 -08:00
Enrico Ros 06cd386c6e Only close the drawer when clicking items within a mobile drawer 2024-01-02 17:45:25 -08:00
Enrico Ros 2632133ba4 New UI: Buttery-smooth transitions 2024-01-02 17:34:31 -08:00
Enrico Ros 1fe43cdc2e Theme: centralized zIndex 2024-01-02 17:12:49 -08:00
Enrico Ros e76939fb5d Root style: change some var names 2024-01-02 17:11:43 -08:00
Enrico Ros 5f4250e3d2 Squircle: fix 2024-01-02 17:10:55 -08:00
Joris Kalz 5653044b1e Fancy colors 2024-01-02 00:33:08 +01:00
Joris Kalz d4da34561d Removed unused items 2024-01-02 00:27:11 +01:00
Joris Kalz fa25e830d5 Removed Folder Title 2024-01-02 00:25:13 +01:00
Joris Kalz c90139923c Add folder selector 2024-01-02 00:23:32 +01:00
Joris Kalz fa5007cb3b Assign new conversation to selected folder 2024-01-02 00:12:38 +01:00
Joris Kalz b979e1313c Enable deletion of all items in a folder 2024-01-02 00:05:00 +01:00
Joris Kalz 1f1bf65c14 Filter by selected folder 2024-01-01 23:50:18 +01:00
Joris Kalz 2bc6a15256 Enable Selection of folders 2024-01-01 23:39:54 +01:00
Joris Kalz dbcdbaa893 Display Folders 2024-01-01 23:28:31 +01:00
Joris Kalz d0ac1d8e1a Refactor "Add Folder" button into a separate file 2024-01-01 23:09:46 +01:00
Joris Kalz 3929e501d8 Add Folder Button 2024-01-01 23:02:47 +01:00
Joris Kalz fa3ae7b821 UI, adding FolderList 2024-01-01 22:49:42 +01:00
Joris Kalz 79052f988c add store to persist folders in local storage 2024-01-01 22:33:47 +01:00
Enrico Ros 18e6e235f3 Merge New UI - details inside:
- OptimaLayout: new responsive UI framework, with nav and drawer for desktop and mobile
 - Nav: new top-level navigation framework (will replace 'routes' going forward)
 - The new (App) Panel is more stable for UI operations (vs. the former Popup)
 - Improved looks on desktop, and uses Drawer on mobile
 - Missing bottom-Nav on mobile, to replace the PageMenu nav
 - Closes #298, #201.

Landing as-is on `main`, will fix smaller bits later.
2023-12-31 17:55:43 -08:00
Enrico Ros 388e897466 New UI: disable mobilenav 2023-12-31 17:43:17 -08:00
Enrico Ros e05a3bc3e9 New UI: bits 2023-12-31 17:33:59 -08:00
Enrico Ros 5bb832f83d New UI: Add Personas 2023-12-31 17:30:36 -08:00
Enrico Ros 43cb19df83 New UI: PageBar hide when not needed on desktop 2023-12-31 17:30:29 -08:00
Enrico Ros 1d770ce012 New UI: desktop nav button 2023-12-31 17:30:10 -08:00
Enrico Ros 550e3e0173 News: gradient 2023-12-31 17:30:01 -08:00
Enrico Ros 043a5f48e8 New UI: enable split branch toggle 2023-12-31 16:28:21 -08:00
Enrico Ros 0b69e0a9d1 New UI: revert show split branching 2023-12-31 16:27:50 -08:00
Enrico Ros 5d8d752693 New UI: fix desktop drawer 2023-12-31 16:26:06 -08:00
Enrico Ros e7067ed4fb New UI: Page Menu working best 2023-12-31 16:17:43 -08:00
Enrico Ros d181e27555 ScrollToBottom: restore 60 2023-12-31 16:17:42 -08:00
Enrico Ros 47d8b220a3 UI: Mobile: improve PageBar 2023-12-31 15:53:34 -08:00
Enrico Ros cc5e310174 UI: Fix layout/2 2023-12-31 15:53:17 -08:00
Enrico Ros 8006f578cd UI: Fix layout 2023-12-31 15:52:57 -08:00
Enrico Ros a303bf7224 Small layout fixes 2023-12-31 06:06:32 -08:00
Enrico Ros dc0ca6d5bc Nav: +News 2023-12-31 05:53:19 -08:00
Enrico Ros 2db3917c1c New UI Layout - #299
Full skeleton of the new 2.0 structure.
2023-12-31 05:53:05 -08:00
Enrico Ros 0c2ae290b0 New UI: uniform inverted bar 2023-12-31 03:53:17 -08:00
Enrico Ros 24dcfeb952 [Nav] #299 2023-12-31 03:51:59 -08:00
Enrico Ros acd7a24cff SquircleIcon: support inversion 2023-12-31 03:51:13 -08:00
Enrico Ros 88c29cf32c Composer: desktop: less gap between buttons 2023-12-31 03:50:54 -08:00
Enrico Ros 26f472b396 Try to use a second React Context for the Optima drawer, to optimize state changes 2023-12-31 03:46:34 -08:00
Enrico Ros 68c5e0b940 Move Optima Layout providers in optima layout 2023-12-31 03:45:58 -08:00
Enrico Ros 03fca40b74 Use a Focused mode on mobile 2023-12-31 03:45:16 -08:00
Enrico Ros 35aff7798e Rename to PreferencesTab 2023-12-31 03:43:44 -08:00
Enrico Ros 6a8cf08ef0 Nav: placeholder application 2023-12-31 01:25:28 -08:00
Enrico Ros 53a9f9acef Love magic numbers 2023-12-31 01:24:11 -08:00
Enrico Ros d4c02dde1d Move the NextLoading progress bar after the single-page check, but before the backend roundtrip 2023-12-31 01:19:47 -08:00
Enrico Ros 660fda8485 Support CSS mime for file attachments. 2023-12-31 00:33:39 -08:00
Enrico Ros 049dfec794 ScrollToBottom: use a smaller sticky margin 2023-12-31 00:26:37 -08:00
Enrico Ros 2e6f1939dc UI: useNextLoadProgress as hook, and import style 2023-12-30 22:51:32 -08:00
Enrico Ros f3b1e4698a UI: extract and move icons 2023-12-30 18:44:50 -08:00
Enrico Ros 34e0102d82 UI: Add app CSS 2023-12-30 18:43:16 -08:00
Enrico Ros 3f5aed6f9b Merge pull request #318 from kursad-k/patch-1
Update config-browse.md
2023-12-30 17:32:21 -08:00
kursad-k 29647ad106 Update config-browse.md
added internet proxy settings
2023-12-30 19:31:19 -06:00
Enrico Ros 9426a45b88 Reduce uses of useRouter()
Note: the Link component is still using them really aggressively.
2023-12-30 16:24:21 -08:00
Enrico Ros 5b52544c6c Composer: some style fixes 2023-12-30 04:07:59 -08:00
Enrico Ros fc1c15ba87 Small image download hint 2023-12-30 03:57:03 -08:00
Enrico Ros e973fce3f7 UI: restyle IconButtons
The size of the picture inside the icon stays the same, 24x24, but the overall IconButton
and Button go down to 36x36 (was 40).

This includes a revert from a style change that originated from:
https://github.com/mui/material-ui/commit/7f81475ea148a416ec8fab252120ce6567c62897#diff-45dca083057933d78377b59e031146804cfedb68fe1514955bc8a5b3c38d7c44

The overall layout is getting smaller, so let's adapt to smaller IconButtons
2023-12-30 03:44:18 -08:00
Enrico Ros 99759654f2 Bug_report: improve copy 2023-12-29 23:33:41 -08:00
Enrico Ros 390a1effb1 Bug_report: test newlines 2023-12-29 23:31:41 -08:00
Enrico Ros f357291560 Improve placeholders 2023-12-29 23:30:59 -08:00
Enrico Ros c3a8b7e859 Update label 2023-12-29 23:28:15 -08:00
Enrico Ros 8931544349 Removed steps to reproduce 2023-12-29 23:27:43 -08:00
Enrico Ros 865e420e34 Update the BUG issue template, following the great example of tRPC 2023-12-29 23:26:27 -08:00
Enrico Ros 574c2b936e Create bug_test.yml 2023-12-29 23:18:10 -08:00
Enrico Ros 4f6a596cc7 Hold trpc back - bundle size increased by 20k 2023-12-29 22:54:44 -08:00
Enrico Ros edd36ea780 Roll packages 2023-12-29 19:01:37 -08:00
Enrico Ros 5a325b98ee Merge 'feature-newi' Phase 2 - details below:
- ChatCommands abstraction and registration (execution still specialized)
 - ReAct: improve display of steps, and UI
 - lineHeight: use unified number for consistency of rhythm
 - OptimaLayout: begin breakdown
2023-12-29 18:30:35 -08:00
Enrico Ros 8f6e2a3b5f Commands: registration framework 2023-12-29 18:05:27 -08:00
Enrico Ros cf2fc96107 Composer: move text interception 2023-12-29 02:45:06 -08:00
Enrico Ros 8837a1fc65 Chat Panes: alt+click to remove focus 2023-12-29 02:45:06 -08:00
Enrico Ros 91970f088e Composer: move buttons 2023-12-29 02:11:08 -08:00
Enrico Ros f59f77e50a Ephemerals: improve looks 2023-12-29 02:08:15 -08:00
Enrico Ros 50b1f00b5a Commands: much improve the parser 2023-12-29 01:38:32 -08:00
Enrico Ros 4f98a8a319 UI: github markdown: deviate from upstream, and don't redefine font properties 2023-12-29 01:02:38 -08:00
Enrico Ros fb8aa3936b UI: font: removed from html 2023-12-29 01:02:16 -08:00
Enrico Ros 335876555f UI: chat message: smaller avatar text 2023-12-29 01:01:42 -08:00
Enrico Ros 7da3b1f4c4 UI: rhythm (line heights) 2023-12-29 01:01:10 -08:00
Enrico Ros e80bc4cea7 reAct: improve logging 2023-12-29 00:39:03 -08:00
Enrico Ros 448755ff8d OptimaL: destructure 2023-12-28 22:41:14 -08:00
Enrico Ros 3a4c23840a Panes: extract Resize handler 2023-12-28 21:20:28 -08:00
Enrico Ros 13c69111f9 Stop button: soft warning 2023-12-28 20:43:53 -08:00
Enrico Ros 0b9feb9fda Scroll To Bottom: fix one edge case #312 2023-12-28 20:42:48 -08:00
246 changed files with 10787 additions and 3371 deletions
-25
View File
@@ -1,25 +0,0 @@
---
name: Bug report
about: Omg what's happening?
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
Where is it happening?
- Which device [Mobile/Desktop, os version]:
- Which browser:
- Which website:
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots / context**
If applicable, please add screenshots or additional context
+32
View File
@@ -0,0 +1,32 @@
name: 🐞 Bug Report
description: Create a report to help us improve
title: '[BUG]'
labels: [ 'type: bug' ]
body:
- type: markdown
attributes:
value: Thank you for reporting a bug.
- type: textarea
attributes:
label: Description
description: (required) Please provide a clear description. Please also provide the steps to reproduce.
placeholder: 'Concise description + steps to reproduce.'
validations:
required: true
- type: textarea
attributes:
label: Device and browser
description: '(required) Please specify your Mobile/Desktop device, OS version, browser.'
placeholder: 'Device: (e.g., iPhone 16, Pixel 9, PC, Macbook...), OS: (e.g., iOS 17, Windows 12), Browser: (e.g., Chrome 119, Safari 18, Firefox..)'
validations:
required: true
- type: textarea
attributes:
label: Screenshots and more
placeholder: 'Attach screenshots, or add any additional context here.'
- type: checkboxes
attributes:
label: Willingness to Contribute
description: We appreciate contributions - would you be willing to submit a pull request?
options:
- label: '🙋‍♂️ Yes, I would like to contribute a fix.'
+30 -20
View File
@@ -23,15 +23,15 @@ assignees: enricoros
- [ ] 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 the readme with the new release
- [ ] Update the README.md with the new release
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
- Release:
- [ ] merge onto main
- [ ] merge onto main `git checkout main && git merge --no-ff release-1.2.3`
- [ ] re-tag `git tag -f v1.2.3 && git push opensource --tags -f`
- [ ] verify deployment on Vercel
- [ ] verify container on GitHub Packages
- create a GitHub release
- [ ] name it 'vX.Y.Z'
- [ ] copy the release notes and link appropriate artifacts
- [ ] update the GitHub release
- [ ] push as stable `git push opensource main:main-stable`
- Announce:
- [ ] Discord announcement
- [ ] Twitter announcement
@@ -39,14 +39,13 @@ assignees: enricoros
### Links
- Milestone: https://github.com/enricoros/big-AGI/milestone/X
- GitHub release: https://github.com/enricoros/big-AGI/releases/tag/vX.Y.Z
- Former release task: https://github.com/enricoros/big-AGI/issues/XXX
- GitHub release: https://github.com/enricoros/big-AGI/releases/tag/v1.2.3
- Former release task: #...
## Artifacts Generation
```markdown
You help me generate the following collateral for the new release of my opensource application
called big-AGI. The new release is 1.2.3.
You help me generate the following collateral for the new release of my opensource application called big-AGI. The new release is 1.2.3.
To familiarize yourself with the application, the following are the Website and the GitHub README.md.
```
@@ -55,8 +54,7 @@ To familiarize yourself with the application, the following are the Website and
```markdown
I am announcing a new version, 1.2.3.
For reference, the following was the collateral for 1.1.0 (Discord announcement,
GitHub Release, in-app-news file news.data.tsx, changelog.md).
For reference, the following was the collateral for 1.1.0 (Discord announcement, GitHub Release, in-app-news file news.data.tsx).
```
- paste the former: `discord announcement`,
@@ -66,28 +64,39 @@ GitHub Release, in-app-news file news.data.tsx, changelog.md).
```markdown
The following are the new developments for 1.2.3:
- ...
- git log --pretty=format:"%h %an %B" v1.1.0..v1.2.3 | clip
```
- paste the link to the milestone (closed) and each individual issue (content will be downloaded)
- paste the git changelog `git log v1.1.0..v1.2.3 | clip`
- paste the output of the git log command
### news.data.TSX
### news.data.tsx
```markdown
I need the following from you:
1. a table summarizing all the new features in 1.2.3 (description, significance, usefulness, do not link the commit, but have the issue number), which will be used for the artifacts later
2. after the table score each feature from a user impact and magnitude point of view
3. 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
4. I want you then to update the news.data.tsx for the new release
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)
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
```
### Readme (and Changelog)
```markdown
I need you to update the README.md and the with the new release.
Attaching the in-app news, with my language for you to improve on, but keep the tone.
```
### GitHub release
```markdown
Please create the 1.2.3 Release Notes for GitHub.
Please create the 1.2.3 Release Notes for GitHub, following the format of the 1.1.0 GitHub release notes attached before.
Use a truthful and honest tone, understanding that people's time and attention span is short.
Today is 2024-1-1.
Today is 2024-XXXX-YYYY.
```
Now paste-attachment the former release notes (or 1.5.0 which was accurate and great), including the new contributors and
@@ -96,5 +105,6 @@ some stats (# of commits, etc.), and roll it for the new release.
### Discord announcement
```markdown
Can you generate my 1.2.3 big-AGI discord announcement from the GitHub Release announcement, and the in-app News?
Can you generate my 1.2.3 big-AGI discord announcement from the GitHub Release announcement?
Please keep the formatting and stye of the discord announcement for 1.1.0, but with the new messaging above.
```
+5 -4
View File
@@ -8,10 +8,11 @@ assignees: ''
---
**Why**
The reason behind the request - we love it to be framed for "users will be able to do x" rather than quick-aging hype-tech-of-the-day requests
(replace this text with yours) The reason behind the request - we love it to be framed for "users will be able to do x" rather than quick-aging hype-tech-of-the-day requests
**Concise description**
A clear and concise description of what you want to happen.
**Description**
Clear and concise description of what you want to happen.
**Requirements**
If you can, please detail the changes you expect in UX, user workflows, technology, architecture (if not, the reviewers will do it for you)
If you can, Please break-down the changes use cases, UX, technology, architecture, etc.
- [ ] ...
+31 -23
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 8 vendors and
simplicity, and speed. Powered by the latest models from 11 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.
@@ -21,32 +21,40 @@ shows the current developments and future ideas.
- 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.9.0 · Dec 28, 2023 · Creative Horizons
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
- **DALL·E 3 integration** for enhanced image generation. [#212](https://github.com/enricoros/big-AGI/issues/212)
- **Perfect scrolling mechanics** across devices. [#304](https://github.com/enricoros/big-AGI/issues/304)
- Persona creation now supports **text input**. [#287](https://github.com/enricoros/big-AGI/pull/287)
- Openrouter updates for better model management and rate limit handling
- Image drawing UX improvements
- Layout fix for Firefox users
- Developer enhancements: Text2Image subsystem, Optima layout, ScrollToBottom library, Panes library, and Llms subsystem updates.
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
### What's New in 1.8.0 · Dec 20, 2023
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
- 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)
- **Google Gemini Support**: Use the newest Google models. [#275](https://github.com/enricoros/big-agi/issues/275)
- **Mistral Platform**: Mixtral and future models support. [#273](https://github.com/enricoros/big-agi/issues/273)
- **Diagram Instructions**. Thanks to @joriskalz! [#280](https://github.com/enricoros/big-agi/pull/280)
- Ollama Chats: Enhanced chatting experience. [#270](https://github.com/enricoros/big-agi/issues/270)
- Mac Shortcuts Fix: Improved UX on Mac
- **Single-Tab Mode**: Data integrity with single window. [#268](https://github.com/enricoros/big-agi/issues/268)
- **Updated Models**: Latest Ollama (v0.1.17) and OpenRouter models
- Official Downloads: Easy access to the latest big-AGI on [big-AGI.com](https://big-agi.com)
- For developers: [troubleshot networking](https://github.com/enricoros/big-AGI/issues/276#issuecomment-1858591483), fixed Vercel deployment, cleaned up the LLMs/Streaming framework
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
### What's New in... ?
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
> [To The Moon And Back, Attachment Theory, Surf's Up, Loaded, and more releases...](docs/changelog.md).
> Check out the [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2)
- **Find chats**: search in titles and content, with frequency ranking. [#329](https://github.com/enricoros/big-AGI/issues/329)
- **Commands**: command auto-completion (type '/'). [#327](https://github.com/enricoros/big-AGI/issues/327)
- **[Together AI](https://www.together.ai/products#inference)** inference platform support (good speed and newer models). [#346](https://github.com/enricoros/big-AGI/issues/346)
- Persona Creator history, deletion, custom creation, fix llm API timeouts
- 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
- **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)
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
- Resizable panes in split-screen conversations.
- Large performance optimizations
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
For full details and former releases, check out the [changelog](docs/changelog.md).
## ✨ Key Features 👊
@@ -105,7 +113,7 @@ after installing the required dependencies.
```bash
# .. repeat the steps above up to `npm install`, then:
npm run build
npm run start --port 3000
next start --port 3000
```
The app will be running on the specified port, e.g. `http://localhost:3000`.
+35 -2
View File
@@ -5,11 +5,44 @@ by release.
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
### 1.10.0 - Jan 2024
### 1.13.0 - Feb 2024
- milestone: [1.10.0](https://github.com/enricoros/big-agi/milestone/10)
- milestone: [1.13.0](https://github.com/enricoros/big-agi/milestone/13)
- 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
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
- 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
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
- **Find chats**: search in titles and content, with frequency ranking. [#329](https://github.com/enricoros/big-AGI/issues/329)
- **Commands**: command auto-completion (type '/'). [#327](https://github.com/enricoros/big-AGI/issues/327)
- **[Together AI](https://www.together.ai/products#inference)** inference platform support (good speed and newer models). [#346](https://github.com/enricoros/big-AGI/issues/346)
- Persona Creator history, deletion, custom creation, fix llm API timeouts
- 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
- **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)
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
- Resizable panes in split-screen conversations.
- Large performance optimizations
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
### What's New in 1.9.0 · Dec 28, 2023 · Creative Horizons
- **DALL·E 3 integration** for enhanced image generation. [#212](https://github.com/enricoros/big-AGI/issues/212)
+25 -1
View File
@@ -50,10 +50,34 @@ Now you can use the following connection string in `big-AGI`: `ws://127.0.0.1:92
You can also browse to [http://127.0.0.1:9222](http://127.0.0.1:9222) to see the Browserless debug viewer
and configure some options.
The chat agent won't be able to access the web sites if the browserless container does not have direct Internet access. You can resolve the issue by defining internet proxy for the running container. You can then use the evironment file in the a `docker-compose.yaml
```
browserless:
image: browserless/chrome:latest
env_file:
- .env
ports:
- "9222:3000" # Map host's port 9222 to container's port 3000
environment:
- MAX_CONCURRENT_SESSIONS=10
```
You can then add the proyy lines to your `.env` file.
```
https_proxy=http://PROXY-IP:PROXY-PORT
http_proxy=http://PROXY-IP:PROXY-PORT
```
This is how you can define it in a one liner docker
`docker run --env https_proxy=http://PROXY-IP:PROXY-PORT --env http_proxy=http://PROXY-IP:PROXY-PORT -p 9222:3000 browserless/chrome:latest `
Note: if you are using `docker-compose`, please see the
[docker/docker-compose-browserless.yaml](docker/docker-compose-browserless.yaml) file for an example
on how to run `big-AGI` and Browserless simultaneously in a single application.
### 🌐 Your own Chrome browser
***EXPERIMENTAL - UNTESTED*** - You can use your own Chrome browser as a browsing service, by configuring it to expose
@@ -84,4 +108,4 @@ 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!
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
+2
View File
@@ -28,6 +28,7 @@ GEMINI_API_KEY=
MISTRAL_API_KEY=
OLLAMA_API_HOST=
OPENROUTER_API_KEY=
TOGETHERAI_API_KEY=
# Model Observability: Helicone
HELICONE_API_KEY=
@@ -85,6 +86,7 @@ requiring the user to enter an API key
| `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) | |
| `OPENROUTER_API_KEY` | The API key for OpenRouter | Optional |
| `TOGETHERAI_API_KEY` | The API key for Together AI | Optional |
### Model Observability: Helicone
+5
View File
@@ -26,6 +26,11 @@ let nextConfig = {
return config;
},
// Uncomment the following leave console messages in production
// compiler: {
// removeConsole: false,
// },
};
// Validate environment variables, if set at build time. Will be actually read and used at runtime.
+955 -382
View File
File diff suppressed because it is too large Load Diff
+29 -26
View File
@@ -1,71 +1,74 @@
{
"name": "big-agi",
"version": "1.9.0",
"version": "1.12.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"env:pull": "npx vercel env pull .env.development.local",
"postinstall": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"vercel:env:pull": "npx vercel env pull .env.development.local"
},
"dependencies": {
"@dqbd/tiktoken": "^1.0.7",
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1",
"@emotion/react": "^11.11.3",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.1",
"@mui/joy": "^5.0.0-beta.19",
"@next/bundle-analyzer": "^14.0.4",
"@prisma/client": "^5.7.1",
"@mui/icons-material": "^5.15.6",
"@mui/joy": "^5.0.0-beta.24",
"@next/bundle-analyzer": "^14.1.0",
"@prisma/client": "^5.8.1",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.7.1",
"@t3-oss/env-nextjs": "^0.8.0",
"@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.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",
"browser-fs-access": "^0.35.0",
"eventsource-parser": "^1.1.1",
"idb-keyval": "^6.2.1",
"next": "^14.0.4",
"next": "^14.1.0",
"nprogress": "^0.2.0",
"pdfjs-dist": "4.0.269",
"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-dom": "^18.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^1.0.5",
"react-resizable-panels": "^1.0.9",
"react-timeago": "^7.2.0",
"remark-gfm": "^4.0.0",
"superjson": "^2.2.1",
"tesseract.js": "^5.0.3",
"tesseract.js": "^5.0.4",
"tiktoken": "^1.0.11",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zustand": "^4.4.7"
"zustand": "^4.5.0"
},
"devDependencies": {
"@cloudflare/puppeteer": "^0.0.5",
"@types/node": "^20.10.5",
"@types/node": "^20.11.7",
"@types/nprogress": "^0.2.3",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.45",
"@types/react": "^18.2.48",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.18",
"@types/react-katex": "^3.0.4",
"@types/react-timeago": "^4.1.7",
"@types/uuid": "^9.0.7",
"@types/uuid": "^9.0.8",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"prettier": "^3.1.1",
"prisma": "^5.7.1",
"eslint-config-next": "^14.1.0",
"prettier": "^3.2.4",
"prisma": "^5.8.1",
"typescript": "^5.3.3"
},
"engines": {
+5
View File
@@ -2,6 +2,8 @@ import * as React from 'react';
import Head from 'next/head';
import { MyAppProps } from 'next/app';
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
import { SpeedInsights as VercelSpeedInsights } from '@vercel/speed-insights/next';
import { Brand } from '~/common/app.config';
import { apiQuery } from '~/common/util/trpc.client';
@@ -9,6 +11,8 @@ import { apiQuery } from '~/common/util/trpc.client';
import 'katex/dist/katex.min.css';
import '~/common/styles/CodePrism.css';
import '~/common/styles/GithubMarkdown.css';
import '~/common/styles/NProgress.css';
import '~/common/styles/app.styles.css';
import { ProviderBackendAndNoSSR } from '~/common/providers/ProviderBackendAndNoSSR';
import { ProviderBootstrapLogic } from '~/common/providers/ProviderBootstrapLogic';
@@ -41,6 +45,7 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
</ProviderTheming>
<VercelAnalytics debug={false} />
<VercelSpeedInsights debug={false} sampleRate={1 / 10} />
</>;
+5 -5
View File
@@ -5,7 +5,7 @@ import createEmotionServer from '@emotion/server/create-instance';
import { getInitColorSchemeScript } from '@mui/joy/styles';
import { Brand } from '~/common/app.config';
import { bodyFontClassName, createEmotionCache } from '~/common/app.theme';
import { createEmotionCache } from '~/common/app.theme';
interface MyDocumentProps extends DocumentProps {
@@ -14,7 +14,7 @@ interface MyDocumentProps extends DocumentProps {
export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
return (
<Html lang='en' className={bodyFontClassName}>
<Html lang='en'>
<Head>
{/* Meta (missing Title, set by the App or Page) */}
<meta name='description' content={Brand.Meta.Description} />
@@ -51,9 +51,9 @@ export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
{emotionStyleTags}
</Head>
<body>
{getInitColorSchemeScript()}
<Main />
<NextScript />
{getInitColorSchemeScript()}
<Main />
<NextScript />
</body>
</Html>
);
+10
View File
@@ -0,0 +1,10 @@
import * as React from 'react';
import { AppDraw } from '../src/apps/draw/AppDraw';
import { withLayout } from '~/common/layout/withLayout';
export default function DrawPage() {
return withLayout({ type: 'optima' }, <AppDraw />);
}
+4 -1
View File
@@ -6,9 +6,12 @@ import { useRedirectToNewsOnUpdates } from '../src/apps/news/news.hooks';
import { withLayout } from '~/common/layout/withLayout';
export default function ChatPage() {
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
// For now it offers the chat experience, but this will change. #299
return withLayout({ type: 'optima' }, <AppChat />);
}
+4 -8
View File
@@ -1,5 +1,4 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { Box, Typography } from '@mui/joy';
@@ -7,8 +6,7 @@ import { useModelsStore } from '~/modules/llms/store-llms';
import { InlineError } from '~/common/components/InlineError';
import { apiQuery } from '~/common/util/trpc.client';
import { navigateToIndex } from '~/common/app.routes';
import { themeBgApp } from '~/common/app.theme';
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
import { withLayout } from '~/common/layout/withLayout';
@@ -43,7 +41,6 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
return (
<Box sx={{
flexGrow: 1,
backgroundColor: themeBgApp,
overflowY: 'auto',
display: 'flex', justifyContent: 'center',
p: { xs: 3, md: 6 },
@@ -86,9 +83,8 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
*/
export default function CallbackPage() {
// get the 'code=...' from the URL
const { query } = useRouter();
const { code: openRouterCode } = query;
// external state - get the 'code=...' from the URL
const { code } = useRouterQuery<{ code: string | undefined }>();
return withLayout({ type: 'plain' }, <CallbackOpenRouterPage openRouterCode={openRouterCode as (string | undefined)} />);
return withLayout({ type: 'plain' }, <CallbackOpenRouterPage openRouterCode={code} />);
}
+6 -5
View File
@@ -1,14 +1,15 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { AppChatLink } from '../../../src/apps/link/AppChatLink';
import { AppLinkChat } from '../../../src/apps/link/AppLinkChat';
import { useRouterQuery } from '~/common/app.routes';
import { withLayout } from '~/common/layout/withLayout';
export default function ChatLinkPage() {
const { query } = useRouter();
const chatLinkId = query?.chatLinkId as string ?? '';
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppChatLink linkId={chatLinkId} />);
// external state
const { chatLinkId } = useRouterQuery<{ chatLinkId: string | undefined }>();
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppLinkChat chatLinkId={chatLinkId || null} />);
}
+9 -10
View File
@@ -1,5 +1,4 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { Alert, Box, Button, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -10,8 +9,7 @@ import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { LogoProgress } from '~/common/components/LogoProgress';
import { asValidURL } from '~/common/util/urlUtils';
import { navigateToIndex } from '~/common/app.routes';
import { themeBgApp } from '~/common/app.theme';
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
import { withLayout } from '~/common/layout/withLayout';
@@ -32,8 +30,10 @@ function AppShareTarget() {
const [isDownloading, setIsDownloading] = React.useState(false);
// external state
const { query } = useRouter();
const { url: queryUrl, text: queryText } = useRouterQuery<{
url: string | string[] | undefined,
text: string | string[] | undefined,
}>();
const queueComposerTextAndLaunchApp = React.useCallback((text: string) => {
setComposerStartupText(text);
@@ -44,11 +44,11 @@ function AppShareTarget() {
// Detect the share Intent from the query
React.useEffect(() => {
// skip when query is not parsed yet
if (!Object.keys(query).length)
let queryTextItem = queryUrl || queryText || null;
if (!queryTextItem)
return;
// single item from the query
let queryTextItem: string[] | string | null = query.url || query.text || null;
if (Array.isArray(queryTextItem))
queryTextItem = queryTextItem[0];
@@ -59,9 +59,9 @@ function AppShareTarget() {
else if (queryTextItem)
setIntentText(queryTextItem);
else
setErrorMessage('No text or url. Received: ' + JSON.stringify(query));
setErrorMessage('No text or url. Received: ' + JSON.stringify({ queryText, queryUrl }));
}, [query.url, query.text, query]);
}, [queryText, queryUrl]);
// Text -> Composer
@@ -91,7 +91,6 @@ function AppShareTarget() {
return (
<Box sx={{
backgroundColor: themeBgApp,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
File diff suppressed because one or more lines are too long
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react';
import { Box, Typography } from '@mui/joy';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { useRouterRoute } from '~/common/app.routes';
/**
* https://github.com/enricoros/big-AGI/issues/299
*/
export function AppPlaceholder(props: { text?: string }) {
// external state
const route = useRouterRoute();
// derived state
const placeholderAppName = capitalizeFirstLetter(route.replace('/', '') || 'Home');
return (
<Box sx={{
flexGrow: 1,
overflowY: 'auto',
p: { xs: 3, md: 6 },
border: '1px solid blue',
}}>
<Box sx={{
my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 4,
border: '1px solid red',
}}>
<Typography level='h1'>
{placeholderAppName}
</Typography>
<Typography>
{props.text || 'Intelligent applications to help you learn, think, and do'}
</Typography>
</Box>
</Box>
);
}
+58 -24
View File
@@ -1,43 +1,77 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { Container, Sheet } from '@mui/joy';
import { AppCallQueryParams } from '~/common/app.routes';
import { InlineError } from '~/common/components/InlineError';
import type { DConversationId } from '~/common/state/store-chats';
import { useRouterQuery } from '~/common/app.routes';
import { CallUI } from './CallUI';
import { CallWizard } from './CallWizard';
import { Contacts } from './Contacts';
import { Telephone } from './Telephone';
import { useAppCallStore } from './state/store-app-call';
/**
* Used to define the intent of the call from other apps (via query params) or
* from the contacts list (via the 'call' button).
*/
export interface AppCallIntent {
conversationId: DConversationId | null;
personaId: string;
backTo: 'app-chat' | 'app-call-contacts';
}
export function AppCall() {
// external state
const { query } = useRouter();
// derived state
const { conversationId, personaId } = query as any as AppCallQueryParams;
const validInput = !!conversationId && !!personaId;
// state
const [callIntent, setCallIntent] = React.useState<AppCallIntent | null>(null);
// external state
const grayUI = useAppCallStore(state => state.grayUI);
const query = useRouterQuery<Partial<AppCallIntent>>();
// [effect] set intent from the query parameters
React.useEffect(() => {
if (query.personaId) {
setCallIntent({
conversationId: query.conversationId ?? null,
personaId: query.personaId,
backTo: query.backTo || 'app-chat',
});
}
}, [query.backTo, query.conversationId, query.personaId]);
const hasIntent = !!callIntent && !!callIntent.personaId;
return (
<Sheet variant='solid' color='neutral' invertedColors sx={{
display: 'flex', flexDirection: 'column', justifyContent: 'center',
flexGrow: 1,
overflowY: 'auto',
minHeight: 96,
}}>
<Sheet
variant={grayUI ? 'solid' : 'soft'}
invertedColors={grayUI ? true : undefined}
sx={{
// take the full V-area (we're inside PageWrapper) and scroll as needed
flexGrow: 1,
overflowY: 'auto',
<Container maxWidth='sm' sx={{
display: 'flex', flexDirection: 'column',
alignItems: 'center',
minHeight: '80dvh', justifyContent: 'space-evenly',
gap: { xs: 2, md: 4 },
// container will take the full v-area
display: 'grid',
}}>
{!validInput && <InlineError error={`Something went wrong. ${JSON.stringify(query)}`} />}
<Container
maxWidth={hasIntent ? 'sm' : 'md'}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: hasIntent ? 'space-evenly' : undefined,
gap: hasIntent ? 1 : undefined,
}}>
{validInput && (
<CallWizard conversationId={conversationId}>
<CallUI conversationId={conversationId} personaId={personaId} />
{!hasIntent ? (
<Contacts setCallIntent={setCallIntent} />
) : (
<CallWizard conversationId={callIntent.conversationId}>
<Telephone callIntent={callIntent} backToContacts={() => setCallIntent(null)} />
</CallWizard>
)}
+26 -20
View File
@@ -1,5 +1,4 @@
import * as React from 'react';
import { keyframes } from '@emotion/react';
import { Box, Button, Card, CardContent, IconButton, ListItemDecorator, Typography } from '@mui/joy';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
@@ -10,14 +9,15 @@ import MicIcon from '@mui/icons-material/Mic';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import WarningIcon from '@mui/icons-material/Warning';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
import { navigateBack } from '~/common/app.routes';
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useChatStore } from '~/common/state/store-chats';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUICounter } from '~/common/state/store-ui';
const cssRainbowBackgroundKeyframes = keyframes`
/*export const cssRainbowBackgroundKeyframes = keyframes`
100%, 0% {
background-color: rgb(128, 0, 0);
}
@@ -53,7 +53,7 @@ const cssRainbowBackgroundKeyframes = keyframes`
}
91% {
background-color: rgb(102, 0, 51);
}`;
}`;*/
function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: string, button?: React.JSX.Element }) {
return (
@@ -75,22 +75,26 @@ function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: s
}
export function CallWizard(props: { strict?: boolean, conversationId: string, children: React.ReactNode }) {
export function CallWizard(props: { strict?: boolean, conversationId: string | null, children: React.ReactNode }) {
// state
const [chatEmptyOverride, setChatEmptyOverride] = React.useState(false);
const [recognitionOverride, setRecognitionOverride] = React.useState(false);
// external state
const { openPreferences } = useOptimaLayout();
const { openPreferencesTab } = useOptimaLayout();
const recognition = useCapabilityBrowserSpeechRecognition();
const synthesis = useCapabilityElevenLabs();
const chatIsEmpty = useChatStore(state => {
if (!props.conversationId)
return false;
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return !(conversation?.messages?.length);
});
const { novel, touch } = useUICounter('call-wizard');
// derived state
const outOfTheBlue = !props.conversationId;
const overriddenEmptyChat = chatEmptyOverride || !chatIsEmpty;
const overriddenRecognition = recognitionOverride || recognition.mayWork;
const allGood = overriddenEmptyChat && overriddenRecognition && synthesis.mayWork;
@@ -104,7 +108,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
const handleOverrideRecognition = () => setRecognitionOverride(true);
const handleConfigureElevenLabs = () => {
openPreferences(3);
openPreferencesTab(PreferencesTab.Voice);
};
const handleFinishButton = () => {
@@ -118,16 +122,11 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
<Box sx={{ flexGrow: 0.5 }} />
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, lineHeight: '1.5em', textAlign: 'center' }}>
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, textAlign: 'center' }}>
Welcome to<br />
<Typography
component='span'
sx={{
backgroundColor: 'primary.solidActiveBg', mx: -0.5, px: 0.5,
animation: `${cssRainbowBackgroundKeyframes} 15s linear infinite`,
}}>
<Box component='span' sx={{ animation: `${cssRainbowColorKeyframes} 15s linear infinite` }}>
your first call
</Typography>
</Box>
</Typography>
<Box sx={{ flexGrow: 0.5 }} />
@@ -138,7 +137,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
</Typography>
{/* Chat Empty status */}
<StatusCard
{!outOfTheBlue && <StatusCard
icon={<ChatIcon />}
hasIssue={!overriddenEmptyChat}
text={overriddenEmptyChat ? 'Great! Your chat has messages.' : 'The chat is empty. Calls are effective when the caller has context.'}
@@ -147,7 +146,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
Ignore
</Button>
)}
/>
/>}
{/* Add the speech to text feature status */}
<StatusCard
@@ -199,14 +198,21 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
</Typography>
<IconButton
size='lg' variant={allGood ? 'soft' : 'solid'} color={allGood ? 'success' : 'danger'}
onClick={handleFinishButton} sx={{ borderRadius: '50px' }}
size='lg'
variant='solid' color={allGood ? 'success' : 'danger'}
onClick={handleFinishButton}
sx={{
borderRadius: '50px',
mr: 0.5,
// animation: `${cssRainbowBackgroundKeyframes} 15s linear infinite`,
// boxShadow: allGood ? 'md' : 'none',
}}
>
{allGood ? <ArrowForwardIcon sx={{ fontSize: '1.5em' }} /> : <CloseIcon sx={{ fontSize: '1.5em' }} />}
</IconButton>
</Box>
<Box sx={{ flexGrow: 0.5 }} />
<Box sx={{ flexGrow: 2 }} />
</>;
}
+345
View File
@@ -0,0 +1,345 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { keyframes } from '@emotion/react';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, Card, CardContent, Chip, IconButton, Link as MuiLink, ListDivider, MenuItem, Sheet, Switch, Typography } from '@mui/joy';
import CallIcon from '@mui/icons-material/Call';
import { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
import { MockPersona, useMockPersonas } from './state/useMockPersonas';
import { useAppCallStore } from './state/store-app-call';
// number of conversations to show before collapsing
const COLLAPSED_COUNT = 2;
export const niceShadowKeyframes = keyframes`
100%, 0% {
//background-color: rgb(102, 0, 51);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(183, 255, 0);
}
25% {
//background-color: rgb(76, 0, 76);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 251, 0);
//scale: 1.2;
}
50% {
//background-color: rgb(63, 0, 128);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgba(0, 255, 81);
//scale: 0.8;
}
75% {
//background-color: rgb(0, 0, 128);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 153, 0);
}`;
const ContactCardAvatar = (props: { size: string, symbol?: string, imageUrl?: string, onClick?: () => void, sx?: SxProps }) =>
<Avatar
// variant='outlined'
onClick={props.onClick}
src={props.imageUrl}
sx={{
'--Avatar-size': props.size,
fontSize: props.size,
backgroundColor: 'background.popup',
boxShadow: !props.imageUrl ? 'sm' : null,
...props.sx,
}}
>
{/* As fallback, show the large Persona Symbol */}
{!props.imageUrl && <Box>{props.symbol}</Box>}
</Avatar>;
const ContactCardConversationCall = (props: { conversation: DConversation, onConversationClicked: (conversationId: DConversationId) => void, }) =>
<Chip
variant='plain' color='primary' size='sm'
endDecorator={<CallIcon />}
onClick={() => props.onConversationClicked(props.conversation.id)}
slotProps={{
root: {
sx: {
maxWidth: 'unset',
mx: -1,
px: 1,
py: 0.25,
},
},
}}
>
{conversationTitle(props.conversation, 'Chat')}
</Chip>;
function CallContactCard(props: {
persona: MockPersona,
callGrayUI: boolean,
conversations: DConversation[],
setCallIntent: (intent: AppCallIntent) => void,
}) {
// state
const [conversationsExpanded, setConversationsExpanded] = React.useState(false);
// derived state
const { persona, setCallIntent } = props;
const conversations = props.conversations.slice(0, conversationsExpanded ? undefined : COLLAPSED_COUNT);
const hasConversations = !!conversations.length;
const showExpander = props.conversations.length > COLLAPSED_COUNT && !conversationsExpanded;
const handleCallPersona = React.useCallback(() => setCallIntent({
conversationId: null,
personaId: persona.personaId,
backTo: 'app-call-contacts',
}), [persona.personaId, setCallIntent]);
const handleCallPersonaRe = React.useCallback((conversationId: DConversationId | null) => setCallIntent({
conversationId: conversationId,
personaId: persona.personaId,
backTo: 'app-call-contacts',
}), [persona.personaId, setCallIntent]);
return (
<Box sx={{ mt: 3.5 }}>
<Card sx={{
// boxShadow: 'lg',
height: '100%',
gap: 0,
}}>
{/* Persona Symbol - Overlapping */}
<ContactCardAvatar
size='6rem'
symbol={persona.symbol}
imageUrl={persona?.imageUri}
sx={{
mx: 'auto',
mt: '-2.5rem',
zIndex: 1,
}}
/>
<CardContent sx={{ my: 2, display: 'flex' }}>
{/* Persona Description */}
<Typography level='body-xs' sx={{ minHeight: '3em', mb: hasConversations ? 1.5 : undefined }}>
{typeof persona.description === 'string' ? persona.description : 'Custom persona'}
</Typography>
{/*{hasConversations && <Divider>*/}
{/*<Typography level='body-xs'>call about</Typography>*/}
{/*</Divider>}*/}
{/* Persona Recent Converstions */}
{conversations.map(conversation =>
<ContactCardConversationCall
key={conversation.id}
conversation={conversation}
onConversationClicked={handleCallPersonaRe}
/>,
)}
{showExpander && <Chip
variant='plain' color='primary' size='sm'
onClick={() => setConversationsExpanded(true)}
slotProps={{
root: {
sx: {
maxWidth: 'unset',
mx: -1,
px: 1,
py: 0.25,
},
},
}}
>
{`+${props.conversations.length - COLLAPSED_COUNT} more`}
</Chip>}
</CardContent>
{/*<Divider />*/}
{/* Bottom Name and "Call" Button */}
<Sheet
variant='soft' color='primary'
invertedColors={props.callGrayUI ? undefined : true}
sx={{
// emulate CardOverflow, because CardOverflow doesn't work well with Sheet/Inverted
// (there's also a potential top-level inversion)
'--variant-borderWidth': '1px',
'--CardOverflow-offset': 'calc(-1 * var(--Card-padding))',
'--CardOverflow-radius': 'calc(var(--Card-radius) - var(--variant-borderWidth, 0px))',
margin: '0 var(--CardOverflow-offset) var(--CardOverflow-offset)',
borderRadius: '0 0 var(--CardOverflow-radius) var(--CardOverflow-radius)',
padding: '0.5rem var(--Card-padding)',
// contents
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 1,
}}
>
<Typography level='title-md'>
{persona.title}
</Typography>
<MuiLink overlay onClick={handleCallPersona}>
<IconButton size='md' variant='soft' sx={{
// borderRadius: '50%',
ml: 'auto',
mr: -1,
}}>
<CallIcon />
</IconButton>
</MuiLink>
</Sheet>
</Card>
</Box>
);
}
function useConversationsByPersona() {
const conversations = useChatStore(state => state.conversations, shallow);
return React.useMemo(() => {
// group by personaId
const groupedConversations: { [personaId: string]: DConversation[] } = conversations.reduce((acc, conversation) => {
const personaId = conversation.systemPurposeId;
acc[personaId] = [...acc[personaId] || [], conversation];
return acc;
}, {} as { [personaId: string]: DConversation[] });
// sort conversations by time and limit to 3
Object.values(groupedConversations).forEach(conversations =>
conversations.sort((a, b) => (b.updated || b.created) - (a.updated || a.created)),
);
return groupedConversations;
}, [conversations]);
}
export function Contacts(props: { setCallIntent: (intent: AppCallIntent) => void }) {
// external state
const {
grayUI, toggleGrayUI,
showConversations, toggleShowConversations,
showSupport, toggleShowSupport,
} = useAppCallStore();
const { personas } = useMockPersonas();
const conversationsByPersona = useConversationsByPersona();
// pluggable UI
const menuItems = React.useMemo(() => <>
<MenuItem onClick={toggleShowConversations}>
Conversations
<Switch checked={showConversations} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem onClick={toggleShowSupport}>
Support
<Switch checked={showSupport} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem onClick={toggleGrayUI}>
Grayed UI
<Switch checked={grayUI} sx={{ ml: 'auto' }} />
</MenuItem>
</>, [grayUI, showConversations, showSupport, toggleGrayUI, toggleShowConversations, toggleShowSupport]);
usePluggableOptimaLayout(null, null, menuItems, 'CallUI');
return <>
{/* Header "Call AGI" */}
<Box sx={{
my: 6,
display: 'flex', alignItems: 'center',
gap: 3,
}}>
<IconButton
variant='soft' color='success'
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
pointerEvents: 'none',
backgroundColor: 'background.popup',
animation: `${niceShadowKeyframes} 5s infinite`,
}}>
<CallIcon />
</IconButton>
<Box>
<Typography level='title-lg'>
Call AGI
</Typography>
<Typography level='title-sm' sx={{ mt: 1 }}>
Explore ideas and ignite creativity
</Typography>
<Chip variant='outlined' size='sm' sx={{ px: 1, py: 0.5, mt: 0.25, ml: -1, textWrap: 'wrap' }}>
Out-of-the-blue, or within a conversation
</Chip>
</Box>
</Box>
<ListDivider>
Personas
</ListDivider>
{/* Personas Cards */}
<Box
sx={{
width: '100%',
my: 5,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: { xs: 1, md: 2 },
}}
>
{personas.map((persona) =>
<CallContactCard
key={persona.personaId}
persona={persona}
callGrayUI={grayUI}
conversations={!showConversations ? [] : conversationsByPersona[persona.personaId] || []}
setCallIntent={props.setCallIntent}
/>,
)}
</Box>
{showSupport && <ListDivider sx={{ my: 1 }} />}
{showSupport && <GitHubProjectIssueCard
issue={354}
text='Call App: Support thread and compatibility matrix'
note={<>
Voice input uses the HTML Web Speech API, and speech output requires an ElevenLabs API Key.
</>}
// note2='Please report any issues you encounter'
sx={{
width: '100%',
mb: 2,
mt: 5,
}}
/>}
</>;
}
@@ -1,12 +1,10 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { useRouter } from 'next/router';
import { Box, Card, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import { Box, Card, ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CallEndIcon from '@mui/icons-material/CallEnd';
import CallIcon from '@mui/icons-material/Call';
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined';
import MicIcon from '@mui/icons-material/Mic';
import MicNoneIcon from '@mui/icons-material/MicNone';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
@@ -21,14 +19,16 @@ import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVo
import { Link } from '~/common/components/Link';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
import { CallAvatar } from './components/CallAvatar';
import { CallButton } from './components/CallButton';
import { CallMessage } from './components/CallMessage';
import { CallStatus } from './components/CallStatus';
import { ROUTE_APP_CHAT } from '~/common/app.routes';
import { useAppCallStore } from './state/store-app-call';
function CallMenuItems(props: {
@@ -39,6 +39,7 @@ function CallMenuItems(props: {
}) {
// external state
const { grayUI, toggleGrayUI } = useAppCallStore();
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
@@ -64,8 +65,14 @@ function CallMenuItems(props: {
{voicesDropdown}
</MenuItem>
<ListDivider />
<MenuItem onClick={toggleGrayUI}>
Grayed UI
<Switch checked={grayUI} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem component={Link} href='https://github.com/enricoros/big-agi/issues/175' target='_blank'>
<ListItemDecorator><ChatOutlinedIcon /></ListItemDecorator>
Voice Calls Feedback
</MenuItem>
@@ -73,9 +80,9 @@ function CallMenuItems(props: {
}
export function CallUI(props: {
conversationId: string,
personaId: string,
export function Telephone(props: {
callIntent: AppCallIntent,
backToContacts: () => void,
}) {
// state
@@ -89,16 +96,17 @@ export function CallUI(props: {
const responseAbortController = React.useRef<AbortController | null>(null);
// external state
const { push: routerPush } = useRouter();
const { chatLLMId, chatLLMDropdown } = useChatLLMDropdown();
const { chatTitle, messages } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
const { chatTitle, reMessages } = useChatStore(state => {
const conversation = props.callIntent.conversationId
? state.conversations.find(conversation => conversation.id === props.callIntent.conversationId) ?? null
: null;
return {
chatTitle: conversation ? conversationTitle(conversation) : 'no conversation',
messages: conversation ? conversation.messages : [],
chatTitle: conversation ? conversationTitle(conversation) : null,
reMessages: conversation ? conversation.messages : null,
};
}, shallow);
const persona = SystemPurposes[props.personaId as SystemPurposeId] ?? undefined;
const persona = SystemPurposes[props.callIntent.personaId as SystemPurposeId] ?? undefined;
const personaCallStarters = persona?.call?.starters ?? undefined;
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
const personaSystemMessage = persona?.systemMessage ?? undefined;
@@ -178,9 +186,7 @@ export function CallUI(props: {
// command: close the call
case 'Goodbye.':
setStage('ended');
setTimeout(() => {
void routerPush(ROUTE_APP_CHAT);
}, 2000);
setTimeout(launchAppChat, 2000);
return;
// command: regenerate answer
case 'Retry.':
@@ -197,8 +203,8 @@ export function CallUI(props: {
if (!chatLLMId) return;
// temp fix: when the chat has no messages, only assume a single system message
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = messages.length > 0
? messages
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = (reMessages && reMessages.length > 0)
? reMessages
: personaSystemMessage
? [{ role: 'system', text: personaSystemMessage }]
: [];
@@ -227,16 +233,18 @@ export function CallUI(props: {
error = err;
}).finally(() => {
setPersonaTextInterim(null);
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
if (finalText || error)
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
// fire/forget
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
if (finalText?.length >= 1)
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
});
return () => {
responseAbortController.current?.abort();
responseAbortController.current = null;
};
}, [isConnected, callMessages, chatLLMId, messages, personaVoiceId, personaSystemMessage, routerPush]);
}, [isConnected, callMessages, chatLLMId, personaVoiceId, personaSystemMessage, reMessages]);
// [E] Message interrupter
const abortTrigger = isConnected && isRecordingSpeech;
@@ -298,7 +306,7 @@ export function CallUI(props: {
<CallStatus
callerName={isConnected ? undefined : personaName}
statusText={isRinging ? 'is calling you' : isDeclined ? 'call declined' : isEnded ? 'call ended' : callElapsedTime}
statusText={isRinging ? '' /*'is calling you'*/ : isDeclined ? 'call declined' : isEnded ? 'call ended' : callElapsedTime}
regardingText={chatTitle}
micError={!isMicEnabled} speakError={!isTTSEnabled}
/>
@@ -307,11 +315,18 @@ export function CallUI(props: {
{(isConnected || isEnded) && (
<Card variant='soft' sx={{
flexGrow: 1,
minHeight: '15dvh', maxHeight: '24dvh',
overflow: 'auto',
maxHeight: '24%',
minHeight: '15%',
width: '100%',
// style
backgroundColor: 'background.surface',
borderRadius: 'lg',
flexDirection: 'column-reverse',
boxShadow: 'sm',
// children
display: 'flex', flexDirection: 'column-reverse',
overflow: 'auto',
}}>
{/* Messages in reverse order, for auto-scroll from the bottom */}
@@ -348,18 +363,18 @@ export function CallUI(props: {
)}
{/* Call Buttons */}
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-evenly' }}>
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-evenly', gap: 4 }}>
{/* [ringing] Decline / Accept */}
{isRinging && <CallButton Icon={CallEndIcon} text='Decline' color='danger' onClick={() => setStage('declined')} />}
{isRinging && isEnabled && <CallButton Icon={CallIcon} text='Accept' color='success' variant='soft' onClick={() => setStage('connected')} />}
{isRinging && <CallButton Icon={CallEndIcon} text='Decline' color='danger' variant='solid' onClick={() => setStage('declined')} />}
{isRinging && isEnabled && <CallButton Icon={CallIcon} text='Accept' color='success' variant='solid' onClick={() => setStage('connected')} />}
{/* [Calling] Hang / PTT (mute not enabled yet) */}
{isConnected && <CallButton Icon={CallEndIcon} text='Hang up' color='danger' onClick={handleCallStop} />}
{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'} />
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'} sx={!isRecording ? { backgroundColor: 'background.surface' } : undefined} />
: null
// <CallButton disabled={true} Icon={MicOffIcon} onClick={() => setMicMuted(muted => !muted)}
// text={micMuted ? 'Muted' : 'Mute'}
@@ -367,7 +382,7 @@ export function CallUI(props: {
)}
{/* [ended] Back / Call Again */}
{(isEnded || isDeclined) && <Link noLinkStyle href={ROUTE_APP_CHAT}><CallButton Icon={ArrowBackIcon} text='Back' variant='soft' /></Link>}
{(isEnded || isDeclined) && <CallButton Icon={ArrowBackIcon} text='Back' variant='soft' onClick={() => props.callIntent.backTo === 'app-chat' ? navigateToIndex() : props.backToContacts()} />}
{(isEnded || isDeclined) && <CallButton Icon={CallIcon} text='Call Again' color='success' variant='soft' onClick={() => setStage('connected')} />}
</Box>
+5 -6
View File
@@ -16,17 +16,16 @@ const cssScaleKeyframes = keyframes`
}`;
export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging: boolean, onClick: () => void }) {
export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging?: boolean, onClick: () => void }) {
return (
<Avatar
variant='soft' color='neutral'
onClick={props.onClick}
src={props.imageUrl}
sx={{
'--Avatar-size': { xs: '160px', md: '200px' },
'--variant-borderWidth': '4px',
boxShadow: !props.imageUrl ? 'md' : null,
fontSize: { xs: '100px', md: '120px' },
'--Avatar-size': { xs: '10rem', md: '11.5rem' },
backgroundColor: 'background.popup',
boxShadow: !props.imageUrl ? 'sm' : null,
fontSize: { xs: '6rem', md: '7rem' },
}}
>
+12 -6
View File
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Box, ColorPaletteProp, IconButton, Typography, VariantProp } from '@mui/joy';
import { ColorPaletteProp, FormControl, IconButton, Typography, VariantProp } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
/**
@@ -14,9 +15,10 @@ export function CallButton(props: {
Icon: React.FC, text: string,
variant?: VariantProp, color?: ColorPaletteProp, disabled?: boolean,
onClick?: () => void,
sx?: SxProps,
}) {
return (
<Box
<FormControl
onClick={() => !props.disabled && props.onClick?.()}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
@@ -25,19 +27,23 @@ export function CallButton(props: {
>
<IconButton
disabled={props.disabled} variant={props.variant || 'solid'} color={props.color}
aria-label={props.text}
variant={props.variant || 'solid'} color={props.color}
disabled={props.disabled}
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
// boxShadow: 'lg',
}}>
...props.sx,
}}
>
<props.Icon />
</IconButton>
<Typography level='title-md' variant={props.disabled ? 'soft' : undefined}>
<Typography aria-hidden level='title-md' variant={props.disabled ? 'soft' : undefined}>
{props.text}
</Typography>
</Box>
</FormControl>
);
}
+7 -7
View File
@@ -15,7 +15,7 @@ import { InlineError } from '~/common/components/InlineError';
export function CallStatus(props: {
callerName?: string,
statusText: string,
regardingText?: string,
regardingText: string | null,
micError: boolean, speakError: boolean,
// llmComponent?: React.JSX.Element,
}) {
@@ -28,19 +28,19 @@ export function CallStatus(props: {
{/*{props.llmComponent}*/}
<Typography level='body-md' sx={{ textAlign: 'center' }}>
{!!props.statusText && <Typography level='body-md' sx={{ textAlign: 'center' }}>
{props.statusText}
</Typography>
</Typography>}
{!!props.regardingText && <Typography level='body-md' sx={{ textAlign: 'center', mt: 0 }}>
re: {props.regardingText}
{!!props.regardingText && <Typography level='body-md' sx={{ textAlign: 'center', mt: 1 }}>
Re: <Box component='span' sx={{ color: 'text.primary' }}>{props.regardingText}</Box>
</Typography>}
{props.micError && <InlineError
severity='danger' error='But this browser does not support speech recognition... 🤦‍♀️ - Try Chrome on Windows?' />}
severity='danger' error='Looks like this Browser may not support speech recognition. You can try Chrome on Windows or Android instead.' />}
{props.speakError && <InlineError
severity='danger' error='And text-to-speech is not configured... 🤦‍♀️ - Configure it in Settings?' />}
severity='danger' error='Text-to-speech does not appear to be configured. Please set it up in Preferences > Voice.' />}
</Box>
);
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Call settings
interface AppCallStore {
grayUI: boolean;
toggleGrayUI: () => void;
showConversations: boolean;
toggleShowConversations: () => void;
showSupport: boolean;
toggleShowSupport: () => void;
}
export const useAppCallStore = create<AppCallStore>()(persist(
(_set, _get) => ({
grayUI: false,
toggleGrayUI: () => _set(state => ({ grayUI: !state.grayUI })),
showConversations: true,
toggleShowConversations: () => _set(state => ({ showConversations: !state.showConversations })),
showSupport: true,
toggleShowSupport: () => _set(state => ({ showSupport: !state.showSupport })),
}), {
name: 'app-app-call',
},
));
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import { usePurposeStore } from '../../chat/components/persona-selector/store-purposes';
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../data';
/**
* This is a 'mock' persona because Soon we'll have real personas definitions
* and stores. Until then, we just mimic a reactive system here.
*/
export interface MockPersona extends SystemPurposeData {
personaId: SystemPurposeId,
}
export function useMockPersonas(): { personas: MockPersona[], personaIDs: SystemPurposeId[] } {
// only react to hiddenPurposeIDs changes
const hiddenPurposeIDs = usePurposeStore(state => state.hiddenPurposeIDs);
// Dependency array is empty because SystemPurposes is constant
return React.useMemo(() => {
const personaIDs = Object.keys(SystemPurposes) as SystemPurposeId[];
const personas = personaIDs
.filter((key) => !hiddenPurposeIDs.includes(key))
.map((key) => ({
...SystemPurposes[key as SystemPurposeId],
personaId: key as SystemPurposeId,
}));
return { personas, personaIDs };
}, [hiddenPurposeIDs]);
}
+184 -159
View File
@@ -1,46 +1,45 @@
import * as React from 'react';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { Panel, PanelGroup } from 'react-resizable-panels';
import { Box, useTheme } from '@mui/joy';
import { useTheme } from '@mui/joy';
import { CmdRunBrowse } from '~/modules/browse/browse.client';
import { CmdRunReact } from '~/modules/aifn/react/react';
import { CmdRunT2I, useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { DiagramConfig, DiagramsModal } from '~/modules/aifn/digrams/DiagramsModal';
import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal';
import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
import { getChatLLMId, useChatLLM } from '~/modules/llms/store-llms';
import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
import { useChatLLM, useModelsStore } from '~/modules/llms/store-llms';
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { Brand } from '~/common/app.config';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
import { GoodPanelResizeHandler } from '~/common/components/panes/GoodPanelResizeHandler';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
import { themeBgApp, themeBgAppChatComposer } from '~/common/app.theme';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { useFolderStore } from '~/common/state/store-folders';
import { useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
import { ChatDrawerItemsMemo } from './components/applayout/ChatDrawerItems';
import { ChatDrawerMemo } from './components/applayout/ChatDrawer';
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
import { ChatMessageList } from './components/ChatMessageList';
import { CmdAddRoleMessage, CmdHelp, createCommandsHelpMessage, extractCommands } from './editors/commands';
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 { extractChatCommand, findAllChatCommands } from './commands/commands.registry';
import { runAssistantUpdatingState } from './editors/chat-stream';
import { runBrowseUpdatingState } from './editors/browse-load';
import { runImageGenerationUpdatingState } from './editors/image-generate';
import { runReActUpdatingState } from './editors/react-tangent';
/**
* Mode: how to treat the input from the Composer
*/
@@ -64,6 +63,7 @@ export function AppChat() {
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
const showNextTitle = React.useRef(false);
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const [_activeFolderId, setActiveFolderId] = React.useState<string | null>(null);
// external state
const theme = useTheme();
@@ -90,6 +90,7 @@ export function AppChat() {
isChatEmpty: isFocusedChatEmpty,
areChatsEmpty,
newConversationId,
conversationsLength,
_remove_systemPurposeId: focusedSystemPurposeId,
prependNewConversation,
branchConversation,
@@ -100,10 +101,18 @@ export function AppChat() {
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
const { activeFolderId, activeFolderConversationsCount } = 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 panesConversationIDs = chatPanes.length > 0 ? chatPanes.map((pane) => pane.conversationId) : [null];
const isSplitPane = chatPanes.length > 1;
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
@@ -135,39 +144,45 @@ export function AppChat() {
}
}, [focusedChatNumber, focusedChatTitle]);
// Execution
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
const { chatLLMId } = useModelsStore.getState();
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
const chatLLMId = getChatLLMId();
if (!chatModeId || !conversationId || !chatLLMId) return;
// "/command ...": overrides the chat mode
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
if (lastMessage?.role === 'user') {
const pieces = extractCommands(lastMessage.text);
if (pieces.length == 2 && pieces[0].type === 'cmd' && pieces[1].type === 'text') {
const [command, prompt] = [pieces[0].value, pieces[1].value];
if (CmdRunT2I.includes(command)) {
setMessages(conversationId, history);
return await runImageGenerationUpdatingState(conversationId, prompt);
}
if (CmdRunReact.includes(command) && chatLLMId) {
setMessages(conversationId, history);
return await runReActUpdatingState(conversationId, prompt, chatLLMId);
}
if (CmdRunBrowse.includes(command) && prompt?.trim() && useBrowseStore.getState().enableCommandBrowse) {
setMessages(conversationId, history);
return await runBrowseUpdatingState(conversationId, prompt);
}
if (CmdAddRoleMessage.includes(command)) {
lastMessage.role = command.startsWith('/s') ? 'system' : command.startsWith('/a') ? 'assistant' : 'user';
lastMessage.sender = 'Bot';
lastMessage.text = prompt;
return setMessages(conversationId, history);
}
if (CmdHelp.includes(command)) {
return setMessages(conversationId, [...history, createCommandsHelpMessage()]);
const chatCommand = extractChatCommand(lastMessage.text)[0];
if (chatCommand && chatCommand.type === 'cmd') {
switch (chatCommand.providerId) {
case 'ass-browse':
setMessages(conversationId, history);
return await runBrowseUpdatingState(conversationId, chatCommand.params!);
case 'ass-t2i':
setMessages(conversationId, history);
return await runImageGenerationUpdatingState(conversationId, chatCommand.params!);
case 'ass-react':
setMessages(conversationId, history);
return await runReActUpdatingState(conversationId, chatCommand.params!, chatLLMId);
case 'chat-alter':
Object.assign(lastMessage, {
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
sender: 'Bot',
text: chatCommand.params || '',
} satisfies Partial<DMessage>);
return setMessages(conversationId, history);
case 'cmd-help':
const chatCommandsText = findAllChatCommands()
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
.join('\n');
const helpMessage = createDMessage('assistant', 'Available Chat Commands:\n' + chatCommandsText);
helpMessage.originLLM = Brand.Title.Base;
return setMessages(conversationId, [...history, helpMessage]);
}
}
}
@@ -184,9 +199,10 @@ export function AppChat() {
case 'generate-image':
if (!lastMessage?.text)
break;
// also add a 'fake' user message with the '/draw' command
setMessages(conversationId, history.map(message => message.id !== lastMessage.id ? message : {
...message,
text: `${CmdRunT2I[0]} ${lastMessage.text}`,
text: `/draw ${lastMessage.text}`,
}));
return await runImageGenerationUpdatingState(conversationId, lastMessage.text);
@@ -204,7 +220,6 @@ export function AppChat() {
}, [focusedSystemPurposeId, setMessages]);
const handleComposerAction = (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
// validate inputs
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
addSnackbar({
@@ -232,8 +247,9 @@ export function AppChat() {
return true;
};
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
await _handleExecute('generate-text', conversationId, history);
}, [_handleExecute]);
const handleMessageRegenerateLast = React.useCallback(async () => {
const focusedConversation = getConversation(focusedConversationId);
@@ -246,9 +262,9 @@ export function AppChat() {
}
}, [focusedConversationId, _handleExecute]);
const handleTextDiagram = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
const handleTextImagine = async (conversationId: DConversationId, messageText: string) => {
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string): Promise<void> => {
const conversation = getConversation(conversationId);
if (!conversation)
return;
@@ -257,27 +273,34 @@ export function AppChat() {
...conversation.messages,
createDMessage('user', imaginedPrompt),
]);
};
}, [_handleExecute]);
const handleTextSpeak = async (text: string) => {
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
await speakText(text);
};
}, []);
// Chat actions
const handleConversationNew = React.useCallback(() => {
// activate an existing new conversation if present, or create another
setFocusedConversationId(newConversationId
const conversationId = newConversationId
? newConversationId
: prependNewConversation(focusedSystemPurposeId ?? undefined),
);
: prependNewConversation(focusedSystemPurposeId ?? undefined);
setFocusedConversationId(conversationId);
// if a folder is active, add the new conversation to the folder
if (activeFolderId && conversationId)
useFolderStore.getState().addConversationToFolder(activeFolderId, conversationId);
// focus the composer
composerTextAreaRef.current?.focus();
}, [focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
}, [activeFolderId, focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
const handleConversationExport = React.useCallback((conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId }), []);
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
showNextTitle.current = true;
@@ -301,7 +324,6 @@ export function AppChat() {
const handleConversationFlatten = (conversationId: DConversationId) => setFlattenConversationId(conversationId);
const handleConfirmedClearConversation = React.useCallback(() => {
if (clearConversationId) {
setMessages(clearConversationId, []);
@@ -309,14 +331,13 @@ export function AppChat() {
}
}, [clearConversationId, setMessages]);
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
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);
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, activeFolderId);
else
nextConversationId = deleteConversation(deleteConversationId);
setFocusedConversationId(nextConversationId);
@@ -324,20 +345,20 @@ export function AppChat() {
}
};
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
if (bypassConfirmation)
setFocusedConversationId(deleteConversation(conversationId));
else
setDeleteConversationId(conversationId);
}, [deleteConversation, setFocusedConversationId]);
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
const handleConversationDelete = React.useCallback(
(conversationId: DConversationId, bypassConfirmation: boolean) => {
if (bypassConfirmation) setFocusedConversationId(deleteConversation(conversationId));
else setDeleteConversationId(conversationId);
},
[deleteConversation, setFocusedConversationId],
);
// Shortcuts
const handleOpenChatLlmOptions = React.useCallback(() => {
const { chatLLMId } = useModelsStore.getState();
const chatLLMId = getChatLLMId();
if (!chatLLMId) return;
openLlmOptions(chatLLMId);
}, [openLlmOptions]);
@@ -346,15 +367,14 @@ export function AppChat() {
['o', true, true, false, handleOpenChatLlmOptions],
['r', true, true, false, handleMessageRegenerateLast],
['n', true, false, true, handleConversationNew],
['b', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationBranch(focusedConversationId, null)],
['x', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationClear(focusedConversationId)],
['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)],
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
], [focusedConversationId, handleConversationBranch, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
useGlobalShortcuts(shortcuts);
// Pluggable ApplicationBar components
const centerItems = React.useMemo(() =>
@@ -366,17 +386,20 @@ export function AppChat() {
[focusedConversationId, isSplitPane, toggleSplitPane],
);
const drawerItems = React.useMemo(() =>
<ChatDrawerItemsMemo
const drawerContent = React.useMemo(() =>
<ChatDrawerMemo
activeConversationId={focusedConversationId}
activeFolderId={activeFolderId}
disableNewButton={isFocusedChatEmpty}
onConversationActivate={setFocusedConversationId}
onConversationDelete={handleConversationDelete}
onConversationExportDialog={handleConversationExport}
onConversationImportDialog={handleConversationImportDialog}
onConversationNew={handleConversationNew}
onConversationsDeleteAll={handleConversationsDeleteAll}
setActiveFolderId={setActiveFolderId}
/>,
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, setFocusedConversationId],
[activeFolderId, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, setFocusedConversationId],
);
const menuItems = React.useMemo(() =>
@@ -388,99 +411,92 @@ export function AppChat() {
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationClear={handleConversationClear}
onConversationExport={handleConversationExport}
onConversationFlatten={handleConversationFlatten}
/>,
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, isFocusedChatEmpty, isMessageSelectionMode],
);
usePluggableOptimaLayout(drawerItems, centerItems, menuItems, 'AppChat');
usePluggableOptimaLayout(drawerContent, centerItems, menuItems, 'AppChat');
return <>
<PanelGroup direction='horizontal'>
<PanelGroup
direction='horizontal'
id='app-chat-panels'
>
{panesConversationIDs.map((_conversationId, idx, panels) => <React.Fragment key={`chat-pane-${idx}-${panels.length}-${_conversationId}`}>
<Panel
id={'chat-pane-' + _conversationId}
order={idx}
collapsible
defaultSize={panels.length > 0 ? Math.round(100 / panels.length) : undefined}
minSize={20}
onClick={() => setFocusedPane(idx)}
onCollapse={() => setTimeout(() => removePane(idx), 50)}
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}` }),
}}
>
<ScrollToBottom
bootToBottom
stickToBottom
sx={{
// allows the content to be scrolled (all browsers)
overflowY: 'auto',
// actually make sure this scrolls & fills
height: '100%',
{panesConversationIDs.map((_conversationId, idx, panels) =>
<React.Fragment key={`chat-pane-${idx}-${panels.length}-${_conversationId}`}>
<Panel
id={'chat-pane-' + _conversationId}
order={idx}
collapsible
defaultSize={panels.length > 0 ? Math.round(100 / panels.length) : undefined}
minSize={20}
onClick={(event) => {
const setFocus = chatPanes.length < 2 || !event.altKey;
setFocusedPane(setFocus ? idx : -1);
}}
onCollapse={() => setTimeout(() => removePane(idx), 50)}
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}` }),
}}
>
<ChatMessageList
conversationId={_conversationId}
capabilityHasT2I={capabilityHasT2I}
chatLLMContextTokens={chatLLM?.contextTokens}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
<ScrollToBottom
bootToBottom
stickToBottom
sx={{
backgroundColor: themeBgApp,
minHeight: '100%', // ensures filling of the blank space on newer chats
}}
/>
<Ephemerals
conversationId={_conversationId}
sx={{
// TODO: Fixme post panels?
// flexGrow: 0.1,
flexShrink: 0.5,
// allows the content to be scrolled (all browsers)
overflowY: 'auto',
minHeight: 64,
}} />
// actually make sure this scrolls & fills
height: '100%',
}}
>
{/* Visibility and actions are handled via Context */}
<ScrollToBottomButton />
<ChatMessageList
conversationId={_conversationId}
capabilityHasT2I={capabilityHasT2I}
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
sx={{
minHeight: '100%', // ensures filling of the blank space on newer chats
}}
/>
</ScrollToBottom>
<Ephemerals
conversationId={_conversationId}
sx={{
// TODO: Fixme post panels?
// flexGrow: 0.1,
flexShrink: 0.5,
overflowY: 'auto',
minHeight: 64,
}}
/>
</Panel>
{/* Visibility and actions are handled via Context */}
<ScrollToBottomButton />
</ScrollToBottom>
</Panel>
{/* Panel Separators & Resizers */}
{idx < panels.length - 1 && (
<PanelResizeHandle>
<Box sx={{
backgroundColor: themeBgApp,
height: '100%',
width: '4px',
'&:hover': {
backgroundColor: 'primary.softActiveBg',
},
}} />
</PanelResizeHandle>
)}
{/* Panel Separators & Resizers */}
{idx < panels.length - 1 && <GoodPanelResizeHandler />}
</React.Fragment>)}
</React.Fragment>)}
</PanelGroup>
@@ -498,8 +514,8 @@ export function AppChat() {
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
}} />
}}
/>
{/* Diagrams */}
{!!diagramConfig && <DiagramsModal config={diagramConfig} onClose={() => setDiagramConfig(null)} />}
@@ -514,25 +530,34 @@ export function AppChat() {
)}
{/* Import / Export */}
{!!tradeConfig && <TradeModal config={tradeConfig} onConversationActivate={setFocusedConversationId} onClose={() => setTradeConfig(null)} />}
{!!tradeConfig && (
<TradeModal
config={tradeConfig}
onConversationActivate={setFocusedConversationId}
onClose={() => setTradeConfig(null)}
/>
)}
{/* [confirmation] Reset Conversation */}
{!!clearConversationId && <ConfirmationModal
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText={'Are you sure you want to discard all messages?'} positiveActionText={'Clear conversation'}
/>}
{!!clearConversationId && (
<ConfirmationModal
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 ALL conversations? This action cannot be undone.'
? `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'
? `Yes, delete all ${activeFolderConversationsCount} conversations`
: 'Delete conversation'}
/>}
</>;
}
+19
View File
@@ -0,0 +1,19 @@
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsAlter: ICommandsProvider = {
id: 'chat-alter',
rank: 20,
getCommands: () => [{
primary: '/assistant',
alternatives: ['/a'],
arguments: ['text'],
description: 'Injects assistant response',
}, {
primary: '/system',
alternatives: ['/s'],
arguments: ['text'],
description: 'Injects system message',
}],
};
+16
View File
@@ -0,0 +1,16 @@
import LanguageIcon from '@mui/icons-material/Language';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsBrowse: ICommandsProvider = {
id: 'ass-browse',
rank: 25,
getCommands: () => [{
primary: '/browse',
arguments: ['URL'],
description: 'Assistant will download the web page',
Icon: LanguageIcon,
}],
};
+17
View File
@@ -0,0 +1,17 @@
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsDraw: ICommandsProvider = {
id: 'ass-t2i',
rank: 10,
getCommands: () => [{
primary: '/draw',
alternatives: ['/imagine', '/img'],
arguments: ['prompt'],
description: 'Assistant will draw the text',
Icon: FormatPaintIcon,
}],
};
+13
View File
@@ -0,0 +1,13 @@
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsHelp: ICommandsProvider = {
id: 'cmd-help',
rank: 99,
getCommands: () => [{
primary: '/help',
alternatives: ['/?'],
description: 'Display this list of commands',
}],
};
+16
View File
@@ -0,0 +1,16 @@
import PsychologyIcon from '@mui/icons-material/Psychology';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsReact: ICommandsProvider = {
id: 'ass-react',
rank: 15,
getCommands: () => [{
primary: '/react',
arguments: ['prompt'],
description: 'Use the AI ReAct strategy to answer your query',
Icon: PsychologyIcon,
}],
};
@@ -0,0 +1,24 @@
import type { FunctionComponent } from 'react';
import type { CommandsProviderId } from './commands.registry';
export interface ChatCommand {
primary: string; // The primary command
alternatives?: string[]; // Alternative commands
arguments?: string[]; // Arguments for the command
description: string; // Description of what the command does
// usage?: string; // Example of how to use the command
Icon?: FunctionComponent; // Icon to display next to the command
}
export interface ICommandsProvider {
id: CommandsProviderId; // Unique identifier for the command provider
rank: number; // Rank of the provider, used to sort the providers in the UI
// Function to get commands with their alternatives and details
getCommands: () => ChatCommand[];
// Function to execute a command with optional parameters
// executeCommand: (command: string, params?: string[]) => Promise<boolean>;
}
@@ -0,0 +1,67 @@
import { ChatCommand, ICommandsProvider } from './ICommandsProvider';
import { CommandsAlter } from './CommandsAlter';
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';
type TextCommandPiece =
| { type: 'text'; value: string; }
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isError?: boolean };
const ChatCommandsProviders: Record<CommandsProviderId, ICommandsProvider> = {
'ass-browse': CommandsBrowse,
'ass-react': CommandsReact,
'ass-t2i': CommandsDraw,
'chat-alter': CommandsAlter,
'cmd-help': CommandsHelp,
};
export function findAllChatCommands(): ChatCommand[] {
return Object.values(ChatCommandsProviders)
.sort((a, b) => a.rank - b.rank)
.map(p => p.getCommands())
.flat();
}
export function extractChatCommand(input: string): TextCommandPiece[] {
const inputTrimmed = input.trim();
// quick exit: command does not start with '/'
if (!inputTrimmed.startsWith('/'))
return [{ type: 'text', value: input }];
// Find the first space to separate the command from its parameters (if any)
const firstSpaceIndex = inputTrimmed.indexOf(' ');
const potentialCommand = inputTrimmed.substring(0, firstSpaceIndex >= 0 ? firstSpaceIndex : inputTrimmed.length);
// Check if the potential command is an actual command
for (const provider of Object.values(ChatCommandsProviders)) {
for (const cmd of provider.getCommands()) {
if (cmd.primary === potentialCommand || cmd.alternatives?.includes(potentialCommand)) {
// command needs arguments: take the rest of the input as parameters
if (cmd.arguments?.length) {
const params = firstSpaceIndex >= 0 ? inputTrimmed.substring(firstSpaceIndex + 1) : '';
return [{ type: 'cmd', providerId: provider.id, command: potentialCommand, params: params || undefined, isError: !params || undefined }];
}
// command without arguments, treat any text after as a separate text piece
const pieces: TextCommandPiece[] = [{ type: 'cmd', providerId: provider.id, command: potentialCommand, params: undefined }];
const textAfterCommand = firstSpaceIndex >= 0 ? inputTrimmed.substring(firstSpaceIndex + 1) : '';
if (textAfterCommand)
pieces.push({ type: 'text', value: textAfterCommand });
return pieces;
}
}
}
// No command found, return the entire input as text
return [{ type: 'text', value: input }];
}
+19 -18
View File
@@ -6,11 +6,11 @@ import { SxProps } from '@mui/joy/styles/types';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
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 { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ChatMessageMemo } from './message/ChatMessage';
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
@@ -25,13 +25,13 @@ import { useScrollToBottom } from './scroll-to-bottom/useScrollToBottom';
export function ChatMessageList(props: {
conversationId: DConversationId | null,
capabilityHasT2I: boolean,
chatLLMContextTokens?: number,
chatLLMContextTokens: number | null,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => void,
onTextDiagram: (diagramConfig: DiagramConfig | null) => Promise<any>,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<any>,
onTextSpeak: (selectedText: string) => Promise<any>,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
onTextSpeak: (selectedText: string) => Promise<void>,
sx?: SxProps,
}) {
@@ -42,7 +42,7 @@ export function ChatMessageList(props: {
// external state
const { notifyBooting } = useScrollToBottom();
const { openPreferences } = useOptimaLayout();
const { openPreferencesTab } = useOptimaLayout();
const [showSystemMessages] = useChatShowSystemMessages();
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
@@ -62,8 +62,9 @@ export function ChatMessageList(props: {
// text actions
const handleRunExample = (text: string) =>
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
const handleRunExample = React.useCallback(async (text: string) => {
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
// message menu methods proxy
@@ -72,11 +73,11 @@ export function ChatMessageList(props: {
conversationId && onConversationBranch(conversationId, messageId);
}, [conversationId, onConversationBranch]);
const handleConversationRestartFrom = React.useCallback((messageId: string, offset: number) => {
const handleConversationRestartFrom = React.useCallback(async (messageId: string, offset: number) => {
const messages = getConversation(conversationId)?.messages;
if (messages) {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
conversationId && onConversationExecuteHistory(conversationId, truncatedHistory);
conversationId && await onConversationExecuteHistory(conversationId, truncatedHistory);
}
}, [conversationId, onConversationExecuteHistory]);
@@ -97,26 +98,26 @@ export function ChatMessageList(props: {
}, [conversationId, editMessage]);
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
conversationId && await onTextDiagram({ conversationId: conversationId, messageId, text });
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
}, [conversationId, onTextDiagram]);
const handleTextImagine = React.useCallback(async (text: string) => {
if (!capabilityHasT2I)
return openPreferences(2);
return openPreferencesTab(PreferencesTab.Draw);
if (conversationId) {
setIsImagining(true);
await onTextImagine(conversationId, text);
setIsImagining(false);
}
}, [capabilityHasT2I, conversationId, onTextImagine, openPreferences]);
}, [capabilityHasT2I, conversationId, onTextImagine, openPreferencesTab]);
const handleTextSpeak = React.useCallback(async (text: string) => {
if (!isSpeakable)
return openPreferences(3);
return openPreferencesTab(PreferencesTab.Voice);
setIsSpeaking(true);
await onTextSpeak(text);
setIsSpeaking(false);
}, [isSpeakable, onTextSpeak, openPreferences]);
}, [isSpeakable, onTextSpeak, openPreferencesTab]);
// operate on the local selection set
@@ -209,7 +210,7 @@ export function ChatMessageList(props: {
<CleanerMessage
key={'sel-' + message.id}
message={message}
remainingTokens={(props.chatLLMContextTokens || 0) - historyTokenCount}
remainingTokens={props.chatLLMContextTokens ? (props.chatLLMContextTokens - historyTokenCount) : undefined}
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
/>
+22 -15
View File
@@ -1,11 +1,12 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Grid, IconButton, Sheet, Stack, styled, Typography, useTheme } from '@mui/joy';
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 { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
import { lineHeightChatText } from '~/common/app.theme';
const StateLine = styled(Typography)(({ theme }) => ({
@@ -15,7 +16,7 @@ const StateLine = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSize.xs,
fontFamily: theme.fontFamily.code,
marginLeft: theme.spacing(1),
lineHeight: 2,
lineHeight: lineHeightChatText,
}));
function isPrimitive(value: any): boolean {
@@ -52,11 +53,11 @@ function StateRenderer(props: { state: object }) {
const entries = Object.entries(props.state);
return (
<Stack>
<Typography level='body-sm' sx={{ mb: 1 }}>
Internal State
<Box>
<Typography fontSize='smaller' sx={{ mb: 1 }}>
## Internal State
</Typography>
<Sheet>
<Sheet sx={{ p: 1 }}>
{!entries && <Typography level='body-sm'>No state variables</Typography>}
{entries.map(([key, value]) =>
isPrimitive(value)
@@ -68,13 +69,12 @@ function StateRenderer(props: { state: object }) {
: <Typography key={'state-' + key} level='body-sm'>{key}: {value}</Typography>,
)}
</Sheet>
</Stack>
</Box>
);
}
function EphemeralItem({ conversationId, ephemeral }: { conversationId: string, ephemeral: DEphemeral }) {
const theme = useTheme();
return <Box
sx={{
p: { xs: 1, md: 2 },
@@ -84,8 +84,8 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
}}>
{/* Title */}
{ephemeral.title && <Typography>
{ephemeral.title} <b>Development Tools</b>
{ephemeral.title && <Typography level='title-sm' sx={{ mb: 1.5 }}>
{ephemeral.title} Development Tools
</Typography>}
{/* Vertical | split */}
@@ -93,7 +93,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: 1.75 }}>
<Typography fontSize='smaller' sx={{ overflowWrap: 'anywhere', whiteSpace: 'break-spaces', lineHeight: lineHeightChatText }}>
{ephemeral.text}
</Typography>
</Grid>
@@ -102,8 +102,8 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
{!!ephemeral.state && <Grid
xs={12} md={6}
sx={{
borderLeft: { md: `1px solid ${theme.palette.divider}` },
borderTop: { xs: `1px solid ${theme.palette.divider}`, md: 'none' },
borderLeft: { md: `1px dashed` },
borderTop: { xs: `1px dashed`, md: 'none' },
}}>
<StateRenderer state={ephemeral.state} />
</Grid>}
@@ -123,10 +123,15 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
</Box>;
}
// const dashedBorderSVG = encodeURIComponent(`
// <svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'>
// <rect x='0' y='0' width='100%' height='100%' fill='none' stroke='currentColor' stroke-width='2' stroke-dasharray='16, 2' />
// </svg>
// `);
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
// global state
const theme = useTheme();
const ephemerals = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return conversation ? conversation.ephemerals : [];
@@ -138,7 +143,9 @@ export function Ephemerals(props: { conversationId: DConversationId | null, sx?:
<Sheet
variant='soft' color='success' invertedColors
sx={{
border: `4px dashed ${theme.palette.divider}`,
// backgroundImage: `url("data:image/svg+xml,${dashedBorderSVG.replace('currentColor', '%23A1E8A1')}")`,
// backgroundSize: '100% 100%',
// backgroundRepeat: 'no-repeat',
...(props.sx || {}),
}}>
@@ -0,0 +1,367 @@
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>
)}
</>;
}
@@ -0,0 +1,339 @@
import * as React from 'react';
import { Avatar, Box, Divider, 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 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 { 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';
// set to true to display the conversation IDs
// const DEBUG_CONVERSATION_IDS = false;
export const FadeInButton = styled(IconButton)({
opacity: 0.5,
transition: 'opacity 0.2s',
'&:hover': { opacity: 1 },
});
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem);
export interface ChatNavigationItemData {
conversationId: DConversationId;
isActive: boolean;
isEmpty: boolean;
title: string;
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
messageCount: number;
assistantTyping: boolean;
systemPurposeId: SystemPurposeId;
searchFrequency?: number;
}
export interface FolderChangeRequest {
conversationId: DConversationId;
anchorEl: HTMLButtonElement;
currentFolder: DFolder | null;
}
function ChatDrawerItem(props: {
item: ChatNavigationItemData,
isLonely: boolean,
showSymbols: boolean,
bottomBarBasis: number,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationDelete: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId) => void,
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
}) {
// state
const [isEditingTitle, setIsEditingTitle] = 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 isNew = messageCount === 0;
// [effect] auto-disarm when inactive
const shallClose = deleteArmed && !isActive;
React.useEffect(() => {
if (shallClose)
setDeleteArmed(false);
}, [shallClose]);
// Activate
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
// export
const handleConversationExport = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
conversationId && onConversationExport(conversationId);
}, [conversationId, onConversationExport]);
// Folder change
const handleFolderChangeBegin = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onConversationFolderChange({
conversationId,
anchorEl: event.currentTarget,
currentFolder: folder ?? null,
});
}, [conversationId, folder, onConversationFolderChange]);
// Title Edit
const handleTitleEditBegin = React.useCallback(() => setIsEditingTitle(true), []);
const handleTitleEditCancel = React.useCallback(() => {
setIsEditingTitle(false);
}, []);
const handleTitleEditChange = React.useCallback((text: string) => {
setIsEditingTitle(false);
useChatStore.getState().setUserTitle(conversationId, text.trim());
}, [conversationId]);
const handleTitleEditAuto = React.useCallback(() => {
conversationAutoTitle(conversationId, true);
}, [conversationId]);
// Delete
const handleDeleteButtonShow = React.useCallback(() => setDeleteArmed(true), []);
const handleDeleteButtonHide = React.useCallback(() => setDeleteArmed(false), []);
const handleConversationDelete = React.useCallback((event: React.MouseEvent) => {
if (deleteArmed) {
setDeleteArmed(false);
event.stopPropagation();
props.onConversationDelete(conversationId);
}
}, [conversationId, deleteArmed, props]);
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
const progress = props.bottomBarBasis ? 100 * (searchFrequency ?? messageCount) / props.bottomBarBasis : 0;
const titleRowComponent = React.useMemo(() => <>
{/* Symbol, if globally enabled */}
{props.showSymbols && <ListItemDecorator>
{assistantTyping
? (
<Avatar
alt='typing' variant='plain'
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
sx={{
width: '1.5rem',
height: '1.5rem',
borderRadius: 'var(--joy-radius-sm)',
}}
/>
) : (
<Typography>
{isNew ? '' : textSymbol}
</Typography>
)}
</ListItemDecorator>}
{/* Title */}
{!isEditingTitle ? (
<Typography
// level={isActive ? 'title-md' : 'body-md'}
onDoubleClick={handleTitleEditBegin}
sx={{
color: isActive ? 'text.primary' : 'text.secondary',
flex: 1,
}}
>
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
{title.trim() ? title : 'Chat'}{assistantTyping && '...'}
</Typography>
) : (
<InlineTextarea
invertedColors
initialText={title}
onEdit={handleTitleEditChange}
onCancel={handleTitleEditCancel}
sx={{
flexGrow: 1,
ml: -1.5, mr: -0.5,
}}
/>
)}
{/* Display search frequency if it exists and is greater than 0 */}
{searchFrequency && searchFrequency > 0 && (
<Box sx={{ ml: 1 }}>
<Typography level='body-sm'>
{searchFrequency}
</Typography>
</Box>
)}
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title]);
const progressBarFixedComponent = React.useMemo(() =>
progress > 0 && (
<Box sx={{
backgroundColor: 'neutral.softBg',
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
}} />
), [progress]);
return isActive ? (
// Active Conversation
<Sheet
variant={isActive ? 'solid' : 'plain'}
invertedColors={isActive}
sx={{
// common
'--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
// style
borderRadius: 'md',
mx: '0.25rem',
'&:hover > button': {
opacity: 1, // fade in buttons when hovering, but by default wash them out a bit
},
}}
>
<ListItem sx={{ border: 'none', display: 'grid', gap: 0, px: 'calc(var(--ListItem-paddingX) - 0.25rem)' }}>
{/* 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' }}>
<ListItemDecorator />
{/* 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 />
</FadeInButton>
)}
</Tooltip>
<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />
</>}
<Tooltip disableInteractive title='Rename'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditBegin}>
<EditIcon />
</FadeInButton>
</Tooltip>
{!isNew && <>
<Tooltip disableInteractive title='Auto-Title'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
<AutoFixHighIcon />
</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 }} />
{/* Delete [armed, arming] buttons */}
{!props.isLonely && !searchFrequency && <>
{deleteArmed && (
<Tooltip disableInteractive title='Confirm Deletion'>
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1 }}>
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
</FadeInButton>
</Tooltip>
)}
<Tooltip disableInteractive title={deleteArmed ? 'Cancel Delete' : 'Delete'}>
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
{deleteArmed ? <CloseIcon /> : <DeleteOutlineIcon />}
</FadeInButton>
</Tooltip>
</>}
</Box>
</ListItem>
{/* Optional progress bar, underlay */}
{progressBarFixedComponent}
</Sheet>
) : (
// Inactive Conversation - click to activate
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
<ListItemButton
onClick={handleConversationActivate}
sx={{
border: 'none', // there's a default border of 1px and invisible.. hmm
position: 'relative', // for the progress bar
}}
>
{titleRowComponent}
{/* Optional progress bar, underlay */}
{progressBarFixedComponent}
</ListItemButton>
</ListItem>
);
}
@@ -1,150 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, ListDivider, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { ChatNavigationItemMemo } from './ChatNavigationItem';
// type ListGrouping = 'off' | 'persona';
export const ChatDrawerItemsMemo = React.memo(ChatDrawerItems);
function ChatDrawerItems(props: {
activeConversationId: DConversationId | null,
disableNewButton: boolean,
onConversationActivate: (conversationId: DConversationId) => void,
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
onConversationImportDialog: () => void,
onConversationNew: () => void,
onConversationsDeleteAll: () => void,
}) {
// local state
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
// const [grouping] = React.useState<ListGrouping>('off');
// external state
const { closeAppDrawer } = useOptimaLayout();
const conversations = useChatStore(state => state.conversations, shallow);
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
// derived state
const maxChatMessages = conversations.reduce((longest, _c) => Math.max(longest, _c.messages.length), 1);
const totalConversations = conversations.length;
const hasChats = totalConversations > 0;
const singleChat = totalConversations === 1;
const softMaxReached = totalConversations >= 50;
const handleButtonNew = React.useCallback(() => {
onConversationNew();
closeAppDrawer();
}, [closeAppDrawer, onConversationNew]);
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
onConversationActivate(conversationId);
if (closeMenu)
closeAppDrawer();
}, [closeAppDrawer, onConversationActivate]);
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
!singleChat && conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete, singleChat]);
// 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 <>
{/*<ListItem>*/}
{/* <Typography level='body-sm'>*/}
{/* Active chats*/}
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem disabled={props.disableNewButton} onClick={handleButtonNew}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
New
{/*<KeyStroke combo='Ctrl + Alt + N' />*/}
</Box>
</MenuItem>
<ListDivider sx={{ mb: 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>*/}
{conversations.map(conversation =>
<ChatNavigationItemMemo
key={'nav-' + conversation.id}
conversation={conversation}
isActive={conversation.id === props.activeConversationId}
isLonely={singleChat}
maxChatMessages={(labsEnhancedUI || softMaxReached) ? maxChatMessages : 0}
showSymbols={showSymbols}
onConversationActivate={handleConversationActivate}
onConversationDelete={handleConversationDelete}
/>)}
</Box>
<ListDivider sx={{ mt: 0 }} />
<MenuItem onClick={props.onConversationImportDialog}>
<ListItemDecorator>
<FileUploadIcon />
</ListItemDecorator>
Import chats
<OpenAIIcon sx={{ fontSize: 'xl', ml: 'auto' }} />
</MenuItem>
<MenuItem disabled={!hasChats} onClick={props.onConversationsDeleteAll}>
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
<Typography>
Delete {totalConversations >= 2 ? `all ${totalConversations} chats` : 'chat'}
</Typography>
</MenuItem>
</>;
}
@@ -8,6 +8,7 @@ import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { useChatLLMDropdown } from './useLLMDropdown';
import { usePersonaIdDropdown } from './usePersonaDropdown';
import { useFolderDropdown } from './folder/useFolderDropdown';
export function ChatDropdowns(props: {
@@ -19,26 +20,29 @@ export function ChatDropdowns(props: {
// state
const { chatLLMDropdown } = useChatLLMDropdown();
const { personaDropdown } = usePersonaIdDropdown(props.conversationId);
const { folderDropdown } = useFolderDropdown(props.conversationId);
// external state
const labsSplitBranching = useUXLabsStore(state => state.labsSplitBranching);
return <>
{/* Model selector */}
{chatLLMDropdown}
{/* Persona selector */}
{personaDropdown}
{/* Model selector */}
{chatLLMDropdown}
{/* Folder selector */}
{folderDropdown}
{/* Split Panes button */}
{labsSplitBranching && <IconButton
variant={props.isSplitPanes ? 'solid' : 'plain'}
color='neutral'
variant={props.isSplitPanes ? 'solid' : undefined}
onClick={props.onToggleSplitPanes}
sx={{
ml: 'auto',
}}
// sx={{
// ml: 'auto',
// }}
>
<VerticalSplitIcon />
</IconButton>}
@@ -5,14 +5,12 @@ import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlin
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
import ClearIcon from '@mui/icons-material/Clear';
import CompressIcon from '@mui/icons-material/Compress';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
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 { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUICounter } from '~/common/state/store-ui';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useChatShowSystemMessages } from '../../store-app-chat';
@@ -25,13 +23,11 @@ export function ChatMenuItems(props: {
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationClear: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId | null) => void,
onConversationFlatten: (conversationId: DConversationId) => void,
}) {
// external state
const { closeAppMenu } = useOptimaLayout();
const { touch: shareTouch } = useUICounter('export-share');
const { closePageMenu } = useOptimaDrawers();
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
// derived state
@@ -40,7 +36,7 @@ export function ChatMenuItems(props: {
const closeMenu = (event: React.MouseEvent) => {
event.stopPropagation();
closeAppMenu();
closePageMenu();
};
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -53,12 +49,6 @@ export function ChatMenuItems(props: {
props.conversationId && props.onConversationBranch(props.conversationId, null);
};
const handleConversationExport = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.onConversationExport(!disabled ? props.conversationId : null);
shareTouch();
};
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationFlatten(props.conversationId);
@@ -107,17 +97,10 @@ export function ChatMenuItems(props: {
</span>
</MenuItem>
<MenuItem disabled={!props.hasConversations} onClick={handleConversationExport}>
<ListItemDecorator>
<FileDownloadIcon />
</ListItemDecorator>
Share / Export ...
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Reset
Reset Chat
{!disabled && <KeyStroke combo='Ctrl + Alt + X' />}
</Box>
</MenuItem>
@@ -1,178 +0,0 @@
import * as React from 'react';
import { Avatar, Box, IconButton, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import CloseIcon from '@mui/icons-material/Close';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { SystemPurposes } from '../../../../data';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
const DEBUG_CONVERSATION_IDs = false;
export const ChatNavigationItemMemo = React.memo(ChatNavigationItem);
function ChatNavigationItem(props: {
conversation: DConversation,
isActive: boolean,
isLonely: boolean,
maxChatMessages: number,
showSymbols: boolean,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationDelete: (conversationId: DConversationId) => void,
}) {
const { conversation, isActive } = props;
// state
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [deleteArmed, setDeleteArmed] = React.useState(false);
// external state
const doubleClickToEdit = useUIPreferencesStore(state => state.doubleClickToEdit);
// derived state
const { id: conversationId } = conversation;
const isNew = conversation.messages.length === 0;
const messageCount = conversation.messages.length;
const assistantTyping = !!conversation.abortController;
const systemPurposeId = conversation.systemPurposeId;
const title = conversationTitle(conversation, 'new conversation');
// const setUserTitle = state.setUserTitle;
// auto-close the arming menu when clicking away
// NOTE: there currently is a bug (race condition) where the menu closes on a new item right after opening
// because the isActive prop is not yet updated
React.useEffect(() => {
if (deleteArmed && !isActive)
setDeleteArmed(false);
}, [deleteArmed, isActive]);
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
const handleTitleEdit = () => setIsEditingTitle(true);
const handleTitleEdited = (text: string) => {
setIsEditingTitle(false);
useChatStore.getState().setUserTitle(conversationId, text);
};
const handleDeleteButtonShow = (event: React.MouseEvent) => {
event.stopPropagation();
if (!isActive)
props.onConversationActivate(conversationId, false);
else
setDeleteArmed(true);
};
const handleDeleteButtonHide = () => setDeleteArmed(false);
const handleConversationDelete = (event: React.MouseEvent) => {
if (deleteArmed) {
setDeleteArmed(false);
event.stopPropagation();
props.onConversationDelete(conversationId);
}
};
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
const buttonSx: SxProps = { ml: 1, ...(isActive ? { color: 'white' } : {}) };
const progress = props.maxChatMessages ? 100 * messageCount / props.maxChatMessages : 0;
return (
<MenuItem
variant={isActive ? 'solid' : 'plain'} color='neutral'
selected={isActive}
onClick={handleConversationActivate}
sx={{
// py: 0,
position: 'relative',
border: 'none', // note, there's a default border of 1px and invisible.. hmm
'&:hover > button': { opacity: 1 },
...(isActive ? { bgcolor: 'red' } : {}),
}}
>
{/* Optional progress bar, underlay */}
{progress > 0 && (
<Box sx={{
backgroundColor: 'neutral.softActiveBg',
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
}} />
)}
{/* Icon */}
{props.showSymbols && <ListItemDecorator>
{assistantTyping
? (
<Avatar
alt='typing' variant='plain'
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
sx={{
width: 24,
height: 24,
borderRadius: 'var(--joy-radius-sm)',
}}
/>
) : (
<Typography sx={{ fontSize: '18px' }}>
{isNew ? '' : textSymbol}
</Typography>
)}
</ListItemDecorator>}
{/* Text */}
{!isEditingTitle ? (
<Box onDoubleClick={() => doubleClickToEdit ? handleTitleEdit() : null} sx={{ flexGrow: 1 }}>
{DEBUG_CONVERSATION_IDs ? conversationId.slice(0, 10) : title}{assistantTyping && '...'}
</Box>
) : (
<InlineTextarea initialText={title} onEdit={handleTitleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
)}
{/* // TODO: Commented code */}
{/* Edit */}
{/*<IconButton*/}
{/* variant='plain' color='neutral'*/}
{/* onClick={() => props.onEditTitle(props.conversationId)}*/}
{/* sx={{*/}
{/* opacity: 0, transition: 'opacity 0.3s', ml: 'auto',*/}
{/* }}>*/}
{/* <EditIcon />*/}
{/*</IconButton>*/}
{/* Delete Arming */}
{!props.isLonely && !deleteArmed && (
<IconButton
variant={isActive ? 'solid' : 'outlined'} color='neutral'
size='sm' sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.3s', ...buttonSx }}
onClick={handleDeleteButtonShow}>
<DeleteOutlineIcon />
</IconButton>
)}
{/* Delete / Cancel buttons */}
{!props.isLonely && deleteArmed && <>
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleConversationDelete}>
<DeleteOutlineIcon />
</IconButton>
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteButtonHide}>
<CloseIcon />
</IconButton>
</>}
</MenuItem>
);
}
@@ -0,0 +1,71 @@
import * as React from 'react';
import { Button, ListItem, ListItemDecorator } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import FolderIcon from '@mui/icons-material/Folder';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { getRotatingFolderColor, useFolderStore } from '~/common/state/store-folders';
export function AddFolderButton() {
// state
const [isAddingFolder, setIsAddingFolder] = React.useState(false);
const [newFolderColor, setNewFolderColor] = React.useState<string | null>(null);
const handleAddFolder = () => {
setNewFolderColor(getRotatingFolderColor());
setIsAddingFolder(true);
};
const handleCreateFolder = (name: string) => {
if (name.trim())
useFolderStore.getState().createFolder(name.trim(), newFolderColor || undefined);
setIsAddingFolder(false);
};
const handleCancelAddFolder = () => {
setIsAddingFolder(false);
};
return isAddingFolder ? (
<ListItem sx={{
'--ListItem-paddingLeft': '0.75rem',
'--ListItem-minHeight': '3rem', // --Folder-ListItem-height
display: 'flex', alignItems: 'center', gap: 1,
}}>
<ListItemDecorator>
<FolderIcon style={{ color: newFolderColor || 'inherit' }} />
</ListItemDecorator>
<InlineTextarea
initialText='' placeholder='Folder Name'
onEdit={handleCreateFolder}
onCancel={handleCancelAddFolder}
sx={{
flexGrow: 1,
}} />
{/*<IconButton color='danger' onClick={handleCancelAddFolder}>*/}
{/* <CloseIcon />*/}
{/*</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>
);
}
@@ -0,0 +1,137 @@
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 FolderIcon from '@mui/icons-material/Folder';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { AddFolderButton } from './AddFolderButton';
import { FolderListItem } from './FolderListItem';
import { StrictModeDroppable } from './StrictModeDroppable';
export function ChatFolderList(props: {
folders: DFolder[];
activeFolderId: string | null;
onFolderSelect: (folderId: string | null) => void;
}) {
// derived props
const { folders, onFolderSelect, activeFolderId } = props;
// handlers
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
useFolderStore.getState().moveFolder(result.source.index, result.destination.index);
};
return (
<Sheet variant='soft' sx={{ p: 2 }}>
<List
variant='plain'
sx={(theme) => ({
'& ul': {
'--List-gap': '0px',
bgcolor: 'background.surface',
'& > li:first-of-type > [role="button"]': {
borderTopRightRadius: 'var(--List-radius)',
borderTopLeftRadius: 'var(--List-radius)',
},
'& > li:last-child > [role="button"]': {
borderBottomRightRadius: 'var(--List-radius)',
borderBottomLeftRadius: 'var(--List-radius)',
},
},
// 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
'--List-radius': '8px',
'--List-gap': '1rem',
'--ListDivider-gap': '0px',
// '--ListItem-paddingY': '0.5rem',
// override global variant tokens
'--joy-palette-neutral-plainHoverBg': 'rgba(0 0 0 / 0.08)',
'--joy-palette-neutral-plainActiveBg': 'rgba(0 0 0 / 0.12)',
[theme.getColorSchemeSelector('light')]: {
'--joy-palette-divider': 'rgba(0 0 0 / 0.08)',
},
[theme.getColorSchemeSelector('dark')]: {
'--joy-palette-neutral-plainHoverBg': 'rgba(255 255 255 / 0.1)',
'--joy-palette-neutral-plainActiveBg': 'rgba(255 255 255 / 0.16)',
},
})}
>
<ListItem nested>
<DragDropContext onDragEnd={onDragEnd}>
<StrictModeDroppable
droppableId='folder'
renderClone={(provided, snapshot, rubric) => (
<FolderListItem
activeFolderId={activeFolderId}
folder={folders[rubric.source.index]}
onFolderSelect={onFolderSelect}
provided={provided}
snapshot={snapshot}
/>
)}
>
{(provided) => (
<List ref={provided.innerRef} {...provided.droppableProps}>
{/* First item is the 'All' button */}
<ListItem>
<ListItemButton
// handle folder select
onClick={(event) => {
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
onFolderSelect(null);
}}
selected={!activeFolderId}
sx={{
border: 0,
justifyContent: 'space-between',
'&:hover .menu-icon': {
visibility: 'visible', // Hide delete icon for default folder
},
}}
>
<ListItemDecorator>
<FolderIcon />
</ListItemDecorator>
<ListItemContent>
<Typography>All</Typography>
</ListItemContent>
</ListItemButton>
</ListItem>
{folders.map((folder, index) => (
<Draggable key={folder.id} draggableId={folder.id} index={index}>
{(provided, snapshot) => (
<FolderListItem
activeFolderId={activeFolderId}
folder={folder}
onFolderSelect={onFolderSelect}
provided={provided}
snapshot={snapshot}
/>
)}
</Draggable>
))}
{provided.placeholder}
</List>
)}
</StrictModeDroppable>
</DragDropContext>
</ListItem>
</List>
<AddFolderButton />
</Sheet>
);
}
@@ -0,0 +1,320 @@
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 DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import Done from '@mui/icons-material/Done';
import EditIcon from '@mui/icons-material/Edit';
import FolderIcon from '@mui/icons-material/Folder';
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';
export function FolderListItem(props: {
activeFolderId: string | null;
folder: DFolder;
onFolderSelect: (folderId: string | null) => void;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
}) {
// internal state
const [deleteArmed, setDeleteArmed] = useState(false);
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
// State to control the open state of the Menu
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLAnchorElement>(null);
// derived props
const { activeFolderId, folder, onFolderSelect, provided, snapshot } = props;
// Menu
const handleMenuOpen = (event: React.MouseEvent<HTMLAnchorElement>) => {
setMenuAnchorEl(event.currentTarget);
setDeleteArmed(false); // Reset delete armed state
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
// Edit Title
const handleEditTitle = (event: React.MouseEvent<HTMLElement, MouseEvent>, folderId: string) => {
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
setEditingFolderId(folderId);
};
const handleCancelEditTitle = () => {
setEditingFolderId(null);
};
const handleSetTitle = (newTitle: string, folderId: string) => {
if (newTitle.trim())
useFolderStore.getState().setFolderName(folderId, newTitle.trim());
setEditingFolderId(null); // Exit edit mode
// Blur the input element if it's currently focused
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
// Deletion
const handleDeleteButtonShow = (event: React.MouseEvent) => {
event.stopPropagation();
setDeleteArmed(true);
};
const handleDeleteConfirmed = (event: React.MouseEvent) => {
if (deleteArmed) {
setDeleteArmed(false);
event.stopPropagation();
useFolderStore.getState().deleteFolder(folder.id);
handleMenuClose();
}
};
const handleDeleteCanceled = (event: React.MouseEvent) => {
if (deleteArmed) {
setDeleteArmed(false);
event.stopPropagation();
}
};
// Color
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
useFolderStore.getState().setFolderColor(folder.id, event.target.value);
handleMenuClose();
};
const getItemStyle = (isDragging: boolean, draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({
userSelect: 'none',
borderRadius: '8px',
backgroundColor: isDragging ? 'rgba(0, 80, 80, 0.18)' : 'transparent',
...draggableStyle,
// Any additional styles you want to apply during dragging
...(isDragging &&
{
// Apply any drag-specific styles here
// marginLeft: '12px',
}),
});
const getListItemContentStyle = (isDragging: boolean, _draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({
...(isDragging && {
// Apply any drag-specific styles here
marginLeft: '20px',
}),
});
const getListItemDecoratorStyle = (isDragging: boolean, _draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({
...(isDragging && {
// Apply any drag-specific styles here
marginLeft: '12px',
}),
});
const handleFolderSelect = (folderId: string | null) => {
onFolderSelect(folderId);
};
return (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...getItemStyle(snapshot.isDragging, provided.draggableProps.style),
userSelect: 'none',
}}
>
<ListItemButton
// handle folder select
onClick={(event) => {
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
handleFolderSelect(folder.id);
}}
selected={folder.id === activeFolderId}
sx={{
border: 0,
justifyContent: 'space-between',
'&:hover .menu-icon': {
visibility: 'visible', // Hide delete icon for default folder
},
}}
>
<ListItemDecorator
style={{
...getListItemDecoratorStyle(snapshot.isDragging, provided.draggableProps.style),
userSelect: 'none',
}}
>
<FolderIcon style={{ color: folder.color || 'inherit' }} />
</ListItemDecorator>
{editingFolderId === folder.id ? (
<InlineTextarea
initialText={folder.title}
onEdit={newTitle => handleSetTitle(newTitle, folder.id)}
onCancel={handleCancelEditTitle}
sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }}
/>
) : (
<ListItemContent
onDoubleClick={event => handleEditTitle(event, folder.id)}
style={{
...getListItemContentStyle(snapshot.isDragging, provided.draggableProps.style),
userSelect: 'none',
}}
>
<Typography>{folder.title}</Typography>
</ListItemContent>
)}
{/* Icon to show the Popup menu */}
<IconButton
variant='outlined'
className='menu-icon'
onClick={handleMenuOpen}
sx={{
visibility: 'hidden',
}}
>
<MoreVertIcon />
</IconButton>
{!!menuAnchorEl && (
<CloseableMenu
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
placement='top'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
<MenuItem
onClick={(event) => {
handleEditTitle(event, folder.id);
handleMenuClose();
}}
>
<ListItemDecorator>
<EditIcon />
</ListItemDecorator>
Edit
</MenuItem>
{!deleteArmed ? (
<MenuItem onClick={handleDeleteButtonShow}>
<ListItemDecorator>
<DeleteOutlineIcon />
</ListItemDecorator>
Delete
</MenuItem>
) : (
<>
<MenuItem onClick={handleDeleteCanceled}>
<ListItemDecorator>
<CloseIcon />
</ListItemDecorator>
Cancel
</MenuItem>
<MenuItem onClick={handleDeleteConfirmed} color='danger' sx={{ color: 'danger' }}>
<ListItemDecorator>
<DeleteOutlineIcon />
</ListItemDecorator>
Confirm Deletion
</MenuItem>
</>
)}
<MenuItem
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
p: 2,
minWidth: 200,
}}
>
<FormLabel
id='folder-color'
sx={{
mb: 1.5,
fontWeight: 'xl',
textTransform: 'uppercase',
fontSize: 'xs',
letterSpacing: '0.1em',
}}
>
Color
</FormLabel>
<RadioGroup
aria-labelledby='product-color-attribute'
defaultValue={folder.color || 'warning'}
onChange={handleColorChange}
sx={{ gap: 2, flexWrap: 'wrap', flexDirection: 'row', maxWidth: 240 }}
>
{FOLDERS_COLOR_PALETTE.map((color, index) => (
<Sheet
key={index}
sx={{
position: 'relative',
width: 20,
height: 20,
flexShrink: 0,
bgcolor: `${color}`,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Radio
overlay
variant='solid'
checkedIcon={<Done />}
value={color}
color='neutral'
slotProps={{
input: { 'aria-label': color },
radio: {
sx: {
display: 'contents',
'--variant-borderWidth': '2px',
},
},
}}
sx={{
'--joy-focus-outlineOffset': '4px',
'--joy-palette-focusVisible': color,
[`& .${radioClasses.action}.${radioClasses.focusVisible}`]: {
outlineWidth: '2px',
},
}}
/>
</Sheet>
))}
</RadioGroup>
</MenuItem>
</CloseableMenu>
)}
</ListItemButton>
</ListItem>
);
}
@@ -0,0 +1,22 @@
import { useEffect, useState } from "react";
import { Droppable, DroppableProps } from "react-beautiful-dnd";
export const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) {
return null;
}
return <Droppable {...props}>{children}</Droppable>;
};
@@ -0,0 +1,77 @@
import * as React from 'react';
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 { useFolderStore } from '~/common/state/store-folders';
export const ClearFolderText = 'Clear Folder';
const SPECIAL_ID_CLEAR_FOLDER = '_REMOVE_';
export function useFolderDropdown(conversationId: DConversationId | null) {
// external state
const { folders, enableFolders } = useFolderStore();
// Prepare items for the dropdown
const folderItems: DropdownItems = React.useMemo(() => {
// add one item per folder
const items = folders.reduce((items, folder) => {
items[folder.id] = {
title: folder.title,
icon: <FolderIcon sx={{ color: folder.color }} />,
};
return items;
}, {} as DropdownItems);
// add one item representing no folder
items[SPECIAL_ID_CLEAR_FOLDER] = {
title: ClearFolderText,
};
return items;
}, [folders]);
// Handle dropdown folder change
const handleFolderChange = React.useCallback((_event: any, folderId: string | null) => {
if (conversationId && folderId) {
// Remove conversation from all folders
folders.forEach(folder => {
if (folder.conversationIds.includes(conversationId)) {
useFolderStore.getState().removeConversationFromFolder(folder.id, conversationId);
}
});
// Add conversation to the selected folder
if (folderId !== SPECIAL_ID_CLEAR_FOLDER)
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
}
}, [conversationId, folders]);
// find the folder ID for the active Conversation
const currentFolderId = folders.find(folder => folder.conversationIds.includes(conversationId || ''))?.id || null;
// Create the dropdown component
const folderDropdown = React.useMemo(() => {
// don't show the dropdown if folders are not enabled
if (!enableFolders)
return null;
return (
<PageBarDropdown
items={folderItems}
value={currentFolderId}
onChange={handleFolderChange}
placeholder='Select a folder'
showSymbols
/>
);
}, [currentFolderId, enableFolders, folderItems, handleFolderChange]);
return { folderDropdown };
}
@@ -7,7 +7,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
import { GoodDropdown, DropdownItems } from '~/common/components/GoodDropdown';
import { PageBarDropdown, DropdownItems } from '~/common/layout/optima/components/PageBarDropdown';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
@@ -54,7 +54,7 @@ function AppBarLLMDropdown(props: {
return (
<GoodDropdown
<PageBarDropdown
items={llmItems}
value={props.chatLlmId} onChange={handleChatLLMChange}
placeholder={props.placeholder || 'Models …'}
@@ -1,22 +1,16 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { ListItemButton, ListItemDecorator } from '@mui/joy';
import CallIcon from '@mui/icons-material/Call';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { GoodDropdown } from '~/common/components/GoodDropdown';
import { launchAppCall } from '~/common/app.routes';
import { PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
function AppBarPersonaDropdown(props: {
systemPurposeId: SystemPurposeId | null,
setSystemPurposeId: (systemPurposeId: SystemPurposeId | null) => void,
onCall?: () => void,
}) {
// external state
@@ -29,23 +23,13 @@ function AppBarPersonaDropdown(props: {
// options
let appendOption: React.JSX.Element | undefined = undefined;
if (props.onCall) {
const enableCallOption = !!props.systemPurposeId;
appendOption = (
<ListItemButton color='primary' disabled={!enableCallOption} key='menu-call-persona' onClick={props.onCall} sx={{ minWidth: 160 }}>
<ListItemDecorator><CallIcon color={enableCallOption ? 'primary' : 'warning'} /></ListItemDecorator>
Call&nbsp; {!!props.systemPurposeId && SystemPurposes[props.systemPurposeId]?.symbol}
</ListItemButton>
);
}
// let appendOption: React.JSX.Element | undefined = undefined;
return (
<GoodDropdown
<PageBarDropdown
items={SystemPurposes} showSymbols={zenMode !== 'cleaner'}
value={props.systemPurposeId} onChange={handleSystemPurposeChange}
appendOption={appendOption}
// appendOption={appendOption}
/>
);
@@ -54,7 +38,6 @@ function AppBarPersonaDropdown(props: {
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
// external state
const labsCalling = useUXLabsStore(state => state.labsCalling);
const { systemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
return {
@@ -69,12 +52,8 @@ export function usePersonaIdDropdown(conversationId: DConversationId | null) {
if (conversationId && systemPurposeId)
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
}}
onCall={labsCalling ? () => {
if (conversationId && systemPurposeId)
launchAppCall(conversationId, systemPurposeId);
} : undefined}
/> : null,
[conversationId, labsCalling, systemPurposeId],
[conversationId, systemPurposeId],
);
return { personaDropdown };
@@ -1,47 +0,0 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import { CameraCaptureModal } from './CameraCaptureModal';
const attachCameraLegend = (isMobile: boolean) =>
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
<b>Attach photo</b><br />
{isMobile ? 'Auto-OCR to read text' : 'See the world, on the go'}
</Box>;
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
function ButtonAttachCamera(props: { isMobile?: boolean, onAttachImage: (file: File) => void }) {
// state
const [open, setOpen] = React.useState(false);
return <>
{/* The Button */}
{props.isMobile ? (
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)}>
<AddAPhotoIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
<Button fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
sx={{ justifyContent: 'flex-start' }}>
Camera
</Button>
</Tooltip>
)}
{/* The actual capture dialog, which will stream the video */}
{open && (
<CameraCaptureModal
onCloseModal={() => setOpen(false)}
onAttachImage={props.onAttachImage}
/>
)}
</>;
}
@@ -170,7 +170,7 @@ export function CameraCaptureModal(props: {
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'space-between' }}>
{/* Info */}
<IconButton disabled={!info} variant='soft' color='neutral' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
<IconButton size='lg' disabled={!info} variant='soft' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
<InfoIcon />
</IconButton>
{/*<Button disabled={ocrProgress !== null} fullWidth variant='solid' size='lg' onClick={handleVideoOCRClicked} sx={{ flex: 1, maxWidth: 260 }}>*/}
@@ -189,7 +189,7 @@ export function CameraCaptureModal(props: {
</Button>
{/* Download */}
<IconButton variant='soft' color='neutral' onClick={handleVideoDownloadClicked}>
<IconButton size='lg' variant='soft' onClick={handleVideoDownloadClicked}>
<DownloadIcon />
</IconButton>
</Box>
+149 -64
View File
@@ -3,8 +3,9 @@ import { shallow } from 'zustand/shallow';
import { fileOpen, FileWithHandle } from 'browser-fs-access';
import { keyframes } from '@emotion/react';
import { Box, Button, ButtonGroup, Card, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Stack, 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';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import AutoModeIcon from '@mui/icons-material/AutoMode';
@@ -23,38 +24,43 @@ import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vend
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
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 { playSoundUrl } from '~/common/util/audioUtils';
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
import { useDebouncer } from '~/common/components/useDebouncer';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ActileItem, ActileProvider } from './actile/ActileProvider';
import { providerCommands } from './actile/providerCommands';
import { useActileManager } from './actile/useActileManager';
import type { AttachmentId } from './attachments/store-attachments';
import { Attachments } from './attachments/Attachments';
import { getTextBlockText, useLLMAttachments } from './attachments/useLLMAttachments';
import { useAttachments } from './attachments/useAttachments';
import type { ComposerOutputMultiPart } from './composer.types';
import { ButtonAttachCameraMemo } from './ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './ButtonAttachFile';
import { ButtonCall } from './ButtonCall';
import { ButtonMicContinuationMemo } from './ButtonMicContinuation';
import { ButtonMicMemo } from './ButtonMic';
import { ButtonOptionsDraw } from './ButtonOptionsDraw';
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
import { ButtonCall } from './buttons/ButtonCall';
import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
import { ButtonMicMemo } from './buttons/ButtonMic';
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
import { ChatModeMenu } from './ChatModeMenu';
import { TokenBadgeMemo } from './TokenBadge';
import { TokenProgressbarMemo } from './TokenProgressbar';
import { useComposerStartupText } from './store-composer';
const animationStopEnter = keyframes`
export const animationStopEnter = keyframes`
from {
opacity: 0;
transform: translateY(8px)
@@ -89,19 +95,18 @@ export function Composer(props: {
// external state
const isMobile = useIsMobile();
const { openPreferences } = useOptimaLayout();
const { labsCalling, labsCameraDesktop } = useUXLabsStore(state => ({
labsCalling: state.labsCalling,
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
const { labsCameraDesktop } = useUXLabsStore(state => ({
labsCameraDesktop: state.labsCameraDesktop,
}), shallow);
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
const [startupText, setStartupText] = useComposerStartupText();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
const { assistantTyping, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(state => {
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(state => {
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
return {
assistantTyping: conversation ? !!conversation.abortController : false,
assistantAbortible: conversation ? !!conversation.abortController : false,
systemPurposeId: conversation?.systemPurposeId ?? null,
tokenCount: conversation ? conversation.tokenCount : 0,
stopTyping: state.stopTyping,
@@ -125,7 +130,7 @@ export function Composer(props: {
const tokensComposerText = React.useMemo(() => {
if (!debouncedText || !chatLLMId)
return 0;
return countModelTokens(debouncedText, chatLLMId, 'composer text');
return countModelTokens(debouncedText, chatLLMId, 'composer text') ?? 0;
}, [chatLLMId, debouncedText]);
let tokensComposer = tokensComposerText + llmAttachments.tokenCountApprox;
if (tokensComposer > 0)
@@ -167,24 +172,6 @@ export function Composer(props: {
return enqueued;
}, [clearAttachments, conversationId, llmAttachments, onAction, setComposeText]);
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
// Alt: append the message instead
if (e.altKey) {
handleSendAction('append-user', composeText);
return e.preventDefault();
}
// Shift: toggles the 'enter is newline'
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (!assistantTyping)
handleSendAction(chatModeId, composeText);
return e.preventDefault();
}
}
}, [assistantTyping, chatModeId, composeText, enterIsNewline, handleSendAction]);
const handleSendClicked = () => handleSendAction(chatModeId, composeText);
const handleStopClicked = () => props.conversationId && stopTyping(props.conversationId);
@@ -194,7 +181,7 @@ export function Composer(props: {
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
const handleDrawOptionsClicked = () => openPreferences(2);
const handleDrawOptionsClicked = () => openPreferencesTab(PreferencesTab.Draw);
const handleTextImagineClicked = () => {
if (!composeText || !props.conversationId)
@@ -217,6 +204,75 @@ export function Composer(props: {
};
// Actiles
const onActileCommandSelect = React.useCallback((item: ActileItem) => {
if (props.composerTextAreaRef.current) {
const textArea = props.composerTextAreaRef.current;
const currentText = textArea.value;
const cursorPos = textArea.selectionStart;
// Find the position where the command starts
const commandStart = currentText.lastIndexOf('/', cursorPos);
// Construct the new text with the autocompleted command
const newText = currentText.substring(0, commandStart) + item.label + ' ' + currentText.substring(cursorPos);
// Update the text area with the new text
setComposeText(newText);
// Move the cursor to the end of the autocompleted command
const newCursorPos = commandStart + item.label.length + 1;
textArea.setSelectionRange(newCursorPos, newCursorPos);
}
}, [props.composerTextAreaRef, setComposeText]);
const actileProviders: ActileProvider[] = React.useMemo(() => {
return [providerCommands(onActileCommandSelect)];
}, [onActileCommandSelect]);
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
// Text typing
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setComposeText(e.target.value);
isMobile && actileInterceptTextChange(e.target.value);
}, [actileInterceptTextChange, isMobile, setComposeText]);
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// disable keyboard handling if the actile is visible
if (actileInterceptKeydown(e))
return;
// Enter: primary action
if (e.key === 'Enter') {
// Alt: append the message instead
if (e.altKey) {
handleSendAction('append-user', composeText);
return e.preventDefault();
}
// Shift: toggles the 'enter is newline'
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (!assistantAbortible)
handleSendAction(chatModeId, composeText);
return e.preventDefault();
}
}
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);
// Focus mode
// const handleFocusModeOn = React.useCallback(() => setIsFocusedMode(true), [setIsFocusedMode]);
// const handleFocusModeOff = React.useCallback(() => setIsFocusedMode(false), [setIsFocusedMode]);
// Mic typing & continuation mode
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
@@ -233,7 +289,7 @@ export function Composer(props: {
nextText = nextText ? nextText + ' ' + transcript : transcript;
// auto-send (mic continuation mode) if requested
const autoSend = micContinuation && nextText.length >= 1 && !!props.conversationId; //&& assistantTyping;
const autoSend = micContinuation && nextText.length >= 1 && !!props.conversationId; //&& assistantAbortible;
const notUserStop = result.doneReason !== 'manual';
if (autoSend) {
if (notUserStop)
@@ -255,7 +311,7 @@ export function Composer(props: {
useGlobalShortcut('m', true, false, false, toggleRecording);
const micIsRunning = !!speechInterimResult;
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantTyping;
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible && !isSpeechError;
const micColor: ColorPaletteProp = isSpeechError ? 'danger' : isRecordingSpeech ? 'primary' : isRecordingAudio ? 'primary' : 'neutral';
const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'soft' : 'soft'; //(isDesktop ? 'soft' : 'plain');
@@ -285,6 +341,8 @@ export function Composer(props: {
void attachAppendFile('camera', file);
}, [attachAppendFile]);
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
const handleAttachFilePicker = React.useCallback(async () => {
try {
const selectedFiles: FileWithHandle[] = await fileOpen({ multiple: true });
@@ -367,7 +425,9 @@ export function Composer(props: {
const isChat = isText || isAppend;
const isReAct = chatModeId === 'generate-react';
const isDraw = chatModeId === 'generate-image';
const buttonColor: ColorPaletteProp = isReAct ? 'success' : isDraw ? 'warning' : 'primary';
const buttonColor: ColorPaletteProp = assistantAbortible
? 'warning'
: isReAct ? 'success' : isDraw ? 'warning' : 'primary';
const textPlaceholder: string =
isDraw
@@ -377,12 +437,12 @@ export function Composer(props: {
: props.isDeveloperMode
? 'Chat with me · drop source files · attach code...'
: props.capabilityHasT2I
? 'Chat · /react · /draw · drop text files...'
: 'Chat · /react · drop text files...';
? 'Chat · /react · /draw · drop files...'
: 'Chat · /react · drop files...';
return (
<Box sx={props.sx}>
<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) */}
@@ -390,23 +450,36 @@ export function Composer(props: {
{/* Vertical (insert) buttons */}
{isMobile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { md: 2 } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* [mobile] Mic button */}
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
{/* Responsive Camera OCR button */}
<ButtonAttachCameraMemo isMobile onAttachImage={handleAttachCameraImage} />
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddCircleOutlineIcon />
</MenuButton>
<Menu>
{/* Responsive Camera OCR button */}
<MenuItem>
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
</MenuItem>
{/* Responsive Open Files button */}
<ButtonAttachFileMemo isMobile onAttachFilePicker={handleAttachFilePicker} />
{/* Responsive Open Files button */}
<MenuItem>
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
</MenuItem>
{/* Responsive Paste button */}
{supportsClipboardRead && <ButtonAttachClipboardMemo isMobile onClick={attachAppendClipboardItems} />}
{/* Responsive Paste button */}
{supportsClipboardRead && <MenuItem>
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
</MenuItem>}
</Menu>
</Dropdown>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { md: 2 } }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
{/* Attach*/}
@@ -419,7 +492,7 @@ export function Composer(props: {
{supportsClipboardRead && <ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />}
{/* Responsive Camera OCR button */}
{labsCameraDesktop && <ButtonAttachCameraMemo onAttachImage={handleAttachCameraImage} />}
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
</Box>
)}
@@ -428,7 +501,7 @@ export function Composer(props: {
<Box sx={{
flexGrow: 1,
display: 'flex', flexDirection: 'column', gap: 1,
minWidth: 250, // enable X-scrolling (resetting any possible minWidth due to the attachments)
minWidth: 200, // enable X-scrolling (resetting any possible minWidth due to the attachments)
}}>
{/* Edit box + Overlays + Mic buttons */}
@@ -438,16 +511,20 @@ export function Composer(props: {
<Box sx={{ position: 'relative' }}>
<Textarea
variant='outlined' color={isDraw ? 'warning' : isReAct ? 'success' : 'neutral'}
variant='outlined'
color={isDraw ? 'warning' : isReAct ? 'success' : undefined}
autoFocus
minRows={5} maxRows={10}
minRows={isMobile ? 4 : 5}
maxRows={isMobile ? 8 : 10}
placeholder={textPlaceholder}
value={composeText}
onChange={(event) => setComposeText(event.target.value)}
onChange={handleTextareaTextChange}
onDragEnter={handleTextareaDragEnter}
onDragStart={handleTextareaDragStart}
onKeyDown={handleTextareaKeyDown}
onPasteCapture={handleAttachCtrlV}
// onFocusCapture={handleFocusModeOn}
// onBlurCapture={handleFocusModeOff}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'send',
@@ -460,10 +537,8 @@ export function Composer(props: {
}}
sx={{
backgroundColor: 'background.level1',
'&:focus-within': {
backgroundColor: 'background.popup',
},
lineHeight: 1.75,
'&:focus-within': { backgroundColor: 'background.popup' },
lineHeight: lineHeightTextarea,
}} />
{tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
@@ -564,10 +639,10 @@ export function Composer(props: {
{/* [mobile] bottom-corner secondary button */}
{isMobile && (isChat
? <ButtonCall isMobile disabled={!labsCalling || !props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
? <ButtonCall isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
: isDraw
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
: <IconButton disabled variant='plain' color='neutral' sx={{ mr: { xs: 1, md: 2 } }} />
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
)}
{/* Responsive Send/Stop buttons */}
@@ -579,7 +654,7 @@ export function Composer(props: {
boxShadow: isMobile ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${buttonColor}-mainChannel) / 20%)`,
}}
>
{!assistantTyping ? (
{!assistantAbortible ? (
<Button
key='composer-act'
fullWidth disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
@@ -598,7 +673,7 @@ export function Composer(props: {
) : (
<Button
key='composer-stop'
fullWidth variant='soft' color={isReAct ? 'success' : 'primary'} disabled={!props.conversationId}
fullWidth variant='soft' disabled={!props.conversationId}
onClick={handleStopClicked}
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
sx={{ animation: `${animationStopEnter} 0.1s ease-out` }}
@@ -615,7 +690,11 @@ export function Composer(props: {
</Tooltip>}
{/* Mode expander */}
<IconButton variant={isDraw ? undefined : undefined} disabled={!props.conversationId || !chatLLMId || !!chatModeMenuAnchor} onClick={handleModeSelectorShow}>
<IconButton
variant={assistantAbortible ? 'soft' : isDraw ? undefined : undefined}
disabled={!props.conversationId || !chatLLMId || !!chatModeMenuAnchor}
onClick={handleModeSelectorShow}
>
<ExpandLessIcon />
</IconButton>
</ButtonGroup>
@@ -627,7 +706,7 @@ export function Composer(props: {
{isDesktop && <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1, justifyContent: 'flex-end' }}>
{/* [desktop] Call secondary button */}
{isChat && <ButtonCall disabled={!labsCalling || !props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
{isChat && <ButtonCall disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
{/* [desktop] Draw Options secondary button */}
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
@@ -647,6 +726,12 @@ export function Composer(props: {
/>
)}
{/* Camera */}
{cameraCaptureComponent}
{/* Actile */}
{actileComponent}
</Grid>
</Box>
);
@@ -0,0 +1,84 @@
import * as React from 'react';
import { Box, ListItem, ListItemButton, ListItemDecorator, Sheet, Typography } from '@mui/joy';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import type { ActileItem } from './ActileProvider';
export function ActilePopup(props: {
anchorEl: HTMLElement | null,
onClose: () => void,
title?: string,
items: ActileItem[],
activeItemIndex: number | undefined,
activePrefixLength: number,
onItemClick: (item: ActileItem) => void,
children?: React.ReactNode
}) {
const hasAnyIcon = props.items.some(item => !!item.Icon);
return (
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding noBottomPadding sx={{ minWidth: 320 }}>
{!!props.title && (
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
<Typography level='title-sm'>
{props.title}
</Typography>
</Sheet>
)}
{!props.items.length && (
<ListItem variant='soft' color='warning'>
<Typography level='body-md'>
No matching command
</Typography>
</ListItem>
)}
{props.items.map((item, idx) => {
const isActive = idx === props.activeItemIndex;
const labelBold = item.label.slice(0, props.activePrefixLength);
const labelNormal = item.label.slice(props.activePrefixLength);
return (
<ListItem
key={item.id}
variant={isActive ? 'soft' : undefined}
color={isActive ? 'primary' : undefined}
onClick={() => props.onItemClick(item)}
>
<ListItemButton>
{hasAnyIcon && (
<ListItemDecorator>
{item.Icon ? <item.Icon /> : null}
</ListItemDecorator>
)}
<Box>
<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}
</Typography>
{item.argument && <Typography level='body-sm'>
{item.argument}
</Typography>}
</Box>
{!!item.description && <Typography level='body-xs'>
{item.description}
</Typography>}
</Box>
</ListItemButton>
</ListItem>
);
},
)}
{props.children}
</CloseableMenu>
);
}
@@ -0,0 +1,22 @@
import type { FunctionComponent } from 'react';
export interface ActileItem {
id: string;
label: string;
argument?: string;
description?: string;
Icon?: FunctionComponent;
}
type ActileProviderIds = 'actile-commands' | 'actile-attach-reference';
export interface ActileProvider {
id: ActileProviderIds;
title: string;
searchPrefix: string;
checkTriggerText: (trailingText: string) => boolean;
fetchItems: () => Promise<ActileItem[]>;
onItemSelect: (item: ActileItem) => void;
}
@@ -0,0 +1,24 @@
//import { ActileItem, ActileProvider } from './ActileProvider';
/*export const providerAttachReference: ActileProvider = {
id: 'actile-attach-reference',
title: 'Attach Reference',
searchPrefix: '@',
checkTriggerText: (trailingText: string) =>
trailingText.endsWith(' @'),
fetchItems: async () => {
return [{
id: 'test-1',
label: 'Attach This',
description: 'Attach this to the message',
Icon: undefined,
}];
},
onItemSelect: (item: ActileItem) => {
console.log('Selected item:', item);
},
};*/
@@ -0,0 +1,24 @@
import { ActileItem, ActileProvider } from './ActileProvider';
import { findAllChatCommands } from '../../../commands/commands.registry';
export const providerCommands = (onItemSelect: (item: ActileItem) => void): ActileProvider => ({
id: 'actile-commands',
title: 'Chat Commands',
searchPrefix: '/',
checkTriggerText: (trailingText: string) =>
trailingText.trim() === '/',
fetchItems: async () => {
return findAllChatCommands().map((cmd) => ({
id: cmd.primary,
label: cmd.primary,
argument: cmd.arguments?.join(' ') ?? undefined,
description: cmd.description,
Icon: cmd.Icon,
}));
},
onItemSelect,
});
@@ -0,0 +1,117 @@
import * as React from 'react';
import { ActileItem, ActileProvider } from './ActileProvider';
import { ActilePopup } from './ActilePopup';
export const useActileManager = (providers: ActileProvider[], anchorRef: React.RefObject<HTMLElement>) => {
// state
const [popupOpen, setPopupOpen] = React.useState(false);
const [provider, setProvider] = React.useState<ActileProvider | null>(null);
const [items, setItems] = React.useState<ActileItem[]>([]);
const [activeSearchString, setActiveSearchString] = React.useState<string>('');
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(0);
// derived state
const activeItems = React.useMemo(() => {
const search = activeSearchString.trim().toLowerCase();
return items.filter(item => item.label.toLowerCase().startsWith(search));
}, [items, activeSearchString]);
const activeItem = activeItemIndex >= 0 && activeItemIndex < activeItems.length ? activeItems[activeItemIndex] : null;
const handleClose = React.useCallback(() => {
setPopupOpen(false);
setProvider(null);
setItems([]);
setActiveSearchString('');
setActiveItemIndex(0);
}, []);
const handlePopupItemClicked = React.useCallback((item: ActileItem) => {
provider?.onItemSelect(item);
handleClose();
}, [handleClose, provider]);
const handleEnterKey = React.useCallback(() => {
activeItem && handlePopupItemClicked(activeItem);
}, [activeItem, handlePopupItemClicked]);
const actileInterceptTextChange = React.useCallback((trailingText: string) => {
for (const provider of providers) {
if (provider.checkTriggerText(trailingText)) {
setProvider(provider);
setPopupOpen(true);
setActiveSearchString(provider.searchPrefix);
provider
.fetchItems()
.then(items => setItems(items))
.catch(error => {
handleClose();
console.error('Failed to fetch popup items:', error);
});
return true;
}
}
return false;
}, [handleClose, providers]);
const actileInterceptKeydown = React.useCallback((_event: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
// Popup open: Intercept
const { key, currentTarget, ctrlKey, metaKey } = _event;
if (popupOpen) {
if (key === 'Escape' || key === 'ArrowLeft') {
_event.preventDefault();
handleClose();
} else if (key === 'ArrowUp') {
_event.preventDefault();
setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : activeItems.length - 1));
} else if (key === 'ArrowDown') {
_event.preventDefault();
setActiveItemIndex((prevIndex) => (prevIndex < activeItems.length - 1 ? prevIndex + 1 : 0));
} else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && activeItems.length === 1)) {
_event.preventDefault();
handleEnterKey();
} else if (key === 'Backspace') {
handleClose();
} else if (key.length === 1 && !ctrlKey && !metaKey) {
setActiveSearchString((prev) => prev + key);
setActiveItemIndex(0);
}
return true;
}
// Popup closed: Check for triggers
const trailingText = (currentTarget.value || '') + key;
return actileInterceptTextChange(trailingText);
}, [actileInterceptTextChange, activeItems.length, handleClose, handleEnterKey, popupOpen]);
const actileComponent = React.useMemo(() => {
return !popupOpen ? null : (
<ActilePopup
anchorEl={anchorRef.current}
onClose={handleClose}
title={provider?.title}
items={activeItems}
activeItemIndex={activeItemIndex}
activePrefixLength={activeSearchString.length}
onItemClick={handlePopupItemClicked}
/>
);
}, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, provider?.title]);
return {
actileComponent,
actileInterceptKeydown,
actileInterceptTextChange,
};
};
@@ -87,6 +87,13 @@ function attachmentConverterIcon(attachment: Attachment) {
}
function attachmentLabelText(attachment: Attachment): string {
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
if (converter && attachment.label === 'Rich Text') {
if (converter.id === 'rich-text-table')
return 'Rich Table';
if (converter.id === 'rich-text')
return 'Rich HTML';
}
return ellipsizeFront(attachment.label, 24);
}
@@ -111,7 +111,7 @@ export function Attachments(props: {
{/* Overall Menu button */}
<IconButton
variant='plain' onClick={handleOverallMenuToggle}
onClick={handleOverallMenuToggle}
sx={{
// borderRadius: 'sm',
borderRadius: 0,
@@ -11,6 +11,16 @@ import type { ComposerOutputMultiPart } from '../composer.types';
// extensions to treat as plain text
const PLAIN_TEXT_EXTENSIONS: string[] = ['.ts', '.tsx'];
// mimetypes to treat as plain text
const PLAIN_TEXT_MIMETYPES: string[] = [
'text/plain',
'text/html',
'text/markdown',
'text/csv',
'text/css',
'application/json',
];
/**
* Creates a new Attachment object.
*/
@@ -141,7 +151,7 @@ export function attachmentDefineConverters(sourceType: AttachmentSource['media']
switch (true) {
// plain text types
case ['text/plain', 'text/html', 'text/markdown', 'text/csv', 'application/json'].includes(input.mimeType):
case PLAIN_TEXT_MIMETYPES.includes(input.mimeType):
// handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read
const textOriginHtml = sourceType === 'text' && input.altMimeType === 'text/html' && !!input.altData;
const isHtmlTable = !!input.altData?.startsWith('<table');
@@ -245,7 +255,7 @@ export async function attachmentPerformConversion(attachment: Readonly<Attachmen
outputs.push({
type: 'text-block',
text: input.altData!,
title: ref,
title: ref || '\n<!DOCTYPE html>',
collapsible: true,
});
break;
@@ -24,7 +24,7 @@ import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
// see how we fare on budget
if (chatLLMId) {
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger');
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger') ?? 0;
// simple trigger for the reduction dialog
if (newTextTokens > remainingTokens) {
@@ -10,6 +10,10 @@ import { getClipboardItems } from '~/common/util/clipboardUtils';
import { AttachmentSourceOriginDTO, AttachmentSourceOriginFile, useAttachmentsStore } from './store-attachments';
// enable to debug attachment operations
const ATTACHMENTS_DEBUG_INTAKE = false;
export const useAttachments = (enableLoadURLs: boolean) => {
// state
@@ -24,17 +28,30 @@ export const useAttachments = (enableLoadURLs: boolean) => {
// Creation helpers
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) =>
createAttachment({
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
})
, [createAttachment]);
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) => {
if (ATTACHMENTS_DEBUG_INTAKE)
console.log('attachAppendFile', origin, fileWithHandle, overrideFileName);
return createAttachment({
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
});
}, [createAttachment]);
const attachAppendDataTransfer = React.useCallback((dt: DataTransfer, method: AttachmentSourceOriginDTO, attachText: boolean): 'as_files' | 'as_url' | 'as_text' | false => {
// https://github.com/enricoros/big-AGI/issues/286
const textHtml = dt.getData('text/html') || '';
const heuristicIsExcel = textHtml.includes('"urn:schemas-microsoft-com:office:excel"');
// noinspection HttpUrlsUsage
const heuristicIsPowerPoint = textHtml.includes('xmlns:m="http://schemas.microsoft.com/office/20') && textHtml.includes('<meta name=Generator content="Microsoft PowerPoint');
const heuristicBypassImage = heuristicIsExcel || heuristicIsPowerPoint;
if (ATTACHMENTS_DEBUG_INTAKE)
console.log('attachAppendDataTransfer', dt.types, dt.items, dt.files, textHtml);
// attach File(s)
if (dt.files.length >= 1) {
if (dt.files.length >= 1 && !heuristicBypassImage /* special case: ignore images from Microsoft Office pastes (prioritize the HTML paste) */) {
// rename files from a common prefix, to better relate them (if the transfer contains a list of paths)
let overrideFileNames: string[] = [];
if (dt.types.includes('text/plain')) {
@@ -68,7 +85,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
}
// attach as Text/Html (further conversion, e.g. to markdown is done later)
const textHtml = dt.getData('text/html') || '';
if (attachText && (textHtml || textPlain)) {
void createAttachment({
media: 'text', method, textPlain, textHtml,
@@ -100,13 +116,20 @@ export const useAttachments = (enableLoadURLs: boolean) => {
return;
}
// loop on all the possible attachments
// loop on all the clipboard items
for (const clipboardItem of clipboardItems) {
// https://github.com/enricoros/big-AGI/issues/286
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
const heuristicBypassImage = textHtml.startsWith('<table ');
if (ATTACHMENTS_DEBUG_INTAKE)
console.log(' - attachAppendClipboardItems.item:', clipboardItem, textHtml, heuristicBypassImage);
// attach as image
let imageAttached = false;
for (const mimeType of clipboardItem.types) {
if (mimeType.startsWith('image/')) {
if (mimeType.startsWith('image/') && !heuristicBypassImage) {
try {
const imageBlob = await clipboardItem.getType(mimeType);
const imageName = mimeType.replace('image/', 'clipboard.').replaceAll('/', '.') || 'clipboard.png';
@@ -136,7 +159,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
}
// attach as Text
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
if (textHtml || textPlain) {
void createAttachment({
media: 'text', method: 'clipboard-read', textPlain, textHtml,
@@ -78,7 +78,7 @@ function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: Compo
const tokenCountApprox = llmForTokenCount
? attachmentOutputs.reduce((acc, output) => {
if (output.type === 'text-block')
return acc + countModelTokens(output.text, llmForTokenCount, 'attachments tokens count');
return acc + (countModelTokens(output.text, llmForTokenCount, 'attachments tokens count') ?? 0);
console.warn('Unhandled token preview for output type:', output.type);
return acc;
}, 0)
@@ -0,0 +1,51 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import { CameraCaptureModal } from '../CameraCaptureModal';
const attachCameraLegend = (isMobile: boolean) =>
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
<b>Attach photo</b><br />
{isMobile ? 'Auto-OCR to read text' : 'See the world, on the go'}
</Box>;
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
function ButtonAttachCamera(props: { isMobile?: boolean, onOpenCamera: () => void }) {
return props.isMobile ? (
<IconButton onClick={props.onOpenCamera}>
<AddAPhotoIcon />
</IconButton>
) : (
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
<Button fullWidth variant='plain' color='neutral' onClick={props.onOpenCamera} startDecorator={<AddAPhotoIcon />}
sx={{ justifyContent: 'flex-start' }}>
Camera
</Button>
</Tooltip>
);
}
export function useCameraCaptureModal(onAttachImage: (file: File) => void) {
// state
const [open, setOpen] = React.useState(false);
const openCamera = React.useCallback(() => setOpen(true), []);
const cameraCaptureComponent = React.useMemo(() => open && (
<CameraCaptureModal
onCloseModal={() => setOpen(false)}
onAttachImage={onAttachImage}
/>
), [open, onAttachImage]);
return {
openCamera,
cameraCaptureComponent,
};
}
@@ -22,7 +22,7 @@ function ButtonAttachClipboard(props: { isMobile?: boolean, onClick: () => void
<ContentPasteGoIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={pasteClipboardLegend}>
<Tooltip disableInteractive variant='solid' placement='top-start' title={pasteClipboardLegend}>
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onClick}
sx={{ justifyContent: 'flex-start' }}>
Paste
@@ -19,7 +19,7 @@ function ButtonAttachFile(props: { isMobile?: boolean, onAttachFilePicker: () =>
<AttachFileOutlinedIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachFileLegend}>
<Button fullWidth variant='plain' color='neutral' onClick={props.onAttachFilePicker} startDecorator={<AttachFileOutlinedIcon />}
sx={{ justifyContent: 'flex-start' }}>
File
@@ -16,7 +16,7 @@ export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onCl
<CallIcon />
</IconButton>
) : (
<Tooltip variant='solid' arrow placement='right' title={callConversationLegend}>
<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}>
Call
</Button>
@@ -3,7 +3,7 @@ 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, Stack, Switch, Tooltip, Typography } from '@mui/joy';
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import ClearIcon from '@mui/icons-material/Clear';
@@ -29,7 +29,7 @@ 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 } from '~/common/app.theme';
import { cssRainbowColorKeyframes, lineHeightChatText } from '~/common/app.theme';
import { prettyBaseModel } from '~/common/util/modelUtils';
import { useUIPreferencesStore } from '~/common/state/store-ui';
@@ -48,7 +48,7 @@ import { parseBlocks } from './blocks';
// How long is the user collapsed message
const USER_COLLAPSED_LINES: number = 8;
// Enable the automatic menu on text selection
// Enable the menu on text selection
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
// Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu.
@@ -68,17 +68,18 @@ export function messageBackground(messageRole: DMessage['role'] | string, wasEdi
}
}
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 iconSx = { width: 40, height: 40 };
const mascotSx = size === 'sm' ? { width: 40, height: 40 } : { width: 64, height: 64 };
const mascotSx = size === 'sm' ? avatarIconSx : { width: 64, height: 64 };
switch (messageRole) {
case 'system':
return <SettingsSuggestIcon sx={iconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
return <SettingsSuggestIcon sx={avatarIconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
case 'user':
return <Face6Icon sx={iconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
return <Face6Icon sx={avatarIconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
case 'assistant':
// typing gif (people seem to love this, so keeping it after april fools')
@@ -97,7 +98,7 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
// text-to-image: icon
if (isTextToImage)
return <FormatPaintIcon sx={{
...iconSx,
...avatarIconSx,
animation: `${cssRainbowColorKeyframes} 1s linear 2.66`,
}} />;
@@ -106,13 +107,15 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
if (symbol) return <Box sx={{
fontSize: '24px',
textAlign: 'center',
width: '100%', minWidth: `${iconSx.width}px`, lineHeight: `${iconSx.height}px`,
width: '100%',
minWidth: `${avatarIconSx.width}px`,
lineHeight: `${avatarIconSx.height}px`,
}}>
{symbol}
</Box>;
// default assistant avatar
return <SmartToyOutlinedIcon sx={iconSx} />; // https://mui.com/static/images/avatar/2.jpg
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
}
return <Avatar alt={messageSender} />;
}
@@ -147,13 +150,14 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
</>;
} else if (text.includes('"context_length_exceeded"')) {
// TODO: propose to summarize or split the input?
const pattern = /maximum context length is (\d+) tokens.+you requested (\d+) tokens/;
const pattern = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/;
const match = pattern.exec(text);
const usedText = match ? <b>{parseInt(match[2] || '0').toLocaleString()} tokens &gt; {parseInt(match[1] || '0').toLocaleString()}</b> : '';
errorMessage = <>
This thread <b>surpasses the maximum size</b> allowed for {modelId || 'this model'}. {usedText}.
Please consider removing some earlier messages from the conversation, start a new conversation,
choose a model with larger context, or submit a shorter new message.
{!usedText && ` -- ${text}`}
</>;
}
// [OpenAI] {"error":{"message":"Incorrect API key provided: ...","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}
@@ -210,7 +214,7 @@ export function ChatMessage(props: {
isBottom?: boolean, noBottomBorder?: boolean,
isImagining?: boolean, isSpeaking?: boolean,
onConversationBranch?: (messageId: string) => void,
onConversationRestartFrom?: (messageId: string, offset: number) => void,
onConversationRestartFrom?: (messageId: string, offset: number) => Promise<void>,
onConversationTruncate?: (messageId: string) => void,
onMessageDelete?: (messageId: string) => void,
onMessageEdit?: (messageId: string, text: string) => void,
@@ -298,10 +302,10 @@ export function ChatMessage(props: {
closeOperationsMenu();
};
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
const handleOpsConversationRestartFrom = async (e: React.MouseEvent) => {
e.preventDefault();
props.onConversationRestartFrom && props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
closeOperationsMenu();
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
};
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
@@ -409,15 +413,19 @@ export function ChatMessage(props: {
// 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: '14px',
fontSize: '0.875rem',
fontVariantLigatures: 'none',
lineHeight: 1.75,
lineHeight: lineHeightChatText,
borderRadius: 'var(--joy-radius-sm)',
};
@@ -450,41 +458,55 @@ export function ChatMessage(props: {
>
{/* Avatar */}
{showAvatars && <Stack
sx={{ alignItems: 'center', minWidth: { xs: 50, md: 64 }, maxWidth: 80, textAlign: 'center' }}
onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)}
onClick={event => setOpsMenuAnchor(event.currentTarget)}>
{showAvatars && (
<Box
onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)}
onClick={event => setOpsMenuAnchor(event.currentTarget)}
sx={{
// flexBasis: 0, // this won't let the item grow
display: 'flex', flexDirection: 'column', alignItems: 'center',
minWidth: { xs: 50, md: 64 }, maxWidth: 80,
textAlign: 'center',
}}
>
{isHovering ? (
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'}>
<MoreVertIcon />
</IconButton>
) : (
avatarEl
)}
{isHovering ? (
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'} sx={avatarIconSx}>
<MoreVertIcon />
</IconButton>
) : (
avatarEl
)}
{/* Assistant model name */}
{fromAssistant && (
<Tooltip title={messageOriginLLM || 'unk-model'} variant='solid'>
<Typography level='body-sm' sx={{
fontSize: { xs: 'xs', sm: 'sm' }, fontWeight: 500,
overflowWrap: 'anywhere',
...(messageTyping ? { animation: `${cssRainbowColorKeyframes} 5s linear infinite` } : {}),
}}>
{prettyBaseModel(messageOriginLLM)}
</Typography>
</Tooltip>
)}
{/* Assistant model name */}
{fromAssistant && (
<Tooltip title={messageOriginLLM || 'unk-model'} variant='solid'>
<Typography level='body-xs' sx={{
overflowWrap: 'anywhere',
...(messageTyping ? { animation: `${cssRainbowColorKeyframes} 5s linear infinite` } : {}),
}}>
{prettyBaseModel(messageOriginLLM)}
</Typography>
</Tooltip>
)}
</Stack>}
</Box>
)}
{/* Edit / Blocks */}
{isEditing
{isEditing ? (
? <InlineTextarea initialText={messageText} onEdit={handleTextEdited} sx={{ ...blockSx, lineHeight: 1.75, flexGrow: 1 }} />
<InlineTextarea
initialText={messageText} onEdit={handleTextEdited}
sx={{
...blockSx,
flexGrow: 1,
}} />
: <Box
) : (
<Box
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
sx={{
@@ -526,12 +548,12 @@ export function ChatMessage(props: {
: 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} />
? <RenderLatex key={'latex-' + index} latexBlock={block} sx={typographySx} />
: block.type === 'diff'
? <RenderTextDiff key={'latex-' + index} diffBlock={block} />
? <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} />)}
: <RenderText key={'text-' + index} textBlock={block} sx={typographySx} />)}
{isCollapsed && (
<Button variant='plain' color='neutral' onClick={handleUncollapse}>... expand ...</Button>
@@ -544,14 +566,14 @@ export function ChatMessage(props: {
{/*</Chip>*/}
</Box>
}
)}
{/* Overlay copy icon */}
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
<Tooltip title={fromAssistant ? 'Copy message' : 'Copy input'} variant='solid'>
<IconButton
variant='outlined' color='neutral' onClick={handleOpsCopy}
variant='outlined' onClick={handleOpsCopy}
sx={{
position: 'absolute', ...(fromAssistant ? { right: { xs: 12, md: 28 } } : { left: { xs: 12, md: 28 } }), zIndex: 10,
opacity: 0, transition: 'opacity 0.3s',
@@ -615,7 +637,7 @@ export function ChatMessage(props: {
{!!props.onConversationBranch && <ListDivider />}
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Visualize ...
Diagram ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
@@ -653,7 +675,7 @@ export function ChatMessage(props: {
</MenuItem>
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Visualize ...
Diagram ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
@@ -28,7 +28,7 @@ export const MessagesSelectionHeader = (props: { hasSelected: boolean, sumTokens
Delete
</Button>
<IconButton variant='plain' onClick={props.onClose}>
<IconButton onClick={props.onClose}>
<ClearIcon />
</IconButton>
</Sheet>;
@@ -173,21 +173,21 @@ function RenderCodeImpl(props: {
)}
{isMermaid && (
<Tooltip title={renderMermaid ? 'Show Code' : 'Render Mermaid'} variant='solid'>
<IconButton variant={renderMermaid ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowMermaid(!showMermaid)}>
<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'} color='neutral' onClick={() => setShowPlantUML(!showPlantUML)}>
<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'} color='neutral' onClick={() => setShowSVG(!showSVG)}>
<IconButton variant={renderSVG ? 'solid' : 'outlined'} onClick={() => setShowSVG(!showSVG)}>
<ShapeLineOutlinedIcon />
</IconButton>
</Tooltip>
@@ -195,7 +195,7 @@ function RenderCodeImpl(props: {
{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' color='neutral' onClick={handleCopyToClipboard}>
<IconButton variant='outlined' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>}
@@ -104,7 +104,7 @@ export function RenderHtml(props: { htmlBlock: HtmlBlock, sx?: SxProps }) {
</IconButton>
</Tooltip>
<Tooltip title='Copy Code' variant='solid'>
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
<IconButton variant='outlined' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>
@@ -125,13 +125,13 @@ export const RenderImage = (props: { imageBlock: ImageBlock, isFirst: boolean, a
<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' color='neutral' onClick={props.onRunAgain}>
<IconButton variant='solid' onClick={props.onRunAgain}>
<ReplayIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title='Open in new tab'>
<IconButton component={Link} href={url} target='_blank' variant='solid' color='neutral'>
<IconButton component={Link} href={url} download={alt || 'image'} target='_blank' variant='solid'>
<OpenInNewIcon />
</IconButton>
</Tooltip>
@@ -17,7 +17,6 @@ const RenderLatexDynamic = React.lazy(async () => {
export const RenderLatex = ({ latexBlock, sx }: { latexBlock: LatexBlock; sx?: SxProps; }) =>
<Box
sx={{
lineHeight: 1.75,
mx: 1.5,
...(sx || {}),
}}>
@@ -1,43 +1,53 @@
import * as React from 'react';
import { Box, useTheme } from '@mui/joy';
import { Box, styled } from '@mui/joy';
import { TextBlock } from './blocks';
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 ReactMarkdown = React.lazy(async () => {
const DynamicReactGFM = React.lazy(async () => {
const [markdownModule, remarkGfmModule] = await Promise.all([
import('react-markdown'),
import('remark-gfm')
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={[remarkGfmModule.default]} {...props} />
);
const ReactMarkdownWithRemarkGfm = (props: any) =>
<markdownModule.default remarkPlugins={remarkPlugins} {...props} />;
return { default: ReactMarkdownWithRemarkGfm };
});
export const RenderMarkdown = ({ textBlock }: { textBlock: TextBlock }) => {
const theme = useTheme();
export const RenderMarkdown = (props: { textBlock: TextBlock }) => {
return (
<Box
className={`markdown-body ${theme.palette.mode === 'dark' ? 'markdown-body-dark' : 'markdown-body-light'}`}
sx={{
mx: '12px !important', // margin: 1.5 like other blocks
'& table': { width: 'inherit !important' }, // un-break auto-width (tables have 'max-content', which overflows)
'--color-canvas-default': 'transparent !important', // remove the default background color
fontFamily: `inherit !important`, // use the default font family
lineHeight: '1.75 !important', // line-height: 1.75 like the text block
}}>
{/* Using React.Suspense / React.Lazy loading this */}
<RenderMarkdownBox className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */ >
<React.Suspense fallback={<div>Loading...</div>}>
<ReactMarkdown>{textBlock.content}</ReactMarkdown>
<DynamicReactGFM>
{props.textBlock.content}
</DynamicReactGFM>
</React.Suspense>
</Box>
</RenderMarkdownBox>
);
};
+18 -10
View File
@@ -3,28 +3,36 @@ import * as React from 'react';
import { Chip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { extractCommands } from '../../editors/commands';
import { extractChatCommand } from '../../commands/commands.registry';
import { TextBlock } from './blocks';
import type { TextBlock } from './blocks';
export const RenderText = ({ textBlock, sx }: { textBlock: TextBlock; sx?: SxProps; }) => {
const elements = extractCommands(textBlock.content);
export const RenderText = (props: { textBlock: TextBlock; sx?: SxProps; }) => {
const elements = extractChatCommand(props.textBlock.content);
return (
<Typography
sx={{
lineHeight: 1.75,
mx: 1.5,
display: 'flex', alignItems: 'baseline',
// display: 'flex', // Commented on 2023-12-29: the commands were drawn as columns
alignItems: 'baseline',
overflowWrap: 'anywhere',
whiteSpace: 'break-spaces',
...(sx || {}),
...(props.sx || {}),
}}
>
{elements.map((element, index) =>
element.type === 'cmd'
? <Chip key={index} component='span' size='md' variant='solid' color='neutral' sx={{ mr: 1 }}>{element.value}</Chip>
: <span key={index}>{element.value}</span>,
<React.Fragment key={index}>
{element.type === 'cmd'
? <>
<Chip component='span' size='md' variant='solid' color={element.isError ? 'danger' : 'neutral'} sx={{ mr: 1 }}>
{element.command}
</Chip>
<span>{element.params}</span>
</>
: <span>{element.value}</span>
}
</React.Fragment>,
)}
</Typography>
);
@@ -38,14 +38,13 @@ export const RenderTextDiff = ({ diffBlock, sx }: { diffBlock: DiffBlock; sx?: S
return (
<Typography
sx={{
lineHeight: 1.75,
mx: 1.5,
// display: 'flex', alignItems: 'baseline',
overflowWrap: 'anywhere',
whiteSpace: 'break-spaces',
...(sx || {}),
display: 'block',
zIndex: 200,
...(sx || {}),
}}
>
{textDiffs.map(([op, text], index) =>
@@ -1,12 +1,15 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Typography } from '@mui/joy';
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, 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 SearchIcon from '@mui/icons-material/Search';
import TelegramIcon from '@mui/icons-material/Telegram';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { lineHeightTextarea } from '~/common/app.theme';
import { navigateToPersonas } from '~/common/app.routes';
import { useUIPreferencesStore } from '~/common/state/store-ui';
@@ -129,7 +132,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
placeholder='Search for purpose…'
startDecorator={<SearchIcon />}
endDecorator={searchQuery && (
<IconButton variant='plain' color='neutral' onClick={handleSearchClear}>
<IconButton onClick={handleSearchClear}>
<ClearIcon />
</IconButton>
)}
@@ -143,13 +146,15 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
<Box sx={{ maxWidth: bpMaxWidth }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 2, mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 1 }}>
<Typography level='title-sm'>
AI Persona
</Typography>
<Button variant='plain' color='neutral' size='sm' onClick={toggleEditMode}>
{editMode ? 'Done' : 'Edit'}
</Button>
<Tooltip disableInteractive title={editMode ? 'Done Editing' : 'Edit Tiles'}>
<IconButton size='sm' onClick={toggleEditMode}>
{editMode ? <DoneIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={tileSpacing} sx={{ justifyContent: 'flex-start' }}>
@@ -247,7 +252,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
? <>
Example: {selectedExample}
<IconButton
variant='plain' color='primary' size='md'
color='primary'
onClick={() => props.runExample(selectedExample)}
sx={{ opacity: 0, transition: 'opacity 0.3s' }}
>
@@ -268,7 +273,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
'&:focus-within': {
backgroundColor: 'background.popup',
},
lineHeight: 1.75,
lineHeight: lineHeightTextarea,
mt: 1,
}} />
)}
@@ -30,6 +30,8 @@ import { ScrollToBottomState, UseScrollToBottomProvider } from './useScrollToBot
const DEBUG_SCROLL_TO_BOTTOM = false;
// NOTE: in Chrome a wheel scroll event is 100px
// If you make this too small, the button may show when jumping lines on mobile
// if you make it too large, the user would need a very large flick to unlock the view
const USER_STICKY_MARGIN = 60;
// during the 'booting' timeout, scrolls happen instantly instead of smoothly
@@ -50,6 +52,7 @@ function DebugBorderBox(props: { heightPx: number, color: string }) {
export function ScrollToBottom(props: {
bootToBottom?: boolean
bootSmoothly?: boolean
stickToBottom?: boolean
sx?: SxProps
children: React.ReactNode,
@@ -73,7 +76,7 @@ export function ScrollToBottom(props: {
// derived state
const bootToBottom = props.bootToBottom || false;
const scrollBehavior: ScrollBehavior = state.booting ? 'auto' : 'smooth';
const scrollBehavior: ScrollBehavior = (state.booting && !props.bootSmoothly) ? 'auto' : 'smooth';
// [Debugging]
@@ -132,11 +135,24 @@ export function ScrollToBottom(props: {
if (DEBUG_SCROLL_TO_BOTTOM)
console.log(' -> scrollable children resized', entries.length);
// Edge case: when the content is smaller, we need to reset the bottom state (#312)
const atTop = scrollable.scrollTop == 0;
const unScrollable = scrollable.scrollHeight <= scrollable.offsetHeight;
if (unScrollable && atTop) {
if (DEBUG_SCROLL_TO_BOTTOM)
console.log(' -> large enough window', entries.length);
// udpate state only if this changed
setState(state => (state.atBottom !== true)
? ({ ...state, atBottom: true })
: state,
);
}
if (entries.length > 0 && state.stickToBottom)
doScrollToBottom();
});
// cancelable observer of resize of scrollable's children elements
Array.from(scrollable.children).forEach(child => _containerResizeObserver.observe(child));
return () => _containerResizeObserver.disconnect();
@@ -26,7 +26,7 @@ export function ScrollToBottomButton() {
// </Typography>
// }>
<IconButton
variant='outlined' color='neutral' size='md'
variant='outlined'
onClick={handleStickToBottom}
sx={{
// place this on the bottom-right corner (FAB-like)
+2 -2
View File
@@ -1,7 +1,7 @@
import { DLLMId } from '~/modules/llms/store-llms';
import { SystemPurposeId } from '../../../data';
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
import { autoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { llmStreamingChatGenerate } from '~/modules/llms/llm.client';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
@@ -42,7 +42,7 @@ export async function runAssistantUpdatingState(conversationId: string, history:
startTyping(conversationId, null);
if (autoTitleChat)
autoTitle(conversationId);
conversationAutoTitle(conversationId, false);
if (autoSuggestDiagrams || autoSuggestQuestions)
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
-57
View File
@@ -1,57 +0,0 @@
import { CmdRunBrowse } from '~/modules/browse/browse.client';
import { CmdRunReact } from '~/modules/aifn/react/react';
import { CmdRunSearch } from '~/modules/google/search.client';
import { CmdRunT2I } from '~/modules/t2i/t2i.client';
import { Brand } from '~/common/app.config';
import { createDMessage, DMessage } from '~/common/state/store-chats';
export const CmdAddRoleMessage: string[] = ['/assistant', '/a', '/system', '/s'];
export const CmdHelp: string[] = ['/help', '/h', '/?'];
export const commands = [...CmdRunBrowse, ...CmdRunT2I, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage, ...CmdHelp];
export interface SentencePiece {
type: 'text' | 'cmd';
value: string;
}
/**
* Sentence to pieces (must have a leading slash) from the provided text
* Used by rendering functions, as well as input processing functions.
*/
export function extractCommands(input: string): SentencePiece[] {
// 'help' commands are the only without a space and text after
if (CmdHelp.includes(input))
return [{ type: 'cmd', value: input }, { type: 'text', value: '' }];
const regexFromTags = commands.map(tag => `^\\${tag} `).join('\\b|') + '\\b';
const pattern = new RegExp(regexFromTags, 'g');
const result: SentencePiece[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(input)) !== null) {
if (match.index !== lastIndex)
result.push({ type: 'text', value: input.substring(lastIndex, match.index) });
result.push({ type: 'cmd', value: match[0].trim() });
lastIndex = pattern.lastIndex;
// Remove the space after the matched tag
if (input[lastIndex] === ' ')
lastIndex++;
}
if (lastIndex !== input.length)
result.push({ type: 'text', value: input.substring(lastIndex) });
return result;
}
export function createCommandsHelpMessage(): DMessage {
let text = 'Available Chat Commands:\n';
text += commands.map(c => ` - ${c}`).join('\n');
const helpMessage = createDMessage('assistant', text);
helpMessage.originLLM = Brand.Title.Base;
return helpMessage;
}
+1 -1
View File
@@ -40,7 +40,7 @@ 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), 2 * 1000);
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 4 * 1000);
updateAssistantMessage({ text: reactResult, typing: false });
} catch (error: any) {
+72
View File
@@ -0,0 +1,72 @@
import * as React from 'react';
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useRouterQuery } from '~/common/app.routes';
import { DrawHeading } from './components/DrawHeading';
import { DrawUnconfigured } from './components/DrawUnconfigured';
import { Gallery } from './Gallery';
import { TextToImage } from './TextToImage';
export interface AppDrawIntent {
backTo: 'app-chat';
}
export function AppDraw() {
// state
const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
const [section, setSection] = React.useState<number>(0);
// external state
const isMobile = useIsMobile();
const query = useRouterQuery<Partial<AppDrawIntent>>();
const { activeProviderId, mayWork, providers, setActiveProviderId } = useCapabilityTextToImage();
// [effect] set intent from the query parameters
React.useEffect(() => {
if (query.backTo) {
setDrawIntent({
backTo: query.backTo || 'app-chat',
});
}
}, [query]);
// const hasIntent = !!drawIntent && !!drawIntent.backTo;
// usePluggableOptimaLayout(null, null, null, 'aa');
return <>
{/* The container is a 100dvh, flex column with App bg (see `pageCoreSx`) */}
<DrawHeading
section={section}
setSection={setSection}
showSections
sx={{
px: { xs: 1, md: 2 },
py: { xs: 1, md: 6 },
}}
/>
{!mayWork && <DrawUnconfigured />}
{mayWork && <Gallery />}
{mayWork && (
<TextToImage
isMobile={isMobile}
providers={providers}
activeProviderId={activeProviderId}
setActiveProviderId={setActiveProviderId}
/>
)}
</>;
}
+10
View File
@@ -0,0 +1,10 @@
import { AppPlaceholder } from '../AppPlaceholder';
import * as React from 'react';
export function Gallery() {
return (
<AppPlaceholder text='Drawing App is under development. v1.12 or v1.13.' />
);
}
+80
View File
@@ -0,0 +1,80 @@
import * as React from 'react';
import { Box } from '@mui/joy';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { DesignerPrompt, PromptDesigner } from './components/PromptDesigner';
import { ProviderConfigure } from './components/ProviderConfigure';
export function TextToImage(props: {
isMobile: boolean,
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void
}) {
// state
const [prompts, setPrompts] = React.useState<DesignerPrompt[]>([]);
const handleStopDrawing = React.useCallback(() => {
setPrompts([]);
}, []);
const handlePromptEnqueue = React.useCallback((prompt: DesignerPrompt) => {
setPrompts(prompts => [...prompts, prompt]);
}, []);
return <>
<ProviderConfigure
providers={props.providers}
activeProviderId={props.activeProviderId}
setActiveProviderId={props.setActiveProviderId}
sx={{
p: { xs: 1, md: 2 },
}}
/>
{/* Placeholder */}
<Box sx={{
flexGrow: 1,
overflowY: 'auto',
backgroundColor: 'background.level2',
// border: '1px solid blue',
p: { xs: 1, md: 2 },
}}>
<Box sx={{
my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
border: '1px solid red',
minHeight: '300px',
}}>
{prompts.map((prompt, index) => (
<Box key={index} sx={{
border: '1px solid green',
width: '100%',
}}>
{prompt.prompt}
</Box>
))}
</Box>
</Box>
<PromptDesigner
isMobile={props.isMobile}
queueLength={prompts.length}
onDrawingStop={handleStopDrawing}
onPromptEnqueue={handlePromptEnqueue}
sx={{
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
}}
/>
</>;
}
+94
View File
@@ -0,0 +1,94 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, ButtonGroup, Chip, Divider, IconButton, Typography } from '@mui/joy';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import { niceShadowKeyframes } from '../../call/Contacts';
export function DrawHeading(props: {
section: number,
setSection: (section: number) => void,
showSections?: boolean,
sx?: SxProps,
}) {
return (
<Box sx={{
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 3,
...props.sx,
}}>
{/* Flashy Button */}
<IconButton
variant='soft' color='success'
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
pointerEvents: 'none',
backgroundColor: 'background.popup',
animation: `${niceShadowKeyframes} 5s infinite`,
}}>
<FormatPaintIcon />
</IconButton>
{/* Messaging */}
<Box>
<Typography level='title-lg'>
Draw with AI
</Typography>
<Typography level='title-sm' sx={{ mt: 1 }}>
Turn your ideas into images
</Typography>
<Chip variant='outlined' size='sm' sx={{ px: 1, py: 0.5, mt: 0.25, ml: -1, textWrap: 'wrap' }}>
Multi-models, AI assisted
</Chip>
</Box>
{/* Section Selector*/}
{props.showSections && (
<Divider sx={{ flex: 1 }}>
<ButtonGroup
// color='primary'
size='sm'
orientation='horizontal'
sx={{
mx: 'auto',
backgroundColor: 'background.surface',
boxShadow: 'sm',
'& > button': {
minWidth: 104,
},
}}
>
<Button
variant={props.section === 0 ? 'solid' : 'plain'}
onClick={() => props.setSection(0)}
>
Generate
</Button>
<Button
disabled
variant={props.section === 1 ? 'solid' : 'plain'}
onClick={() => props.setSection(1)}
>
Refine
</Button>
{/*<Button*/}
{/* disabled*/}
{/* variant={props.section === 2 ? 'solid' : 'plain'}*/}
{/* onClick={() => props.setSection(1)}*/}
{/*>*/}
{/* Gallery*/}
{/*</Button>*/}
</ButtonGroup>
</Divider>
)}
</Box>
);
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import { Button, Card, CardActions, CardContent, Typography } from '@mui/joy';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
export function DrawUnconfigured() {
// external state
const { openPreferencesTab } = useOptimaLayout();
const handleConfigure = () => openPreferencesTab(PreferencesTab.Draw);
return (
<Card variant='outlined' color='warning'>
<CardContent>
<Typography>
<strong>Text-to-Image</strong> does not seem available.
Please configure one service, such as an OpenAI LLM service, or the Prodia service.
</Typography>
</CardContent>
<CardActions buttonFlex='0'>
<Button onClick={handleConfigure} sx={{ minWidth: '160px' }}>
Configure
</Button>
</CardActions>
</Card>
);
}
+256
View File
@@ -0,0 +1,256 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, ButtonGroup, Grid, IconButton, Textarea, Tooltip } from '@mui/joy';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import MoreTimeIcon from '@mui/icons-material/MoreTime';
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
import { lineHeightTextarea } from '~/common/app.theme';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { animationStopEnter } from '../../chat/components/composer/Composer';
import { useDrawIdeas } from '../state/useDrawIdeas';
const promptButtonClass = 'PromptDesigner-button';
export interface DesignerPrompt {
prompt: string,
// tags: string[],
// effects: string[],
// style: string[],
// detail: string[],
// restyle: string[],
// [key: string]: string[],
}
export function PromptDesigner(props: {
isMobile: boolean,
queueLength: number,
onDrawingStop: () => void,
onPromptEnqueue: (prompt: DesignerPrompt) => void,
sx?: SxProps,
}) {
// state
const [nextPrompt, setNextPrompt] = React.useState<string>('');
// external state
const { currentIdea, nextRandomIdea } = useDrawIdeas();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
// derived state
const userHasText = !!nextPrompt;
const nonEmptyPrompt = nextPrompt || currentIdea.prompt;
const queueLength = props.queueLength;
const qBusy = queueLength > 0;
// Drawing
const { onDrawingStop, onPromptEnqueue } = props;
const handleDrawStop = React.useCallback(() => {
onDrawingStop();
}, [onDrawingStop]);
const handlePromptEnqueue = React.useCallback(() => {
setNextPrompt('');
onPromptEnqueue({
prompt: nonEmptyPrompt,
});
}, [nonEmptyPrompt, onPromptEnqueue]);
// Typing
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNextPrompt(e.target.value);
// setUserHasChanged(true);
}, []);
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Check for the primary Draw key
if (e.key !== 'Enter')
return;
// Shift: toggles the 'enter is newline'
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (userHasText)
handlePromptEnqueue();
return e.preventDefault();
}
}, [enterIsNewline, handlePromptEnqueue, userHasText]);
// PromptFx
const textEnrichComponents = React.useMemo(() => {
const handleIdeaUse = (event: React.MouseEvent) => {
event.preventDefault();
setNextPrompt(currentIdea.prompt);
// setUserHasChanged(false);
};
const handleClickMissing = (_event: React.MouseEvent) => {
alert('Not implemented yet');
};
return (
// PromptFx Buttons
<Box sx={{
flex: 1,
margin: 1,
// layout
display: 'flex', flexFlow: 'row wrap', alignItems: 'center', gap: 1,
// Buttons (tagged by class)
[`& .${promptButtonClass}`]: {
'--Button-gap': '1.2rem',
transition: 'background-color 0.2s, color 0.2s',
minWidth: 100,
},
}}>
{/* Change / Use idea */}
<ButtonGroup variant='soft' color='neutral' sx={{ borderRadius: 'sm' }}>
<Button className={promptButtonClass} disabled={userHasText} onClick={nextRandomIdea}>
Idea
</Button>
<Tooltip disableInteractive title='Use Idea'>
<IconButton onClick={handleIdeaUse}>
<ArrowDownwardIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
{/* PromptFx */}
<Button
variant='soft' color='success'
disabled={!userHasText}
className={promptButtonClass}
endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}
onClick={handleClickMissing}
sx={{ borderRadius: 'sm' }}
>
Detail
</Button>
<Button
variant='soft' color='success'
disabled={!userHasText}
className={promptButtonClass}
endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}
onClick={handleClickMissing}
sx={{ borderRadius: 'sm' }}
>
Restyle
</Button>
{/* Char counter */}
{/*<Typography level='body-sm' sx={{ ml: 'auto', mr: 1 }}>*/}
{/* {!!nonEmptyPrompt?.length && nonEmptyPrompt.length.toLocaleString()}*/}
{/*</Typography>*/}
</Box>
);
}, [currentIdea.prompt, nextRandomIdea, userHasText]);
return (
<Box aria-label='Drawing Prompt' component='section' sx={props.sx}>
<Grid container spacing={{ xs: 1, md: 2 }}>
{/* Prompt (Text) Box */}
<Grid xs={12} md={9}>
<Textarea
variant='outlined'
// size='sm'
autoFocus
minRows={props.isMobile ? 4 : 3}
maxRows={props.isMobile ? 6 : 8}
placeholder={currentIdea.prompt}
value={nextPrompt}
onChange={handleTextareaTextChange}
onKeyDown={handleTextareaKeyDown}
startDecorator={textEnrichComponents}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'send',
// ref: props.designerTextAreaRef,
},
}}
sx={{
boxShadow: 'lg',
'&:focus-within': { backgroundColor: 'background.popup' },
lineHeight: lineHeightTextarea,
}}
/>
</Grid>
{/* [Desktop: Right, Mobile: Bottom] Buttons */}
<Grid xs={12} md={3} spacing={1}>
<Box sx={{ display: 'grid', gap: 1 }}>
{/* Draw */}
{!qBusy ? (
<Button
key='draw-queue'
variant='solid' color='primary'
endDecorator={<FormatPaintIcon />}
onClick={handlePromptEnqueue}
sx={{
animation: `${animationStopEnter} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-primary-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Draw
</Button>
) : <>
<Button
key='draw-terminate'
variant='soft' color='warning'
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
onClick={handleDrawStop}
sx={{
// animation: `${animationStopEnter} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-warning-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Stop
</Button>
<Button
key='draw-queueup'
variant='soft'
color='primary'
endDecorator={<MoreTimeIcon sx={{ fontSize: 18 }} />}
onClick={handlePromptEnqueue}
sx={{
animation: `${animationStopEnter} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-primary-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Enqueue
</Button>
</>}
</Box>
</Grid>
</Grid> {/* Prompt Designer */}
{/* Modals... */}
{/* ... */}
</Box>
);
}
@@ -0,0 +1,88 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, Card, CardContent } from '@mui/joy';
import ConstructionIcon from '@mui/icons-material/Construction';
import { DallESettings } from '~/modules/t2i/dalle/DallESettings';
import { ProdiaSettings } from '~/modules/t2i/prodia/ProdiaSettings';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { ProviderSelect } from './ProviderSelect';
export function ProviderConfigure(props: {
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void,
sx?: SxProps,
}) {
// state
const [_open, setOpen] = React.useState(false);
// derived state
const { activeProviderId, providers } = props;
const { ProviderConfig } = React.useMemo(() => {
const provider = providers.find(provider => provider.id === activeProviderId);
const ProviderConfig: React.FC | null = provider?.vendor === 'openai' ? DallESettings : provider?.vendor === 'prodia' ? ProdiaSettings : null;
return {
ProviderConfig,
};
}, [activeProviderId, providers]);
const open = _open && !!ProviderConfig;
const handleToggleOpen = React.useCallback(() => {
setOpen(on => !on);
}, []);
return (
<Box
sx={{
flex: 0,
display: 'grid',
...props.sx,
}}
>
{/* Service / Options Button */}
<Box sx={{ display: 'flex', flexFlow: 'row wrap', gap: 1 }}>
<ProviderSelect
providers={props.providers}
activeProviderId={props.activeProviderId}
setActiveProviderId={props.setActiveProviderId}
/>
<Button
variant={open ? 'solid' : 'outlined'}
color={open ? 'primary' : 'neutral'}
endDecorator={<ConstructionIcon />}
onClick={handleToggleOpen}
sx={{ backgroundColor: open ? undefined : 'background.surface' }}
>
Options
</Button>
</Box>
{/* Service-Specific Configuration */}
{open && (
<Card variant='outlined' sx={{ my: 1, borderTopColor: 'primary.softActiveBg' }}>
<CardContent sx={{ gap: 2 }}>
<ProviderConfig />
</CardContent>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,62 @@
import * as React from 'react';
import { FormControl, FormLabel, ListItemDecorator, Option, Select } from '@mui/joy';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon';
import { hideOnMobile } from '~/common/app.theme';
export function ProviderSelect(props: {
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void
}) {
// create the options
const providerOptions = React.useMemo(() => {
const options: { label: string, value: string, configured: boolean, Icon?: React.FC }[] = [];
props.providers.forEach(provider => {
options.push({
label: provider.label + (provider.painter !== provider.label ? ` ${provider.painter}` : ''),
value: provider.id,
configured: provider.configured,
Icon: provider.vendor === 'openai' ? OpenAIIcon : FormatPaintIcon,
});
});
return options;
}, [props.providers]);
return (
<FormControl orientation='horizontal' sx={{ justifyContent: 'start', alignItems: 'center' }}>
<FormLabel sx={hideOnMobile}>
Service:
</FormLabel>
<Select
variant='outlined'
value={props.activeProviderId}
placeholder='Select a service'
onChange={(_event, value) => value && props.setActiveProviderId(value)}
// startDecorator={<FormatPaintIcon sx={{ display: { xs: 'none', sm: 'inherit' } }} />}
sx={{
minWidth: '12rem',
}}
>
{providerOptions.map(option => (
<Option key={option.value} value={option.value} disabled={!option.configured}>
<ListItemDecorator>
{!!option.Icon && <option.Icon />}
</ListItemDecorator>
{option.label}
{!option.configured && ' (not configured)'}
</Option>
))}
</Select>
</FormControl>
);
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from 'react';
interface DrawIdea {
author: string,
prompt: string,
short: string,
score: number,
}
/**
* The following are drawing ideas, offered to people.
* Generated with: https://github.com/enricoros/big-AGI/issues/311#issuecomment-1909473441
*/
const allIdeas: DrawIdea[] = [
{ author: 'Beatriz', prompt: 'An intricate book nook with miniature worlds nestled between classic tomes, casting a magical glow over a cozy reading corner.', short: 'Magical book nook miniature', score: 46 },
{ author: 'Charlie', prompt: 'A powerful black-and-white portrait of diverse hands united, each marked with a word of hope, capturing the essence of solidarity.', short: 'United hands with words of hope', score: 45 },
{ author: 'Disha', prompt: 'A serene garden oasis with a violin resting against an ancient tree, as if the music itself could make flowers bloom.', short: 'Garden oasis with violin', score: 43 },
{ author: 'Fatima', prompt: 'A night sky canvas with constellations drawn by the city lights below, a blend of urban design and celestial wonder.', short: 'Night sky and city light constellations', score: 46 },
{ author: 'Hana', prompt: 'A vibrant mural of the Earth, with real plants growing out of the painting, blurring the lines between art and environmental activism.', short: 'Earth mural with real plants', score: 47 },
{ author: 'Julia', prompt: 'A child\'s hand gently holding a bird, with the shadow cast forming a heart, capturing a moment of pure connection with nature.', short: 'Heart shadow with bird in hand', score: 49 },
{ author: 'Julia', prompt: 'A whimsical photo of a deck of cards mid-shuffle, with birds seemingly flying out of the fanned cards into a sunset sky.', short: 'Cards with birds in sunset', score: 48 },
{ author: 'Lina', prompt: 'A stop-motion of a pottery wheel spinning, each frame capturing a different historical era\'s pottery style coming to life.', short: 'Pottery wheel through historical eras', score: 45 },
{ author: 'Mason', prompt: 'A heartwarming snapshot of a loyal golden retriever patiently waiting at a train station, its reflection mirroring in the glossy floor, encapsulating the themes of loyalty and anticipation.', short: 'Loyal dog awaiting its owner', score: 47 },
{ author: 'Nia', prompt: 'A fairytale book with plants growing from the pages, creating a living story that captures the imagination of both young and old.', short: 'Fairytale book with living plants', score: 50 },
{ author: 'Omar', prompt: 'A building being \'drawn\' in the sky by a crane, as if architecture is being sketched in real-time.', short: 'Building \'drawn\' in the sky', score: 43 },
{ author: 'Priya', prompt: 'A photo capturing the fluid motion of a traditional dance, with colorful fabric swirling around the dancer like a living painting.', short: 'Traditional dance with colorful fabric', score: 43 },
{ author: 'Quin', prompt: 'A breathtaking summit view with a single flag planted, the colors of which morph into a vibrant time-lapse of the sky changing.', short: 'Summit view with time-lapse sky', score: 45 },
{ author: 'Quin', prompt: 'A cliffside yoga pose with the sun setting into the ocean below, embodying the perfect balance between adventure and tranquility.', short: 'Cliffside yoga at sunset', score: 48 },
{ author: 'Rosa', prompt: 'An experiment in color: vibrant chemical reactions captured in crystal-clear glassware, showcasing the beauty of science.', short: 'Colorful science reactions', score: 48 },
{ author: 'Samir', prompt: 'A stunning photo of ancient script carved into a mountain, juxtaposed with the modern skyline in the distance.', short: 'Ancient script and modern skyline', score: 48 },
{ author: 'Sofia', prompt: 'A whimsical and vibrant image of a capybara sculpted entirely from pink cotton candy, set against a minimalist backdrop with splashes of bright, contrasting colors.', short: 'Cotton candy capybara in color splashes', score: 49 },
{ author: 'Tanya', prompt: 'A mural blending street art with digital pixels, where the physical wall seems to dissolve into a virtual game world.', short: 'Street art to digital game world mural', score: 45 },
{ author: 'Tanya', prompt: 'A paintbrush touching a canvas, where each stroke animates into a scene from an indie game, illustrating the art behind the code.', short: 'Animated indie game art', score: 50 },
].sort(() => Math.random() - 0.5); // shuffle the ideas, once
function _randomDrawIdea() {
return allIdeas[Math.floor(Math.random() * allIdeas.length)];
}
export function useDrawIdeas() {
// state
const [currentIdea, setCurrentIdea] = React.useState<DrawIdea>(_randomDrawIdea());
const nextRandomIdea = React.useCallback(() => {
setCurrentIdea(prevIdea => {
let nextIdea = _randomDrawIdea();
while (nextIdea === prevIdea)
nextIdea = _randomDrawIdea();
return nextIdea;
});
}, []);
return { allIdeas, currentIdea, nextRandomIdea };
}
-109
View File
@@ -1,109 +0,0 @@
import * as React from 'react';
import Head from 'next/head';
import { useQuery } from '@tanstack/react-query';
import { Box, Typography } from '@mui/joy';
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
import { useHasChatLinkItems } from '~/modules/trade/store-module-trade';
import { Brand } from '~/common/app.config';
import { InlineError } from '~/common/components/InlineError';
import { LogoProgress } from '~/common/components/LogoProgress';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle } from '~/common/state/store-chats';
import { themeBgAppDarker } from '~/common/app.theme';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { AppChatLinkDrawerItems } from './AppChatLinkDrawerItems';
import { AppChatLinkMenuItems } from './AppChatLinkMenuItems';
import { ViewChatLink } from './ViewChatLink';
const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) =>
<Box sx={{
backgroundColor: props.backgroundColor,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
{props.children}
</Box>;
const ShowLoading = () =>
<Centerer backgroundColor={themeBgAppDarker}>
<LogoProgress showProgress={true} />
<Typography level='title-sm' sx={{ mt: 2 }}>
Loading Chat...
</Typography>
</Centerer>;
const ShowError = (props: { error: any }) =>
<Centerer backgroundColor={themeBgAppDarker}>
<InlineError error={props.error} severity='warning' />
</Centerer>;
/**
* Fetches the object using tRPC
* Note: we don't have react-query for the Node functions, so we use the immediate API here,
* and wrap it in a react-query hook
*/
async function fetchStoredChatV1(objectId: string) {
// fetch
const result = await apiAsyncNode.trade.storageGet.query({ objectId });
if (result.type === 'error')
throw result.error;
// validate a CHAT_V1
const { dataType, dataObject, storedAt, expiresAt } = result;
if (dataType !== 'CHAT_V1')
throw new Error('Unsupported data type: ' + dataType);
// convert to DConversation
const restored = createConversationFromJsonV1(dataObject as any);
if (!restored)
throw new Error('Could not restore conversation');
return { conversation: restored, storedAt, expiresAt };
}
export function AppChatLink(props: { linkId: string }) {
// external state
const { data, isError, error, isLoading } = useQuery({
enabled: !!props.linkId,
queryKey: ['chat-link', props.linkId],
queryFn: () => fetchStoredChatV1(props.linkId),
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
});
const hasLinkItems = useHasChatLinkItems();
// pluggable UI
const drawerItems = React.useMemo(() => <AppChatLinkDrawerItems />, []);
const menuItems = React.useMemo(() => <AppChatLinkMenuItems />, []);
usePluggableOptimaLayout(hasLinkItems ? drawerItems : null, null, menuItems, 'AppChatLink');
const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Chat Link';
return <>
<Head>
<title>{capitalizeFirstLetter(pageTitle)} · {Brand.Title.Base} 🚀</title>
</Head>
{isLoading
? <ShowLoading />
: isError
? <ShowError error={error} />
: !!data?.conversation
? <ViewChatLink conversation={data.conversation} storedAt={data.storedAt} expiresAt={data.expiresAt} />
: <Centerer backgroundColor={themeBgAppDarker} />}
</>;
}
-69
View File
@@ -1,69 +0,0 @@
import * as React from 'react';
import TimeAgo from 'react-timeago';
import { Box, ListDivider, ListItem, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useChatLinkItems } from '~/modules/trade/store-module-trade';
import { Brand } from '~/common/app.config';
import { Link } from '~/common/components/Link';
import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
/**
* Drawer Items are all the links already shared, for quick access.
* This is stores in the Trade Store (local storage).
*/
export function AppChatLinkDrawerItems() {
// external state
const { closeAppDrawer } = useOptimaLayout();
const chatLinkItems = useChatLinkItems()
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const notEmpty = chatLinkItems.length > 0;
return <>
<MenuItem
onClick={closeAppDrawer}
component={Link} href={ROUTE_INDEX} noLinkStyle
>
<ListItemDecorator><ArrowBackIcon /></ListItemDecorator>
{Brand.Title.Base}
</MenuItem>
{notEmpty && <ListDivider />}
{notEmpty && <ListItem>
<Typography level='body-sm'>
Links shared by you
</Typography>
</ListItem>}
{notEmpty && <Box sx={{ overflowY: 'auto' }}>
{chatLinkItems.map(item => (
<MenuItem
key={'chat-link-' + item.objectId}
component={Link} href={getChatLinkRelativePath(item.objectId)} noLinkStyle
sx={{
display: 'flex', flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
{item.chatTitle || 'Untitled Chat'}
</Typography>
<Typography level='body-xs'>
<TimeAgo date={item.createdAt} />
</Typography>
</MenuItem>
))}
</Box>}
</>;
}
+267
View File
@@ -0,0 +1,267 @@
import * as React from 'react';
import Head from 'next/head';
import { useQuery } from '@tanstack/react-query';
import { Box, Button, Card, CardContent, Divider, Input, Typography } from '@mui/joy';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
import { forgetChatLinkItem, useSharedChatLinkItems } from '~/modules/trade/link/store-link';
import { Brand } from '~/common/app.config';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { GoodModal } from '~/common/components/GoodModal';
import { InlineError } from '~/common/components/InlineError';
import { LogoProgress } from '~/common/components/LogoProgress';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle } from '~/common/state/store-chats';
import { themeBgAppDarker } from '~/common/app.theme';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { LinkChat } from './LinkChat';
import { LinkChatDrawer } from './LinkChatDrawer';
import { LinkChatMenuItems } from './LinkChatMenuItems';
import { addSnackbar } from '~/common/components/useSnackbarsStore';
import { navigateToChatLinkList } from '~/common/app.routes';
const SPECIAL_LIST_PAGE_ID = 'list';
const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) =>
<Box sx={{
backgroundColor: props.backgroundColor,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
{props.children}
</Box>;
const ListPlaceholder = (props: { hasLinks: boolean }) =>
<Box sx={{ p: { xs: 3, md: 6 } }}>
<Card>
<CardContent>
<Typography level='title-md'>
Shared Conversations
</Typography>
<Typography level='body-sm'>
{props.hasLinks
? 'Here you can see formely exported shared conversations. Please select a conversation from the drawer.'
: 'No shared conversations found. Please export a conversation from this browser first.'}
</Typography>
</CardContent>
</Card>
</Box>;
const ShowLoading = () =>
<Centerer backgroundColor={themeBgAppDarker}>
<LogoProgress showProgress={true} />
<Typography level='title-sm' sx={{ mt: 2 }}>
Loading Chat...
</Typography>
</Centerer>;
const ShowError = (props: { error: any }) =>
<Centerer backgroundColor={themeBgAppDarker}>
<InlineError error={props.error} severity='warning' />
</Centerer>;
/**
* Fetches the object using tRPC
* Note: we don't have react-query for the Node functions, so we use the immediate API here,
* and wrap it in a react-query hook
*/
async function fetchStoredChatV1(objectId: string | null) {
if (!objectId)
throw new Error('No Stored Chat');
// fetch
const result = await apiAsyncNode.trade.storageGet.query({ objectId });
if (result.type === 'error')
throw result.error;
// validate a CHAT_V1
const { dataType, dataObject, storedAt, expiresAt } = result;
if (dataType !== 'CHAT_V1')
throw new Error('Unsupported data type: ' + dataType);
// convert to DConversation
const restored = createConversationFromJsonV1(dataObject as any);
if (!restored)
throw new Error('Could not restore conversation');
return { conversation: restored, storedAt, expiresAt };
}
export function AppLinkChat(props: { chatLinkId: string | null }) {
// state
const [deleteConfirmId, setDeleteConfirmId] = React.useState<string | null>(null);
const [deleteConfirmKey, setDeleteConfirmKey] = React.useState<string | null>(null);
const [showDeletionKeys, setShowDeletionKeys] = React.useState<boolean>(false);
// derived state 1
const isListPage = props.chatLinkId === SPECIAL_LIST_PAGE_ID;
const linkId = isListPage ? null : props.chatLinkId;
// external state
const sharedChatLinkItems = useSharedChatLinkItems();
const { data, isError, error, isLoading } = useQuery({
enabled: !!linkId,
queryKey: ['chat-link', linkId],
queryFn: () => fetchStoredChatV1(linkId),
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
});
// derived state 2
const hasLinks = sharedChatLinkItems.length > 0;
const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Shared Chat'; // also the (nav) App title
const handleDelete = React.useCallback(async (objectId: string, deletionKey: string) => {
setDeleteConfirmId(null);
setDeleteConfirmKey(null);
// delete from storage
let err: string | null = null;
try {
const response = await apiAsyncNode.trade.storageDelete.mutate({ objectId, deletionKey });
if (response.type === 'error')
err = response.error || 'unknown error';
} catch (error: any) {
err = error?.message ?? error?.toString() ?? 'unknown error';
}
// delete from local store
if (!err)
forgetChatLinkItem(objectId);
// UI feedback
addSnackbar({
key: err ? 'chatlink-deletion-issue' : 'chatlink-deletion-success',
type: err ? 'issue' : 'success',
message: err ? 'Could not delete link: ' + err : 'Link deleted successfully',
});
// move to the list page
if (!err)
void navigateToChatLinkList();
}, []);
// Delete: ID confirmation
const handleConfirmDeletion = React.useCallback((linkId: string) => linkId && setDeleteConfirmId(linkId), []);
const handleCancelDeletion = React.useCallback(() => setDeleteConfirmId(null), []);
// Delete: Key confirmation
const handleConfirmDeletionKey = React.useCallback(() => {
if (!deleteConfirmId) return;
// if we already have the key, we can delete right away
const item = sharedChatLinkItems.find(i => i.objectId === deleteConfirmId);
let deletionKey = (item && item.deletionKey) ? item.deletionKey : null;
if (deletionKey)
return handleDelete(deleteConfirmId, deletionKey);
// otherwise ask for the key
setDeleteConfirmKey('');
}, [deleteConfirmId, handleDelete, sharedChatLinkItems]);
const handleCancelDeletionKey = React.useCallback(() => {
setDeleteConfirmId(null);
setDeleteConfirmKey(null);
}, []);
const handleDeletionKeyConfirmed = React.useCallback(() => {
deleteConfirmId && deleteConfirmKey && handleDelete(deleteConfirmId, deleteConfirmKey);
}, [deleteConfirmId, deleteConfirmKey, handleDelete]);
// pluggable UI
const drawerContent = React.useMemo(() => <LinkChatDrawer
activeLinkId={linkId}
sharedChatLinkItems={sharedChatLinkItems}
showDeletionKeys={showDeletionKeys}
onDeleteLink={handleConfirmDeletion}
/>, [handleConfirmDeletion, linkId, sharedChatLinkItems, showDeletionKeys]);
const menuItems = React.useMemo(() => <LinkChatMenuItems
activeLinkId={linkId}
showDeletionKeys={showDeletionKeys}
onDeleteLink={handleConfirmDeletion}
onToggleDeletionKeys={() => setShowDeletionKeys(on => !on)}
/>, [handleConfirmDeletion, linkId, showDeletionKeys]);
usePluggableOptimaLayout(drawerContent, null, menuItems, 'AppChatLink');
return <>
<Head>
<title>{capitalizeFirstLetter(pageTitle)} · {Brand.Title.Base} 🚀</title>
</Head>
{isListPage
? <ListPlaceholder hasLinks={hasLinks} />
: isLoading
? <ShowLoading />
: isError
? <ShowError error={error} />
: !!data?.conversation
? <LinkChat conversation={data.conversation} storedAt={data.storedAt} expiresAt={data.expiresAt} />
: <Centerer backgroundColor={themeBgAppDarker} />}
{/* Delete confirmation */}
{!!deleteConfirmId && (deleteConfirmKey === null) && (
<ConfirmationModal
onClose={handleCancelDeletion} onPositive={handleConfirmDeletionKey}
confirmationText='Are you sure you want to delete this link?'
positiveActionText={'Yes, Delete'}
/>
)}
{/* Deletion Key Input */}
{!!deleteConfirmId && (deleteConfirmKey !== null) && (
<GoodModal
open title='Enter Deletion Key'
titleStartDecorator={<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />}
onClose={handleCancelDeletionKey}
hideBottomClose
>
<Divider />
<Typography level='body-md'>
You need to enter the original deletion key to delete this conversation.
</Typography>
<Input
value={deleteConfirmKey}
onChange={event => setDeleteConfirmKey(event.target.value)}
sx={{ flexGrow: 1 }}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
<Button autoFocus variant='plain' color='neutral' onClick={handleCancelDeletionKey}>
Cancel
</Button>
<Button
variant='solid' color='danger'
disabled={!deleteConfirmKey.trim()}
onClick={handleDeletionKeyConfirmed}
sx={{ lineHeight: '1.5em' }}
>
Delete
</Button>
</Box>
</GoodModal>
)}
</>;
}

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