Compare commits

...

225 Commits

Author SHA1 Message Date
Enrico Ros 5cc5df6909 1.7.0: Fix 2023-12-10 04:52:00 -08:00
Enrico Ros 11d8cf8996 Update GitHub docker action 2023-12-10 04:51:37 -08:00
Enrico Ros eae578970e 1.7.0: UpDate 2023-12-10 04:12:05 -08:00
Enrico Ros e076953c6a Merge branch 'release-1.7.0' 2023-12-10 04:08:29 -08:00
Enrico Ros 5c455591ea 1.7.0: Readme and Changelog 2023-12-10 04:06:50 -08:00
Enrico Ros 19b3dcd927 Update maintainers-release.md 2023-12-10 03:43:42 -08:00
Enrico Ros 702e27edbf Update deploy-authentication.md 2023-12-10 03:29:13 -08:00
Enrico Ros 7c872de9af Update deploy-authentication.md 2023-12-10 03:28:38 -08:00
Enrico Ros 53b18143e7 Update deploy-authentication.md 2023-12-10 03:27:49 -08:00
Enrico Ros d812813aac Update deploy-authentication.md 2023-12-10 03:27:09 -08:00
Enrico Ros 9505b7fd7f Update deploy-authentication.md 2023-12-10 03:26:27 -08:00
Enrico Ros 9e07822598 Update deploy-authentication.md 2023-12-10 03:26:02 -08:00
Enrico Ros 6d6604a043 Update maintainers-release.md 2023-12-10 03:10:29 -08:00
Enrico Ros 64d5071eb4 Update maintainers-release.md 2023-12-10 03:02:27 -08:00
Enrico Ros 4a29ff0b19 Update maintainers-release.md 2023-12-10 02:43:42 -08:00
Enrico Ros 6acab83ac5 1.7.0: Version 2023-12-10 02:28:54 -08:00
Enrico Ros a3391b46ec 1.7.0: News 2023-12-10 02:28:54 -08:00
Enrico Ros 9d021a0ea9 News: improve page 2023-12-10 01:58:15 -08:00
Enrico Ros 5b35435136 Removed stray page. #177 2023-12-10 01:56:48 -08:00
Enrico Ros 38b1cd1e4b Composer: premature optimizations 2023-12-10 01:47:37 -08:00
Enrico Ros 50e4bf30f2 Composer: more optimizations 2023-12-10 01:30:16 -08:00
Enrico Ros 6f8d6462b9 Composer: optimizations 2023-12-10 01:07:32 -08:00
Enrico Ros 596bb1ccc6 Readme: refer to http basic auth. #269 2023-12-10 00:20:21 -08:00
Enrico Ros 8023d4fd7e Improve HTTP Basic Auth docs. Improves #269 2023-12-10 00:17:34 -08:00
Enrico Ros 5808c5ae27 Merge branch 'LennardSchwarz-add-basic-auth' 2023-12-10 00:11:04 -08:00
Enrico Ros 0945bc1e74 Documented HTTP basic Auth. Fixes #269 2023-12-10 00:10:01 -08:00
Enrico Ros c82ea978da Improve Build/Deploy instructions 2023-12-09 23:05:56 -08:00
Enrico Ros 9184e28691 Merge branch 'add-basic-auth' of https://github.com/LennardSchwarz/lenn-big-agi into LennardSchwarz-add-basic-auth 2023-12-09 22:26:59 -08:00
Enrico Ros 59784af72c Browser: initial screenshot support 2023-12-08 04:45:43 -08:00
Enrico Ros 8feb1881b9 Merge branch 'feature-new-attachments'
Fixes #251
2023-12-08 04:45:26 -08:00
Enrico Ros 62747e07f1 Mic: greatly improve, with unmounting 2023-12-08 04:37:11 -08:00
Enrico Ros 934511a21f Mic: properly fix #221. The timeout was not reapplied. 2023-12-08 04:37:11 -08:00
Enrico Ros e36b71db9c Mic: Fix back on Desktop 2023-12-08 04:37:11 -08:00
Enrico Ros 924cd7018f Attachments: MultiPart-ready. Closes #251 for this stage. 2023-12-08 04:37:11 -08:00
Enrico Ros d5e91f9ce7 Optimize 2023-12-08 04:37:11 -08:00
Enrico Ros f1ad8cd55e Attachments: cleanups 2023-12-08 04:37:11 -08:00
Enrico Ros d177c73642 Attachments: Send! 2023-12-08 04:37:11 -08:00
Enrico Ros 011bcf8ccd Misc smaller improvements 2023-12-08 04:37:11 -08:00
Enrico Ros 7d0e5809e1 Misc cleanups 2023-12-08 04:37:11 -08:00
Enrico Ros b369148057 Attachments: Inlining: done. Use a hook that derives data from another hook. 2023-12-08 04:37:11 -08:00
Enrico Ros 2e0105b5ed Attachments: improvements and cleanups (still not attaching) 2023-12-08 04:37:11 -08:00
Enrico Ros 3f24ade8e6 Attachments: expire older parts 2023-12-08 04:37:11 -08:00
Enrico Ros 9cdaf26174 Attachments: remove Camera OCR (now common image OCR) 2023-12-08 04:37:11 -08:00
Enrico Ros 3b2c604615 Attachments: first inlining 2023-12-08 04:37:11 -08:00
Enrico Ros 223689316b Token Progress Bar: improve margins 2023-12-08 04:37:11 -08:00
Enrico Ros 6456a0de0c Token Progress Bar: disable Tooltip 2023-12-08 04:37:10 -08:00
Enrico Ros 57458fb32f Attachments: closer to ejection 2023-12-08 04:37:10 -08:00
Enrico Ros b2521060cc Attachments: cleanup Outputs 2023-12-08 04:37:10 -08:00
Enrico Ros 13b6a1ba7e Attachments: use ComposerOutputPart and cleanups 2023-12-08 04:37:10 -08:00
Enrico Ros ec81d802d5 Attachments: extract item menu 2023-12-08 04:37:10 -08:00
Enrico Ros f6eca257d6 Cleanup action group, slightly improves #258 2023-12-08 04:37:10 -08:00
Enrico Ros e744b1afcd Attachments: bits 2023-12-08 04:37:10 -08:00
Enrico Ros bfcae972f7 Attachments: cached token counting 2023-12-08 04:37:10 -08:00
Enrico Ros 360f886c37 Attachments: improve console log 2023-12-08 04:37:10 -08:00
Enrico Ros 305c278e1c Beauty: right align 2023-12-08 04:37:10 -08:00
Enrico Ros ccfcf6235f Beauty: by 2 pixels 2023-12-08 04:37:10 -08:00
Enrico Ros 62f7d92bb2 Beauty: token reporting 2023-12-08 04:37:10 -08:00
Enrico Ros f8915141c8 Attachments: major steps forward towards ejectability 2023-12-08 04:37:10 -08:00
Enrico Ros 7e1e4af19b Beauty: highlight user commands 2023-12-08 04:37:10 -08:00
Enrico Ros 439c462a9b Beauty: buttons 2023-12-08 04:37:10 -08:00
Enrico Ros 95aa71abd6 Beauty: mic buttons 2023-12-08 04:37:10 -08:00
Enrico Ros 3c829cbf97 Good Tooltip 2023-12-08 04:37:10 -08:00
Enrico Ros 29a31d5ca3 Beauty: main button 2023-12-08 04:37:10 -08:00
Enrico Ros 4a8bb24c0f Attachments: move withing composer 2023-12-08 04:37:10 -08:00
Enrico Ros 6b6c3afe0c Attachment: improve UX 2023-12-08 04:37:10 -08:00
Enrico Ros fd41388584 Attachment: outputsLoading for the spinners 2023-12-08 04:37:10 -08:00
Enrico Ros b418b69dc3 Attachment: improve Unsupported (without requiring user action to switch to the generic text-block) 2023-12-08 04:37:10 -08:00
Enrico Ros e1e2962a02 Attachment: bits 2023-12-08 04:37:10 -08:00
Enrico Ros f1662e174f Attachment: PDF to text, sync conversion, and debug 2023-12-08 04:37:10 -08:00
Enrico Ros a73c55fc1f Attachment: fixes 2023-12-08 04:37:10 -08:00
Enrico Ros 0aa923a99d Attachment: remove 2023-12-08 04:37:10 -08:00
Enrico Ros b75160bb2b Attachment: rename pipeline 2023-12-08 04:37:10 -08:00
Enrico Ros 3d515102a1 Attachment: initial image support 2023-12-08 04:37:10 -08:00
Enrico Ros b857cc18d8 Attachment: many cleanups 2023-12-08 04:37:10 -08:00
Enrico Ros 4737d962db Attachment: begin conversions 2023-12-08 04:37:10 -08:00
Enrico Ros 7ba71078a8 Attachment: conversion logic for text, finished popups 2023-12-08 04:37:10 -08:00
Enrico Ros bee0fa8751 Attachment: group Logic 2023-12-08 04:37:10 -08:00
Enrico Ros 5916dfb08d pdfUtils: move 2023-12-08 04:37:10 -08:00
Enrico Ros 9d13b03923 Enable Camera on desktop, #233 2023-12-08 04:37:10 -08:00
Enrico Ros 48e6385ac7 FormLabelStart: try with 'minWidth' 2023-12-08 04:37:10 -08:00
Enrico Ros cf664ff486 Attachment: improve auto-mime 2023-12-08 04:37:09 -08:00
Enrico Ros 5ccf8ba128 Attachment: push forward flow 2023-12-08 04:37:09 -08:00
Enrico Ros 3cd5917207 Attachment: set tooltip on button only 2023-12-08 04:37:09 -08:00
Enrico Ros e2dcca274f Browser: close incognito context 2023-12-08 04:37:09 -08:00
Enrico Ros 7369e898af Browser: make the wss endpoint always overridable 2023-12-08 04:37:09 -08:00
Enrico Ros 1e2c12fddb New Attach System: downloads almost ok 2023-12-08 04:37:09 -08:00
Enrico Ros 4f7369b940 Browser: improve behavior when loading non-pages (files) 2023-12-08 04:37:09 -08:00
Enrico Ros f566049890 Browser: further improve error handling 2023-12-08 04:37:09 -08:00
Enrico Ros fbc2da8b09 Browser: further improve error handling 2023-12-08 04:37:09 -08:00
Enrico Ros af70b39515 Browse: beginning to cleanup page load 2023-12-08 04:37:09 -08:00
Enrico Ros e080d72e8a New Attach System: Components 2023-12-08 04:37:09 -08:00
Enrico Ros fd24e3676a Confirmation Modals: prettier 2023-12-08 04:37:09 -08:00
Enrico Ros 942cd461f5 Drag & drop in Composer: exclude self-drags 2023-12-08 04:37:09 -08:00
Enrico Ros 9567e1cbaa New Attach System: renames 2023-12-08 04:37:09 -08:00
Enrico Ros 2d5d31268e New Attach System: transfer specialized functions to the hook 2023-12-08 04:37:09 -08:00
Enrico Ros b376608709 Fix on-demand clipboard item read.
Note: shall remove this and go for ctrl+v only?
2023-12-08 04:37:09 -08:00
Enrico Ros 551e502caf New Attach System: porting 2023-12-08 04:37:09 -08:00
Enrico Ros 9fb7fcd22f New Attach System: framework 2023-12-08 04:37:09 -08:00
Enrico Ros 1cda7d195b Revert "Browser: initial screenshot support"
This reverts commit 4a02923dda.
2023-12-08 04:36:17 -08:00
Enrico Ros 4a02923dda Browser: initial screenshot support 2023-12-08 04:13:44 -08:00
Enrico Ros a8a45631c2 Browser: update the documentation - large #247 improvement (@stevenlafl) 2023-12-08 03:40:51 -08:00
Enrico Ros eaa755d4ce Browser: update the documentation - integrates #247 2023-12-08 03:17:02 -08:00
Enrico Ros 872396a90e Browser: update Markdown, see #247 2023-12-08 02:08:00 -08:00
Enrico Ros 6b3a2772cc Bits 2023-12-08 01:41:56 -08:00
Enrico Ros f378733abe Oobabooga: document the changes 2023-12-07 22:18:06 -08:00
Enrico Ros 0cf8f0439d Oobabooga: fix with recent API changes 2023-12-07 22:09:28 -08:00
Enrico Ros ab53087b3a LLM Overheat: intuitive UX 2023-12-05 15:13:08 -08:00
Enrico Ros b50923a3b7 Denser menus: Message context & Selection 2023-12-05 14:59:45 -08:00
Enrico Ros 1b4a8da313 Backend: add support for analytics (log which host name responds) 2023-12-05 02:48:14 -08:00
Enrico Ros 31684c2fee [shortcuts] Ctrl+Shift+O: current Chat Model options (temperature, etc..) 2023-12-04 23:54:48 -08:00
Enrico Ros fedd4b1fda Fix setting reactivity on the new Voice Input Timeout. Closes #221 2023-12-04 23:36:18 -08:00
Enrico Ros a41667f427 Overheat LLMs
OpenAI LLMs can go up to 2 as far as temperature.
We don't enable >1 by default, but we have a new labs setting
to enable 'overheating' (max temperature raised
from 1 to 2) for Really Well Done LLMs.
2023-12-04 23:15:40 -08:00
Enrico Ros 021fa3b313 Update README.md 2023-12-02 01:50:49 -08:00
Lennard Schwarz b7ca69aa0e Update realm info 2023-12-01 18:31:04 +01:00
Lennard Schwarz 1efcadbf46 Update readme 2023-12-01 18:29:06 +01:00
Lennard Schwarz 598a6a8e0b Merge branch 'main' of github-ls:LennardSchwarz/lenn-big-agi into add-basic-auth 2023-12-01 18:25:58 +01:00
Enrico Ros 1cd441a2f5 Clipboard: intercept exception, e.g. when a jpeg/png file is copied to clipboard, chrome won't consider it valid on read (yes on ctrl+v) 2023-11-29 15:40:12 -08:00
Enrico Ros 783dc55d02 Ollama: pulling warning 2023-11-29 11:30:07 -08:00
Enrico Ros 88418d1ed0 Enable Toppy-M 2023-11-29 11:13:42 -08:00
Enrico Ros 6a74d1900f History truncation 2023-11-29 11:06:52 -08:00
Enrico Ros 5566e29bcc OpenRouter: update models 2023-11-29 10:43:10 -08:00
Enrico Ros 1f49195251 Ollama: update models, including a marker of the new models 2023-11-29 10:16:31 -08:00
Enrico Ros c5e15ece14 Composer: bits 2023-11-28 14:10:41 -08:00
Enrico Ros 7ceb176d70 Composer: cleanup overlays 2023-11-28 14:08:32 -08:00
Enrico Ros b93bd1bd0b move pdfToText 2023-11-28 12:35:38 -08:00
Enrico Ros 088133ec37 Configurable Voice Input timeout. #221 2023-11-28 03:46:23 -08:00
Enrico Ros 784766442d Extract FormRadioControl 2023-11-28 03:28:06 -08:00
Enrico Ros e014a7c828 Clarityx 2023-11-28 02:45:11 -08:00
Enrico Ros 224e745a71 Cosmetix 2023-11-28 02:35:06 -08:00
Enrico Ros 28ef74f1e9 Merge branch 'release-1.6.0' 2023-11-28 01:41:30 -08:00
Enrico Ros 70091ac39b 1.6.0 version 2023-11-28 01:40:29 -08:00
Enrico Ros cc1011659d 1.6.0 README and changelog 2023-11-28 01:39:03 -08:00
Enrico Ros 7eaa4a11bd 1.6.0 news 2023-11-28 01:32:14 -08:00
Enrico Ros 495f25e2d4 Update news hiding 2023-11-28 01:30:03 -08:00
Enrico Ros f2396000f2 Update template 2023-11-28 01:29:41 -08:00
Enrico Ros 77533aa385 Fix, thanks lint 2023-11-27 16:02:50 -08:00
Enrico Ros 01b2bf6fa3 Flattener: move to streaming, using a new helper 2023-11-27 16:01:25 -08:00
Enrico Ros 6d7843805e Small bits 2023-11-27 15:33:02 -08:00
Enrico Ros 0a593fb2c6 Fix focusing of imported chats. #233 2023-11-27 13:31:37 -08:00
Enrico Ros 57f277f269 ElevenLabs: improve config UX 2023-11-27 13:24:25 -08:00
Enrico Ros 6924e02a17 Link Import: fix Chat URL 2023-11-27 13:20:47 -08:00
Enrico Ros f4b645fd78 Update config-browse.md 2023-11-25 11:47:28 -08:00
Enrico Ros fdb46d3072 Browse: Improve errors reporting 2023-11-24 15:32:54 -08:00
Enrico Ros 858e9d3cb3 Browse: Local (ws://) in incognito 2023-11-24 15:32:46 -08:00
Enrico Ros 52a9dc7bec Browse: Documentation 2023-11-24 15:19:03 -08:00
Enrico Ros 16fbd3b6a3 Browse: cleanups2 2023-11-24 14:23:14 -08:00
Enrico Ros aa09e60f5f Browse: cleanups 2023-11-24 14:20:50 -08:00
Enrico Ros 3b2983831d Spell 2023-11-24 14:01:30 -08:00
Enrico Ros 16e69d0d0b commands: /help (primitive) 2023-11-24 13:55:47 -08:00
Enrico Ros 548f52c770 Browse: user configuration 2023-11-24 13:50:46 -08:00
Enrico Ros 8adac0d193 Browse: /browse -> loads as assistant response 2023-11-24 13:50:46 -08:00
Enrico Ros c0d3c6c982 Browse: /react support (as 'loadURL' tool) 2023-11-24 13:35:57 -08:00
Enrico Ros c1516e7be0 Browse: Share Target -> Composer attachment 2023-11-24 13:11:44 -08:00
Enrico Ros 8473894be2 Browse: CTRL+V (url) and 'Paste' (url) -> Composer attachment 2023-11-24 13:11:44 -08:00
Enrico Ros d5e2fbed0e Browse: page loading service, using remote Puppeteer
also: moved to tRPC (node)
2023-11-24 12:49:45 -08:00
Enrico Ros 2dfa78fbe0 Voice Calls - Labs option 2023-11-24 10:58:30 -08:00
Enrico Ros dff83c5ede Roll packages 2023-11-24 10:49:01 -08:00
Enrico Ros 483f483c4a Copy to clipboard snacks 2023-11-23 02:15:57 -08:00
Enrico Ros f780daf1b1 Anthropic Claude 2.1 support. Closes #245 2023-11-23 01:34:54 -08:00
Enrico Ros 5e6e5bf017 Improved Models Tooltip 2023-11-23 01:27:31 -08:00
Enrico Ros bfe2882ac3 Adding optional Pricing schema 2023-11-23 01:11:14 -08:00
Enrico Ros 0574be04f4 Update soft knowledge cutoff for 1106 models. 2023-11-22 23:36:04 -08:00
Enrico Ros 53b5da8cb8 OpenAI Shared Chats: import from Clipboard too, and copy json object 2023-11-22 22:32:45 -08:00
Enrico Ros 5387b17c36 Also show the branched title. 2023-11-22 13:03:03 -08:00
Enrico Ros 0e854b8772 Title: show the chat index (1: first, 2: second most recently created, etc) 2023-11-22 04:32:19 -08:00
Enrico Ros d23f247a8c Large Perf Boost on Messages 2023-11-22 04:06:27 -08:00
Enrico Ros ce13c04e96 Perf Boost - large gains on the Nav Drawer 2023-11-22 04:00:28 -08:00
Enrico Ros e55fbe9ad0 Fix missing hook dep 2023-11-22 03:14:16 -08:00
Enrico Ros e5a11af6d2 Rename 2023-11-22 02:32:24 -08:00
Enrico Ros 76f21f8c96 Rename 2023-11-22 02:22:20 -08:00
Enrico Ros ea4d9afff2 Ctrl + Shift + ?: show shortcuts 2023-11-22 01:52:13 -08:00
Enrico Ros d884970a02 Do not require confirmation for 'armed' deletions. 2023-11-22 01:39:23 -08:00
Enrico Ros ee11787dcc README.md - roadmap comment 2023-11-22 01:38:16 -08:00
Enrico Ros 13e1ba977f Update 1.5.0 release notes 2023-11-22 01:25:56 -08:00
Enrico Ros 7137ebdda2 Merge pull request #240 from g1ibby/fix-ollama-listModels
fix: ollama listModel endpoint when a model doesn't have TEMPLATE
2023-11-22 01:07:40 -08:00
Enrico Ros 9b71b08fe1 Chat Layout: push the chatmessagelist two levels down #233 2023-11-22 01:06:35 -08:00
Enrico Ros 45a18edac0 ChatMessageList: undo the Ephemeral move 2023-11-22 00:59:17 -08:00
Enrico Ros f1b1ca0a5f Window manager: split functions 2023-11-22 00:59:03 -08:00
Enrico Ros 0c1718bf9c Split-branch settings 2023-11-22 00:56:58 -08:00
Enrico Ros a934ca548e usePanesManager: optional debug 2023-11-21 22:52:24 -08:00
Enrico Ros 2896bd7287 Move Ephemerals Down 2023-11-21 22:41:55 -08:00
Enrico Ros 5ad103a8a2 Refer. 2023-11-21 22:21:22 -08:00
Enrico Ros 16916db247 Improve routing, and move the action pwa action receiver 2023-11-21 22:17:17 -08:00
g1ibby 669eb1414f fix: ollama listModel endpoint when a model doesn't have TEMPLATE or PARAMETER 2023-11-22 13:14:46 +07:00
Enrico Ros 6ed8529d6a Roll types 2023-11-21 22:06:18 -08:00
Enrico Ros bb36dbc4b9 Removed the Labs page, removed a store 2023-11-21 21:31:21 -08:00
Enrico Ros f9e38c7220 Ctrl + Alt + Left/Right: fast history navigation, closes #207 2023-11-21 19:27:22 -08:00
Enrico Ros 2b5a051a9e Ctrl + Alt + Left/Right: navigates in history 2023-11-21 18:45:19 -08:00
Enrico Ros 9793236941 Shortcuts: use fewer listeners 2023-11-21 18:04:33 -08:00
Enrico Ros 497d1c9559 Snackbar: chat title (disabled for now) 2023-11-21 17:41:00 -08:00
Enrico Ros 75c4fe5e67 Snackbars: useEffect compatible 2023-11-21 17:36:39 -08:00
Enrico Ros f4d3d3bd28 Snackbars: add the 'title' type 2023-11-21 17:36:12 -08:00
Enrico Ros 853aadaa0e Confirm branching. 2023-11-21 16:55:36 -08:00
Enrico Ros 8bf23e121c Snackbar Framework animations - Improves #206 2023-11-21 16:55:25 -08:00
Enrico Ros cbffc3f6d5 Snackbar Framework - Closes #206 2023-11-21 16:41:12 -08:00
Enrico Ros 52fc4ec5d8 Improve Restart messaging 2023-11-21 16:40:42 -08:00
Enrico Ros ab94579a30 Branching: duplication up to a message. Partial #235
This commit also largely cleanups the hierarchy tree of component callbacks/handlers
and sets a common nomenclature.
2023-11-21 15:16:58 -08:00
Enrico Ros 43ddc79939 Roll packages 2023-11-21 13:43:45 -08:00
Enrico Ros 6938c6b8d0 UI: Improve options location - Fixes #236 2023-11-21 13:41:27 -08:00
Enrico Ros ba5d835248 Improve spacing 2023-11-21 13:14:06 -08:00
Enrico Ros 510d58ba69 Cleanup News page - part of #236 2023-11-21 12:57:29 -08:00
Enrico Ros c23b0770bf tRPC: enforce more separation of the runtime
The build system was requiring (erroneously) some nodejs packages
when inside routers in the Edge route.
2023-11-21 02:29:05 -08:00
Enrico Ros cb4fdc56a5 Moved chat/commands 2023-11-21 00:28:59 -08:00
Enrico Ros 3b28767212 Renamed to ChatPane 2023-11-21 00:28:46 -08:00
Enrico Ros a1d6cb8cd0 Window management: separate stores again 2023-11-21 00:16:35 -08:00
Enrico Ros 0a094ef0b0 Improve Stores naming 2023-11-20 17:38:35 -08:00
Enrico Ros 17c349af94 Window management: framework
This includes moving the full responsibility for the active window
(and history) to the panes.
2023-11-20 16:19:04 -08:00
Enrico Ros 97f2a19227 Moved and renamed Trade, where it belongs 2023-11-20 16:07:08 -08:00
Enrico Ros 6fc2415e5d Chats store: removed the activeConversationId 2023-11-20 15:24:39 -08:00
Enrico Ros d68c131bbc Window management: ancillary nothingness 2 2023-11-20 15:18:04 -08:00
Enrico Ros 0b6c217da6 Window management: ancillary nothingness 2023-11-20 14:35:36 -08:00
Enrico Ros 432d78fc9d Window management: ancillary component cleanups 2023-11-20 14:32:06 -08:00
Enrico Ros 769ca1546a Window management: ancillary small changes 2023-11-20 14:20:19 -08:00
Enrico Ros 989684884c Window management: ancillary component changes 2023-11-20 14:19:09 -08:00
Enrico Ros a2b6554e73 ChatMessageList: do not collapse on null conversations, but show an helpful message 2023-11-20 02:16:25 -08:00
Enrico Ros 28555445c9 InlineError: allow 'info' 2023-11-20 02:14:24 -08:00
Enrico Ros 20bddfe6c6 Uniform sxprops 2023-11-20 02:14:06 -08:00
Enrico Ros 01243f7422 globalStoredList: begin abstracting stored lists 2023-11-19 19:02:43 -08:00
Enrico Ros 741edb499c Chat: begin moving window state up 2023-11-19 16:09:48 -08:00
Enrico Ros a3fd877a75 Default mobile corner button 2023-11-19 15:58:09 -08:00
Enrico Ros 0c19c4c8ac Clear for 1.6.0 2023-11-19 15:57:38 -08:00
Lennard Schwarz 89f3e6f955 Update readme 2023-10-30 14:57:51 +01:00
Lennard Schwarz e79b429c5e Update Readme 2023-10-30 14:57:45 +01:00
Lennard Schwarz c240f6bd5b Add deploy button 2023-10-30 14:55:53 +01:00
Lennard Schwarz 33312e0fd9 Add my middleware thing 2023-10-30 14:52:43 +01:00
166 changed files with 6287 additions and 2209 deletions
+46 -4
View File
@@ -7,7 +7,7 @@ assignees: enricoros
---
Release checklist:
## Release checklist:
- [ ] Update the [Roadmap](https://github.com/users/enricoros/projects/4/views/2) calling out shipped features
- [ ] Create and update a [Milestone](https://github.com/enricoros/big-agi/milestones) for the release
@@ -15,12 +15,14 @@ Release checklist:
- [ ] Assign all the shipped roadmap Issues
- [ ] Assign the relevant [recently closed Isssues](https://github.com/enricoros/big-agi/issues?q=is%3Aclosed+sort%3Aupdated-desc)
- Code changes:
- [ ] Create a release branch 'release-x.y.z', and commit:
- [ ] Create a release branch 'release-x.y.z': `git checkout -b release-1.2.3`
- [ ] Create a temporary tag `git tag v1.2.3 && git push opensource --tags`
- [ ] Create a [New Draft GitHub Release](https://github.com/enricoros/big-agi/releases/new), and generate the automated changelog (for new contributors)
- [ ] Update the release version in package.json, and `npm i`
- [ ] Update in-app News [src/apps/news/news.data.tsx](src/apps/news/news.data.tsx)
- [ ] Update 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
- [ ] Copy the highlights to the [changelog](docs/changelog.md)
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
- Release:
- [ ] merge onto main
- [ ] verify deployment on Vercel
@@ -31,3 +33,43 @@ Release checklist:
- Announce:
- [ ] Discord announcement
- [ ] Twitter announcement
## Links
Milestone:
Former release task:
GitHub release:
## Artifacts Generation
1) The following is my opensource application
- paste README.md
2) I am announcing a new version, 1.7.0. The following were the announcements for 1.6.0. Discord announcement, GitHub Release, in-app news.data.tsx, changelog.md.
- paste the former: `discord announcement`, `GitHub release`, `news.data.tsx`, `changelog.md`
3) The following is the new data I have for 1.7.0
- paste the link to the milestone (closed) and each individual issue (content will be downloaded)
- paste the git changelog `git log v1.6.0..v1.7.0 | clip`
### 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
```
### GitHub release
Now paste the former release (or 1.5.0 which was accurate and great), including the new contributors and
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?
```
+14 -4
View File
@@ -7,11 +7,15 @@
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Create and publish a Docker image
name: Create and publish Docker images
on:
push:
branches: ['main']
branches:
- main
- main-stable # Trigger on pushes to the main-stable branch
tags:
- 'v*' # Trigger on version tags (e.g., v1.7.0)
env:
REGISTRY: ghcr.io
@@ -26,7 +30,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
@@ -40,11 +44,17 @@ jobs:
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=development,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main-stable' }}
type=ref,event=tag # Use the tag name as a tag for tag builds
type=semver,pattern={{version}} # Generate semantic versioning tags for tag builds
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
+50 -14
View File
@@ -1,6 +1,6 @@
# BIG-AGI 🧠✨
Welcome to big-AGI 👋, the GPT application for Pro users that combines utility,
Welcome to big-AGI 👋, the GPT application for professionals that need form, function,
simplicity, and speed. Powered by the latest models from 7 vendors, including
open-source, `big-AGI` offers best-in-class Voice and Chat with AI Personas,
visualizations, coding, drawing, calling, and quite more -- all in a polished UX.
@@ -13,15 +13,36 @@ Or fork & run on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY,OPENAI_API_HOST&envDescription=OpenAI%20KEY%20for%20your%20deployment.%20Set%20HOST%20only%20if%20non-default.)
## 🗺️ Explore the Roadmap
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2)
The development of big-AGI is an open book. Our **[public roadmap](https://github.com/users/enricoros/projects/4/views/2)** is
live, providing a detailed look at the current and future development of the application.
big-AGI is an open book; our **[public roadmap](https://github.com/users/enricoros/projects/4/views/2)**
shows the current developments and future ideas.
- 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.5.0 🌟
### What's New in 1.7.0 · Dec 10, 2023 · Attachment Theory 🌟
- **Attachments System Overhaul**: Drag, paste, link, snap, text, images, PDFs and more. [#251](https://github.com/enricoros/big-agi/issues/251)
- **Desktop Webcam Capture**: Image capture now available as Labs feature. [#253](https://github.com/enricoros/big-agi/issues/253)
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Overheat LLMs**: Push the creativity with higher LLM temperatures. [#256](https://github.com/enricoros/big-agi/issues/256)
- **Model Options Shortcut**: Quick adjust with `Ctrl+Shift+O`
- Optimized Voice Input and Performance
- Latest Ollama and Oobabooga models
- For developers: **Password Protection**: HTTP Basic Auth. [Learn How](https://github.com/enricoros/big-agi/blob/main/docs/deploy-authentication.md)
### What's New in 1.6.0 - Nov 28, 2023
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Branching Discussions**: Create new conversations from any message
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
- **Performance Boost**: Faster rendering for a smoother experience
- **UI Enhancements**: Refined interface based on user feedback
- **New Features**: Anthropic Claude 2.1, `/help` command, and Flattener tool
- **For Developers**: Code quality upgrades and snackbar notifications
### What's New in 1.5.0 - Nov 19, 2023
- **Continued Voice**: Engage with hands-free interaction for a seamless experience
- **Visualization Tool**: Create data representations with our new visualization capabilities
@@ -72,7 +93,8 @@ the [past releases changelog](docs/changelog.md).
![React](https://img.shields.io/badge/React-61DAFB?style=&logo=react&logoColor=black)
![Next.js](https://img.shields.io/badge/Next.js-000000?style=&logo=vercel&logoColor=white)
Clone this repo, install the dependencies, and run the development server:
Clone this repo, install the dependencies (all locally), and run the development server (which auto-watches the
files for changes):
```bash
git clone https://github.com/enricoros/big-agi.git
@@ -81,15 +103,23 @@ npm install
npm run dev
```
The app will be running on `http://localhost:3000`
The development app will be running on `http://localhost:3000`. Development builds have the advantage of not requiring
a build step, but can be slower than production builds. Also, development builds won't have timeout on edge functions.
Integrations:
## 🌐 Deploy manually
* Local models: Ollama, Oobabooga, LocalAi, etc.
* [ElevenLabs](https://elevenlabs.io/) Voice Synthesis (bring your own voice too) - Settings > Text To Speech
* [Helicone](https://www.helicone.ai/) LLM Observability Platform - Models > OpenAI > Advanced > API Host: 'oai.hconeai.com'
* [Paste.gg](https://paste.gg/) Paste Sharing - Chat Menu > Share via paste.gg
* [Prodia](https://prodia.com/) Image Generation - Settings > Image Generation > Api Key & Model
The _production_ build of the application is optimized for performance and is performed by the `npm run build` command,
after installing the required dependencies.
```bash
# .. repeat the steps above up to `npm install`, then:
npm run build
npm run start --port 3000
```
The app will be running on the specified port, e.g. `http://localhost:3000`.
Want to deploy with username/password? See the [Authentication](docs/deploy-authentication.md) guide.
## 🐳 Deploy with Docker
@@ -105,7 +135,7 @@ docker run -d -p 3000:3000 big-agi
Or run the official container:
- manually: `docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi`
- or, with docker-compose: `docker-compose up`
- or, with docker-compose: `docker-compose up` or see [the documentation](docs/deploy-docker.md) for a composer file with integrated browsing
## ☁️ Deploy on Cloudflare Pages
@@ -117,7 +147,13 @@ Create your GitHub fork, create a Vercel project over that fork, and deploy it.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY,OPENAI_API_HOST&envDescription=OpenAI%20KEY%20for%20your%20deployment.%20Set%20HOST%20only%20if%20non-default.)
## Integrations:
* Local models: Ollama, Oobabooga, LocalAi, etc.
* [ElevenLabs](https://elevenlabs.io/) Voice Synthesis (bring your own voice too) - Settings > Text To Speech
* [Helicone](https://www.helicone.ai/) LLM Observability Platform - Models > OpenAI > Advanced > API Host: 'oai.hconeai.com'
* [Paste.gg](https://paste.gg/) Paste Sharing - Chat Menu > Share via paste.gg
* [Prodia](https://prodia.com/) Image Generation - Settings > Image Generation > Api Key & Model
<br/>
+1 -1
View File
@@ -1,6 +1,6 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouterEdge } from '~/server/api/trpc.router';
import { appRouterEdge } from '~/server/api/trpc.router-edge';
import { createTRPCFetchContext } from '~/server/api/trpc.server';
const handlerEdgeRoutes = (req: Request) =>
+1 -1
View File
@@ -1,6 +1,6 @@
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouterNode } from '~/server/api/trpc.router';
import { appRouterNode } from '~/server/api/trpc.router-node';
import { createTRPCFetchContext } from '~/server/api/trpc.server';
const handlerNodeRoutes = (req: Request) =>
+4
View File
@@ -1,3 +1,7 @@
# Very simple docker-compose file to run the app on http://localhost:3000 (or http://127.0.0.1:3000).
#
# For more examples, such runnin big-AGI alongside a web browsing service, see the `docs/docker` folder.
version: '3.9'
services:
+40 -3
View File
@@ -2,10 +2,36 @@
This is a high-level changelog. Calls out some of the high level features batched
by release.
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
### ✨ What's New in 1.5.0 👊 - Nov 19, 2023
### 1.8.0 - Dec 2023
- 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)
- milestone: [1.8.0](https://github.com/enricoros/big-agi/milestone/8)
### What's New in 1.7.0 · Dec 10, 2023 · Attachment Theory 🌟
- **Attachments System Overhaul**: Drag, paste, link, snap, text, images, PDFs and more. [#251](https://github.com/enricoros/big-agi/issues/251)
- **Desktop Webcam Capture**: Image capture now available as Labs feature. [#253](https://github.com/enricoros/big-agi/issues/253)
- **Independent Browsing**: Full browsing support with Browserless. [Learn More](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Overheat LLMs**: Push the creativity with higher LLM temperatures. [#256](https://github.com/enricoros/big-agi/issues/256)
- **Model Options Shortcut**: Quick adjust with `Ctrl+Shift+O`
- Optimized Voice Input and Performance
- Latest Ollama and Oobabooga models
- For developers: **Password Protection**: HTTP Basic Auth. [Learn How](https://github.com/enricoros/big-agi/blob/main/docs/deploy-authentication.md)
### What's New in 1.6.0 - Nov 28, 2023 · Surf's Up
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
- **Branching Discussions**: Create new conversations from any message
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
- **Performance Boost**: Faster rendering for a smoother experience
- **UI Enhancements**: Refined interface based on user feedback
- **New Features**: Anthropic Claude 2.1, `/help` command, and Flattener tool
- **For Developers**: Code quality upgrades and snackbar notifications
### What's New in 1.5.0 - Nov 19, 2023 · Loaded
- **Continued Voice**: Engage with hands-free interaction for a seamless experience
- **Visualization Tool**: Create data representations with our new visualization capabilities
@@ -17,6 +43,17 @@ by release.
- **Cloudflare OpenAI API Gateway**: Integrate with Cloudflare for a robust API gateway
- **Helicone for Anthropic**: Utilize Helicone's tools for Anthropic models
For Developers:
- Runtime Server-Side configuration: https://github.com/enricoros/big-agi/issues/189. Env vars are
not required to be set at build time anymore. The frontend will roundtrip to the backend at the
first request to get the configuration. See
https://github.com/enricoros/big-agi/blob/main/src/modules/backend/backend.router.ts.
- CloudFlare developers: please change the deployment command to
`rm app/api/trpc-node/[trpc]/route.ts && npx @cloudflare/next-on-pages@1`,
as we transitioned to the App router in NextJS 14. The documentation in
[docs/deploy-cloudflare.md](../docs/deploy-cloudflare.md) is updated
### 1.4.0: Sept/Oct: scale OUT
- **Expanded Model Support**: Azure and [OpenRouter](https://openrouter.ai/docs#models) models, including gpt-4-32k
@@ -32,7 +69,7 @@ by release.
- **Backup/Restore** - save chats, and restore them later
- **[Local model support with Oobabooga server](../docs/config-local-oobabooga)** - run your own LLMs!
- **Flatten conversations** - conversations summarizer with 4 modes
- **Fork conversations** - create a new chat, to experiment with different endings
- **Fork conversations** - create a new chat, to try with different endings
- New commands: /s to add a System message, and /a for an Assistant message
- New Chat modes: Write-only - just appends the message, without assistant response
- Fix STOP generation - in sync with the Vercel team to fix a long-standing NextJS issue
+87
View File
@@ -0,0 +1,87 @@
# Browse Functionality in big-AGI 🌐
Allows users to load web pages across various components of `big-AGI`. This feature is supported by Puppeteer-based
browsing services, which are the most common way to render web pages in a headless environment.
Once configured, the Browsing service provides this functionality:
- **Paste a URL**: Simply paste/drag a URL into the chat, and `big-AGI` will load and attach the page (very effective)
- **Use /browse**: Type `/browse [URL]` in the chat to command `big-AGI` to load the specified web page
- **ReAct**: ReAct will automatically use the `loadURL()` function whenever a URL is encountered
First of all, you need to procure a Puppteer web browsing service endpoint. `big-AGI` supports services like:
| Service | Working | Type | Location | Special Features |
|--------------------------------------------------------------------------------------|---------|-------------|----------------|---------------------------------------------|
| [BrightData Scraping Browser](https://brightdata.com/products/scraping-browser) | Yes | Proprietary | Cloud | Advanced scraping tools, global IP pool |
| [Cloudflare Browser Rendering](https://developers.cloudflare.com/browser-rendering/) | ? | Proprietary | Cloud | Integrated CDN, optimized browser rendering |
| ⬇️ [Browserless 2.0](#-browserless-20) | Okay | OpenSource | Local (Docker) | Parallelism, debug viewer, advanced APIs |
| ⬇️ [Your Chrome Browser (ALPHA)](#-your-own-chrome-browser) | Alpha | Proprietary | Local (Chrome) | Personal, experimental use (ALPHA!) |
| other Puppeteer-based WSS Services | ? | Varied | Cloud/Local | Service-specific features |
## Configuration
1. **Procure an Endpoint**
- Ensure that your browsing service is running (remote or local) and has a WebSocket endpoint available
- Write down the address: `wss://${auth}@{some host}:{port}`, or ws:// for local services on your machine
2. **Configure `big-AGI`**
- navigate to **Preferences** > **Tools** > **Browse**
- Enter the 'wss://...' connection string provided by your browsing service
3. **Enable Features**: Choose which browse-related features you want to enable:
- **Attach URLs**: Automatically load and attach a page when pasting a URL into the composer
- **/browse Command**: Use the `/browse` command in the chat to load a web page
- **ReAct**: Enable the `loadURL()` function in ReAct for advanced interactions
### 🌐 Browserless 2.0
[Browserless 2.0](https://github.com/browserless/browserless) is a Docker-based service that provides a headless
browsing experience compatible with `big-AGI`. An open-source solution that simplifies web automation tasks,
in a scalable manner.
Launch Browserless with:
```bash
docker run -p 9222:3000 browserless/chrome:latest
```
Now you can use the following connection string in `big-AGI`: `ws://127.0.0.1:9222`.
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.
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
a WebSocket endpoint.
- close all the Chrome instances (on Windows, check the Task Manager if still running)
- start Chrome with the following command line options (on Windows, you can edit the shortcut properties):
- `--remote-debugging-port=9222`
- go to http://localhost:9222/json/version and copy the `webSocketDebuggerUrl` value
- it should be something like: `ws://localhost:9222/...`
- paste the value into the Endpoint configuration (see point 2 in the configuration)
### Server-Side Configuration
You can set the Puppeteer WebSocket endpoint (`PUPPETEER_WSS_ENDPOINT`) in the deployment before running it.
This is useful for self-hosted instances or when you want to pre-configure the endpoint for all users, and will
allow your to skip points 2 and 3 above.
Always deploy your own user authentication, authorization and security solution. For this feature, the tRPC
route that provides browsing service, shall be secured with a user authentication and authorization solution,
to prevent unauthorized access to the browsing service.
## Support
If you encounter any issues or have questions about configuring the browse functionality, join our community on Discord for support and discussions.
[![Official Discord](https://discordapp.com/api/guilds/1098796266906980422/widget.png?style=banner2)](https://discord.gg/MkH4qj2Jp9)
---
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
+17 -10
View File
@@ -4,7 +4,7 @@ Integrate local Large Language Models (LLMs) with
[oobabooga/text-generation-webui](https://github.com/oobabooga/text-generation-webui),
a specialized interface that includes a custom variant of the OpenAI API for a smooth integration process.
_Last updated on Nov 7, 2023_
_Last updated on Dec 7, 2023_
### Components
@@ -20,26 +20,31 @@ This guide assumes that **big-AGI** is already installed on your system. Note th
### Text-web-ui Installation & Configuration:
1. Install [text-generation-webui](https://github.com/oobabooga/text-generation-webui#Installation).
- Download the one-click installer, extract it, and double-click on "start" - ~10 minutes
- Close it afterwards as we need to modify the startup flags
1. Install [text-generation-webui](https://github.com/oobabooga/text-generation-webui#Installation):
- Follow the instructions in the official page (basicall clone the repo and run a script) [~10 minutes]
- Stop the Web UI as we need to modify the startup flags to enable the OpenAI API
2. Enable the **openai extension**
- Edit `CMD_FLAGS.txt`
- Make sure that `--listen --extensions openai` is present and uncommented
- Make sure that `--listen --api` is present and uncommented
3. Restart text-generation-webui
- Double-click on "start"
- You should see something like:
```
2023-11-07 21:24:26 INFO:Loading the extension "openai"...
2023-11-07 21:24:27 INFO:OpenAI compatible API URL:
2023-12-07 21:51:21 INFO:Loading the extension "openai"...
2023-12-07 21:51:21 INFO:OpenAI-compatible API URL:
http://0.0.0.0:5000/v1
http://0.0.0.0:5000
...
INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit)
Running on local URL: http://0.0.0.0:7860
```
- The OpenAI API is now running on port 5000, on both localhost (127.0.0.1) and your network IP address
- This shows that:
- The Web UI is running on port 7860: http://127.0.0.1:7860
- **The OpenAI API is running on port 5000: http://127.0.0.1:5000**
4. Load your first model
- Open the text-generation-webui at [127.0.0.1:7860](http://127.0.0.1:7860/)
- Switch to the **Model** tab
- Download, for instance, `TheBloke/Llama-2-7b-Chat-GPTQ:gptq-4bit-32g-actorder_True` - 4.3 GB
- Download, for instance, `TheBloke/Llama-2-7B-Chat-GPTQ`
- Select the model once it's loaded
### Integrating text-web-ui with big-AGI:
@@ -51,4 +56,6 @@ This guide assumes that **big-AGI** is already installed on your system. Note th
- The active model must be selected and LOADED on the text-generation-webui as it doesn't support model switching or parallel requests.
- Select model & Chat
![config-oobabooga-0.png](pixels/config-oobabooga-0.png)
Enjoy the privacy and flexibility of local LLMs with `big-AGI` and `text-generation-webui`!
+45
View File
@@ -0,0 +1,45 @@
# Authentication
`big-AGI` does not come with built-in authentication. To secure your deployment, you can implement authentication
in one of the following ways:
1. Build `big-AGI` with support for ⬇️ [HTTP Authentication](#http-authentication)
2. Utilize user authentication features provided by your ⬇️ [cloud deployment platform](#cloud-deployments-authentication)
3. Develop a custom authentication solution
<br/>
### HTTP Authentication
[HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) is a simple method
to secure your application.
To enable it in `big-AGI`, you **must manually build the application**:
- Build `big-AGI` with HTTP authentication enabled:
- Clone the repository
- Rename `middleware_BASIC_AUTH.ts` to `middleware.ts`
- Build: usual simple build procedure (e.g. [Deploy manually](../README.md#-deploy-manually) or [Deploying with Docker](deploy-docker.md))
- Configure the following [environment variables](environment-variables.md) before launching `big-AGI`:
```dotenv
HTTP_BASIC_AUTH_USERNAME=<your username>
HTTP_BASIC_AUTH_PASSWORD=<your password>
```
- Start the application 🔒
<br/>
### Cloud Deployments Authentication
> This approach allows you to enable authentication without rebuilding the application by using the features
> provided by your cloud platform to manage user accounts and access.
Many cloud deployment platforms offer built-in authentication mechanisms. Refer to the platform's documentation
for setup instructions:
1. [CloudFlare Access / Zero Trust](https://www.cloudflare.com/zero-trust/products/access/)
2. [Vercel Authentication](https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/vercel-authentication)
3. [Vercel Password Protection](https://vercel.com/docs/security/deployment-protection/methods-to-protect-deployments/password-protection)
4. Let us know when you test more solutions (Heroku, AWS IAM, Google IAP, etc.)
@@ -0,0 +1,31 @@
# This file is used to run `big-AGI` and `browserless` with Docker Compose.
#
# The two containers are linked together and `big-AGI` is configured to use `browserless`
# as its Puppeteer endpoint (from the containers intranet, it is available browserless:3000).
#
# From your host, you can access big-AGI on http://127.0.0.1:3000 and browserless on http://127.0.0.1:9222.
#
# To start the containers, run:
# docker-compose -f docs/docker/docker-compose-browserless.yaml up
version: '3.9'
services:
big-agi:
image: ghcr.io/enricoros/big-agi:main
ports:
- "3000:3000"
env_file:
- .env
environment:
- PUPPETEER_WSS_ENDPOINT=ws://browserless:3000
command: [ "next", "start", "-p", "3000" ]
depends_on:
- browserless
browserless:
image: browserless/chrome:latest
ports:
- "9222:3000" # Map host's port 9222 to container's port 3000
environment:
- MAX_CONCURRENT_SESSIONS=10
+26 -15
View File
@@ -9,10 +9,6 @@ which take place over _defaults_. This file is kept in sync with [`../src/server
Environment variables can be set by creating a `.env` file in the root directory of the project.
> For Docker deployment, ensure all necessary environment variables are set **both during build and run**.
> If the Docker container is built without setting environment variables, the frontend UI will be unaware
> of them, despite the backend being able to use them at runtime.
The following is an example `.env` for copy-paste convenience:
```bash
@@ -43,6 +39,15 @@ PRODIA_API_KEY=
# Google Custom Search
GOOGLE_CLOUD_API_KEY=
GOOGLE_CSE_ID=
# Browse
PUPPETEER_WSS_ENDPOINT=
# Backend Analytics
BACKEND_ANALYTICS=
# Backend HTTP Basic Authentication
HTTP_BASIC_AUTH_USERNAME=
HTTP_BASIC_AUTH_PASSWORD=
```
## Variables Documentation
@@ -93,17 +98,23 @@ It is currently supported for:
Enable the app to Talk, Draw, and Google things up.
| Variable | Description |
|:-------------------------|:------------------------------------------------------------------------------------------------------------------------|
| **Text-To-Speech** | [ElevenLabs](https://elevenlabs.io/) is a high quality speech synthesis service |
| `ELEVENLABS_API_KEY` | ElevenLabs API Key - used for calls, etc. |
| `ELEVENLABS_API_HOST` | Custom host for ElevenLabs |
| `ELEVENLABS_VOICE_ID` | Default voice ID for ElevenLabs |
| **Google Custom Search** | [Google Programmable Search Engine](https://programmablesearchengine.google.com/about/) produces links to pages |
| `GOOGLE_CLOUD_API_KEY` | Google Cloud API Key, used with the '/react' command - [Link to GCP](https://console.cloud.google.com/apis/credentials) |
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
| Variable | Description |
|:---------------------------|:------------------------------------------------------------------------------------------------------------------------|
| **Text-To-Speech** | [ElevenLabs](https://elevenlabs.io/) is a high quality speech synthesis service |
| `ELEVENLABS_API_KEY` | ElevenLabs API Key - used for calls, etc. |
| `ELEVENLABS_API_HOST` | Custom host for ElevenLabs |
| `ELEVENLABS_VOICE_ID` | Default voice ID for ElevenLabs |
| **Google Custom Search** | [Google Programmable Search Engine](https://programmablesearchengine.google.com/about/) produces links to pages |
| `GOOGLE_CLOUD_API_KEY` | Google Cloud API Key, used with the '/react' command - [Link to GCP](https://console.cloud.google.com/apis/credentials) |
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
| **Browse** | |
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing, etc. |
| **Backend** | |
| `BACKEND_ANALYTICS` | Semicolon-separated list of analytics flags (see backend.analytics.ts). Flags: `domain` logs the responding domain. |
| `HTTP_BASIC_AUTH_USERNAME` | Username for HTTP Basic Authentication. See the [Authentication](deploy-authentication.md) guide. |
| `HTTP_BASIC_AUTH_PASSWORD` | Password for HTTP Basic Authentication. |
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

+59
View File
@@ -0,0 +1,59 @@
/**
* Middleware to protect `big-AGI` with HTTP Basic Authentication
*
* For more information on how to deploy with HTTP Basic Authentication, see:
* - [deploy-authentication.md](docs/deploy-authentication.md)
*/
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
// noinspection JSUnusedGlobalSymbols
export function middleware(request: NextRequest) {
// Validate deployment configuration
if (!process.env.HTTP_BASIC_AUTH_USERNAME || !process.env.HTTP_BASIC_AUTH_PASSWORD) {
console.warn('HTTP Basic Authentication is enabled but not configured');
return new Response('Unauthorized/Unconfigured', unauthResponse);
}
// Request client authentication if no credentials are provided
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Basic '))
return new Response('Unauthorized', unauthResponse);
// Request authentication if credentials are invalid
const base64Credentials = authHeader.split(' ')[1];
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
const [username, password] = credentials.split(':');
if (
!username || !password ||
username !== process.env.HTTP_BASIC_AUTH_USERNAME ||
password !== process.env.HTTP_BASIC_AUTH_PASSWORD
)
return new Response('Unauthorized', unauthResponse);
return NextResponse.next();
}
// Response to send when authentication is required
const unauthResponse: ResponseInit = {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Secure big-AGI"',
},
};
export const config = {
matcher: [
// Include root
'/',
// Include pages
'/(call|index|news|personas|link)(.*)',
// Include API routes
'/api(.*)',
// Note: this excludes _next, /images etc..
],
};
+5
View File
@@ -9,6 +9,11 @@ let nextConfig = {
// },
// },
// [puppeteer] https://github.com/puppeteer/puppeteer/issues/11052
experimental: {
serverComponentsExternalPackages: ['puppeteer-core'],
},
webpack: (config, _options) => {
// @mui/joy: anything material gets redirected to Joy
config.resolve.alias['@mui/material'] = '@mui/joy';
+201 -95
View File
@@ -1,12 +1,12 @@
{
"name": "big-agi",
"version": "1.5.0",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "big-agi",
"version": "1.5.0",
"version": "1.7.0",
"hasInstallScript": true,
"dependencies": {
"@dqbd/tiktoken": "^1.0.7",
@@ -14,17 +14,17 @@
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.16",
"@mui/icons-material": "^5.14.18",
"@mui/joy": "^5.0.0-beta.15",
"@next/bundle-analyzer": "^14.0.3",
"@prisma/client": "^5.6.0",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.43.3",
"@trpc/next": "^10.43.3",
"@trpc/react-query": "^10.43.3",
"@trpc/server": "^10.43.3",
"@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",
"browser-fs-access": "^0.35.0",
"eventsource-parser": "^1.1.1",
@@ -36,7 +36,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.0",
"react-markdown": "^9.0.1",
"react-timeago": "^7.2.0",
"remark-gfm": "^4.0.0",
"superjson": "^2.2.1",
@@ -46,11 +46,12 @@
"zustand": "~4.3.9"
},
"devDependencies": {
"@types/node": "^20.9.2",
"@cloudflare/puppeteer": "^0.0.5",
"@types/node": "^20.10.0",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/react": "^18.2.38",
"@types/react-dom": "^18.2.17",
"@types/react-katex": "^3.0.3",
"@types/react-timeago": "^4.1.6",
"@types/uuid": "^9.0.7",
@@ -58,7 +59,7 @@
"eslint-config-next": "^14.0.3",
"prettier": "^3.1.0",
"prisma": "^5.6.0",
"typescript": "^5.2.2"
"typescript": "^5.3.2"
},
"engines": {
"node": "^20.0.0 || ^18.0.0"
@@ -74,11 +75,11 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.22.13",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
"integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==",
"dependencies": {
"@babel/highlight": "^7.22.13",
"@babel/highlight": "^7.23.4",
"chalk": "^2.4.2"
},
"engines": {
@@ -161,9 +162,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.22.5",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
"engines": {
"node": ">=6.9.0"
}
@@ -177,9 +178,9 @@
}
},
"node_modules/@babel/highlight": {
"version": "7.22.20",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
"dependencies": {
"@babel/helper-validator-identifier": "^7.22.20",
"chalk": "^2.4.2",
@@ -254,9 +255,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.23.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
"integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz",
"integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@@ -265,11 +266,11 @@
}
},
"node_modules/@babel/types": {
"version": "7.23.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz",
"integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==",
"version": "7.23.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz",
"integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==",
"dependencies": {
"@babel/helper-string-parser": "^7.22.5",
"@babel/helper-string-parser": "^7.23.4",
"@babel/helper-validator-identifier": "^7.22.20",
"to-fast-properties": "^2.0.0"
},
@@ -277,6 +278,23 @@
"node": ">=6.9.0"
}
},
"node_modules/@cloudflare/puppeteer": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@cloudflare/puppeteer/-/puppeteer-0.0.5.tgz",
"integrity": "sha512-K+DLUmDVSM5UNzFokSqie0LPIFAPvdkLKHWnx8Gmck/M41387aCyLlUjWIeUGV3QifSRwaxTRfeMpELQW0lDZg==",
"dev": true,
"dependencies": {
"debug": "4.3.4",
"devtools-protocol": "0.0.1019158",
"events": "3.3.0",
"stream": "0.0.2",
"url": "0.11.0",
"util": "0.12.5"
},
"engines": {
"node": ">=14.1.0"
}
},
"node_modules/@dqbd/tiktoken": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@dqbd/tiktoken/-/tiktoken-1.0.7.tgz",
@@ -1102,9 +1120,9 @@
"integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw=="
},
"node_modules/@rushstack/eslint-patch": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.5.1.tgz",
"integrity": "sha512-6i/8UoL0P5y4leBIGzvkZdS85RDMG9y1ihZzmTZQ5LdHUYmZ7pKFoj8X0236s3lusPs1Fa5HTQUpwI+UfTcmeA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz",
"integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==",
"dev": true
},
"node_modules/@sanity/diff-match-patch": {
@@ -1190,20 +1208,20 @@
}
},
"node_modules/@trpc/client": {
"version": "10.43.6",
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-10.43.6.tgz",
"integrity": "sha512-gQSxCQgPeBn/wqBEScu5Nq9UKqA16e965vWBj+BbdvI4URV72T44/yg0cl/E6xtBgycCVwdzwn7CuZaM8FA/VQ==",
"version": "10.44.1",
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-10.44.1.tgz",
"integrity": "sha512-vTWsykNcgz1LnwePVl2fKZnhvzP9N3GaaLYPkfGINo314ZOS0OBqe9x0ytB2LLUnRVTAAZ2WoONzARd8nHiqrA==",
"funding": [
"https://trpc.io/sponsor"
],
"peerDependencies": {
"@trpc/server": "10.43.6"
"@trpc/server": "10.44.1"
}
},
"node_modules/@trpc/next": {
"version": "10.43.6",
"resolved": "https://registry.npmjs.org/@trpc/next/-/next-10.43.6.tgz",
"integrity": "sha512-srV4twQKp8FohivGZ5wxNUdTzgPjjKeuNWG3Bpy5hVrIvg8VHDcglPMHS+3eWTUDDtCbhWpvANre+81B8b9Fgg==",
"version": "10.44.1",
"resolved": "https://registry.npmjs.org/@trpc/next/-/next-10.44.1.tgz",
"integrity": "sha512-ez2oYUzmaQ+pGch627sRBfeEk3h+UIwNicR8WjTAM54TPcdP5W9ZyWCyO5HZTEfjHgGixYM4tCIxewdKOWY9yA==",
"funding": [
"https://trpc.io/sponsor"
],
@@ -1212,33 +1230,33 @@
},
"peerDependencies": {
"@tanstack/react-query": "^4.18.0",
"@trpc/client": "10.43.6",
"@trpc/react-query": "10.43.6",
"@trpc/server": "10.43.6",
"@trpc/client": "10.44.1",
"@trpc/react-query": "10.44.1",
"@trpc/server": "10.44.1",
"next": "*",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@trpc/react-query": {
"version": "10.43.6",
"resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-10.43.6.tgz",
"integrity": "sha512-Twf0/wvcrDwmaJ6OLf0YVNwiv8+gtoSFyKYqe+5lMkFtUYSl+4KGvSqiN9ynbnofHCvuPgjJmjdS8pxYkcWxCw==",
"version": "10.44.1",
"resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-10.44.1.tgz",
"integrity": "sha512-Sgi/v0YtdunOXjBRi7om9gILGkOCFYXPzn5KqLuEHiZw5dr5w4qGHFwCeMAvndZxmwfblJrl1tk2AznmsVu8MA==",
"funding": [
"https://trpc.io/sponsor"
],
"peerDependencies": {
"@tanstack/react-query": "^4.18.0",
"@trpc/client": "10.43.6",
"@trpc/server": "10.43.6",
"@trpc/client": "10.44.1",
"@trpc/server": "10.44.1",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@trpc/server": {
"version": "10.43.6",
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-10.43.6.tgz",
"integrity": "sha512-ziN7UXGAycxe4i3FwJstTe6jzCcKBlPociCrC9XtfPzFpMTf0hNbRQQlFiZjJk23ZGQSVYDjk9RO4yIHt94mJg==",
"version": "10.44.1",
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-10.44.1.tgz",
"integrity": "sha512-mF7B+K6LjuboX8I1RZgKE5GA/fJhsJ8tKGK2UBt3Bwik7hepEPb4NJgNr7vO6BK5IYwPdBLRLTctRw6XZx0sRg==",
"funding": [
"https://trpc.io/sponsor"
],
@@ -1282,9 +1300,9 @@
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="
},
"node_modules/@types/node": {
"version": "20.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.2.tgz",
"integrity": "sha512-WHZXKFCEyIUJzAwh3NyyTHYSR35SevJ6mZ1nWwJafKtiQbqRTIKSRcw3Ma3acqgsent3RRDqeVwpHntMk+9irg==",
"version": "20.10.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -1311,14 +1329,14 @@
"dev": true
},
"node_modules/@types/prop-types": {
"version": "15.7.10",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.10.tgz",
"integrity": "sha512-mxSnDQxPqsZxmeShFH+uwQ4kO4gcJcGahjjMFeLbKE95IAZiiZyiEepGZjtXJ7hN/yfu0bu9xN2ajcU0JcxX6A=="
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
},
"node_modules/@types/react": {
"version": "18.2.37",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.37.tgz",
"integrity": "sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==",
"version": "18.2.38",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.38.tgz",
"integrity": "sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw==",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -1326,9 +1344,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "18.2.15",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.15.tgz",
"integrity": "sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg==",
"version": "18.2.17",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz",
"integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==",
"dev": true,
"dependencies": {
"@types/react": "*"
@@ -1362,9 +1380,9 @@
}
},
"node_modules/@types/scheduler": {
"version": "0.16.6",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.6.tgz",
"integrity": "sha512-Vlktnchmkylvc9SnwwwozTv04L/e1NykF5vgoQ0XTmI8DD+wxfjQuHuvHS3p0r2jz2x2ghPs2h1FVeDirIteWA=="
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
},
"node_modules/@types/unist": {
"version": "3.0.2",
@@ -1378,15 +1396,15 @@
"dev": true
},
"node_modules/@typescript-eslint/parser": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.11.0.tgz",
"integrity": "sha512-+whEdjk+d5do5nxfxx73oanLL9ghKO3EwM9kBCkUtWMRwWuPaFv9ScuqlYfQ6pAD6ZiJhky7TZ2ZYhrMsfMxVQ==",
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.12.0.tgz",
"integrity": "sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.11.0",
"@typescript-eslint/types": "6.11.0",
"@typescript-eslint/typescript-estree": "6.11.0",
"@typescript-eslint/visitor-keys": "6.11.0",
"@typescript-eslint/scope-manager": "6.12.0",
"@typescript-eslint/types": "6.12.0",
"@typescript-eslint/typescript-estree": "6.12.0",
"@typescript-eslint/visitor-keys": "6.12.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1406,13 +1424,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.11.0.tgz",
"integrity": "sha512-0A8KoVvIURG4uhxAdjSaxy8RdRE//HztaZdG8KiHLP8WOXSk0vlF7Pvogv+vlJA5Rnjj/wDcFENvDaHb+gKd1A==",
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.12.0.tgz",
"integrity": "sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.11.0",
"@typescript-eslint/visitor-keys": "6.11.0"
"@typescript-eslint/types": "6.12.0",
"@typescript-eslint/visitor-keys": "6.12.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -1423,9 +1441,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.11.0.tgz",
"integrity": "sha512-ZbEzuD4DwEJxwPqhv3QULlRj8KYTAnNsXxmfuUXFCxZmO6CF2gM/y+ugBSAQhrqaJL3M+oe4owdWunaHM6beqA==",
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.12.0.tgz",
"integrity": "sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -1436,13 +1454,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.11.0.tgz",
"integrity": "sha512-Aezzv1o2tWJwvZhedzvD5Yv7+Lpu1by/U1LZ5gLc4tCx8jUmuSCMioPFRjliN/6SJIvY6HpTtJIWubKuYYYesQ==",
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.12.0.tgz",
"integrity": "sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.11.0",
"@typescript-eslint/visitor-keys": "6.11.0",
"@typescript-eslint/types": "6.12.0",
"@typescript-eslint/visitor-keys": "6.12.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1463,12 +1481,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.11.0.tgz",
"integrity": "sha512-+SUN/W7WjBr05uRxPggJPSzyB8zUpaYo2hByKasWbqr3PM8AXfZt8UHdNpBS1v9SA62qnSSMF3380SwDqqprgQ==",
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.12.0.tgz",
"integrity": "sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.11.0",
"@typescript-eslint/types": "6.12.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@@ -1909,9 +1927,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001563",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001563.tgz",
"integrity": "sha512-na2WUmOxnwIZtwnFI2CZ/3er0wdNzU7hN+cPYz/z2ajHThnkWjNBOpEPP4n+4r2WPM847JaMotaJE3bnfzjyKw==",
"version": "1.0.30001564",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001564.tgz",
"integrity": "sha512-DqAOf+rhof+6GVx1y+xzbFPeOumfQnhYzVnZD6LAXijR77yPtm9mfOcqOnT3mpnJiZVT+kwLAFnRlZcIz+c6bg==",
"funding": [
{
"type": "opencollective",
@@ -2227,6 +2245,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1019158",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1019158.tgz",
"integrity": "sha512-wvq+KscQ7/6spEV7czhnZc9RM/woz1AY+/Vpd8/h2HFMwJSdTliu7f/yr1A6vDdJfKICZsShqsYpEQbdhg8AFQ==",
"dev": true
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -2306,6 +2330,15 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/emitter-component": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz",
"integrity": "sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -2869,6 +2902,15 @@
"node": ">=0.10.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"dev": true,
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/eventsource-parser": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.1.tgz",
@@ -3502,6 +3544,22 @@
"node": ">= 0.4"
}
},
"node_modules/is-arguments": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.2",
"has-tostringtag": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz",
@@ -5500,6 +5558,16 @@
"node": ">=6"
}
},
"node_modules/querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
"dev": true,
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6053,6 +6121,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stream": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz",
"integrity": "sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g==",
"dev": true,
"dependencies": {
"emitter-component": "^1.1.1"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -6496,9 +6573,9 @@
}
},
"node_modules/typescript": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
@@ -6619,6 +6696,22 @@
"punycode": "^2.1.0"
}
},
"node_modules/url": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
"integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
"dev": true,
"dependencies": {
"punycode": "1.3.2",
"querystring": "0.2.0"
}
},
"node_modules/url/node_modules/punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
"dev": true
},
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
@@ -6627,6 +6720,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"dev": true,
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+12 -11
View File
@@ -1,6 +1,6 @@
{
"name": "big-agi",
"version": "1.5.0",
"version": "1.7.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -18,17 +18,17 @@
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.16",
"@mui/icons-material": "^5.14.18",
"@mui/joy": "^5.0.0-beta.15",
"@next/bundle-analyzer": "^14.0.3",
"@prisma/client": "^5.6.0",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.7.1",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.43.3",
"@trpc/next": "^10.43.3",
"@trpc/react-query": "^10.43.3",
"@trpc/server": "^10.43.3",
"@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",
"browser-fs-access": "^0.35.0",
"eventsource-parser": "^1.1.1",
@@ -40,7 +40,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.0",
"react-markdown": "^9.0.1",
"react-timeago": "^7.2.0",
"remark-gfm": "^4.0.0",
"superjson": "^2.2.1",
@@ -50,11 +50,12 @@
"zustand": "~4.3.9"
},
"devDependencies": {
"@types/node": "^20.9.2",
"@cloudflare/puppeteer": "^0.0.5",
"@types/node": "^20.10.0",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/react": "^18.2.38",
"@types/react-dom": "^18.2.17",
"@types/react-katex": "^3.0.3",
"@types/react-timeago": "^4.1.6",
"@types/uuid": "^9.0.7",
@@ -62,7 +63,7 @@
"eslint-config-next": "^14.0.3",
"prettier": "^3.1.0",
"prisma": "^5.6.0",
"typescript": "^5.2.2"
"typescript": "^5.3.2"
},
"engines": {
"node": "^20.0.0 || ^18.0.0"
+6 -3
View File
@@ -11,6 +11,7 @@ import '~/common/styles/CodePrism.css';
import '~/common/styles/GithubMarkdown.css';
import { ProviderBackend } from '~/common/state/ProviderBackend';
import { ProviderSnacks } from '~/common/state/ProviderSnacks';
import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient';
import { ProviderTheming } from '~/common/state/ProviderTheming';
@@ -25,9 +26,11 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
<ProviderTheming emotionCache={emotionCache}>
<ProviderTRPCQueryClient>
<ProviderBackend>
<Component {...pageProps} />
</ProviderBackend>
<ProviderSnacks>
<ProviderBackend>
<Component {...pageProps} />
</ProviderBackend>
</ProviderSnacks>
</ProviderTRPCQueryClient>
</ProviderTheming>
-14
View File
@@ -1,14 +0,0 @@
import * as React from 'react';
import { AppLabs } from '../src/apps/labs/AppLabs';
import { AppLayout } from '~/common/layout/AppLayout';
export default function LabsPage() {
return (
<AppLayout suspendAutoModelsSetup>
<AppLabs />
</AppLayout>
);
}
@@ -4,11 +4,14 @@ import { useRouter } from 'next/router';
import { Alert, Box, Button, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { setComposerStartupText } from '../src/apps/chat/components/composer/store-composer';
import { setComposerStartupText } from '../../src/apps/chat/components/composer/store-composer';
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { AppLayout } from '~/common/layout/AppLayout';
import { LogoProgress } from '~/common/components/LogoProgress';
import { asValidURL } from '~/common/util/urlUtils';
import { navigateToIndex } from '~/common/app.routes';
/**
@@ -28,13 +31,13 @@ function AppShareTarget() {
const [isDownloading, setIsDownloading] = React.useState(false);
// external state
const { query, push: routerPush, replace: routerReplace } = useRouter();
const { query } = useRouter();
const queueComposerTextAndLaunchApp = React.useCallback((text: string) => {
setComposerStartupText(text);
void routerReplace('/');
}, [routerReplace]);
void navigateToIndex(true);
}, []);
// Detect the share Intent from the query
@@ -71,18 +74,15 @@ function AppShareTarget() {
React.useEffect(() => {
if (intentURL) {
setIsDownloading(true);
// TEMP: until the Browse module is ready, just use the URL, verbatim
queueComposerTextAndLaunchApp(intentURL);
setIsDownloading(false);
/*callBrowseFetchSinglePage(intentURL)
.then(pageContent => {
if (pageContent)
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + pageContent + '\n```\n');
callBrowseFetchPage(intentURL)
.then(page => {
if (page.stopReason !== 'error')
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + page.content + '\n```\n');
else
setErrorMessage('Could not read any data');
setErrorMessage('Could not read any data' + page.error ? ': ' + page.error : '');
})
.catch(error => setErrorMessage(error?.message || error || 'Unknown error'))
.finally(() => setIsDownloading(false));*/
.finally(() => setIsDownloading(false));
}
}, [intentURL, queueComposerTextAndLaunchApp]);
@@ -110,7 +110,7 @@ function AppShareTarget() {
</Alert>
<Button
variant='solid' color='danger'
onClick={() => routerPush('/')}
onClick={() => navigateToIndex()}
endDecorator={<ArrowBackIcon />}
sx={{ mt: 2 }}
>
@@ -130,7 +130,7 @@ function AppShareTarget() {
/**
* This page will be invoked on mobile when sharing Text/URLs/Files from other APPs
* Example URL: https://get.big-agi.com/launch?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
* Example URL: https://localhost:3000/link/share_target?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
*/
export default function LaunchPage() {
return (
+1 -1
View File
@@ -25,7 +25,7 @@
}
],
"share_target": {
"action": "/launch",
"action": "/link/share_target",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
-3
View File
@@ -10,9 +10,6 @@ import { CallUI } from './CallUI';
import { CallWizard } from './CallWizard';
export const APP_CALL_ENABLED = false;
export function AppCall() {
// external state
const { query } = useRouter();
+1 -1
View File
@@ -113,7 +113,7 @@ export function CallUI(props: {
setCallMessages(messages => [...messages, createDMessage('user', transcribed)]);
}
}, []);
const { isSpeechEnabled, isRecording, isRecordingAudio, isRecordingSpeech, startRecording, stopRecording, toggleRecording } = useSpeechRecognition(onSpeechResultCallback, 1000, false);
const { isSpeechEnabled, isRecording, isRecordingAudio, isRecordingSpeech, startRecording, stopRecording, toggleRecording } = useSpeechRecognition(onSpeechResultCallback, 1000);
// derived state
const isRinging = stage === 'ring';
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Chip, ColorPaletteProp, VariantProp } from '@mui/joy';
import { SxProps } from '@mui/system';
import { SxProps } from '@mui/joy/styles/types';
import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
+319 -135
View File
@@ -1,67 +1,123 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box } from '@mui/joy';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import { CmdRunBrowse } from '~/modules/browse/browse.client';
import { CmdRunProdia } from '~/modules/prodia/prodia.client';
import { CmdRunReact } from '~/modules/aifn/react/react';
import { DiagramConfig, DiagramsModal } from '~/modules/aifn/digrams/DiagramsModal';
import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal';
import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText';
import { useModelsStore } from '~/modules/llms/store-llms';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
import { useChatLLM, useModelsStore } from '~/modules/llms/store-llms';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useLayoutPluggable } from '~/common/layout/store-applayout';
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
import { openLayoutLLMOptions, useLayoutPluggable } from '~/common/layout/store-applayout';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { ChatDrawerItems } from './components/applayout/ChatDrawerItems';
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
import { ChatDrawerItemsMemo } from './components/applayout/ChatDrawerItems';
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
import { ChatMessageList } from './components/ChatMessageList';
import { ChatModeId } from './components/composer/store-composer';
import { CmdAddRoleMessage, extractCommands } from './commands';
import { CmdAddRoleMessage, CmdHelp, createCommandsHelpMessage, extractCommands } from './editors/commands';
import { Composer } from './components/composer/Composer';
import { Ephemerals } from './components/Ephemerals';
import { usePanesManager } from './components/usePanesManager';
import { TradeConfig, TradeModal } from './trade/TradeModal';
import { runAssistantUpdatingState } from './editors/chat-stream';
import { runBrowseUpdatingState } from './editors/browse-load';
import { runImageGenerationUpdatingState } from './editors/image-generate';
import { runReActUpdatingState } from './editors/react-tangent';
const SPECIAL_ID_ALL_CHATS = 'all-chats';
/**
* Mode: how to treat the input from the Composer
*/
export type ChatModeId = 'immediate' | 'write-user' | 'react' | 'draw-imagine' | 'draw-imagine-plus';
const SPECIAL_ID_WIPE_ALL: DConversationId = 'wipe-chats';
export function AppChat() {
// state
const [isMessageSelectionMode, setIsMessageSelectionMode] = React.useState(false);
const [diagramConfig, setDiagramConfig] = React.useState<DiagramConfig | null>(null);
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(null);
const [clearConfirmationId, setClearConfirmationId] = React.useState<string | null>(null);
const [deleteConfirmationId, setDeleteConfirmationId] = React.useState<string | null>(null);
const [flattenConversationId, setFlattenConversationId] = React.useState<string | null>(null);
const [clearConversationId, setClearConversationId] = React.useState<DConversationId | null>(null);
const [deleteConversationId, setDeleteConversationId] = React.useState<DConversationId | null>(null);
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
const showNextTitle = React.useRef(false);
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
// external state
const { activeConversationId, isConversationEmpty, hasAnyContent, newConversation, duplicateConversation, deleteAllConversations, setMessages, systemPurposeId, setAutoTitle } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === state.activeConversationId);
const isConversationEmpty = conversation ? !conversation.messages.length : true;
const hasAnyContent = state.conversations.length > 1 || !isConversationEmpty;
return {
activeConversationId: state.activeConversationId,
isConversationEmpty,
hasAnyContent,
newConversation: state.createConversationOrSwitch,
duplicateConversation: state.duplicateConversation,
deleteAllConversations: state.deleteAllConversations,
setMessages: state.setMessages,
systemPurposeId: conversation?.systemPurposeId ?? null,
setAutoTitle: state.setAutoTitle,
};
}, shallow);
const { chatLLM } = useChatLLM();
const {
chatPanes,
focusedConversationId,
navigateHistoryInFocusedPane,
openConversationInFocusedPane,
openConversationInSplitPane,
setFocusedPaneIndex,
} = usePanesManager();
const {
title: focusedChatTitle,
chatIdx: focusedChatNumber,
isChatEmpty: isFocusedChatEmpty,
areChatsEmpty,
newConversationId,
_remove_systemPurposeId: focusedSystemPurposeId,
prependNewConversation,
branchConversation,
deleteConversation,
wipeAllConversations,
setMessages,
} = useConversation(focusedConversationId);
const handleExecuteConversation = async (chatModeId: ChatModeId, conversationId: string, history: DMessage[]) => {
// Window actions
const chatPaneIDs = chatPanes.length > 0 ? chatPanes.map(pane => pane.conversationId) : [null];
const setActivePaneIndex = React.useCallback((idx: number) => {
setFocusedPaneIndex(idx);
}, [setFocusedPaneIndex]);
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
conversationId && openConversationInFocusedPane(conversationId);
}, [openConversationInFocusedPane]);
const openSplitConversationId = React.useCallback((conversationId: DConversationId | null) => {
conversationId && openConversationInSplitPane(conversationId);
}, [openConversationInSplitPane]);
const handleNavigateHistory = React.useCallback((direction: 'back' | 'forward') => {
if (navigateHistoryInFocusedPane(direction))
showNextTitle.current = true;
}, [navigateHistoryInFocusedPane]);
React.useEffect(() => {
if (showNextTitle.current) {
showNextTitle.current = false;
const title = (focusedChatNumber >= 0 ? `#${focusedChatNumber + 1} · ` : '') + (focusedChatTitle || 'New Chat');
const id = addSnackbar({ key: 'focused-title', message: title, type: 'title' });
return () => removeSnackbar(id);
}
}, [focusedChatNumber, focusedChatTitle]);
// Execution
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
const { chatLLMId } = useModelsStore.getState();
if (!chatModeId || !conversationId || !chatLLMId) return;
@@ -79,20 +135,27 @@ export function AppChat() {
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()]);
}
}
}
// synchronous long-duration tasks, which update the state as they go
if (chatLLMId && systemPurposeId) {
if (chatLLMId && focusedSystemPurposeId) {
switch (chatModeId) {
case 'immediate':
return await runAssistantUpdatingState(conversationId, history, chatLLMId, systemPurposeId);
return await runAssistantUpdatingState(conversationId, history, chatLLMId, focusedSystemPurposeId);
case 'write-user':
return setMessages(conversationId, history);
case 'react':
@@ -118,141 +181,256 @@ export function AppChat() {
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
console.log('handleExecuteConversation: issue running', chatModeId, conversationId, lastMessage);
setMessages(conversationId, history);
};
}, [focusedSystemPurposeId, setMessages]);
const _findConversation = (conversationId: string) =>
conversationId ? useChatStore.getState().conversations.find(c => c.id === conversationId) ?? null : null;
const handleComposerAction = (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
const handleExecuteChatHistory = async (conversationId: string, history: DMessage[]) =>
await handleExecuteConversation('immediate', conversationId, history);
const handleDiagramFromText = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
const handleImagineFromText = async (conversationId: string, messageText: string) => {
const conversation = _findConversation(conversationId);
if (conversation)
return await handleExecuteConversation('draw-imagine-plus', conversationId, [...conversation.messages, createDMessage('user', messageText)]);
};
const handleComposerNewMessage = async (chatModeId: ChatModeId, conversationId: string, userText: string) => {
const conversation = _findConversation(conversationId);
if (conversation)
return await handleExecuteConversation(chatModeId, conversationId, [...conversation.messages, createDMessage('user', userText)]);
};
const handleRegenerateAssistant = async () => {
const conversation = activeConversationId ? _findConversation(activeConversationId) : null;
if (conversation?.messages?.length) {
const lastMessage = conversation.messages[conversation.messages.length - 1];
if (lastMessage.role === 'assistant') {
const newMessages = [...conversation.messages];
newMessages.pop();
return await handleExecuteConversation('immediate', conversation.id, newMessages);
}
// validate inputs
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
addSnackbar({
key: 'chat-composer-action-invalid',
message: 'Only a single text part is supported for now.',
type: 'issue',
overrides: {
autoHideDuration: 2000,
},
});
return false;
}
const userText = multiPartMessage[0].text;
// find conversation
const conversation = getConversation(conversationId);
if (!conversation)
return false;
// start execution (async)
void _handleExecute(chatModeId, conversationId, [
...conversation.messages,
createDMessage('user', userText),
]);
return true;
};
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
await _handleExecute('immediate', conversationId, history);
const handleMessageRegenerateLast = React.useCallback(async () => {
const focusedConversation = getConversation(focusedConversationId);
if (focusedConversation?.messages?.length) {
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
return await _handleExecute('immediate', focusedConversation.id, lastMessage.role === 'assistant'
? focusedConversation.messages.slice(0, -1)
: [...focusedConversation.messages],
);
}
}, [focusedConversationId, _handleExecute]);
const handleTextDiagram = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
const handleTextImaginePlus = async (conversationId: DConversationId, messageText: string) => {
const conversation = getConversation(conversationId);
if (conversation)
return await _handleExecute('draw-imagine-plus', conversationId, [
...conversation.messages,
createDMessage('user', messageText),
]);
};
const handleTextSpeak = async (text: string) => {
await speakText(text);
};
useGlobalShortcut('r', true, true, false, handleRegenerateAssistant);
const handleImportConversation = () => setTradeConfig({ dir: 'import' });
// Chat actions
const handleExportConversation = (conversationId: string | null) => setTradeConfig({ dir: 'export', conversationId });
const handleFlattenConversation = (conversationId: string) => setFlattenConversationId(conversationId);
useGlobalShortcut('n', true, false, true, () => {
newConversation();
const handleConversationNew = React.useCallback(() => {
// activate an existing new conversation if present, or create another
setFocusedConversationId(newConversationId
? newConversationId
: prependNewConversation(focusedSystemPurposeId ?? undefined),
);
composerTextAreaRef.current?.focus();
});
}, [focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
const handleCloneConversation = (conversationId: string) => duplicateConversation(conversationId);
useGlobalShortcut('f', true, false, true, () => isConversationEmpty || activeConversationId && handleCloneConversation(activeConversationId));
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
const handleClearConversation = (conversationId: string) => setClearConfirmationId(conversationId);
useGlobalShortcut('x', true, false, true, () => isConversationEmpty || setClearConfirmationId(activeConversationId));
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
const handleConfirmedClearConversation = () => {
if (clearConfirmationId) {
setMessages(clearConfirmationId, []);
setAutoTitle(clearConfirmationId, '');
setClearConfirmationId(null);
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
showNextTitle.current = true;
const branchedConversationId = branchConversation(conversationId, messageId);
addSnackbar({
key: 'branch-conversation',
message: 'Branch started.',
type: 'success',
overrides: {
autoHideDuration: 3000,
startDecorator: <ForkRightIcon />,
},
});
const branchInAltPanel = useUXLabsStore.getState().labsSplitBranching;
if (branchInAltPanel)
openSplitConversationId(branchedConversationId);
else
setFocusedConversationId(branchedConversationId);
return branchedConversationId;
}, [branchConversation, openSplitConversationId, setFocusedConversationId]);
const handleConversationFlatten = (conversationId: DConversationId) => setFlattenConversationId(conversationId);
const handleConfirmedClearConversation = React.useCallback(() => {
if (clearConversationId) {
setMessages(clearConversationId, []);
setClearConversationId(null);
}
};
}, [clearConversationId, setMessages]);
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
const handleDeleteAllConversations = () => setDeleteConfirmationId(SPECIAL_ID_ALL_CHATS);
const handleConfirmedDeleteConversation = () => {
if (deleteConfirmationId) {
if (deleteConfirmationId === SPECIAL_ID_ALL_CHATS) {
deleteAllConversations();
}// else
// deleteConversation(deleteConfirmationId);
setDeleteConfirmationId(null);
if (deleteConversationId) {
let nextConversationId: DConversationId | null;
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined);
else
nextConversationId = deleteConversation(deleteConversationId);
setFocusedConversationId(nextConversationId);
setDeleteConversationId(null);
}
};
useGlobalShortcut('d', true, false, true, () => isConversationEmpty || setDeleteConfirmationId(activeConversationId));
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]);
// Shortcuts
const handleOpenChatLlmOptions = React.useCallback(() => {
const { chatLLMId } = useModelsStore.getState();
if (!chatLLMId) return;
openLayoutLLMOptions(chatLLMId);
}, []);
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
['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)],
['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]);
useGlobalShortcuts(shortcuts);
// Pluggable ApplicationBar components
const centerItems = React.useMemo(() =>
<ChatDropdowns conversationId={activeConversationId} />,
[activeConversationId],
<ChatDropdowns conversationId={focusedConversationId} />,
[focusedConversationId],
);
const drawerItems = React.useMemo(() =>
<ChatDrawerItems
conversationId={activeConversationId}
onImportConversation={handleImportConversation}
onDeleteAllConversations={handleDeleteAllConversations}
<ChatDrawerItemsMemo
activeConversationId={focusedConversationId}
disableNewButton={isFocusedChatEmpty}
onConversationActivate={setFocusedConversationId}
onConversationDelete={handleConversationDelete}
onConversationImportDialog={handleConversationImportDialog}
onConversationNew={handleConversationNew}
onConversationsDeleteAll={handleConversationsDeleteAll}
/>,
[activeConversationId],
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, setFocusedConversationId],
);
const menuItems = React.useMemo(() =>
<ChatMenuItems
conversationId={activeConversationId} isConversationEmpty={isConversationEmpty} hasConversations={hasAnyContent}
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
onClearConversation={handleClearConversation}
onDuplicateConversation={duplicateConversation}
onExportConversation={handleExportConversation}
onFlattenConversation={handleFlattenConversation}
conversationId={focusedConversationId}
hasConversations={!areChatsEmpty}
isConversationEmpty={isFocusedChatEmpty}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationClear={handleConversationClear}
onConversationExport={handleConversationExport}
onConversationFlatten={handleConversationFlatten}
/>,
[activeConversationId, duplicateConversation, hasAnyContent, isConversationEmpty, isMessageSelectionMode],
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
);
useLayoutPluggable(centerItems, drawerItems, menuItems);
return <>
<ChatMessageList
conversationId={activeConversationId}
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
onExecuteChatHistory={handleExecuteChatHistory}
onDiagramFromText={handleDiagramFromText}
onImagineFromText={handleImagineFromText}
sx={{
flexGrow: 1,
backgroundColor: 'background.level1',
overflowY: 'auto', // overflowY: 'hidden'
minHeight: 96,
}} />
<Box sx={{
flexGrow: 1,
display: 'flex', flexDirection: { xs: 'column', md: 'row' },
overflow: 'clip',
}}>
<Ephemerals
conversationId={activeConversationId}
sx={{
// flexGrow: 0.1,
flexShrink: 0.5,
overflowY: 'auto',
minHeight: 64,
}} />
{chatPaneIDs.map((_conversationId, idx) => (
<Box key={'chat-pane-' + idx} onClick={() => setActivePaneIndex(idx)} sx={{
flexGrow: 1, flexBasis: 1,
display: 'flex', flexDirection: 'column',
overflow: 'clip',
}}>
<ChatMessageList
conversationId={_conversationId}
chatLLMContextTokens={chatLLM?.contextTokens}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImaginePlus}
onTextSpeak={handleTextSpeak}
sx={{
flexGrow: 1,
backgroundColor: 'background.level1',
overflowY: 'auto',
minHeight: 96,
// outline the current focused pane
...(chatPaneIDs.length < 2 ? {}
: (_conversationId === focusedConversationId)
? {
border: '2px solid',
borderColor: 'primary.solidBg',
} : {
padding: '2px',
}),
}}
/>
<Ephemerals
conversationId={_conversationId}
sx={{
// flexGrow: 0.1,
flexShrink: 0.5,
overflowY: 'auto',
minHeight: 64,
}} />
</Box>
))}
</Box>
<Composer
conversationId={activeConversationId} messageId={null}
isDeveloperMode={systemPurposeId === 'Developer'}
chatLLM={chatLLM}
composerTextAreaRef={composerTextAreaRef}
onNewMessage={handleComposerNewMessage}
conversationId={focusedConversationId}
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
onAction={handleComposerAction}
sx={{
zIndex: 21, // position: 'sticky', bottom: 0,
backgroundColor: 'background.surface',
@@ -266,25 +444,31 @@ export function AppChat() {
{!!diagramConfig && <DiagramsModal config={diagramConfig} onClose={() => setDiagramConfig(null)} />}
{/* Flatten */}
{!!flattenConversationId && <FlattenerModal conversationId={flattenConversationId} onClose={() => setFlattenConversationId(null)} />}
{!!flattenConversationId && (
<FlattenerModal
conversationId={flattenConversationId}
onConversationBranch={handleConversationBranch}
onClose={() => setFlattenConversationId(null)}
/>
)}
{/* Import / Export */}
{!!tradeConfig && <TradeModal config={tradeConfig} onClose={() => setTradeConfig(null)} />}
{!!tradeConfig && <TradeModal config={tradeConfig} onConversationActivate={setFocusedConversationId} onClose={() => setTradeConfig(null)} />}
{/* [confirmation] Reset Conversation */}
{!!clearConfirmationId && <ConfirmationModal
open onClose={() => setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation}
confirmationText={'Are you sure you want to discard all the 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 */}
{!!deleteConfirmationId && <ConfirmationModal
open onClose={() => setDeleteConfirmationId(null)} onPositive={handleConfirmedDeleteConversation}
confirmationText={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
{!!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 sure you want to delete this conversation?'}
positiveActionText={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
? 'Yes, delete all'
: 'Delete conversation'}
/>}
+94 -73
View File
@@ -4,16 +4,15 @@ import { shallow } from 'zustand/shallow';
import { Box, List } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import { useChatLLM } from '~/modules/llms/store-llms';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import { GlobalShortcut, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { InlineError } from '~/common/components/InlineError';
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
import { openLayoutPreferences } from '~/common/layout/store-applayout';
import { useCapabilityElevenLabs, useCapabilityProdia } from '~/common/components/useCapabilities';
import { ChatMessage } from './message/ChatMessage';
import { ChatMessageMemo } from './message/ChatMessage';
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
import { PersonaSelector } from './persona-selector/PersonaSelector';
import { useChatShowSystemMessages } from '../store-app-chat';
@@ -23,12 +22,15 @@ import { useChatShowSystemMessages } from '../store-app-chat';
* A list of ChatMessages
*/
export function ChatMessageList(props: {
conversationId: string | null,
conversationId: DConversationId | null,
chatLLMContextTokens?: number,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onExecuteChatHistory: (conversationId: string, history: DMessage[]) => void,
onDiagramFromText: (diagramConfig: DiagramConfig | null) => Promise<any>,
onImagineFromText: (conversationId: string, selectedText: string) => Promise<any>,
sx?: SxProps
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>,
sx?: SxProps,
}) {
// state
@@ -38,65 +40,80 @@ export function ChatMessageList(props: {
// external state
const [showSystemMessages] = useChatShowSystemMessages();
const { messages, editMessage, deleteMessage, historyTokenCount } = useChatStore(state => {
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
messages: conversation ? conversation.messages : [],
editMessage: state.editMessage, deleteMessage: state.deleteMessage,
conversationMessages: conversation ? conversation.messages : [],
historyTokenCount: conversation ? conversation.tokenCount : 0,
deleteMessage: state.deleteMessage,
editMessage: state.editMessage,
setMessages: state.setMessages,
};
}, shallow);
const { chatLLM } = useChatLLM();
const { mayWork: isImaginable } = useCapabilityProdia();
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
// derived state
const { conversationId, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
// text actions
const handleAppendMessage = (text: string) =>
props.conversationId && props.onExecuteChatHistory(props.conversationId, [...messages, createDMessage('user', text)]);
const handleTextDiagram = async (messageId: string, text: string) => {
if (props.conversationId) {
await props.onDiagramFromText({ conversationId: props.conversationId, messageId, text });
} else
return Promise.reject('No conversation');
};
const handleTextImagine = async (text: string) => {
if (!isImaginable) {
openLayoutPreferences(2);
} else if (props.conversationId) {
setIsImagining(true);
await props.onImagineFromText(props.conversationId, text);
setIsImagining(false);
} else
return Promise.reject('No conversation');
};
const handleTextSpeak = async (text: string) => {
if (!isSpeakable) {
openLayoutPreferences(3);
} else {
setIsSpeaking(true);
await speakText(text);
setIsSpeaking(false);
}
};
const handleRunExample = (text: string) =>
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
// message menu methods proxy
const handleMessageDelete = (messageId: string) =>
props.conversationId && deleteMessage(props.conversationId, messageId);
const handleConversationBranch = React.useCallback((messageId: string) => {
conversationId && onConversationBranch(conversationId, messageId);
}, [conversationId, onConversationBranch]);
const handleMessageEdit = (messageId: string, newText: string) =>
props.conversationId && editMessage(props.conversationId, messageId, { text: newText }, true);
const handleConversationRestartFrom = React.useCallback((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, onConversationExecuteHistory]);
const handleMessageRestartFrom = (messageId: string, offset: number) => {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
props.conversationId && props.onExecuteChatHistory(props.conversationId, truncatedHistory);
};
const handleConversationTruncate = React.useCallback((messageId: string) => {
const messages = getConversation(conversationId)?.messages;
if (conversationId && messages) {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + 1);
setMessages(conversationId, truncatedHistory);
}
}, [conversationId, setMessages]);
const handleMessageDelete = React.useCallback((messageId: string) => {
conversationId && deleteMessage(conversationId, messageId);
}, [conversationId, deleteMessage]);
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
}, [conversationId, editMessage]);
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
conversationId && await onTextDiagram({ conversationId: conversationId, messageId, text });
}, [conversationId, onTextDiagram]);
const handleTextImagine = React.useCallback(async (text: string) => {
if (!isImaginable)
return openLayoutPreferences(2);
if (conversationId) {
setIsImagining(true);
await onTextImagine(conversationId, text);
setIsImagining(false);
}
}, [conversationId, isImaginable, onTextImagine]);
const handleTextSpeak = React.useCallback(async (text: string) => {
if (!isSpeakable)
return openLayoutPreferences(3);
setIsSpeaking(true);
await onTextSpeak(text);
setIsSpeaking(false);
}, [isSpeakable, onTextSpeak]);
// operate on the local selection set
@@ -104,7 +121,7 @@ export function ChatMessageList(props: {
const handleSelectAll = (selected: boolean) => {
const newSelected = new Set<string>();
if (selected)
for (const message of messages)
for (const message of conversationMessages)
newSelected.add(message.id);
setSelectedMessages(newSelected);
};
@@ -116,13 +133,13 @@ export function ChatMessageList(props: {
};
const handleSelectionDelete = () => {
if (props.conversationId)
if (conversationId)
for (const selectedMessage of selectedMessages)
deleteMessage(props.conversationId, selectedMessage);
deleteMessage(conversationId, selectedMessage);
setSelectedMessages(new Set());
};
useGlobalShortcut(props.isMessageSelectionMode && GlobalShortcut.Esc, false, false, false, () => {
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
props.setIsMessageSelectionMode(false);
});
@@ -130,7 +147,7 @@ export function ChatMessageList(props: {
// text-diff functionality, find the messages to diff with
const { diffMessage, diffText } = React.useMemo(() => {
const [msgB, msgA] = messages.filter(m => m.role === 'assistant').reverse();
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
if (msgB?.text && msgA?.text && !msgB?.typing) {
const textA = msgA.text, textB = msgB.text;
const lenA = textA.length, lenB = textB.length;
@@ -138,21 +155,22 @@ export function ChatMessageList(props: {
return { diffMessage: msgB, diffText: textA };
}
return { diffMessage: undefined, diffText: undefined };
}, [messages]);
}, [conversationMessages]);
// no content: show the persona selector
const filteredMessages = messages
const filteredMessages = conversationMessages
.filter(m => m.role !== 'system' || showSystemMessages) // hide the System message if the user choses to
.reverse(); // 'reverse' is because flexDirection: 'column-reverse' to auto-snap-to-bottom
// when there are no messages, show the purpose selector
if (!filteredMessages.length)
return props.conversationId ? (
<Box sx={props.sx || {}}>
<PersonaSelector conversationId={props.conversationId} runExample={handleAppendMessage} />
return (
<Box sx={{ ...props.sx }}>
{conversationId
? <PersonaSelector conversationId={conversationId} runExample={handleRunExample} />
: <InlineError severity='info' error='Select a conversation' sx={{ m: 2 }} />}
</Box>
) : null;
);
return (
<List sx={{
@@ -160,7 +178,7 @@ export function ChatMessageList(props: {
// this makes sure that the the window is scrolled to the bottom (column-reverse)
display: 'flex', flexDirection: 'column-reverse',
// fix for the double-border on the last message (one by the composer, one to the bottom of the message)
marginBottom: '-1px',
// marginBottom: '-1px',
}}>
{filteredMessages.map((message, idx) =>
@@ -169,23 +187,26 @@ export function ChatMessageList(props: {
<CleanerMessage
key={'sel-' + message.id}
message={message}
isBottom={idx === 0} remainingTokens={(chatLLM ? chatLLM.contextTokens : 0) - historyTokenCount}
isBottom={idx === 0} remainingTokens={(props.chatLLMContextTokens || 0) - historyTokenCount}
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
/>
) : (
<ChatMessage
<ChatMessageMemo
key={'msg-' + message.id}
message={message}
diffPreviousText={message === diffMessage ? diffText : undefined}
isBottom={idx === 0}
isImagining={isImagining} isSpeaking={isSpeaking}
onMessageDelete={() => handleMessageDelete(message.id)}
onMessageEdit={newText => handleMessageEdit(message.id, newText)}
onMessageRunFrom={(offset: number) => handleMessageRestartFrom(message.id, offset)}
onTextDiagram={(text: string) => handleTextDiagram(message.id, text)}
onTextImagine={handleTextImagine} onTextSpeak={handleTextSpeak}
onConversationBranch={handleConversationBranch}
onConversationRestartFrom={handleConversationRestartFrom}
onConversationTruncate={handleConversationTruncate}
onMessageDelete={handleMessageDelete}
onMessageEdit={handleMessageEdit}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
/>
),
+2 -2
View File
@@ -5,7 +5,7 @@ import { Box, Grid, IconButton, Sheet, Stack, styled, Typography, useTheme } fro
import { SxProps } from '@mui/joy/styles/types';
import CloseIcon from '@mui/icons-material/Close';
import { DEphemeral, useChatStore } from '~/common/state/store-chats';
import { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
const StateLine = styled(Typography)(({ theme }) => ({
@@ -124,7 +124,7 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
}
export function Ephemerals(props: { conversationId: string | null, sx?: SxProps }) {
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
// global state
const theme = useTheme();
const ephemerals = useChatStore(state => {
@@ -6,63 +6,64 @@ 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 { closeLayoutDrawer } from '~/common/layout/store-applayout';
import { useChatStore } from '~/common/state/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { ConversationItem } from './ConversationItem';
import { ChatNavigationItemMemo } from './ChatNavigationItem';
type ListGrouping = 'off' | 'persona';
// type ListGrouping = 'off' | 'persona';
export function ChatDrawerItems(props: {
conversationId: string | null
onDeleteAllConversations: () => void,
onImportConversation: () => void,
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 [grouping] = React.useState<ListGrouping>('off');
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
// const [grouping] = React.useState<ListGrouping>('off');
// external state
const { conversationIDs, topNewConversationId, maxChatMessages, setActiveConversationId, createConversationOrSwitch, deleteConversation } = useChatStore(state => ({
conversationIDs: state.conversations.map(conversation => conversation.id),
topNewConversationId: state.conversations.length ? state.conversations[0].messages.length === 0 ? state.conversations[0].id : null : null,
maxChatMessages: state.conversations.reduce((longest, conversation) => Math.max(longest, conversation.messages.length), 0),
setActiveConversationId: state.setActiveConversationId,
createConversationOrSwitch: state.createConversationOrSwitch,
deleteConversation: state.deleteConversation,
}), shallow);
const { experimentalLabs, showSymbols } = useUIPreferencesStore(state => ({
experimentalLabs: state.experimentalLabs,
showSymbols: state.zenMode !== 'cleaner',
}), shallow);
const conversations = useChatStore(state => state.conversations, shallow);
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
const totalConversations = conversationIDs.length;
// 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 handleNew = () => {
createConversationOrSwitch();
closeLayoutDrawer();
};
const handleConversationActivate = React.useCallback((conversationId: string, closeMenu: boolean) => {
setActiveConversationId(conversationId);
const handleButtonNew = React.useCallback(() => {
onConversationNew();
closeLayoutDrawer();
}, [onConversationNew]);
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
onConversationActivate(conversationId);
if (closeMenu)
closeLayoutDrawer();
}, [setActiveConversationId]);
}, [onConversationActivate]);
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
!singleChat && conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete, singleChat]);
const handleConversationDelete = React.useCallback((conversationId: string) => {
if (!singleChat && conversationId)
deleteConversation(conversationId);
}, [deleteConversation, singleChat]);
// grouping
let sortedIds = conversationIDs;
/*let sortedIds = conversationIDs;
if (grouping === 'persona') {
const conversations = useChatStore.getState().conversations;
@@ -79,7 +80,7 @@ export function ChatDrawerItems(props: {
// flatten grouped conversations
sortedIds = Object.values(groupedConversations).flat();
}
}*/
return <>
@@ -89,7 +90,7 @@ export function ChatDrawerItems(props: {
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem disabled={!!topNewConversationId && topNewConversationId === props.conversationId} onClick={handleNew}>
<MenuItem disabled={props.disableNewButton} onClick={handleButtonNew}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
New
@@ -114,22 +115,22 @@ export function ChatDrawerItems(props: {
{/* </ToggleButtonGroup>*/}
{/*</ListItem>*/}
{sortedIds.map(conversationId =>
<ConversationItem
key={'c-id-' + conversationId}
conversationId={conversationId}
isActive={conversationId === props.conversationId}
isSingle={singleChat}
{conversations.map(conversation =>
<ChatNavigationItemMemo
key={'nav-' + conversation.id}
conversation={conversation}
isActive={conversation.id === props.activeConversationId}
isLonely={singleChat}
maxChatMessages={(labsEnhancedUI || softMaxReached) ? maxChatMessages : 0}
showSymbols={showSymbols}
maxChatMessages={(experimentalLabs || softMaxReached) ? maxChatMessages : 0}
conversationActivate={handleConversationActivate}
conversationDelete={handleConversationDelete}
onConversationActivate={handleConversationActivate}
onConversationDelete={handleConversationDelete}
/>)}
</Box>
<ListDivider sx={{ mt: 0 }} />
<MenuItem onClick={props.onImportConversation}>
<MenuItem onClick={props.onConversationImportDialog}>
<ListItemDecorator>
<FileUploadIcon />
</ListItemDecorator>
@@ -137,24 +138,12 @@ export function ChatDrawerItems(props: {
<OpenAIIcon sx={{ fontSize: 'xl', ml: 'auto' }} />
</MenuItem>
<MenuItem disabled={!hasChats} onClick={props.onDeleteAllConversations}>
<MenuItem disabled={!hasChats} onClick={props.onConversationsDeleteAll}>
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
<Typography>
Delete {totalConversations >= 2 ? `all ${totalConversations} chats` : 'chat'}
</Typography>
</MenuItem>
{/*<ListItem>*/}
{/* <Typography level='body-sm'>*/}
{/* Scratchpad*/}
{/* </Typography>*/}
{/*</ListItem>*/}
{/*<MenuItem>*/}
{/* <ListItemDecorator />*/}
{/* <Typography sx={{ opacity: 0.5 }}>*/}
{/* Feature <Link href={`${Brand.URIs.OpenRepo}/issues/17`} target='_blank'>#17</Link>*/}
{/* </Typography>*/}
{/*</MenuItem>*/}
</>;
}
@@ -1,11 +1,13 @@
import * as React from 'react';
import type { DConversationId } from '~/common/state/store-chats';
import { useChatLLMDropdown } from './useLLMDropdown';
import { usePersonaIdDropdown } from './usePersonaDropdown';
export function ChatDropdowns(props: {
conversationId: string | null
conversationId: DConversationId | null
}) {
// state
@@ -9,6 +9,7 @@ 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 { closeLayoutMenu } from '~/common/layout/store-applayout';
import { useUICounter } from '~/common/state/store-ui';
@@ -17,12 +18,15 @@ import { useChatShowSystemMessages } from '../../store-app-chat';
export function ChatMenuItems(props: {
conversationId: string | null, isConversationEmpty: boolean, hasConversations: boolean,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onClearConversation: (conversationId: string) => void,
onDuplicateConversation: (conversationId: string) => void,
onExportConversation: (conversationId: string | null) => void,
onFlattenConversation: (conversationId: string) => void,
conversationId: DConversationId | null,
hasConversations: boolean,
isConversationEmpty: boolean,
isMessageSelectionMode: boolean,
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
@@ -32,37 +36,40 @@ export function ChatMenuItems(props: {
// derived state
const disabled = !props.conversationId || props.isConversationEmpty;
const handleSystemMessagesToggle = () => setShowSystemMessages(!showSystemMessages);
const handleConversationExport = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
const closeMenu = (event: React.MouseEvent) => {
event.stopPropagation();
closeLayoutMenu();
props.onExportConversation(!disabled ? props.conversationId : null);
};
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationClear(props.conversationId);
};
const handleConversationBranch = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationBranch(props.conversationId, null);
};
const handleConversationExport = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.onConversationExport(!disabled ? props.conversationId : null);
shareTouch();
};
const handleConversationDuplicate = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
closeLayoutMenu();
props.conversationId && props.onDuplicateConversation(props.conversationId);
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationFlatten(props.conversationId);
};
const handleConversationFlatten = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
closeLayoutMenu();
props.conversationId && props.onFlattenConversation(props.conversationId);
};
const handleToggleMessageSelectionMode = (e: React.MouseEvent) => {
e.stopPropagation();
closeLayoutMenu();
const handleToggleMessageSelectionMode = (event: React.MouseEvent) => {
closeMenu(event);
props.setIsMessageSelectionMode(!props.isMessageSelectionMode);
};
const handleConversationClear = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
props.conversationId && props.onClearConversation(props.conversationId);
};
const handleToggleSystemMessages = () => setShowSystemMessages(!showSystemMessages);
return <>
@@ -72,29 +79,21 @@ export function ChatMenuItems(props: {
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem onClick={handleSystemMessagesToggle}>
<MenuItem onClick={handleToggleSystemMessages}>
<ListItemDecorator><SettingsSuggestIcon /></ListItemDecorator>
System message
<Switch checked={showSystemMessages} onChange={handleSystemMessagesToggle} sx={{ ml: 'auto' }} />
<Switch checked={showSystemMessages} onChange={handleToggleSystemMessages} sx={{ ml: 'auto' }} />
</MenuItem>
<ListDivider inset='startContent' />
<MenuItem disabled={disabled} onClick={handleConversationDuplicate}>
<ListItemDecorator>
{/*<Badge size='sm' color='success'>*/}
<ForkRightIcon color='success' />
{/*</Badge>*/}
</ListItemDecorator>
Duplicate
<MenuItem disabled={disabled} onClick={handleConversationBranch}>
<ListItemDecorator><ForkRightIcon /></ListItemDecorator>
Branch
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationFlatten}>
<ListItemDecorator>
{/*<Badge size='sm' color='success'>*/}
<CompressIcon color='success' />
{/*</Badge>*/}
</ListItemDecorator>
<ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>
Flatten
</MenuItem>
@@ -1,5 +1,4 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Avatar, Box, IconButton, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
@@ -9,96 +8,100 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { SystemPurposes } from '../../../../data';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { conversationTitle, useChatStore } from '~/common/state/store-chats';
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
const DEBUG_CONVERSATION_IDs = false;
export function ConversationItem(props: {
conversationId: string,
isActive: boolean, isSingle: boolean, showSymbols: boolean, maxChatMessages: number,
conversationActivate: (conversationId: string, closeMenu: boolean) => void,
conversationDelete: (conversationId: string) => void,
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);
// bind to conversation
const cState = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return conversation && {
isNew: conversation.messages.length === 0,
messageCount: conversation.messages.length,
assistantTyping: !!conversation.abortController,
systemPurposeId: conversation.systemPurposeId,
title: conversationTitle(conversation, 'new conversation'),
setUserTitle: state.setUserTitle,
};
}, shallow);
// 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 && !props.isActive)
if (deleteArmed && !isActive)
setDeleteArmed(false);
}, [deleteArmed, props.isActive]);
}, [deleteArmed, isActive]);
// sanity check: shouldn't happen, but just in case
if (!cState) return null;
const { isNew, messageCount, assistantTyping, setUserTitle, systemPurposeId, title } = cState;
const handleActivate = () => props.conversationActivate(props.conversationId, true);
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
const handleEditBegin = () => setIsEditingTitle(true);
const handleTitleEdit = () => setIsEditingTitle(true);
const handleEdited = (text: string) => {
const handleTitleEdited = (text: string) => {
setIsEditingTitle(false);
setUserTitle(props.conversationId, text);
useChatStore.getState().setUserTitle(conversationId, text);
};
const handleDeleteBegin = (e: React.MouseEvent) => {
e.stopPropagation();
if (!props.isActive)
props.conversationActivate(props.conversationId, false);
const handleDeleteButtonShow = (event: React.MouseEvent) => {
event.stopPropagation();
if (!isActive)
props.onConversationActivate(conversationId, false);
else
setDeleteArmed(true);
};
const handleDeleteConfirm = (e: React.MouseEvent) => {
const handleDeleteButtonHide = () => setDeleteArmed(false);
const handleConversationDelete = (event: React.MouseEvent) => {
if (deleteArmed) {
setDeleteArmed(false);
e.stopPropagation();
props.conversationDelete(props.conversationId);
event.stopPropagation();
props.onConversationDelete(conversationId);
}
};
const handleDeleteCancel = () => setDeleteArmed(false);
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
const buttonSx: SxProps = { ml: 1, ...(props.isActive ? { color: 'white' } : {}) };
const buttonSx: SxProps = { ml: 1, ...(isActive ? { color: 'white' } : {}) };
const progress = props.maxChatMessages ? 100 * messageCount / props.maxChatMessages : 0;
return (
<MenuItem
variant={props.isActive ? 'solid' : 'plain'} color='neutral'
selected={props.isActive}
onClick={handleActivate}
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 prgoress bar */}
{/* Optional progress bar, underlay */}
{progress > 0 && (
<Box sx={{
backgroundColor: 'neutral.softActiveBg',
@@ -129,13 +132,13 @@ export function ConversationItem(props: {
{/* Text */}
{!isEditingTitle ? (
<Box onDoubleClick={() => doubleClickToEdit ? handleEditBegin() : null} sx={{ flexGrow: 1 }}>
{DEBUG_CONVERSATION_IDs ? props.conversationId.slice(0, 10) : title}{assistantTyping && '...'}
<Box onDoubleClick={() => doubleClickToEdit ? handleTitleEdit() : null} sx={{ flexGrow: 1 }}>
{DEBUG_CONVERSATION_IDs ? conversationId.slice(0, 10) : title}{assistantTyping && '...'}
</Box>
) : (
<InlineTextarea initialText={title} onEdit={handleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
<InlineTextarea initialText={title} onEdit={handleTitleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
)}
@@ -151,21 +154,21 @@ export function ConversationItem(props: {
{/*</IconButton>*/}
{/* Delete Arming */}
{!props.isSingle && !deleteArmed && (
{!props.isLonely && !deleteArmed && (
<IconButton
variant={props.isActive ? 'solid' : 'outlined'} color='neutral'
variant={isActive ? 'solid' : 'outlined'} color='neutral'
size='sm' sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.3s', ...buttonSx }}
onClick={handleDeleteBegin}>
onClick={handleDeleteButtonShow}>
<DeleteOutlineIcon />
</IconButton>
)}
{/* Delete / Cancel buttons */}
{!props.isSingle && deleteArmed && <>
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleDeleteConfirm}>
{!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={handleDeleteCancel}>
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteButtonHide}>
<CloseIcon />
</IconButton>
</>}
@@ -14,8 +14,8 @@ import { openLayoutLLMOptions, openLayoutModelsSetup } from '~/common/layout/sto
function AppBarLLMDropdown(props: {
llms: DLLM[],
llmId: DLLMId | null,
setLlmId: (llmId: DLLMId | null) => void,
chatLlmId: DLLMId | null,
setChatLlmId: (llmId: DLLMId | null) => void,
placeholder?: string,
}) {
@@ -23,7 +23,7 @@ function AppBarLLMDropdown(props: {
const llmItems: DropdownItems = {};
let prevSourceId: DModelSourceId | null = null;
for (const llm of props.llms) {
if (!llm.hidden || llm.id === props.llmId) {
if (!llm.hidden || llm.id === props.chatLlmId) {
if (!prevSourceId || llm.sId !== prevSourceId) {
if (prevSourceId)
llmItems[`sep-${llm.id}`] = { type: 'separator', title: llm.sId };
@@ -33,22 +33,25 @@ function AppBarLLMDropdown(props: {
}
}
const handleChatLLMChange = (_event: any, value: DLLMId | null) => value && props.setLlmId(value);
const handleChatLLMChange = (_event: any, value: DLLMId | null) => value && props.setChatLlmId(value);
const handleOpenLLMOptions = () => props.llmId && openLayoutLLMOptions(props.llmId);
const handleOpenLLMOptions = () => props.chatLlmId && openLayoutLLMOptions(props.chatLlmId);
return (
<AppBarDropdown
items={llmItems}
value={props.llmId} onChange={handleChatLLMChange}
value={props.chatLlmId} onChange={handleChatLLMChange}
placeholder={props.placeholder || 'Models …'}
appendOption={<>
{props.llmId && (
{props.chatLlmId && (
<ListItemButton key='menu-opt' onClick={handleOpenLLMOptions}>
<ListItemDecorator><SettingsIcon color='success' /></ListItemDecorator>
Options
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Options
<KeyStroke combo='Ctrl + Shift + O' />
</Box>
</ListItemButton>
)}
@@ -74,7 +77,7 @@ export function useChatLLMDropdown() {
}), shallow);
const chatLLMDropdown = React.useMemo(
() => <AppBarLLMDropdown llms={llms} llmId={chatLLMId} setLlmId={setChatLLMId} />,
() => <AppBarLLMDropdown llms={llms} chatLlmId={chatLLMId} setChatLlmId={setChatLLMId} />,
[llms, chatLLMId, setChatLLMId],
);
@@ -4,14 +4,13 @@ import { shallow } from 'zustand/shallow';
import { ListItemButton, ListItemDecorator } from '@mui/joy';
import CallIcon from '@mui/icons-material/Call';
import { APP_CALL_ENABLED } from '../../../call/AppCall';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { AppBarDropdown } from '~/common/layout/AppBarDropdown';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { launchAppCall } from '~/common/app.routes';
import { useChatStore } from '~/common/state/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
function AppBarPersonaDropdown(props: {
@@ -52,9 +51,10 @@ function AppBarPersonaDropdown(props: {
}
export function usePersonaIdDropdown(conversationId: string | null) {
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 +69,12 @@ export function usePersonaIdDropdown(conversationId: string | null) {
if (conversationId && systemPurposeId)
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
}}
onCall={APP_CALL_ENABLED ? () => {
onCall={labsCalling ? () => {
if (conversationId && systemPurposeId)
launchAppCall(conversationId, systemPurposeId);
} : undefined}
/> : null,
[conversationId, systemPurposeId],
[conversationId, labsCalling, systemPurposeId],
);
return { personaDropdown };
@@ -0,0 +1,47 @@
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}
/>
)}
</>;
}
@@ -0,0 +1,32 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo';
import { KeyStroke } from '~/common/components/KeyStroke';
const pasteClipboardLegend =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
<b>Attach clipboard 📚</b><br />
Auto-converts to the best types<br />
<KeyStroke combo='Ctrl + Shift + V' sx={{ mt: 1, mb: 0.5 }} />
</Box>;
export const ButtonAttachClipboardMemo = React.memo(ButtonAttachClipboard);
function ButtonAttachClipboard(props: { isMobile?: boolean, onClick: () => void }) {
return props.isMobile ? (
<IconButton onClick={props.onClick}>
<ContentPasteGoIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={pasteClipboardLegend}>
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onClick}
sx={{ justifyContent: 'flex-start' }}>
Paste
</Button>
</Tooltip>
);
}
@@ -0,0 +1,29 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined';
const attachFileLegend =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
<b>Attach files</b><br />
Drag & drop in chat for faster loads
</Box>;
export const ButtonAttachFileMemo = React.memo(ButtonAttachFile);
function ButtonAttachFile(props: { isMobile?: boolean, onAttachFilePicker: () => void }) {
return props.isMobile ? (
<IconButton onClick={props.onAttachFilePicker}>
<AttachFileOutlinedIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
<Button fullWidth variant='plain' color='neutral' onClick={props.onAttachFilePicker} startDecorator={<AttachFileOutlinedIcon />}
sx={{ justifyContent: 'flex-start' }}>
File
</Button>
</Tooltip>
);
}
@@ -0,0 +1,25 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import CallIcon from '@mui/icons-material/Call';
const callConversationLegend =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Quick call regarding this chat
</Box>;
export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void, sx?: SxProps }) {
return props.isMobile ? (
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={props.sx}>
<CallIcon />
</IconButton>
) : (
<Tooltip 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>
</Tooltip>
);
}
@@ -1,34 +0,0 @@
import * as React from 'react';
import { Button, IconButton } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import { CameraCaptureModal } from './CameraCaptureModal';
const CAMERA_ENABLE_ON_DESKTOP = false;
export function ButtonCameraCapture(props: { isMobile: boolean, onOCR: (ocrText: string) => void }) {
// state
const [open, setOpen] = React.useState(false);
return <>
{/* The Button */}
{props.isMobile ? (
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)}>
<AddAPhotoIcon />
</IconButton>
) : CAMERA_ENABLE_ON_DESKTOP ? (
<Button
fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
sx={{ justifyContent: 'flex-start' }}>
OCR
</Button>
) : undefined}
{/* The actual capture dialog, which will stream the video */}
{open && <CameraCaptureModal onCloseModal={() => setOpen(false)} onOCR={props.onOCR} />}
</>;
}
@@ -1,31 +0,0 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo';
import { KeyStroke } from '~/common/components/KeyStroke';
const pasteClipboardLegend =
<Box sx={{ p: 1, lineHeight: 2 }}>
<b>Paste as 📚 Markdown attachment</b><br />
Also converts Code and Tables<br />
<KeyStroke combo='Ctrl + Shift + V' />
</Box>;
export function ButtonClipboardPaste(props: { isMobile: boolean, isDeveloperMode: boolean, onPaste: () => void }) {
return props.isMobile ? (
<IconButton onClick={props.onPaste}>
<ContentPasteGoIcon />
</IconButton>
) : (
<Tooltip
variant='solid' placement='top-start'
title={pasteClipboardLegend}>
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onPaste}
sx={{ justifyContent: 'flex-start' }}>
{props.isDeveloperMode ? 'Paste code' : 'Paste'}
</Button>
</Tooltip>
);
}
@@ -1,70 +0,0 @@
import { Box, Button, IconButton, Stack, Tooltip } from '@mui/joy';
import * as React from 'react';
import AttachFileOutlinedIcon from '@mui/icons-material/AttachFileOutlined';
const attachFileLegend =
<Stack sx={{ p: 1, gap: 1 }}>
<Box sx={{ mb: 1 }}>
<b>Attach a file</b>
</Box>
<table>
<tbody>
<tr>
<td><b>Text</b></td>
<td align='center' style={{ opacity: 0.5 }}></td>
<td>📝 As-is</td>
</tr>
<tr>
<td><b>Code</b></td>
<td align='center' style={{ opacity: 0.5 }}></td>
<td>📚 Markdown</td>
</tr>
<tr>
<td><b>PDF</b></td>
<td width={36} align='center' style={{ opacity: 0.5 }}></td>
<td>📝 Text (summarized)</td>
</tr>
</tbody>
</table>
<Box sx={{ mt: 1, fontSize: '14px' }}>
Drag & drop in chat for faster loads
</Box>
</Stack>;
export function ButtonFileAttach(props: { isMobile: boolean, onAttachFiles: (files: FileList) => Promise<void> }) {
// state
const attachmentFileInputRef = React.useRef<HTMLInputElement>(null);
const handleShowFilePicker = () => attachmentFileInputRef.current?.click();
const handleLoadAttachment = (event: React.ChangeEvent<HTMLInputElement>) => {
// NOTE: resetting the target value allows for the selector dialog to pop-up again
const files = event.target?.files;
if (files && files.length >= 1)
props.onAttachFiles(files).finally(() => event.target.value = '');
else
event.target.value = '';
};
return <>
{/* Mobile icon or Desktop button */}
{props.isMobile ? (
<IconButton onClick={handleShowFilePicker}>
<AttachFileOutlinedIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
<Button fullWidth variant='plain' color='neutral' onClick={handleShowFilePicker} startDecorator={<AttachFileOutlinedIcon />}
sx={{ justifyContent: 'flex-start' }}>
Attach
</Button>
</Tooltip>
)}
<input type='file' multiple hidden ref={attachmentFileInputRef} onChange={handleLoadAttachment} />
</>;
}
@@ -0,0 +1,26 @@
import * as React from 'react';
import { Box, IconButton } from '@mui/joy';
import { ColorPaletteProp, VariantProp } from '@mui/joy/styles/types';
import MicIcon from '@mui/icons-material/Mic';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { KeyStroke } from '~/common/components/KeyStroke';
const micLegend =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Voice input<br />
<KeyStroke combo='Ctrl + M' sx={{ mt: 1, mb: 0.5 }} />
</Box>;
export const ButtonMicMemo = React.memo(ButtonMic);
function ButtonMic(props: { variant: VariantProp, color: ColorPaletteProp, noBackground?: boolean, onClick: () => void }) {
return <GoodTooltip placement='top' title={micLegend}>
<IconButton variant={props.variant} color={props.color} onClick={props.onClick} sx={props.noBackground ? { background: 'none' } : {}}>
<MicIcon />
</IconButton>
</GoodTooltip>;
}
@@ -0,0 +1,22 @@
import * as React from 'react';
import { Box, IconButton, Tooltip } from '@mui/joy';
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
import AutoModeIcon from '@mui/icons-material/AutoMode';
const micContinuationLegend =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Voice Continuation
</Box>;
export const ButtonMicContinuationMemo = React.memo(ButtonMicContinuation);
function ButtonMicContinuation(props: { variant: VariantProp, color: ColorPaletteProp, onClick: () => void, sx?: SxProps }) {
return <Tooltip placement='bottom' title={micContinuationLegend}>
<IconButton variant={props.variant} color={props.color} onClick={props.onClick} sx={props.sx}>
<AutoModeIcon />
</IconButton>
</Tooltip>;
}
@@ -0,0 +1,18 @@
import * as React from 'react';
import { Button, IconButton } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
export function ButtonOptionsDraw(props: { isMobile?: boolean, onClick: () => void, sx?: SxProps }) {
return props.isMobile ? (
<IconButton variant='soft' color='warning' onClick={props.onClick} sx={props.sx}>
<FormatPaintIcon />
</IconButton>
) : (
<Button variant='soft' color='warning' onClick={props.onClick} endDecorator={<FormatPaintIcon />} sx={props.sx}>
Options
</Button>
);
}
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Box, Button, CircularProgress, IconButton, LinearProgress, Modal, ModalClose, Option, Select, Sheet, Typography } from '@mui/joy';
import { Box, Button, IconButton, Modal, ModalClose, Option, Select, Sheet, Typography } from '@mui/joy';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import DownloadIcon from '@mui/icons-material/Download';
import InfoIcon from '@mui/icons-material/Info';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
@@ -9,6 +10,12 @@ import { InlineError } from '~/common/components/InlineError';
import { useCameraCapture } from '~/common/components/useCameraCapture';
function prettyFileName(renderedFrame: HTMLCanvasElement) {
const prettyDate = new Date().toISOString().replace(/[:-]/g, '').replace('T', '-').replace('Z', '');
const prettyResolution = `${renderedFrame.width}x${renderedFrame.height}`;
return `camera-${prettyDate}-${prettyResolution}.png`;
}
function renderVideoFrameToCanvas(videoElement: HTMLVideoElement): HTMLCanvasElement {
// paint the video on a canvas, to save it
const canvas = document.createElement('canvas');
@@ -19,6 +26,19 @@ function renderVideoFrameToCanvas(videoElement: HTMLVideoElement): HTMLCanvasEle
return canvas;
}
function renderVideoFrameToFile(videoElement: HTMLVideoElement, callback: (file: File) => void) {
// video to canvas
const renderedFrame = renderVideoFrameToCanvas(videoElement);
// canvas to blob to file to callback
renderedFrame.toBlob((blob) => {
if (blob) {
const file = new File([blob], prettyFileName(renderedFrame), { type: blob.type });
callback(file);
}
}, 'image/png');
}
function downloadVideoFrameAsPNG(videoElement: HTMLVideoElement) {
// video to canvas to png
const renderedFrame = renderVideoFrameToCanvas(videoElement);
@@ -26,15 +46,19 @@ function downloadVideoFrameAsPNG(videoElement: HTMLVideoElement) {
// auto-download
const link = document.createElement('a');
link.download = 'image.png';
link.download = prettyFileName(renderedFrame);
link.href = imageDataURL;
link.click();
}
export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (ocrText: string) => void }) {
export function CameraCaptureModal(props: {
onCloseModal: () => void,
onAttachImage: (file: File) => void
// onOCR: (ocrText: string) => void }
}) {
// state
const [ocrProgress, setOCRProgress] = React.useState<number | null>(null);
// const [ocrProgress/*, setOCRProgress*/] = React.useState<number | null>(null);
const [showInfo, setShowInfo] = React.useState(false);
// camera operations
@@ -51,7 +75,7 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
props.onCloseModal();
};
const handleVideoOCRClicked = async () => {
/*const handleVideoOCRClicked = async () => {
if (!videoRef.current) return;
const renderedFrame = renderVideoFrameToCanvas(videoRef.current);
@@ -68,6 +92,14 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
setOCRProgress(null);
stopAndClose();
props.onOCR(result.data.text);
};*/
const handleVideoSnapClicked = () => {
if (!videoRef.current) return;
renderVideoFrameToFile(videoRef.current, (file) => {
props.onAttachImage(file);
stopAndClose();
});
};
const handleVideoDownloadClicked = () => {
@@ -111,7 +143,7 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
ref={videoRef} autoPlay playsInline
style={{
display: 'block', width: '100%', maxHeight: 'calc(100vh - 200px)',
background: '#8888', opacity: ocrProgress !== null ? 0.5 : 1,
background: '#8888', //opacity: ocrProgress !== null ? 0.5 : 1,
}}
/>
@@ -124,7 +156,7 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
{info}
</Typography>}
{ocrProgress !== null && <CircularProgress sx={{ position: 'absolute', top: 'calc(50% - 34px / 2)', left: 'calc(50% - 34px / 2)', zIndex: 2 }} />}
{/*{ocrProgress !== null && <CircularProgress sx={{ position: 'absolute', top: 'calc(50% - 34px / 2)', left: 'calc(50% - 34px / 2)', zIndex: 2 }} />}*/}
</Box>
{/* Bottom controls (zoom, ocr, download) & progress */}
@@ -134,16 +166,30 @@ export function CameraCaptureModal(props: { onCloseModal: () => void, onOCR: (oc
{zoomControl}
{ocrProgress !== null && <LinearProgress color='primary' determinate value={100 * ocrProgress} sx={{ px: 2 }} />}
{/*{ocrProgress !== null && <LinearProgress color='primary' determinate value={100 * ocrProgress} sx={{ px: 2 }} />}*/}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'space-between' }}>
<IconButton disabled={!info} variant='soft' color='neutral' size='lg' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
{/* Info */}
<IconButton disabled={!info} variant='soft' color='neutral' 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 }}>
Extract Text
{/*<Button disabled={ocrProgress !== null} fullWidth variant='solid' size='lg' onClick={handleVideoOCRClicked} sx={{ flex: 1, maxWidth: 260 }}>*/}
{/* Extract Text*/}
{/*</Button>*/}
{/* Capture */}
<Button
fullWidth
variant='solid' color='neutral'
onClick={handleVideoSnapClicked}
endDecorator={<CameraAltIcon />}
sx={{ flex: 1, maxWidth: 200, py: 2, borderRadius: '3rem' }}
>
Capture
</Button>
<IconButton variant='soft' color='neutral' size='lg' onClick={handleVideoDownloadClicked}>
{/* Download */}
<IconButton variant='soft' color='neutral' onClick={handleVideoDownloadClicked}>
<DownloadIcon />
</IconButton>
</Box>
@@ -5,8 +5,42 @@ import { Box, MenuItem, Radio, Typography } from '@mui/joy';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { ChatModeId, ChatModeItems } from './store-composer';
import { ChatModeId } from '../../AppChat';
interface ChatModeDescription {
label: string;
description: string | React.JSX.Element;
shortcut?: string;
experimental?: boolean;
}
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
'immediate': {
label: 'Chat',
description: 'Persona replies',
},
'write-user': {
label: 'Write',
description: 'Appends a message',
shortcut: 'Alt + Enter',
},
'draw-imagine': {
label: 'Draw',
description: 'AI Image Generation',
},
'draw-imagine-plus': {
label: 'Assisted Draw',
description: 'Assisted Image Generation',
experimental: true,
},
'react': {
label: 'Reason + Act · α',
description: 'Answers questions in multiple steps',
},
};
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
@@ -15,10 +49,11 @@ function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
return shortcut;
}
export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClose: () => void, experimental: boolean, chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void }) => {
export function ChatModeMenu(props: { anchorEl: HTMLAnchorElement | null, onClose: () => void, chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void }) {
// external state
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const labsMagicDraw = useUXLabsStore(state => state.labsMagicDraw);
return <CloseableMenu
placement='top-end' sx={{ minWidth: 320 }}
@@ -33,7 +68,7 @@ export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClos
{/* ChatMode items */}
{Object.entries(ChatModeItems)
.filter(([, { experimental }]) => props.experimental || !experimental)
.filter(([, { experimental }]) => labsMagicDraw || !experimental)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
@@ -43,10 +78,10 @@ export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClos
<Typography level='body-xs'>{data.description}</Typography>
</Box>
{(key === props.chatModeId || !!data.shortcut) && (
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : "ENTER", enterIsNewline)} />
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
)}
</Box>
</MenuItem>)}
</CloseableMenu>;
};
}
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,18 @@
import * as React from 'react';
import { Badge, ColorPaletteProp, Tooltip } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { Badge, Box, ColorPaletteProp, Tooltip } from '@mui/joy';
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, indirectTokens?: number) {
const usedTokens = directTokens + (indirectTokens || 0);
function alignRight(value: number, columnSize: number = 7) {
const str = value.toLocaleString();
return str.padStart(columnSize);
}
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, historyTokens?: number, responseMaxTokens?: number): {
color: ColorPaletteProp, message: string, remainingTokens: number
} {
const usedTokens = directTokens + (historyTokens || 0) + (responseMaxTokens || 0);
const remainingTokens = tokenLimit - usedTokens;
const gteLimit = (remainingTokens <= 0 && tokenLimit > 0);
@@ -17,23 +24,24 @@ export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, i
message += `Requested: ${usedTokens.toLocaleString()} tokens`;
}
// has full information (d + i < l)
else if (indirectTokens) {
else if (historyTokens || responseMaxTokens) {
message +=
`${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens > 0 ? 'available' : 'excess'} tokens\n\n` +
` = Model max tokens: ${tokenLimit.toLocaleString()}\n` +
` - Chat Message: ${directTokens.toLocaleString()}` +
(indirectTokens ? `\n- History + Response: ${indirectTokens?.toLocaleString()}` : '');
`${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens >= 0 ? 'available' : 'excess'} message tokens\n\n` +
` = Model max tokens: ${alignRight(tokenLimit)}\n` +
` - This message: ${alignRight(directTokens)}\n` +
` - History: ${alignRight(historyTokens || 0)}\n` +
` - Max response: ${alignRight(responseMaxTokens || 0)}`;
}
// Cleaner mode: d + ? < R (total is the remaining in this case)
else {
message +=
`${(tokenLimit + usedTokens).toLocaleString()} available tokens after deleting this\n\n` +
` = Currently free: ${tokenLimit.toLocaleString()}\n` +
` + This message: ${usedTokens.toLocaleString()}`;
` = Currently free: ${alignRight(tokenLimit)}\n` +
` + This message: ${alignRight(usedTokens)}`;
}
const color: ColorPaletteProp =
(tokenLimit && remainingTokens < 1)
(tokenLimit && remainingTokens < 0)
? 'danger'
: remainingTokens < tokenLimit / 4
? 'warning'
@@ -43,35 +51,61 @@ export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, i
}
export const TokenTooltip = (props: { message: string | null, color: ColorPaletteProp, placement?: 'top' | 'top-end', children: React.JSX.Element }) =>
<Tooltip
placement={props.placement}
variant={props.color !== 'primary' ? 'solid' : 'soft'} color={props.color}
title={props.message
? <Box sx={{ p: 2, whiteSpace: 'pre' }}>
{props.message}
</Box>
: null
}
sx={{
fontFamily: 'code',
boxShadow: 'xl',
}}
>
{props.children}
</Tooltip>;
/**
* Simple little component to show the token count (and a tooltip on hover)
*/
export function TokenBadge({ directTokens, indirectTokens, tokenLimit, showExcess, absoluteBottomRight, inline, sx }: { directTokens: number, indirectTokens?: number, tokenLimit: number, showExcess?: boolean, absoluteBottomRight?: boolean, inline?: boolean, sx?: SxProps }) {
export const TokenBadgeMemo = React.memo(TokenBadge);
const fontSx: SxProps = { fontFamily: 'code', ...(sx || {}) };
const outerSx: SxProps = absoluteBottomRight ? { position: 'absolute', bottom: 8, right: 8 } : {};
const innerSx: SxProps = (absoluteBottomRight || inline) ? { position: 'static', transform: 'none', ...fontSx } : fontSx;
function TokenBadge(props: {
direct: number, history?: number, responseMax?: number, limit: number,
showExcess?: boolean, absoluteBottomRight?: boolean, inline?: boolean,
}) {
const { message, color, remainingTokens } = tokensPrettyMath(tokenLimit, directTokens, indirectTokens);
const { message, color, remainingTokens } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
const value = (showExcess && (tokenLimit && remainingTokens <= 0))
const value = (props.showExcess && (props.limit && remainingTokens <= 0))
? Math.abs(remainingTokens)
: directTokens;
: props.direct;
return (
<Badge
variant='solid' color={color} max={100000}
invisible={!directTokens && remainingTokens >= 0}
invisible={!props.direct && remainingTokens >= 0}
badgeContent={
<Tooltip title={<span style={{ whiteSpace: 'pre' }}>{message}</span>} color={color} sx={fontSx}>
<TokenTooltip color={color} message={message}>
<span>{value.toLocaleString()}</span>
</Tooltip>
</TokenTooltip>
}
sx={outerSx}
sx={{
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: 8 }),
cursor: 'help',
}}
slotProps={{
badge: {
sx: innerSx,
sx: {
fontFamily: 'code',
...((props.absoluteBottomRight || props.inline) && { position: 'static', transform: 'none' }),
},
},
}}
/>
@@ -1,8 +1,8 @@
import * as React from 'react';
import { Box, Tooltip, useTheme } from '@mui/joy';
import { Box, useTheme } from '@mui/joy';
import { tokensPrettyMath } from './TokenBadge';
import { tokensPrettyMath, TokenTooltip } from './TokenBadge';
/**
@@ -10,15 +10,17 @@ import { tokensPrettyMath } from './TokenBadge';
*
* The Textarea contains it within the Composer (at least).
*/
export function TokenProgressbar(props: { history: number, response: number, direct: number, limit: number }) {
export const TokenProgressbarMemo = React.memo(TokenProgressbar);
function TokenProgressbar(props: { direct: number, history: number, responseMax: number, limit: number }) {
// external state
const theme = useTheme();
if (!(props.limit > 0) || (!props.direct && !props.history && !props.response)) return null;
if (!(props.limit > 0) || (!props.direct && !props.history && !props.responseMax)) return null;
// compute percentages
let historyPct = 100 * props.history / props.limit;
let responsePct = 100 * props.response / props.limit;
let responsePct = 100 * props.responseMax / props.limit;
let directPct = 100 * props.direct / props.limit;
const totalPct = historyPct + responsePct + directPct;
const isOverflow = totalPct >= 100;
@@ -38,7 +40,7 @@ export function TokenProgressbar(props: { history: number, response: number, dir
const overflowColor = theme.palette.danger.softColor;
// tooltip message/color
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history + props.response);
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
// sizes
const containerHeight = 8;
@@ -46,11 +48,11 @@ export function TokenProgressbar(props: { history: number, response: number, dir
return (
<Tooltip title={<span style={{ whiteSpace: 'pre' }}>{message}</span>} color={color} sx={{ fontFamily: 'code' }}>
<TokenTooltip color={color} message={props.direct ? null : message}>
<Box sx={{
position: 'absolute', left: 1, right: 1, bottom: 1, height: containerHeight,
overflow: 'hidden', borderBottomLeftRadius: 7, borderBottomRightRadius: 7,
overflow: 'hidden', borderBottomLeftRadius: 5, borderBottomRightRadius: 5,
}}>
{/* History */}
@@ -79,6 +81,6 @@ export function TokenProgressbar(props: { history: number, response: number, dir
</Box>
</Tooltip>
</TokenTooltip>
);
}
@@ -0,0 +1,201 @@
import * as React from 'react';
import { Box, Button, CircularProgress, ColorPaletteProp, Sheet, Typography } from '@mui/joy';
import AbcIcon from '@mui/icons-material/Abc';
import CodeIcon from '@mui/icons-material/Code';
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import PivotTableChartIcon from '@mui/icons-material/PivotTableChart';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import TextureIcon from '@mui/icons-material/Texture';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { ellipsizeFront, ellipsizeMiddle } from '~/common/util/textUtils';
import type { Attachment, AttachmentConverterType, AttachmentId } from './store-attachments';
import type { LLMAttachment } from './useLLMAttachments';
// default attachment width
const ATTACHMENT_MIN_STYLE = {
height: '100%',
minHeight: '40px',
minWidth: '64px',
};
const ellipsizeLabel = (label?: string) => {
if (!label)
return '';
return ellipsizeMiddle((label || '')
.replace(/https?:\/\/(?:www\.)?/, ''), 30)
.replace(/\/$/, '')
.replace('…', '…\n…');
};
/**
* Displayed while a source is loading
*/
const LoadingIndicator = React.forwardRef((props: { label: string }, _ref) =>
<Sheet
color='success' variant='soft'
sx={{
border: '1px solid',
borderColor: 'success.solidBg',
borderRadius: 'sm',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1,
...ATTACHMENT_MIN_STYLE,
boxSizing: 'border-box',
px: 1,
py: 0.5,
}}
>
<CircularProgress color='success' size='sm' />
<Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
{ellipsizeLabel(props.label)}
</Typography>
</Sheet>,
);
LoadingIndicator.displayName = 'LoadingIndicator';
const InputErrorIndicator = () =>
<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />;
const converterTypeToIconMap: { [key in AttachmentConverterType]: React.ComponentType<any> } = {
'text': TextFieldsIcon,
'rich-text': CodeIcon,
'rich-text-table': PivotTableChartIcon,
'pdf-text': PictureAsPdfIcon,
'pdf-images': PictureAsPdfIcon,
'image': ImageOutlinedIcon,
'image-ocr': AbcIcon,
'unhandled': TextureIcon,
};
function attachmentConverterIcon(attachment: Attachment) {
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
if (converter && converter.id) {
const Icon = converterTypeToIconMap[converter.id] ?? null;
if (Icon)
return <Icon sx={{ width: 24, height: 24 }} />;
}
return null;
}
function attachmentLabelText(attachment: Attachment): string {
return ellipsizeFront(attachment.label, 24);
}
export function AttachmentItem(props: {
llmAttachment: LLMAttachment,
menuShown: boolean,
onItemMenuToggle: (attachmentId: AttachmentId, anchor: HTMLAnchorElement) => void,
}) {
// derived state
const { onItemMenuToggle } = props;
const {
attachment,
isUnconvertible,
isOutputMissing,
isOutputAttachable,
} = props.llmAttachment;
const {
inputError,
inputLoading: isInputLoading,
outputsConverting: isOutputLoading,
} = attachment;
const isInputError = !!inputError;
const showWarning = isUnconvertible || isOutputMissing || !isOutputAttachable;
const handleToggleMenu = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
event.stopPropagation();
onItemMenuToggle(attachment.id, event.currentTarget);
}, [attachment, onItemMenuToggle]);
// compose tooltip
let tooltip: string | null = '';
if (attachment.source.media !== 'text')
tooltip += attachment.source.media + ': ';
tooltip += attachment.label;
// if (hasInput)
// tooltip += `\n(${aInput.mimeType}: ${aInput.dataSize.toLocaleString()} bytes)`;
// if (aOutputs && aOutputs.length >= 1)
// tooltip += `\n\n${JSON.stringify(aOutputs)}`;
// choose variants and color
let color: ColorPaletteProp;
let variant: 'soft' | 'outlined' | 'contained' = 'soft';
if (isInputLoading || isOutputLoading) {
color = 'success';
} else if (isInputError) {
tooltip = `Issue loading the attachment: ${attachment.inputError}\n\n${tooltip}`;
color = 'danger';
} else if (showWarning) {
tooltip = props.menuShown
? null
: isUnconvertible
? `Attachments of type '${attachment.input?.mimeType}' are not supported yet. You can open a feature request on GitHub.\n\n${tooltip}`
: `Not compatible with the selected LLM or not supported. Please select another format.\n\n${tooltip}`;
color = 'warning';
} else {
// all good
tooltip = null;
color = /*props.menuShown ? 'primary' :*/ 'neutral';
variant = 'outlined';
}
return <Box>
<GoodTooltip
title={tooltip}
isError={isInputError}
isWarning={showWarning}
sx={{ p: 1, whiteSpace: 'break-spaces' }}
>
{isInputLoading
? <LoadingIndicator label={attachment.label} />
: (
<Button
size='sm'
variant={variant} color={color}
onClick={handleToggleMenu}
sx={{
backgroundColor: props.menuShown ? `${color}.softActiveBg` : variant === 'outlined' ? 'background.popup' : undefined,
border: variant === 'soft' ? '1px solid' : undefined,
borderColor: variant === 'soft' ? `${color}.solidBg` : undefined,
borderRadius: 'sm',
fontWeight: 'normal',
...ATTACHMENT_MIN_STYLE,
px: 1, py: 0.5,
display: 'flex', flexDirection: 'row', gap: 1,
}}
>
{isInputError
? <InputErrorIndicator />
: <>
{attachmentConverterIcon(attachment)}
{isOutputLoading
? <>Converting <CircularProgress color='success' size='sm' /></>
: <Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
{attachmentLabelText(attachment)}
</Typography>}
</>}
</Button>
)}
</GoodTooltip>
</Box>;
}
@@ -0,0 +1,186 @@
import * as React from 'react';
import { Box, ListDivider, ListItemDecorator, MenuItem, Radio, Typography } from '@mui/joy';
import ClearIcon from '@mui/icons-material/Clear';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import type { LLMAttachment } from './useLLMAttachments';
import { useAttachmentsStore } from './store-attachments';
// enable for debugging
export const DEBUG_ATTACHMENTS = true;
export function AttachmentMenu(props: {
llmAttachment: LLMAttachment,
menuAnchor: HTMLAnchorElement,
isPositionFirst: boolean,
isPositionLast: boolean,
onAttachmentInlineText: (attachmentId: string) => void,
onClose: () => void,
}) {
// derived state
const isPositionFixed = props.isPositionFirst && props.isPositionLast;
const {
attachment,
attachmentOutputs,
isUnconvertible,
isOutputMissing,
isOutputTextInlineable,
tokenCountApprox,
} = props.llmAttachment;
const {
id: aId,
input: aInput,
converters: aConverters,
converterIdx: aConverterIdx,
outputs: aOutputs,
} = attachment;
// operations
const { onClose, onAttachmentInlineText } = props;
const handleInlineText = React.useCallback(() => {
onClose();
onAttachmentInlineText(aId);
}, [aId, onAttachmentInlineText, onClose]);
const handleMoveUp = React.useCallback(() => {
useAttachmentsStore.getState().moveAttachment(aId, -1);
}, [aId]);
const handleMoveDown = React.useCallback(() => {
useAttachmentsStore.getState().moveAttachment(aId, 1);
}, [aId]);
const handleRemove = React.useCallback(() => {
onClose();
useAttachmentsStore.getState().removeAttachment(aId);
}, [aId, onClose]);
const handleSetConverterIdx = React.useCallback(async (converterIdx: number | null) => {
return useAttachmentsStore.getState().setConverterIdx(aId, converterIdx);
}, [aId]);
// const handleSummarizeText = React.useCallback(() => {
// onAttachmentSummarizeText(aId);
// }, [aId, onAttachmentSummarizeText]);
const handleCopyOutputToClipboard = React.useCallback(() => {
if (attachmentOutputs.length >= 1) {
const concat = attachmentOutputs.map(output => {
if (output.type === 'text-block')
return output.text;
else if (output.type === 'image-part')
return output.base64Url;
else
return null;
}).join('\n\n---\n\n');
copyToClipboard(concat.trim(), 'Converted attachment');
}
}, [attachmentOutputs]);
return (
<CloseableMenu
dense placement='top' sx={{ minWidth: 200 }}
open anchorEl={props.menuAnchor} onClose={props.onClose}
noTopPadding noBottomPadding
>
{/* Move Arrows */}
{!isPositionFixed && <Box sx={{ display: 'flex', alignItems: 'center' }}>
<MenuItem
disabled={props.isPositionFirst}
onClick={handleMoveUp}
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
>
<KeyboardArrowLeftIcon />
</MenuItem>
<MenuItem
disabled={props.isPositionLast}
onClick={handleMoveDown}
sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}
>
<KeyboardArrowRightIcon />
</MenuItem>
</Box>}
{!isPositionFixed && <ListDivider sx={{ mt: 0 }} />}
{/* Render Converters as menu items */}
{/*{!isUnconvertible && <ListItem>*/}
{/* <Typography level='body-md'>*/}
{/* Attach as:*/}
{/* </Typography>*/}
{/*</ListItem>}*/}
{!isUnconvertible && aConverters.map((c, idx) =>
<MenuItem
disabled={c.disabled}
key={'c-' + c.id}
onClick={async () => idx !== aConverterIdx && await handleSetConverterIdx(idx)}
>
<ListItemDecorator>
<Radio checked={idx === aConverterIdx} />
</ListItemDecorator>
{c.unsupported
? <Box>Unsupported 🤔 <Typography level='body-xs'>{c.name}</Typography></Box>
: c.name}
</MenuItem>,
)}
{!isUnconvertible && <ListDivider />}
{DEBUG_ATTACHMENTS && !!aInput && (
<MenuItem onClick={handleCopyOutputToClipboard} disabled={!isOutputTextInlineable}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
<Box>
{!!aInput && <Typography level='body-xs'>
🡐 {aInput.mimeType}, {aInput.dataSize.toLocaleString()} bytes
</Typography>}
{/*<Typography level='body-xs'>*/}
{/* Converters: {aConverters.map(((converter, idx) => ` ${converter.id}${(idx === aConverterIdx) ? '*' : ''}`)).join(', ')}*/}
{/*</Typography>*/}
<Typography level='body-xs'>
🡒 {isOutputMissing ? 'empty' : aOutputs.map(output => `${output.type}, ${output.type === 'text-block' ? output.text.length.toLocaleString() : '(base64 image)'} bytes`).join(' · ')}
</Typography>
{!!tokenCountApprox && <Typography level='body-xs'>
🡒 {tokenCountApprox.toLocaleString()} tokens
</Typography>}
</Box>
</MenuItem>
)}
{DEBUG_ATTACHMENTS && !!aInput && <ListDivider />}
{/* Destructive Operations */}
{/*<MenuItem onClick={handleCopyOutputToClipboard} disabled={!isOutputTextInlineable}>*/}
{/* <ListItemDecorator><ContentCopyIcon /></ListItemDecorator>*/}
{/* Copy*/}
{/*</MenuItem>*/}
{/*<MenuItem onClick={handleSummarizeText} disabled={!isOutputTextInlineable}>*/}
{/* <ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>*/}
{/* Shrink*/}
{/*</MenuItem>*/}
<MenuItem onClick={handleInlineText} disabled={!isOutputTextInlineable}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Inline text
</MenuItem>
<MenuItem onClick={handleRemove}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Remove
</MenuItem>
</CloseableMenu>
);
}
@@ -0,0 +1,170 @@
import * as React from 'react';
import { Box, IconButton, ListItemDecorator, MenuItem } from '@mui/joy';
import ClearIcon from '@mui/icons-material/Clear';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import type { AttachmentId } from './store-attachments';
import type { LLMAttachments } from './useLLMAttachments';
import { AttachmentItem } from './AttachmentItem';
import { AttachmentMenu } from './AttachmentMenu';
/**
* Renderer of attachments, with menus, etc.
*/
export function Attachments(props: {
llmAttachments: LLMAttachments,
onAttachmentInlineText: (attachmentId: AttachmentId) => void,
onAttachmentsClear: () => void,
onAttachmentsInlineText: () => void,
}) {
// state
const [confirmClearAttachments, setConfirmClearAttachments] = React.useState<boolean>(false);
const [itemMenu, setItemMenu] = React.useState<{ anchor: HTMLAnchorElement, attachmentId: AttachmentId } | null>(null);
const [overallMenuAnchor, setOverallMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
// derived state
const { llmAttachments, onAttachmentsClear, onAttachmentInlineText, onAttachmentsInlineText } = props;
const { attachments, isOutputTextInlineable } = llmAttachments;
const hasAttachments = attachments.length >= 1;
// derived item menu state
const itemMenuAnchor = itemMenu?.anchor;
const itemMenuAttachmentId = itemMenu?.attachmentId;
const itemMenuAttachment = itemMenuAttachmentId ? attachments.find(la => la.attachment.id === itemMenu.attachmentId) : undefined;
const itemMenuIndex = itemMenuAttachment ? attachments.indexOf(itemMenuAttachment) : -1;
// item menu
const handleItemMenuToggle = React.useCallback((attachmentId: AttachmentId, anchor: HTMLAnchorElement) => {
handleOverallMenuHide();
setItemMenu(prev => prev?.attachmentId === attachmentId ? null : { anchor, attachmentId });
}, []);
const handleItemMenuHide = React.useCallback(() => {
setItemMenu(null);
}, []);
// item menu operations
const handleAttachmentInlineText = React.useCallback((attachmentId: string) => {
handleItemMenuHide();
onAttachmentInlineText(attachmentId);
}, [handleItemMenuHide, onAttachmentInlineText]);
// menu
const handleOverallMenuHide = () => setOverallMenuAnchor(null);
const handleOverallMenuToggle = (event: React.MouseEvent<HTMLAnchorElement>) =>
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
// overall operations
const handleAttachmentsInlineText = React.useCallback(() => {
handleOverallMenuHide();
onAttachmentsInlineText();
}, [onAttachmentsInlineText]);
const handleClearAttachments = () => setConfirmClearAttachments(true);
const handleClearAttachmentsConfirmed = React.useCallback(() => {
handleOverallMenuHide();
setConfirmClearAttachments(false);
onAttachmentsClear();
}, [onAttachmentsClear]);
// no components without attachments
if (!hasAttachments)
return null;
return <>
{/* Attachments bar */}
<Box sx={{ position: 'relative' }}>
{/* Horizontally scrollable Attachments */}
<Box sx={{ display: 'flex', overflowX: 'auto', gap: 1, height: '100%', pr: 5 }}>
{attachments.map((llmAttachment) =>
<AttachmentItem
key={llmAttachment.attachment.id}
llmAttachment={llmAttachment}
menuShown={llmAttachment.attachment.id === itemMenuAttachmentId}
onItemMenuToggle={handleItemMenuToggle}
/>,
)}
</Box>
{/* Overall Menu button */}
<IconButton
variant='plain' onClick={handleOverallMenuToggle}
sx={{
// borderRadius: 'sm',
borderRadius: 0,
position: 'absolute', right: 0, top: 0,
backgroundColor: 'neutral.softDisabledBg',
}}
>
<ExpandLessIcon />
</IconButton>
</Box>
{/* Attachment Menu */}
{!!itemMenuAnchor && !!itemMenuAttachment && (
<AttachmentMenu
llmAttachment={itemMenuAttachment}
menuAnchor={itemMenuAnchor}
isPositionFirst={itemMenuIndex === 0}
isPositionLast={itemMenuIndex === attachments.length - 1}
onAttachmentInlineText={handleAttachmentInlineText}
onClose={handleItemMenuHide}
/>
)}
{/* Overall Menu */}
{!!overallMenuAnchor && (
<CloseableMenu
placement='top-start'
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
noTopPadding noBottomPadding
>
<MenuItem onClick={handleAttachmentsInlineText} disabled={!isOutputTextInlineable}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Inline <span style={{ opacity: 0.5 }}>text attachments</span>
</MenuItem>
<MenuItem onClick={handleClearAttachments}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Clear
</MenuItem>
</CloseableMenu>
)}
{/* 'Clear' Confirmation */}
{confirmClearAttachments && (
<ConfirmationModal
open onClose={() => setConfirmClearAttachments(false)} onPositive={handleClearAttachmentsConfirmed}
title='Confirm Removal'
positiveActionText='Remove All'
confirmationText={`This action will remove all (${attachments.length}) attachments. Do you want to proceed?`}
/>
)}
</>;
}
@@ -0,0 +1,335 @@
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { createBase36Uid } from '~/common/util/textUtils';
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
import { pdfToText } from '~/common/util/pdfUtils';
import type { Attachment, AttachmentConverter, AttachmentId, AttachmentInput, AttachmentSource } from './store-attachments';
import type { ComposerOutputMultiPart } from '../composer.types';
// extensions to treat as plain text
const PLAIN_TEXT_EXTENSIONS: string[] = ['.ts', '.tsx'];
/**
* Creates a new Attachment object.
*/
export function attachmentCreate(source: AttachmentSource, checkDuplicates: AttachmentId[]): Attachment {
return {
id: createBase36Uid(checkDuplicates),
source: source,
label: 'Loading...',
ref: '',
inputLoading: false,
inputError: null,
input: undefined,
converters: [],
converterIdx: null,
outputsConverting: false,
outputs: [],
// metadata: {},
};
}
/**
* Asynchronously loads the input for an Attachment object.
*
* @param {Readonly<AttachmentSource>} source - The source of the attachment.
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
*/
export async function attachmentLoadInputAsync(source: Readonly<AttachmentSource>, edit: (changes: Partial<Attachment>) => void) {
edit({ inputLoading: true });
switch (source.media) {
// Download URL (page, file, ..) and attach as input
case 'url':
edit({ label: source.refUrl, ref: source.refUrl });
try {
const page = await callBrowseFetchPage(source.url);
if (page.content) {
edit({
input: {
mimeType: 'text/plain',
data: page.content,
dataSize: page.content.length,
},
});
} else
edit({ inputError: 'No content found at this link' });
} catch (error: any) {
edit({ inputError: `Issue downloading page: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
}
break;
// Attach file as input
case 'file':
edit({ label: source.refPath, ref: source.refPath });
// fix missing/wrong mimetypes
let mimeType = source.fileWithHandle.type;
if (!mimeType) {
// see note on 'attachAppendDataTransfer'; this is a fallback for drag/drop missing Mimes sometimes
console.warn('Assuming the attachment is text/plain. From:', source.origin, ', name:', source.refPath);
mimeType = 'text/plain';
} else {
// possibly fix wrongly assigned mimetypes (from the extension alone)
if (!mimeType.startsWith('text/') && PLAIN_TEXT_EXTENSIONS.some(ext => source.refPath.endsWith(ext)))
mimeType = 'text/plain';
}
// UX: just a hint of a loading state
await new Promise(resolve => setTimeout(resolve, 100));
try {
const fileArrayBuffer = await source.fileWithHandle.arrayBuffer();
edit({
input: {
mimeType,
data: fileArrayBuffer,
dataSize: fileArrayBuffer.byteLength,
},
});
} catch (error: any) {
edit({ inputError: `Issue loading file: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
}
break;
case 'text':
if (source.textHtml && source.textPlain) {
edit({
label: 'Rich Text',
ref: '',
input: {
mimeType: 'text/plain',
data: source.textPlain,
dataSize: source.textPlain!.length,
altMimeType: 'text/html',
altData: source.textHtml,
},
});
} else {
const text = source.textHtml || source.textPlain || '';
edit({
label: 'Text',
ref: '',
input: {
mimeType: 'text/plain',
data: text,
dataSize: text.length,
},
});
}
break;
}
edit({ inputLoading: false });
}
/**
* Defines the possible converters for an Attachment object based on its input type.
*
* @param {AttachmentSource['media']} sourceType - The media type of the attachment source.
* @param {Readonly<AttachmentInput>} input - The input of the attachment.
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
*/
export function attachmentDefineConverters(sourceType: AttachmentSource['media'], input: Readonly<AttachmentInput>, edit: (changes: Partial<Attachment>) => void) {
// return all the possible converters for the input
const converters: AttachmentConverter[] = [];
switch (true) {
// plain text types
case ['text/plain', 'text/html', 'text/markdown', 'text/csv', 'application/json'].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');
// p1: Tables
if (textOriginHtml && isHtmlTable) {
converters.push({
id: 'rich-text-table',
name: 'Markdown Table',
});
}
// p2: Text
converters.push({
id: 'text',
name: 'Text',
});
// p3: Html
if (textOriginHtml) {
converters.push({
id: 'rich-text',
name: 'HTML',
});
}
break;
// PDF
case ['application/pdf', 'application/x-pdf', 'application/acrobat'].includes(input.mimeType):
converters.push({ id: 'pdf-text', name: `PDF To Text` });
converters.push({ id: 'pdf-images', name: `PDF To Images`, disabled: true });
break;
// images
case input.mimeType.startsWith('image/'):
converters.push({ id: 'image', name: `Image (coming soon)` });
converters.push({ id: 'image-ocr', name: 'As Text (OCR)' });
break;
// catch-all
default:
converters.push({ id: 'unhandled', name: `${input.mimeType}`, unsupported: true });
converters.push({ id: 'text', name: 'As Text' });
break;
}
edit({ converters });
}
/**
* Converts the input of an Attachment object based on the selected converter.
*
* @param {Readonly<Attachment>} attachment - The Attachment object to convert.
* @param {number | null} converterIdx - The index of the selected conversion in the Attachment object's converters array.
* @param {(changes: Partial<Attachment>) => void} edit - A function to edit the Attachment object.
*/
export async function attachmentPerformConversion(attachment: Readonly<Attachment>, converterIdx: number | null, edit: (changes: Partial<Attachment>) => void) {
// set converter index
converterIdx = (converterIdx !== null && converterIdx >= 0 && converterIdx < attachment.converters.length) ? converterIdx : null;
edit({
converterIdx: converterIdx,
outputs: [],
});
// get converter
const { ref, input } = attachment;
const converter = converterIdx !== null ? attachment.converters[converterIdx] : null;
if (!converter || !input)
return;
edit({
outputsConverting: true,
});
// input datacould be a string or an ArrayBuffer
function inputDataToString(data: string | ArrayBuffer | null | undefined): string {
if (typeof data === 'string')
return data;
if (data instanceof ArrayBuffer)
return new TextDecoder().decode(data);
return '';
}
// apply converter to the input
const outputs: ComposerOutputMultiPart = [];
switch (converter.id) {
// text as-is
case 'text':
outputs.push({
type: 'text-block',
text: inputDataToString(input.data),
title: ref,
collapsible: true,
});
break;
// html as-is
case 'rich-text':
outputs.push({
type: 'text-block',
text: input.altData!,
title: ref,
collapsible: true,
});
break;
// html to markdown table
case 'rich-text-table':
let mdTable: string;
try {
mdTable = htmlTableToMarkdown(input.altData!);
} catch (error) {
// fallback to text/plain
mdTable = inputDataToString(input.data);
}
outputs.push({
type: 'text-block',
text: mdTable,
title: ref,
collapsible: true,
});
break;
case 'pdf-text':
if (!(input.data instanceof ArrayBuffer)) {
console.log('Expected ArrayBuffer for PDF converter, got:', typeof input.data);
break;
}
// duplicate the ArrayBuffer to avoid mutation
const pdfData = new Uint8Array(input.data.slice(0));
const pdfText = await pdfToText(pdfData);
outputs.push({
type: 'text-block',
text: pdfText,
title: ref,
collapsible: true,
});
break;
case 'pdf-images':
// TODO: extract all pages as individual images
break;
case 'image':
// TODO: continue here
/*outputs.push({
type: 'image-part',
base64Url: `data:notImplemented.yet:)`,
collapsible: false,
});*/
break;
case 'image-ocr':
if (!(input.data instanceof ArrayBuffer)) {
console.log('Expected ArrayBuffer for Image OCR converter, got:', typeof input.data);
break;
}
try {
const { recognize } = await import('tesseract.js');
const buffer = Buffer.from(input.data);
const result = await recognize(buffer, undefined, {
errorHandler: e => console.error(e),
logger: (message) => {
if (message.status === 'recognizing text')
console.log('OCR progress:', message.progress);
},
});
outputs.push({
type: 'text-block',
text: result.data.text,
title: ref,
collapsible: true,
});
} catch (error) {
console.error(error);
}
break;
case 'unhandled':
// force the user to explicitly select 'as text' if they want to proceed
break;
}
// update
edit({
outputsConverting: false,
outputs,
});
}
@@ -0,0 +1,42 @@
/*
/// REDUCER
import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
const [reducerText, setReducerText] = React.useState('');
const [reducerTextTokens, setReducerTextTokens] = React.useState(0);
{reducerText?.length >= 1 &&
<ContentReducer
initialText={reducerText} initialTokens={reducerTextTokens} tokenLimit={remainingTokens}
onReducedText={handleReducedText} onClose={handleReducerClose}
/>
}
const handleReducerClose = () => setReducerText('');
const handleReducedText = (text: string) => {
handleReducerClose();
setComposeText(_t => _t + text);
};
const handleAttachFiles = async (files: FileList, overrideFileNames?: string[]): Promise<void> => {
// see how we fare on budget
if (chatLLMId) {
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger');
// simple trigger for the reduction dialog
if (newTextTokens > remainingTokens) {
setReducerTextTokens(newTextTokens);
setReducerText(newText);
return;
}
}
// within the budget, so just append
setComposeText(text => expandPromptTemplate(PromptTemplates.Concatenate, { text: newText })(text));
*/
@@ -0,0 +1,201 @@
import { create } from 'zustand';
import type { FileWithHandle } from 'browser-fs-access';
import type { ComposerOutputMultiPart } from '../composer.types';
import { attachmentPerformConversion, attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync } from './pipeline';
// Attachment Types
export type AttachmentSourceOriginDTO = 'drop' | 'paste';
export type AttachmentSourceOriginFile = 'camera' | 'file-open' | 'clipboard-read' | AttachmentSourceOriginDTO;
export type AttachmentSource = {
media: 'url';
url: string;
refUrl: string;
} | {
media: 'file';
origin: AttachmentSourceOriginFile,
fileWithHandle: FileWithHandle;
refPath: string;
} | {
media: 'text';
method: 'clipboard-read' | AttachmentSourceOriginDTO;
textPlain?: string;
textHtml?: string;
};
export type AttachmentInput = {
mimeType: string; // Original MIME type of the file
data: string | ArrayBuffer; // The original data of the attachment
dataSize: number; // Size of the original data in bytes
altMimeType?: string; // Alternative MIME type for the input
altData?: string; // Alternative data for the input
// preview?: AttachmentPreview; // Preview of the input
};
export type AttachmentConverterType =
| 'text' | 'rich-text' | 'rich-text-table'
| 'pdf-text' | 'pdf-images'
| 'image' | 'image-ocr'
| 'unhandled';
export type AttachmentConverter = {
id: AttachmentConverterType;
name: string;
disabled?: boolean;
unsupported?: boolean;
// outputType: ComposerOutputPartType; // The type of the output after conversion
// isAutonomous: boolean; // Whether the conversion does not require user input
// isAsync: boolean; // Whether the conversion is asynchronous
// progress: number; // Conversion progress percentage (0..1)
// errorMessage?: string; // Error message if the conversion failed
}
export type AttachmentId = string;
export type Attachment = {
readonly id: AttachmentId;
readonly source: AttachmentSource,
label: string;
ref: string;
inputLoading: boolean;
inputError: string | null;
input?: AttachmentInput;
// options to convert the input
converters: AttachmentConverter[]; // List of available converters for this attachment
converterIdx: number | null; // Index of the selected converter
outputsConverting: boolean;
outputs: ComposerOutputMultiPart; // undefined: not yet converted, []: conversion failed, [ {}+ ]: conversion succeeded
// metadata: {
// size?: number; // Size of the attachment in bytes
// creationDate?: Date; // Creation date of the file
// modifiedDate?: Date; // Last modified date of the file
// altText?: string; // Alternative text for images for screen readers
// };
};
/*export type AttachmentPreview = {
renderer: 'noPreview',
title: string; // A title for the preview
} | {
renderer: 'textPreview'
fileName: string; // The name of the file
snippet: string; // A text snippet for documents
tooltip?: string; // A tooltip for the preview
} | {
renderer: 'imagePreview'
thumbnail: string; // A thumbnail preview for images, videos, etc.
tooltip?: string; // A tooltip for the preview
};*/
/// Store
interface AttachmentsStore {
attachments: Attachment[];
createAttachment: (source: AttachmentSource) => Promise<void>;
clearAttachments: () => void;
removeAttachment: (attachmentId: AttachmentId) => void;
moveAttachment: (attachmentId: AttachmentId, delta: 1 | -1) => void;
setConverterIdx: (attachmentId: AttachmentId, converterIdx: number | null) => Promise<void>;
_editAttachment: (attachmentId: AttachmentId, update: Partial<Attachment> | ((attachment: Attachment) => Partial<Attachment>)) => void;
_getAttachment: (attachmentId: AttachmentId) => Attachment | undefined;
}
export const useAttachmentsStore = create<AttachmentsStore>()(
(_set, _get) => ({
attachments: [],
createAttachment: async (source: AttachmentSource) => {
const { attachments, _getAttachment, _editAttachment, setConverterIdx } = _get();
const attachment = attachmentCreate(source, attachments.map(a => a.id));
_set({
attachments: [...attachments, attachment],
});
const editFn = (changes: Partial<Attachment>) => _editAttachment(attachment.id, changes);
// 1.Resolve the Input
await attachmentLoadInputAsync(source, editFn);
const loaded = _getAttachment(attachment.id);
if (!loaded || !loaded.input)
return;
// 2. Define the I->O Converters
attachmentDefineConverters(source.media, loaded.input, editFn);
const defined = _getAttachment(attachment.id);
if (!defined || !defined.converters.length || defined.converterIdx !== null)
return;
// 3. Select the first Converter
const firstEnabledIndex = defined.converters.findIndex(_c => !_c.disabled);
await setConverterIdx(attachment.id, firstEnabledIndex > -1 ? firstEnabledIndex : 0);
},
clearAttachments: () => _set({
attachments: [],
}),
removeAttachment: (attachmentId: AttachmentId) =>
_set(state => ({
attachments: state.attachments.filter(attachment => attachment.id !== attachmentId),
})),
moveAttachment: (attachmentId: AttachmentId, delta: 1 | -1) =>
_set(state => {
const attachments = [...state.attachments];
const currentIdx = attachments.findIndex(a => a.id === attachmentId);
// If the attachment is not found, or if trying to move beyond the array boundaries, no move is needed
if (currentIdx === -1 || (currentIdx === 0 && delta === -1) || (currentIdx === attachments.length - 1 && delta === 1))
return state;
// Swap the attachment with the adjacent one in the direction of delta
const targetIdx = currentIdx + delta;
[attachments[currentIdx], attachments[targetIdx]] = [attachments[targetIdx], attachments[currentIdx]];
return { attachments };
}),
setConverterIdx: async (attachmentId: AttachmentId, converterIdx: number | null) => {
const { _getAttachment, _editAttachment } = _get();
const attachment = _getAttachment(attachmentId);
if (!attachment || attachment.converterIdx === converterIdx)
return;
const editFn = (changes: Partial<Attachment>) => _editAttachment(attachmentId, changes);
await attachmentPerformConversion(attachment, converterIdx, editFn);
},
_editAttachment: (attachmentId: AttachmentId, update: Partial<Attachment> | ((attachment: Attachment) => Partial<Attachment>)) =>
_set(state => ({
attachments: state.attachments.map((attachment: Attachment): Attachment =>
attachment.id === attachmentId
? { ...attachment, ...(typeof update === 'function' ? update(attachment) : update) }
: attachment,
),
})),
_getAttachment: (attachmentId: AttachmentId) =>
_get().attachments.find(a => a.id === attachmentId),
}),
);
@@ -0,0 +1,165 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import type { FileWithHandle } from 'browser-fs-access';
import { addSnackbar } from '~/common/components/useSnackbarsStore';
import { asValidURL } from '~/common/util/urlUtils';
import { extractFilePathsWithCommonRadix } from '~/common/util/dropTextUtils';
import { getClipboardItems } from '~/common/util/clipboardUtils';
import { AttachmentSourceOriginDTO, AttachmentSourceOriginFile, useAttachmentsStore } from './store-attachments';
export const useAttachments = (enableLoadURLs: boolean) => {
// state
const { attachments, clearAttachments, createAttachment, removeAttachment } = useAttachmentsStore(state => ({
attachments: state.attachments,
clearAttachments: state.clearAttachments,
createAttachment: state.createAttachment,
removeAttachment: state.removeAttachment,
}), shallow);
// Creation helpers
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) =>
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 => {
// attach File(s)
if (dt.files.length >= 1) {
// 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')) {
const plainText = dt.getData('text/plain');
overrideFileNames = extractFilePathsWithCommonRadix(plainText);
}
const overrideNames = overrideFileNames.length === dt.files.length;
// attach as Files (paste and drop keep the original filename)
for (let i = 0; i < dt.files.length; i++) {
const file = dt.files[i];
// drag/drop of folders (or .tsx from IntelliJ) will have no type
if (!file.type) {
// NOTE: we are fixing it in attachmentLoadInputAsync, but would be better to do it here
}
void attachAppendFile(method, file, overrideNames ? overrideFileNames[i] || undefined : undefined);
}
return 'as_files';
}
// attach as URL
const textPlain = dt.getData('text/plain') || '';
if (textPlain && enableLoadURLs) {
const textPlainUrl = asValidURL(textPlain);
if (textPlainUrl && textPlainUrl) {
void createAttachment({
media: 'url', url: textPlainUrl, refUrl: textPlain,
});
return 'as_url';
}
}
// 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,
});
return 'as_text';
}
if (attachText)
console.warn(`Unhandled '${method}' attachment: `, dt.types?.map(t => `${t}: ${dt.getData(t)}`));
// did not attach anything from this data transfer
return false;
}, [attachAppendFile, createAttachment, enableLoadURLs]);
const attachAppendClipboardItems = React.useCallback(async () => {
// if there's an issue accessing the clipboard, show it passively
const clipboardItems = await getClipboardItems();
if (clipboardItems === null) {
addSnackbar({
key: 'clipboard-issue',
type: 'issue',
message: 'Clipboard empty or access denied',
overrides: {
autoHideDuration: 2000,
},
});
return;
}
// loop on all the possible attachments
for (const clipboardItem of clipboardItems) {
// attach as image
let imageAttached = false;
for (const mimeType of clipboardItem.types) {
if (mimeType.startsWith('image/')) {
try {
const imageBlob = await clipboardItem.getType(mimeType);
const imageName = mimeType.replace('image/', 'clipboard.').replaceAll('/', '.') || 'clipboard.png';
const imageFile = new File([imageBlob], imageName, { type: mimeType });
void attachAppendFile('clipboard-read', imageFile);
imageAttached = true;
} catch (error) {
// ignore getType error..
}
}
}
if (imageAttached)
continue;
// get the Plain text
const textPlain = clipboardItem.types.includes('text/plain') ? await clipboardItem.getType('text/plain').then(blob => blob.text()) : '';
// attach as URL
if (textPlain && enableLoadURLs) {
const textPlainUrl = asValidURL(textPlain);
if (textPlainUrl && textPlainUrl.trim()) {
void createAttachment({
media: 'url', url: textPlainUrl.trim(), refUrl: textPlain,
});
continue;
}
}
// 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,
});
continue;
}
console.warn('Clipboard item has no text/html or text/plain item.', clipboardItem.types, clipboardItem);
}
}, [attachAppendFile, createAttachment, enableLoadURLs]);
return {
// state
attachments,
// create attachments
attachAppendClipboardItems,
attachAppendDataTransfer,
attachAppendFile,
// manage attachments
clearAttachments,
removeAttachment,
};
};
@@ -0,0 +1,147 @@
import * as React from 'react';
import type { DLLMId } from '~/modules/llms/store-llms';
import { countModelTokens } from '~/common/util/token-counter';
import type { Attachment, AttachmentId } from './store-attachments';
import type { ComposerOutputMultiPart, ComposerOutputPartType } from '../composer.types';
export interface LLMAttachments {
attachments: LLMAttachment[];
getAttachmentOutputs: (initialTextBlockText: string | null, attachmentId: AttachmentId) => ComposerOutputMultiPart;
getAttachmentsOutputs: (initialTextBlockText: string | null) => ComposerOutputMultiPart;
isOutputAttacheable: boolean;
isOutputTextInlineable: boolean;
tokenCountApprox: number;
}
export interface LLMAttachment {
attachment: Attachment;
attachmentOutputs: ComposerOutputMultiPart;
isUnconvertible: boolean;
isOutputMissing: boolean;
isOutputAttachable: boolean;
isOutputTextInlineable: boolean;
tokenCountApprox: number | null;
}
export function useLLMAttachments(attachments: Attachment[], chatLLMId: DLLMId | null): LLMAttachments {
return React.useMemo(() => {
// HACK: in the future, switch to LLM capabilities (LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, etc.)
const supportsImages = !!chatLLMId?.endsWith('-vision-preview');
const supportedOutputPartTypes: ComposerOutputPartType[] = supportsImages ? ['text-block', 'image-part'] : ['text-block'];
const llmAttachments = attachments.map(attachment => toLLMAttachment(attachment, supportedOutputPartTypes, chatLLMId));
const getAttachmentOutputs = (initialTextBlockText: string | null, attachmentId: AttachmentId): ComposerOutputMultiPart => {
// get outputs of a specific attachment
const outputs = attachments.find(a => a.id === attachmentId)?.outputs || [];
return attachmentCollapseOutputs(initialTextBlockText, outputs);
};
const getAttachmentsOutputs = (initialTextBlockText: string | null): ComposerOutputMultiPart => {
// accumulate all outputs of all attachments
const allOutputs = llmAttachments.reduce((acc, a) => acc.concat(a.attachment.outputs), [] as ComposerOutputMultiPart);
return attachmentCollapseOutputs(initialTextBlockText, allOutputs);
};
return {
attachments: llmAttachments,
getAttachmentOutputs,
getAttachmentsOutputs,
isOutputAttacheable: llmAttachments.every(a => a.isOutputAttachable),
isOutputTextInlineable: llmAttachments.every(a => a.isOutputTextInlineable),
tokenCountApprox: llmAttachments.reduce((acc, a) => acc + (a.tokenCountApprox || 0), 0),
};
}, [attachments, chatLLMId]);
}
export function getTextBlockText(outputs: ComposerOutputMultiPart): string | null {
const textOutputs = outputs.filter(part => part.type === 'text-block');
return (textOutputs.length === 1 && textOutputs[0].type === 'text-block') ? textOutputs[0].text : null;
}
function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: ComposerOutputPartType[], llmForTokenCount: DLLMId | null): LLMAttachment {
const { converters, outputs } = attachment;
const isUnconvertible = converters.length === 0;
const isOutputMissing = outputs.length === 0;
const isOutputAttachable = areAllOutputsSupported(outputs, supportedOutputPartTypes);
const isOutputTextInlineable = areAllOutputsSupported(outputs, supportedOutputPartTypes.filter(pt => pt === 'text-block'));
const attachmentOutputs = attachmentCollapseOutputs(null, outputs);
const tokenCountApprox = llmForTokenCount
? attachmentOutputs.reduce((acc, output) => {
if (output.type === 'text-block')
return acc + countModelTokens(output.text, llmForTokenCount, 'attachments tokens count');
console.warn('Unhandled token preview for output type:', output.type);
return acc;
}, 0)
: null;
return {
attachment,
attachmentOutputs,
isUnconvertible,
isOutputMissing,
isOutputAttachable,
isOutputTextInlineable,
tokenCountApprox,
};
}
function areAllOutputsSupported(outputs: ComposerOutputMultiPart, supportedOutputPartTypes: ComposerOutputPartType[]) {
return outputs.length
? outputs.every(output => supportedOutputPartTypes.includes(output.type))
: false;
}
function attachmentCollapseOutputs(initialTextBlockText: string | null, outputs: ComposerOutputMultiPart): ComposerOutputMultiPart {
const accumulatedOutputs: ComposerOutputMultiPart = [];
// if there's initial text, make it a collapsible default (unquited) text block
if (initialTextBlockText !== null) {
accumulatedOutputs.push({
type: 'text-block',
text: initialTextBlockText,
title: null,
collapsible: true,
});
}
// Accumulate attachment outputs of the same type and 'collapsible' into a single object of that type.
for (const output of outputs) {
const last = accumulatedOutputs[accumulatedOutputs.length - 1];
// accumulationg over an existing part of the same type
if (last && last.type === output.type && output.collapsible) {
switch (last.type) {
case 'text-block':
last.text += `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``;
break;
default:
console.warn('Unhandled collapsing for output type:', output.type);
}
}
// start a new part
else {
if (output.type === 'text-block') {
accumulatedOutputs.push({
type: 'text-block',
text: `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``,
title: null,
collapsible: false,
});
} else {
accumulatedOutputs.push(output);
}
}
}
return accumulatedOutputs;
}
@@ -0,0 +1,15 @@
export type ComposerOutputPartType = 'text-block' | 'image-part';
export type ComposerOutputPart = {
type: 'text-block',
text: string,
title: string | null,
collapsible: boolean,
} | {
// TODO: not implemented yet
type: 'image-part',
base64Url: string,
collapsible: false,
};
export type ComposerOutputMultiPart = ComposerOutputPart[];
@@ -1,45 +1,8 @@
import * as React from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
export type ChatModeId = 'immediate' | 'write-user' | 'react' | 'draw-imagine' | 'draw-imagine-plus';
/// Describe the chat modes
export const ChatModeItems: {
[key in ChatModeId]: {
label: string;
description: string | React.JSX.Element;
shortcut?: string;
experimental?: boolean
}
} = {
'immediate': {
label: 'Chat',
description: 'Persona replies',
},
'write-user': {
label: 'Write',
description: 'Appends a message',
shortcut: 'Alt + Enter',
},
'draw-imagine': {
label: 'Draw',
description: 'AI Image Generation',
},
'draw-imagine-plus': {
label: 'Assisted Draw',
description: 'Assisted Image Generation',
experimental: true,
},
'react': {
label: 'Reason + Act · α',
description: 'Answers questions in multiple steps',
},
};
/// Composer Store
interface ComposerStore {
@@ -59,20 +22,11 @@ const useComposerStore = create<ComposerStore>()(
{
name: 'app-composer',
version: 1,
/*migrate: (state: any, version): ComposerStore => {
// 0 -> 1: rename history to sentMessages
if (state && version === 0) {
state.sentMessages = state.history;
delete state.history;
}
return state as ComposerStore;
},*/
}),
);
export const setComposerStartupText = (text: string | null) =>
useComposerStore.getState().setStartupText(text);
export const useComposerStartupText = (): [string | null, (text: string | null) => void] =>
useComposerStore(state => [state.startupText, state.setStartupText], shallow);
export const setComposerStartupText = (text: string | null) =>
useComposerStore.getState().setStartupText(text);
useComposerStore(state => [state.startupText, state.setStartupText], shallow);
@@ -11,7 +11,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DifferenceIcon from '@mui/icons-material/Difference';
import EditIcon from '@mui/icons-material/Edit';
import Face6Icon from '@mui/icons-material/Face6';
import FastForwardIcon from '@mui/icons-material/FastForward';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import PaletteOutlinedIcon from '@mui/icons-material/PaletteOutlined';
@@ -19,6 +19,8 @@ import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import ReplayIcon from '@mui/icons-material/Replay';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import TelegramIcon from '@mui/icons-material/Telegram';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DMessage } from '~/common/state/store-chats';
@@ -185,6 +187,8 @@ function useSanityTextDiffs(text: string, diffText: string | undefined, enabled:
}
export const ChatMessageMemo = React.memo(ChatMessage);
/**
* The Message component is a customizable chat message UI component that supports
* different roles (user, assistant, and system), text editing, syntax highlighting,
@@ -200,10 +204,12 @@ export function ChatMessage(props: {
noMarkdown?: boolean, diagramMode?: boolean,
isBottom?: boolean, noBottomBorder?: boolean,
isImagining?: boolean, isSpeaking?: boolean,
onMessageDelete?: () => void,
onMessageEdit?: (text: string) => void,
onMessageRunFrom?: (offset: number) => void,
onTextDiagram?: (text: string) => Promise<void>
onConversationBranch?: (messageId: string) => void,
onConversationRestartFrom?: (messageId: string, offset: number) => void,
onConversationTruncate?: (messageId: string) => void,
onMessageDelete?: (messageId: string) => void,
onMessageEdit?: (messageId: string, text: string) => void,
onTextDiagram?: (messageId: string, text: string) => Promise<void>
onTextImagine?: (text: string) => Promise<void>
onTextSpeak?: (text: string) => Promise<void>
sx?: SxProps,
@@ -228,6 +234,7 @@ export function ChatMessage(props: {
// derived state
const {
id: messageId,
text: messageText,
sender: messageSender,
avatar: messageAvatar,
@@ -256,7 +263,7 @@ export function ChatMessage(props: {
const handleTextEdited = (editedText: string) => {
setIsEditing(false);
if (props.onMessageEdit && editedText?.trim() && editedText !== messageText)
props.onMessageEdit(editedText);
props.onMessageEdit(messageId, editedText);
};
const handleUncollapse = () => setForceUserExpanded(true);
@@ -267,7 +274,7 @@ export function ChatMessage(props: {
const closeOperationsMenu = () => setOpsMenuAnchor(null);
const handleOpsCopy = (e: React.MouseEvent) => {
copyToClipboard(textSel);
copyToClipboard(textSel, 'Text');
e.preventDefault();
closeOperationsMenu();
closeSelectionMenu();
@@ -280,12 +287,24 @@ export function ChatMessage(props: {
closeOperationsMenu();
};
const handleOpsConversationBranch = (e: React.MouseEvent) => {
e.preventDefault();
props.onConversationBranch && props.onConversationBranch(messageId);
closeOperationsMenu();
};
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
e.preventDefault();
props.onConversationRestartFrom && props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
closeOperationsMenu();
};
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
const handleOpsDiagram = async (e: React.MouseEvent) => {
e.preventDefault();
if (props.onTextDiagram) {
await props.onTextDiagram(textSel);
await props.onTextDiagram(messageId, textSel);
closeOperationsMenu();
closeSelectionMenu();
}
@@ -309,12 +328,13 @@ export function ChatMessage(props: {
}
};
const handleOpsRunAgain = (e: React.MouseEvent) => {
e.preventDefault();
if (props.onMessageRunFrom) {
props.onMessageRunFrom(fromAssistant ? -1 : 0);
closeOperationsMenu();
}
const handleOpsTruncate = (_e: React.MouseEvent) => {
props.onConversationTruncate && props.onConversationTruncate(messageId);
closeOperationsMenu();
};
const handleOpsDelete = (_e: React.MouseEvent) => {
props.onMessageDelete && props.onMessageDelete(messageId);
};
@@ -500,12 +520,12 @@ export function ChatMessage(props: {
: block.type === 'code'
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} noCopyButton={props.diagramMode} />
: block.type === 'image'
? <RenderImage key={'image-' + index} imageBlock={block} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsRunAgain} />
? <RenderImage key={'image-' + index} imageBlock={block} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsConversationRestartFrom} />
: block.type === 'latex'
? <RenderLatex key={'latex-' + index} latexBlock={block} />
: block.type === 'diff'
? <RenderTextDiff key={'latex-' + index} diffBlock={block} />
: (renderMarkdown && props.noMarkdown !== true && !fromSystem)
: (renderMarkdown && props.noMarkdown !== true && !fromSystem && !(fromUser && block.content.startsWith('/')))
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
: <RenderText key={'text-' + index} textBlock={block} />)}
@@ -541,7 +561,7 @@ export function ChatMessage(props: {
{/* Operations Menu (3 dots) */}
{!!opsMenuAnchor && (
<CloseableMenu
placement='bottom-end' sx={{ minWidth: 280 }}
dense placement='bottom-end' sx={{ minWidth: 280 }}
open anchorEl={opsMenuAnchor} onClose={closeOperationsMenu}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
@@ -566,13 +586,13 @@ export function ChatMessage(props: {
</MenuItem>
)}
<ListDivider />
{!!props.onMessageRunFrom && (
<MenuItem onClick={handleOpsRunAgain}>
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <FastForwardIcon />}</ListItemDecorator>
{!!props.onConversationRestartFrom && (
<MenuItem onClick={handleOpsConversationRestartFrom}>
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <TelegramIcon />}</ListItemDecorator>
{!fromAssistant
? 'Run from here'
? <>Restart <span style={{ opacity: 0.5 }}>from here</span></>
: !props.isBottom
? 'Retry from here'
? <>Retry <span style={{ opacity: 0.5 }}>from here</span></>
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Retry
<KeyStroke combo='Ctrl + Shift + R' />
@@ -580,7 +600,16 @@ export function ChatMessage(props: {
}
</MenuItem>
)}
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
{!!props.onConversationBranch && (
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
<ListItemDecorator>
<ForkRightIcon />
</ListItemDecorator>
Branch {!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
</MenuItem>
)}
{!!props.onConversationBranch && <ListDivider />}
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Visualize ...
</MenuItem>}
@@ -592,11 +621,17 @@ export function ChatMessage(props: {
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
</MenuItem>}
{!!props.onMessageRunFrom && <ListDivider />}
{!!props.onConversationRestartFrom && <ListDivider />}
{!!props.onConversationTruncate && (
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Truncate <span style={{ opacity: 0.5 }}>after</span>
</MenuItem>
)}
{!!props.onMessageDelete && (
<MenuItem onClick={props.onMessageDelete} disabled={false /*fromSystem*/}>
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Delete
Delete <span style={{ opacity: 0.5 }}>message</span>
</MenuItem>
)}
</CloseableMenu>
@@ -605,12 +640,12 @@ export function ChatMessage(props: {
{/* Selection (Contextual) Menu */}
{!!selMenuAnchor && (
<CloseableMenu
placement='bottom-start' sx={{ minWidth: 220 }}
dense placement='bottom-start' sx={{ minWidth: 220 }}
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
>
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
Copy selection
Copy <span style={{ opacity: 0.5 }}>selection</span>
</MenuItem>
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
@@ -6,7 +6,7 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { DMessage } from '~/common/state/store-chats';
import { TokenBadge } from '../composer/TokenBadge';
import { TokenBadgeMemo } from '../composer/TokenBadge';
import { makeAvatar, messageBackground } from './ChatMessage';
@@ -94,7 +94,7 @@ export function CleanerMessage(props: { message: DMessage, isBottom: boolean, se
</Typography>
{props.remainingTokens !== undefined && <Box sx={{ display: 'flex', minWidth: { xs: 32, sm: 45 }, justifyContent: 'flex-end' }}>
<TokenBadge directTokens={messageTokenCount} tokenLimit={props.remainingTokens} inline />
<TokenBadgeMemo direct={messageTokenCount} limit={props.remainingTokens} inline />
</Box>}
<Typography level='body-md' sx={{
@@ -115,7 +115,7 @@ function RenderCodeImpl(props: {
const handleCopyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation();
copyToClipboard(blockCode);
copyToClipboard(blockCode, 'Code');
};
return (
@@ -31,10 +31,12 @@ declare global {
}
}
const useMermaidStore = create<{
interface MermaidAPIStore {
mermaidAPI: MermaidAPI | null,
loadingError: string | null,
}>()(
}
const useMermaidStore = create<MermaidAPIStore>()(
() => ({
mermaidAPI: null,
loadingError: null,
@@ -59,7 +59,7 @@ export function RenderHtml(props: { htmlBlock: HtmlBlock, sx?: SxProps }) {
const handleCopyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation();
copyToClipboard(props.htmlBlock.html);
copyToClipboard(props.htmlBlock.html, 'HTML');
};
return (
@@ -3,7 +3,7 @@ import * as React from 'react';
import { Chip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { extractCommands } from '../../commands';
import { extractCommands } from '../../editors/commands';
import { TextBlock } from './blocks';
@@ -7,9 +7,10 @@ import ScienceIcon from '@mui/icons-material/Science';
import SearchIcon from '@mui/icons-material/Search';
import TelegramIcon from '@mui/icons-material/Telegram';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { Link } from '~/common/components/Link';
import { useChatStore } from '~/common/state/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { usePurposeStore } from './store-purposes';
@@ -38,7 +39,7 @@ const getRandomElement = <T, >(array: T[]): T | undefined =>
/**
* Purpose selector for the current chat. Clicking on any item activates it for the current chat.
*/
export function PersonaSelector(props: { conversationId: string, runExample: (example: string) => void }) {
export function PersonaSelector(props: { conversationId: DConversationId, runExample: (example: string) => void }) {
// state
const [searchQuery, setSearchQuery] = React.useState('');
const [filteredIDs, setFilteredIDs] = React.useState<SystemPurposeId[] | null>(null);
@@ -46,6 +47,7 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
// external state
const showFinder = useUIPreferencesStore(state => state.showPurposeFinder);
const labsPersonaYTCreator = useUXLabsStore(state => state.labsPersonaYTCreator);
const { systemPurposeId, setSystemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
@@ -184,7 +186,7 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
</Grid>
))}
{/* Button to start the YouTube persona creator */}
<Grid>
{labsPersonaYTCreator && <Grid>
<Button
variant='soft' color='neutral'
component={Link} noLinkStyle href='/personas'
@@ -207,9 +209,8 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
YouTube persona creator
</div>
</Button>
</Grid>
</Grid>}
</Grid>
<Typography
level='body-sm'
sx={{
+288
View File
@@ -0,0 +1,288 @@
import * as React from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
// change this to increase/decrease the number history steps per pane
const MAX_HISTORY_LENGTH = 10;
// change to true to enable verbose console logging
const DEBUG_PANES_MANAGER = false;
interface ChatPane {
conversationId: DConversationId | null;
history: DConversationId[]; // History of the conversationIds for this pane
historyIndex: number; // Current position in the history for this pane
}
interface AppChatPanesStore {
// state
chatPanes: ChatPane[];
chatPaneFocusIndex: number | null;
chatPaneInputMode: 'focused' | 'broadcast';
// actions
openConversationInFocusedPane: (conversationId: DConversationId) => void;
openConversationInSplitPane: (conversationId: DConversationId) => void;
navigateHistoryInFocusedPane: (direction: 'back' | 'forward') => boolean;
setFocusedPaneIndex: (paneIndex: number) => void;
splitChatPane: (numberOfPanes: number) => void;
unsplitChatPane: (paneIndexToKeep: number) => void;
onConversationsChanged: (conversationIds: DConversationId[]) => void;
}
function createPane(conversationId: DConversationId | null = null): ChatPane {
return {
conversationId,
history: conversationId ? [conversationId] : [],
historyIndex: conversationId ? 0 : -1,
};
}
const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
(_set, _get) => ({
// Initial state: no panes
chatPanes: [] as ChatPane[],
chatPaneFocusIndex: null as number | null,
chatPaneInputMode: 'focused' as 'focused' | 'broadcast',
openConversationInFocusedPane: (conversationId: DConversationId) => {
_set((state) => {
const { chatPanes, chatPaneFocusIndex } = state;
// If there's no pane or no focused pane, create and focus a new one.
if (!chatPanes.length || chatPaneFocusIndex === null) {
const newPane = createPane(conversationId);
return {
chatPanes: [newPane],
chatPaneFocusIndex: 0, // Focus the new pane
};
}
// Check if the conversation is already open in the focused pane.
const focusedPane = chatPanes[chatPaneFocusIndex];
if (focusedPane.conversationId === conversationId) {
if (DEBUG_PANES_MANAGER)
console.log(`open-focuses: ${conversationId} is open in focused pane`, chatPaneFocusIndex, chatPanes);
return state;
}
// Truncate the future history before adding the new conversation.
const truncatedHistory = focusedPane.history.slice(0, focusedPane.historyIndex + 1);
const newHistory = [...truncatedHistory, conversationId].slice(-MAX_HISTORY_LENGTH);
// Update the focused pane with the new conversation.
const newPanes = [...chatPanes];
newPanes[chatPaneFocusIndex] = {
...focusedPane,
conversationId,
history: newHistory,
historyIndex: newHistory.length - 1,
};
if (DEBUG_PANES_MANAGER)
console.log(`open-focuses: set ${conversationId} in focused pane`, chatPaneFocusIndex, chatPanes);
// Return the updated state.
return {
chatPanes: newPanes,
};
});
},
openConversationInSplitPane: (conversationId: DConversationId) => {
// Open a conversation in a new pane, reusing an existing pane if possible.
const { chatPanes, chatPaneFocusIndex, openConversationInFocusedPane } = _get();
// one pane open: split it
if (chatPanes.length === 1) {
_set({
chatPanes: Array.from({ length: 2 }, () => ({ ...chatPanes[0] })),
chatPaneFocusIndex: 1,
});
}
// more than 2 panes, reuse the alt pane
else if (chatPanes.length >= 2 && chatPaneFocusIndex !== null) {
_set({
chatPaneFocusIndex: chatPaneFocusIndex === 0 ? 1 : 0,
});
}
// will create a pane if none exists, or load the conversation in the focused pane
openConversationInFocusedPane(conversationId);
if (DEBUG_PANES_MANAGER)
console.log(`open-split-pane: after:`, _get().chatPanes);
},
navigateHistoryInFocusedPane: (direction: 'back' | 'forward'): boolean => {
const { chatPanes, chatPaneFocusIndex } = _get();
if (chatPaneFocusIndex === null)
return false;
const focusedPane = chatPanes[chatPaneFocusIndex];
let newHistoryIndex = focusedPane.historyIndex;
if (direction === 'back' && newHistoryIndex > 0)
newHistoryIndex--;
else if (direction === 'forward' && newHistoryIndex < focusedPane.history.length - 1)
newHistoryIndex++;
else {
if (DEBUG_PANES_MANAGER)
console.log(`navigateHistoryInFocusedPane: no history ${direction} for`, focusedPane);
return false;
}
const newPanes = [...chatPanes];
newPanes[chatPaneFocusIndex] = {
...focusedPane,
conversationId: focusedPane.history[newHistoryIndex],
historyIndex: newHistoryIndex,
};
if (DEBUG_PANES_MANAGER)
console.log(`navigateHistoryInFocusedPane: ${direction} to`, focusedPane, newPanes);
_set({
chatPanes: newPanes,
});
return true;
},
setFocusedPaneIndex: (paneIndex: number) =>
_set(state => {
if (state.chatPaneFocusIndex === paneIndex)
return state;
return {
chatPaneFocusIndex: paneIndex >= 0 && paneIndex < state.chatPanes.length ? paneIndex : null,
};
}),
splitChatPane: (numberOfPanes: number) => {
const { chatPanes, chatPaneFocusIndex } = _get();
const focusedPane = (chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex] : null) ?? createPane();
_set({
chatPanes: Array.from({ length: numberOfPanes }, () => ({ ...focusedPane })),
chatPaneFocusIndex: 0,
});
},
unsplitChatPane: (paneIndexToKeep: number) =>
_set(state => ({
chatPanes: [state.chatPanes[paneIndexToKeep] || createPane()],
chatPaneFocusIndex: 0,
})),
/**
* This function is vital, as is invoked when the conversationId[] changes in the global chats store.
* It takes care of `creating the first pane` as well as `removing invalid history items, reassiging
* conversationIds, and re-focusing the pane`.
*/
onConversationsChanged: (conversationIds: DConversationId[]) =>
_set(state => {
const { chatPanes, chatPaneFocusIndex } = state;
// handle panes
let untouched = true;
const newPanes: ChatPane[] = chatPanes.map(chatPane => {
const { conversationId, history, historyIndex } = chatPane;
// adjust history if any is deleted
let newHistoryIndex = historyIndex;
const newHistory = history.filter((_hId, index) => {
const historyStillPresent = conversationIds.includes(_hId);
if (!historyStillPresent && index <= historyIndex)
newHistoryIndex--;
return historyStillPresent;
});
if (newHistoryIndex < 0 && newHistory.length > 0)
newHistoryIndex = 0;
// check if pointing to a valid conversationId
const needsNewConversationId = !conversationId || !conversationIds.includes(conversationId);
if (!needsNewConversationId && newHistory.length === history.length)
return chatPane;
const nextConversationId = newHistoryIndex >= 0 && newHistoryIndex < newHistory.length
? newHistory[newHistoryIndex]
: newHistory.length > 0
? newHistory[newHistory.length - 1]
: conversationIds[0] ?? null;
untouched = false;
return {
...chatPane,
conversationId: nextConversationId,
history: newHistory,
historyIndex: newHistoryIndex,
};
}).filter(pane => !!pane.conversationId);
// if untouched, return state as-is
if (untouched && newPanes.length >= 1)
return state;
// play it safe, and make sure a pane exists, and is focused
return {
chatPanes: newPanes.length ? newPanes : [createPane(conversationIds[0] ?? null)],
chatPaneFocusIndex: (newPanes.length && chatPaneFocusIndex !== null && chatPaneFocusIndex < newPanes.length) ? state.chatPaneFocusIndex : 0,
};
}),
}), {
name: 'app-app-chat-panes',
},
));
export function usePanesManager() {
// use Panes
const { onConversationsChanged, ...panesFunctions } = useAppChatPanesStore(state => {
const {
chatPaneFocusIndex,
chatPanes,
navigateHistoryInFocusedPane,
onConversationsChanged,
openConversationInFocusedPane,
openConversationInSplitPane,
setFocusedPaneIndex,
} = state;
const focusedConversationId = chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex]?.conversationId ?? null : null;
return {
chatPanes: chatPanes as Readonly<ChatPane[]>,
focusedConversationId,
navigateHistoryInFocusedPane,
onConversationsChanged,
openConversationInFocusedPane,
openConversationInSplitPane,
setFocusedPaneIndex,
};
}, shallow);
// use Conversation IDs[]
const conversationIDs: DConversationId[] = useChatStore(state => {
return state.conversations.map(_c => _c.id);
}, shallow);
// [Effect] Ensure all Panes have a valid Conversation ID
React.useEffect(() => {
onConversationsChanged(conversationIDs);
}, [conversationIDs, onConversationsChanged]);
return {
...panesFunctions,
};
}
+38
View File
@@ -0,0 +1,38 @@
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { DMessage, useChatStore } from '~/common/state/store-chats';
import { createAssistantTypingMessage } from './editors';
export const runBrowseUpdatingState = async (conversationId: string, url: string) => {
const { editMessage } = useChatStore.getState();
// create a blank and 'typing' message for the assistant - to be filled when we're done
// const assistantModelStr = 'react-' + assistantModelId.slice(4, 7); // HACK: this is used to change the Avatar animation
// noinspection HttpUrlsUsage
const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', '');
const assistantMessageId = createAssistantTypingMessage(conversationId, 'web', undefined, `Loading page at ${shortUrl}...`);
const updateAssistantMessage = (update: Partial<DMessage>) => editMessage(conversationId, assistantMessageId, update, false);
try {
const page = await callBrowseFetchPage(url);
if (!page.content) {
// noinspection ExceptionCaughtLocallyJS
throw new Error('No text found.');
}
updateAssistantMessage({
text: page.content,
typing: false,
});
} catch (error: any) {
console.error(error);
updateAssistantMessage({
text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').',
typing: false,
});
}
};
+1 -1
View File
@@ -20,7 +20,7 @@ export async function runAssistantUpdatingState(conversationId: string, history:
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
// update the system message from the active Purpose, if not manually edited
history = updatePurposeInHistory(conversationId, history, systemPurpose);
history = updatePurposeInHistory(conversationId, history, assistantLlmId, systemPurpose);
// create a blank and 'typing' message for the assistant
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantLlmId, history[0].purposeId, '...');
@@ -1,10 +1,16 @@
import { CmdRunBrowse } from '~/modules/browse/browse.client';
import { CmdRunProdia } from '~/modules/prodia/prodia.client';
import { CmdRunReact } from '~/modules/aifn/react/react';
import { CmdRunSearch } from '~/modules/google/search.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 commands = [...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage];
export const CmdHelp: string[] = ['/help', '/h', '/?'];
export const commands = [...CmdRunBrowse, ...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage, ...CmdHelp];
export interface SentencePiece {
type: 'text' | 'cmd';
@@ -16,6 +22,9 @@ export interface SentencePiece {
* 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[] = [];
@@ -37,4 +46,12 @@ export function extractCommands(input: string): SentencePiece[] {
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;
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { SystemPurposeId, SystemPurposes } from '../../../data';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | 'prodia' | 'react-...', assistantPurposeId: SystemPurposeId | undefined, text: string): string {
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | 'prodia' | 'react-...' | 'web', assistantPurposeId: SystemPurposeId | undefined, text: string): string {
const assistantMessage: DMessage = createDMessage('assistant', text);
assistantMessage.typing = true;
assistantMessage.purposeId = assistantPurposeId;
@@ -14,12 +14,13 @@ export function createAssistantTypingMessage(conversationId: string, assistantLl
}
export function updatePurposeInHistory(conversationId: string, history: DMessage[], purposeId: SystemPurposeId): DMessage[] {
export function updatePurposeInHistory(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, purposeId: SystemPurposeId): DMessage[] {
const systemMessageIndex = history.findIndex(m => m.role === 'system');
const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', '');
if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) {
systemMessage.purposeId = purposeId;
systemMessage.text = SystemPurposes[purposeId].systemMessage
.replaceAll('{{Cutoff}}', assistantLlmId.includes('1106') ? '2023-04' : '2021-09')
.replaceAll('{{Today}}', new Date().toISOString().split('T')[0]);
// HACK: this is a special case for the "Custom" persona, to set the message in stone (so it doesn't get updated when switching to another persona)
+4 -4
View File
@@ -1,5 +1,6 @@
import { Agent } from '~/modules/aifn/react/react';
import { DLLMId } from '~/modules/llms/store-llms';
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
import { createDEphemeral, DMessage, useChatStore } from '~/common/state/store-chats';
@@ -11,6 +12,7 @@ import { createAssistantTypingMessage } from './editors';
*/
export async function runReActUpdatingState(conversationId: string, question: string, assistantLlmId: DLLMId) {
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
const { appendEphemeral, updateEphemeralText, updateEphemeralState, deleteEphemeral, editMessage } = useChatStore.getState();
// create a blank and 'typing' message for the assistant - to be filled when we're done
@@ -30,15 +32,13 @@ export async function runReActUpdatingState(conversationId: string, question: st
ephemeralText += (text.length > 300 ? text.slice(0, 300) + '...' : text) + '\n';
updateEphemeralText(conversationId, ephemeral.id, ephemeralText);
};
const showStateInEphemeral = (state: object) => updateEphemeralState(conversationId, ephemeral.id, state);
try {
// react loop
const agent = new Agent();
const reactResult = await agent.reAct(question, assistantLlmId, 5,
logToEphemeral,
(state: object) => updateEphemeralState(conversationId, ephemeral.id, state),
);
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 2 * 1000);
updateAssistantMessage({ text: reactResult, typing: false });
+31 -32
View File
@@ -5,9 +5,10 @@ import { persist } from 'zustand/middleware';
export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
interface AppChatState {
// Chat AI
// Chat Settings (Chat AI & Chat UI)
interface AppChatStore {
autoSpeak: ChatAutoSpeakType;
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void;
@@ -19,9 +20,10 @@ interface AppChatState {
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void;
autoTitleChat: boolean;
setautoTitleChat: (autoTitleChat: boolean) => void;
setAutoTitleChat: (autoTitleChat: boolean) => void;
// Chat
micTimeoutMs: number;
setMicTimeoutMs: (micTimeoutMs: number) => void;
showTextDiff: boolean;
setShowTextDiff: (showTextDiff: boolean) => void;
@@ -31,40 +33,43 @@ interface AppChatState {
}
const useAppChatStore = create<AppChatState>()(persist(
(set) => ({
// Chat AI
const useAppChatStore = create<AppChatStore>()(persist(
(_set, _get) => ({
autoSpeak: 'off',
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => set({ autoSpeak }),
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => _set({ autoSpeak }),
autoSuggestDiagrams: false,
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => set({ autoSuggestDiagrams }),
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => _set({ autoSuggestDiagrams }),
autoSuggestQuestions: false,
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => set({ autoSuggestQuestions }),
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => _set({ autoSuggestQuestions }),
autoTitleChat: true,
setautoTitleChat: (autoTitleChat: boolean) => set({ autoTitleChat }),
setAutoTitleChat: (autoTitleChat: boolean) => _set({ autoTitleChat }),
micTimeoutMs: 2000,
setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }),
showTextDiff: false,
setShowTextDiff: (showTextDiff: boolean) => set({ showTextDiff }),
setShowTextDiff: (showTextDiff: boolean) => _set({ showTextDiff }),
showSystemMessages: false,
setShowSystemMessages: (showSystemMessages: boolean) => set({ showSystemMessages }),
setShowSystemMessages: (showSystemMessages: boolean) => _set({ showSystemMessages }),
}), {
name: 'app-app-chat',
version: 1,
// for now, let text diff be off by default
onRehydrateStorage: () => (state) => {
if (state)
state.showTextDiff = false;
if (!state) return;
// for now, let text diff be off by default
state.showTextDiff = false;
},
migrate: (state: any, fromVersion: number): AppChatState => {
migrate: (state: any, fromVersion: number): AppChatStore => {
// 0 -> 1: autoTitleChat was off by mistake - turn it on [Remove past Dec 1, 2023]
if (state && fromVersion < 1)
state.autoTitleChat = true;
@@ -74,18 +79,7 @@ const useAppChatStore = create<AppChatState>()(persist(
));
// Chat AI
export const useChatAutoAI = (): {
autoSpeak: ChatAutoSpeakType,
autoSuggestDiagrams: boolean,
autoSuggestQuestions: boolean,
autoTitleChat: boolean,
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void,
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => void,
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void,
setautoTitleChat: (autoTitleChat: boolean) => void,
} => useAppChatStore(state => ({
export const useChatAutoAI = () => useAppChatStore(state => ({
autoSpeak: state.autoSpeak,
autoSuggestDiagrams: state.autoSuggestDiagrams,
autoSuggestQuestions: state.autoSuggestQuestions,
@@ -93,7 +87,7 @@ export const useChatAutoAI = (): {
setAutoSpeak: state.setAutoSpeak,
setAutoSuggestDiagrams: state.setAutoSuggestDiagrams,
setAutoSuggestQuestions: state.setAutoSuggestQuestions,
setautoTitleChat: state.setautoTitleChat,
setAutoTitleChat: state.setAutoTitleChat,
}), shallow);
export const getChatAutoAI = (): {
@@ -103,12 +97,17 @@ export const getChatAutoAI = (): {
autoTitleChat: boolean,
} => useAppChatStore.getState();
// Chat
export const useChatMicTimeoutMsValue = (): number =>
useAppChatStore(state => state.micTimeoutMs);
export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void] =>
useAppChatStore(state => [state.micTimeoutMs, state.setMicTimeoutMs], shallow);
export const useChatShowTextDiff = (): [boolean, (showDiff: boolean) => void] =>
useAppChatStore(state => [state.showTextDiff, state.setShowTextDiff], shallow);
export const getChatShowSystemMessages = (): boolean => useAppChatStore.getState().showSystemMessages;
export const getChatShowSystemMessages = (): boolean =>
useAppChatStore.getState().showSystemMessages;
export const useChatShowSystemMessages = (): [boolean, (showSystemMessages: boolean) => void] =>
useAppChatStore(state => [state.showSystemMessages, state.setShowSystemMessages], shallow);
-68
View File
@@ -1,68 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Button, Card, CardContent, Container, Switch, Typography } from '@mui/joy';
import ScienceIcon from '@mui/icons-material/Science';
import { Link } from '~/common/components/Link';
import { useUIPreferencesStore } from '~/common/state/store-ui';
export function AppLabs() {
// external state
const { experimentalLabs, setExperimentalLabs } = useUIPreferencesStore(state => ({
experimentalLabs: state.experimentalLabs, setExperimentalLabs: state.setExperimentalLabs,
}), shallow);
const handleLabsChange = (event: React.ChangeEvent<HTMLInputElement>) => setExperimentalLabs(event.target.checked);
return (
<Box sx={{
backgroundColor: 'background.level1',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
overflowY: 'auto',
minHeight: 96,
p: { xs: 3, md: 6 },
gap: 4,
}}>
<Typography level='h1' sx={{ fontSize: '3.6rem' }}>
Labs <ScienceIcon sx={{ fontSize: '3.3rem' }} />
</Typography>
<Switch checked={experimentalLabs} onChange={handleLabsChange}
endDecorator={experimentalLabs ? 'On' : 'Off'}
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
<Container disableGutters maxWidth='sm'>
<Card>
<CardContent>
<Typography>
The Labs section is where we experiment with new features and ideas.
</Typography>
<Typography level='title-md' sx={{ mt: 2 }}>
Features {experimentalLabs ? 'enabled' : 'disabled'}:
</Typography>
<ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 32 }}>
<li><b>Text tools</b> - complete (highlight differences)</li>
<li><b>YouTube persona synthesizer</b> - alpha, not persisted</li>
<li><b>Chat mode: follow-up/augmentation</b> - alpha (diagrams)</li>
<li><b>Relative chats size</b> - complete</li>
</ul>
<Typography sx={{ mt: 2 }}>
For any questions and creative idea, please join us on Discord, and let&apos;s talk!
</Typography>
</CardContent>
</Card>
</Container>
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
Got it!
</Button>
</Box>
);
}
+2 -2
View File
@@ -4,8 +4,8 @@ import { useQuery } from '@tanstack/react-query';
import { Box, Typography } from '@mui/joy';
import { createConversationFromJsonV1 } from '../chat/trade/trade.client';
import { useHasChatLinkItems } from '../chat/trade/store-sharing';
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';
+3 -3
View File
@@ -4,12 +4,12 @@ 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 '../chat/trade/store-sharing';
import { useChatLinkItems } from '~/modules/trade/store-module-trade';
import { Brand } from '~/common/app.config';
import { Link } from '~/common/components/Link';
import { closeLayoutDrawer } from '~/common/layout/store-applayout';
import { getChatLinkRelativePath, getHomeLink } from '~/common/app.routes';
import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes';
/**
@@ -28,7 +28,7 @@ export function AppChatLinkDrawerItems() {
<MenuItem
onClick={closeLayoutDrawer}
component={Link} href={getHomeLink()} noLinkStyle
component={Link} href={ROUTE_INDEX} noLinkStyle
>
<ListItemDecorator><ArrowBackIcon /></ListItemDecorator>
{Brand.Title.Base}
+2 -2
View File
@@ -57,8 +57,8 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
const handleClone = async (canOverwrite: boolean) => {
setCloning(true);
useChatStore.getState().importConversation({ ...props.conversation }, !canOverwrite);
await navigateToChat();
const importedId = useChatStore.getState().importConversation({ ...props.conversation }, !canOverwrite);
await navigateToChat(importedId);
setCloning(false);
};
+14 -4
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Chip, IconButton, List, ListItem, ListItemButton, Tooltip, Typography } from '@mui/joy';
import { Box, Chip, IconButton, List, ListItem, ListItemButton, Typography } from '@mui/joy';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
@@ -9,6 +9,7 @@ import { DLLM, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms'
import { IModelVendor } from '~/modules/llms/vendors/IModelVendor';
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { openLayoutLLMOptions } from '~/common/layout/store-applayout';
@@ -17,18 +18,27 @@ function ModelItem(props: { llm: DLLM, vendor: IModelVendor, chipChat: boolean,
// derived
const llm = props.llm;
const label = llm.label;
const tooltip = `${llm._source.label}${llm.description ? ' - ' + llm.description : ''} - ${llm.contextTokens?.toLocaleString() || 'unknown tokens size'}`;
let tooltip = llm._source.label;
if (llm.description)
tooltip += ' - ' + llm.description;
tooltip += ' - ';
if (llm.contextTokens) {
tooltip += llm.contextTokens.toLocaleString() + ' tokens';
// if (llm.maxOutputTokens)
// tooltip += ' / ' + llm.maxOutputTokens.toLocaleString() + ' max';
} else
tooltip += 'unknown tokens size';
return (
<ListItem>
<ListItemButton onClick={() => openLayoutLLMOptions(llm.id)} sx={{ alignItems: 'center', gap: 1 }}>
{/* Model Name */}
<Tooltip title={tooltip}>
<GoodTooltip title={tooltip}>
<Typography sx={llm.hidden ? { color: 'neutral.plainDisabledColor' } : undefined}>
{label}
</Typography>
</Tooltip>
</GoodTooltip>
{/* --> */}
<Box sx={{ flex: 1 }} />
-2
View File
@@ -9,7 +9,6 @@ import { createModelSourceForDefaultVendor, findVendorById } from '~/modules/llm
import { GoodModal } from '~/common/components/GoodModal';
import { closeLayoutModelsSetup, openLayoutModelsSetup, useLayoutModelsSetup } from '~/common/layout/store-applayout';
import { settingsGap } from '~/common/app.theme';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { LLMOptionsModal } from './LLMOptionsModal';
import { ModelsList } from './ModelsList';
@@ -36,7 +35,6 @@ export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
modelSources: state.sources,
llmCount: state.llms.length,
}), shallow);
useGlobalShortcut('m', true, true, false, openLayoutModelsSetup);
// auto-select the first source - note: we could use a useEffect() here, but this is more efficient
// also note that state-persistence is unneeded
+50 -21
View File
@@ -1,18 +1,39 @@
import * as React from 'react';
import { keyframes } from '@emotion/react';
import TimeAgo from 'react-timeago';
import { Box, Button, Card, CardContent, Container, IconButton, Typography } from '@mui/joy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Brand } from '~/common/app.config';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { Link } from '~/common/components/Link';
import { ROUTE_INDEX } from '~/common/app.routes';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { newsCallout, NewsItems } from './news.data';
// number of news items to show by default, before the expander
const DEFAULT_NEWS_COUNT = 2;
export const cssColorKeyframes = keyframes`
0%, 100% {
color: #636B74; /* Neutral main color (500) */
}
25% {
color: #12467B; /* Primary darker shade (700) */
}
50% {
color: #0B6BCB; /* Primary main color (500) */
}
75% {
color: #97C3F0; /* Primary lighter shade (300) */
}`;
export function AppNews() {
// state
const [lastNewsIdx, setLastNewsIdx] = React.useState<number>(0);
const [lastNewsIdx, setLastNewsIdx] = React.useState<number>(DEFAULT_NEWS_COUNT - 1);
// news selection
const news = NewsItems.filter((_, idx) => idx <= lastNewsIdx);
@@ -35,13 +56,24 @@ export function AppNews() {
}}>
<Typography level='h1' sx={{ fontSize: '3rem' }}>
Welcome to {Brand.Title.Base} {firstNews?.versionName}!
Welcome to {Brand.Title.Base} <Box component='span' sx={{ animation: `${cssColorKeyframes} 10s infinite` }}>{firstNews?.versionCode}</Box>!
</Typography>
<Typography>
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionName}.
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionCode}
</Typography>
<Box>
<Button
variant='solid' color='neutral' size='lg'
component={Link} href={ROUTE_INDEX} noLinkStyle
endDecorator='✨'
sx={{ minWidth: 200 }}
>
Sweet
</Button>
</Box>
{!!newsCallout && <Container disableGutters maxWidth='sm'>{newsCallout}</Container>}
{!!news && <Container disableGutters maxWidth='sm'>
@@ -49,27 +81,30 @@ export function AppNews() {
const firstCard = idx === 0;
const hasCardAfter = news.length < NewsItems.length;
const showExpander = hasCardAfter && (idx === news.length - 1);
const addPadding = !firstCard; // || showExpander;
const addPadding = false; //!firstCard; // || showExpander;
return <Card key={'news-' + idx} sx={{ mb: 2, minHeight: 32 }}>
<CardContent sx={{ position: 'relative', pr: addPadding ? 4 : 0 }}>
{!!ni.text && <Typography component='div'>
{ni.text}
</Typography>}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<GoodTooltip title={ni.versionName || null} placement='top-start'>
<Typography level='title-sm' component='div' sx={{ flexGrow: 1 }}>
{ni.text ? ni.text : ni.versionName ? `${ni.versionCode} · ${ni.versionName}` : `Version ${ni.versionCode}:`}
</Typography>
</GoodTooltip>
{/*!firstCard &&*/ (
<Typography level='body-sm'>
{!!ni.versionDate && <TimeAgo date={ni.versionDate} />}
</Typography>
)}
</Box>
{!!ni.items && (ni.items.length > 0) && <ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 24 }}>
{ni.items.map((item, idx) => <li key={idx}>
<Typography component='div'>
{ni.items.filter(item => item.dev !== true).map((item, idx) => <li key={idx}>
<Typography component='div' level='body-sm'>
{item.text}
</Typography>
</li>)}
</ul>}
{/*!firstCard &&*/ (
<Typography level='body-sm' sx={{ position: 'absolute', right: 0, top: 0 }}>
{ni.versionName}
</Typography>
)}
{showExpander && (
<IconButton
variant='plain' size='sm'
@@ -85,12 +120,6 @@ export function AppNews() {
})}
</Container>}
<Box>
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
Got it!
</Button>
</Box>
{/*<Typography sx={{ textAlign: 'center' }}>*/}
{/* Enjoy!*/}
{/* <br /><br />*/}
+53 -15
View File
@@ -1,15 +1,16 @@
import * as React from 'react';
import { Box, Button, Card, CardContent, Grid, Typography } from '@mui/joy';
import { Box, Button, Card, CardContent, Chip, Grid, Typography } from '@mui/joy';
import LaunchIcon from '@mui/icons-material/Launch';
import { Brand } from '~/common/app.config';
import { Link } from '~/common/components/Link';
import { clientUtmSource } from '~/common/util/pwaUtils';
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
// update this variable every time you want to broadcast a new version to clients
export const incrementalVersion: number = 6;
export const incrementalVersion: number = 8;
const B = (props: { href?: string, children: React.ReactNode }) => {
const boldText = <Typography color={!!props.href ? 'primary' : 'warning'} sx={{ fontWeight: 600 }}>{props.children}</Typography>;
@@ -26,8 +27,8 @@ const RIssues = `${OpenRepo}/issues`;
export const newsCallout =
<Card>
<CardContent sx={{ gap: 2 }}>
<Typography level='h2'>
Open Roadmap
<Typography level='h4'>
Open Roadmap
</Typography>
<Typography>
The roadmap is officially out. For the first time you get a look at what&apos;s brewing, up and coming, and get a chance to pick up cool features!
@@ -35,7 +36,7 @@ export const newsCallout =
<Grid container spacing={1}>
<Grid xs={12} sm={7}>
<Button
fullWidth variant='solid' color='primary' size='lg' endDecorator={<LaunchIcon />}
fullWidth variant='soft' color='primary' endDecorator={<LaunchIcon />}
component={Link} href={OpenProject} noLinkStyle target='_blank'
>
Explore the Roadmap
@@ -43,7 +44,7 @@ export const newsCallout =
</Grid>
<Grid xs={12} sm={5} sx={{ display: 'flex', flexAlign: 'center', justifyContent: 'center' }}>
<Button
fullWidth variant='outlined' color='primary' endDecorator={<LaunchIcon />}
fullWidth variant='plain' color='primary' endDecorator={<LaunchIcon />}
component={Link} href={RIssues + '/new?template=roadmap-request.md&title=%5BSuggestion%5D'} noLinkStyle target='_blank'
>
Suggest a Feature
@@ -57,14 +58,48 @@ export const newsCallout =
// news and feature surfaces
export const NewsItems: NewsItem[] = [
/*{
versionName: 'NEXT',
// https://github.com/enricoros/big-agi/milestone/7
// https://github.com/users/enricoros/projects/4/views/2
versionName: '1.7.0',
items: [
{ text: <><B href='...'>Voice Calling</B> ...</> },
// multi-window support
// phone calls
],
},*/
{
versionName: '1.5.0',
text: 'Enjoy what\'s new:',
versionCode: '1.7.0',
versionName: 'Attachment Theory',
versionDate: new Date('2023-12-10T12:00:00Z'), // new Date().toISOString()
items: [
{ text: <>Redesigned <B href={RIssues + '/251'}>attachments system</B>: drag, paste, link, snap, images, text, pdfs</> },
{ text: <>Desktop <B href={RIssues + '/253'}>webcam access</B> for direct image capture (Labs option)</> },
{ text: <>Independent browsing with <B href={RCode + '/docs/config-browse.md'}>Browserless</B> support</> },
{ text: <><B href={RIssues + '/256'}>Overheat</B> LLMs with higher temperature limits</> },
{ text: <>Enhanced security via <B href={RCode + '/docs/deploy-authentication.md'}>password protection</B></> },
{ text: <>{platformAwareKeystrokes('Ctrl+Shift+O')}: quick access to model options</> },
{ text: <>Optimized voice input and performance</> },
{ text: <>Latest Ollama and Oobabooga models</> },
],
},
{
versionCode: '1.6.0',
versionName: 'Surf\'s Up',
versionDate: new Date('2023-11-28T21:00:00Z'),
items: [
{ text: <><B href={RIssues + '/237'}>Web Browsing</B> support, see the <B href={RCode + '/docs/config-browse.md'}>browsing user guide</B></> },
{ text: <><B href={RIssues + '/235'}>Branching Discussions</B> at any message</> },
{ text: <><B href={RIssues + '/207'}>Keyboard Navigation</B>: use {platformAwareKeystrokes('Ctrl+Shift+Left/Right')} to navigate chats</> },
{ text: <><B href={RIssues + '/236'}>UI fixes</B> (thanks to the first sponsor)</> },
{ text: <>Added support for Anthropic Claude 2.1</> },
{ text: <>Large rendering performance optimization</> },
{ text: <>More: <Chip>/help</Chip>, import ChatGPT from source, new Flattener</> },
{ text: <>Devs: improved code quality, snackbar framework</>, dev: true },
],
},
{
versionCode: '1.5.0',
versionName: 'Loaded!',
versionDate: new Date('2023-11-19T21:00:00Z'),
items: [
{ text: <><B href={RIssues + '/190'}>Continued Voice</B> for hands-free interaction</> },
{ text: <><B href={RIssues + '/192'}>Visualization</B> Tool for data representations</> },
@@ -78,7 +113,7 @@ export const NewsItems: NewsItem[] = [
],
},
{
versionName: '1.4.0',
versionCode: '1.4.0',
items: [
{ text: <><B>Share and clone</B> conversations, with public links</> },
{ text: <><B href={RCode + '/docs/config-azure-openai.md'}>Azure</B> models, incl. gpt-4-32k</> },
@@ -88,7 +123,7 @@ export const NewsItems: NewsItem[] = [
],
},
{
versionName: '1.3.5',
versionCode: '1.3.5',
items: [
{ text: <>AI in the real world with <B>Camera OCR</B> - MOBILE-ONLY</> },
{ text: <><B>Anthropic</B> models full support</> },
@@ -99,7 +134,7 @@ export const NewsItems: NewsItem[] = [
],
},
{
versionName: '1.3.1',
versionCode: '1.3.1',
items: [
{ text: <><B>Flattener</B> - 4-mode conversations summarizer</> },
{ text: <><B>Forking</B> - branch your conversations</> },
@@ -109,7 +144,7 @@ export const NewsItems: NewsItem[] = [
],
},
{
versionName: '1.2.1',
versionCode: '1.2.1',
// text: '',
items: [
{ text: <>New home page: <b><Link href={Brand.URIs.Home + clientUtmSource()} target='_blank'>{Brand.URIs.Home.replace('https://', '')}</Link></b></> },
@@ -121,9 +156,12 @@ export const NewsItems: NewsItem[] = [
interface NewsItem {
versionName: string;
versionCode: string;
versionName?: string;
versionDate?: Date;
text?: string | React.JSX.Element;
items?: {
text: string | React.JSX.Element;
dev?: boolean;
}[];
}
+1 -1
View File
@@ -177,7 +177,7 @@ export function YTPersonaCreator() {
</Alert>
<Tooltip title='Copy system prompt' variant='solid'>
<IconButton
variant='outlined' color='neutral' onClick={() => copyToClipboard(chainOutput)}
variant='outlined' color='neutral' onClick={() => copyToClipboard(chainOutput, 'Persona prompt')}
sx={{
position: 'absolute', right: 0, zIndex: 10,
// opacity: 0, transition: 'opacity 0.3s',
@@ -11,9 +11,9 @@ import { useChatAutoAI } from '../chat/store-app-chat';
export function AppChatSettingsAI() {
// external state
const { autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat, setAutoSuggestDiagrams, setAutoSuggestQuestions, setautoTitleChat } = useChatAutoAI();
const { autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat, setAutoSuggestDiagrams, setAutoSuggestQuestions, setAutoTitleChat } = useChatAutoAI();
const handleAutoSetChatTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => setautoTitleChat(event.target.checked);
const handleAutoSetChatTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => setAutoTitleChat(event.target.checked);
const handleAutoSuggestDiagramsChange = (event: React.ChangeEvent<HTMLInputElement>) => setAutoSuggestDiagrams(event.target.checked);
+43 -34
View File
@@ -1,26 +1,37 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Button, FormControl, Radio, RadioGroup, Switch } from '@mui/joy';
import { Button, FormControl, Switch } from '@mui/joy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import WidthNormalIcon from '@mui/icons-material/WidthNormal';
import WidthWideIcon from '@mui/icons-material/WidthWide';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { FormRadioControl } from '~/common/components/forms/FormRadioControl';
import { isPwa } from '~/common/util/pwaUtils';
import { openLayoutModelsSetup } from '~/common/layout/store-applayout';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ShortcutsModal } from './ShortcutsModal';
// configuration
const SHOW_PURPOSE_FINDER = false;
export function AppChatSettingsUI() {
const ModelOptionsButton = () =>
<Button
// variant='soft' color='success'
onClick={openLayoutModelsSetup}
startDecorator={<BuildCircleIcon />}
sx={{
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
}}
>
Models
</Button>;
// local state
const [showShortcuts, setShowShortcuts] = React.useState<boolean>(false);
export function AppChatSettingsUI() {
// external state
const isMobile = useIsMobile();
@@ -40,20 +51,22 @@ export function AppChatSettingsUI() {
zenMode: state.zenMode, setZenMode: state.setZenMode,
}), shallow);
const handleCenterModeChange = (event: React.ChangeEvent<HTMLInputElement>) => setCenterMode(event.target.value as 'narrow' | 'wide' | 'full' || 'wide');
const handleEnterIsNewlineChange = (event: React.ChangeEvent<HTMLInputElement>) => setEnterIsNewline(!event.target.checked);
const handleDoubleClickToEditChange = (event: React.ChangeEvent<HTMLInputElement>) => setDoubleClickToEdit(event.target.checked);
const handleZenModeChange = (event: React.ChangeEvent<HTMLInputElement>) => setZenMode(event.target.value as 'clean' | 'cleaner');
const handleRenderMarkdownChange = (event: React.ChangeEvent<HTMLInputElement>) => setRenderMarkdown(event.target.checked);
const handleShowSearchBarChange = (event: React.ChangeEvent<HTMLInputElement>) => setShowPurposeFinder(event.target.checked);
return <>
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='AI Models'
description='Setup' />
<ModelOptionsButton />
</FormControl>
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
<FormLabelStart title='Enter sends ⏎'
description={enterIsNewline ? 'New line' : 'Sends message'} />
@@ -86,31 +99,27 @@ export function AppChatSettingsUI() {
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
</FormControl>}
<FormControl orientation='horizontal' sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<FormLabelStart title='Appearance'
description={zenMode === 'clean' ? 'Show senders' : 'Minimal UI'} />
<RadioGroup orientation='horizontal' value={zenMode} onChange={handleZenModeChange}>
{/*<Radio value='clean' label={<Face6Icon sx={{ width: 24, height: 24, mt: -0.25 }} />} />*/}
<Radio value='clean' label='Clean' />
<Radio value='cleaner' label='Zen' />
</RadioGroup>
</FormControl>
<FormRadioControl
title='Appearance'
description={zenMode === 'clean' ? 'Show senders' : 'Minimal UI'}
options={[
{ label: 'Clean', value: 'clean' },
{ label: 'Zen', value: 'cleaner' },
]}
value={zenMode} onChange={setZenMode} />
{!isPwa() && !isMobile && <FormControl orientation='horizontal' sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<FormLabelStart title='Page Size'
description={centerMode === 'full' ? 'Full screen chat' : centerMode === 'narrow' ? 'Narrow chat' : 'Wide'} />
<RadioGroup orientation='horizontal' value={centerMode} onChange={handleCenterModeChange}>
<Radio value='narrow' label={<WidthNormalIcon sx={{ width: 25, height: 24, mt: -0.25 }} />} />
<Radio value='wide' label={<WidthWideIcon sx={{ width: 25, height: 24, mt: -0.25 }} />} />
<Radio value='full' label='Full' />
</RadioGroup>
</FormControl>}
{!isMobile && <Button variant='soft' onClick={() => setShowShortcuts(true)} sx={{ ml: 'auto' }}>
👉 See Shortcuts
</Button>}
{showShortcuts && <ShortcutsModal onClose={() => setShowShortcuts(false)}/>}
{!isPwa() && !isMobile && (
<FormRadioControl
title='Page Size'
description={centerMode === 'full' ? 'Full screen chat' : centerMode === 'narrow' ? 'Narrow chat' : 'Wide'}
options={[
{ value: 'narrow', label: <WidthNormalIcon sx={{ width: 25, height: 24, mt: -0.25 }} /> },
{ value: 'wide', label: <WidthWideIcon sx={{ width: 25, height: 24, mt: -0.25 }} /> },
{ value: 'full', label: 'Full' },
]}
value={centerMode} onChange={setCenterMode}
/>
)}
</>;
}
+38 -33
View File
@@ -2,19 +2,18 @@ import * as React from 'react';
import { Accordion, AccordionDetails, accordionDetailsClasses, AccordionGroup, AccordionSummary, accordionSummaryClasses, Avatar, Button, Divider, ListItemContent, Stack, styled, Tab, tabClasses, TabList, TabPanel, Tabs } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import ScienceIcon from '@mui/icons-material/Science';
import SearchIcon from '@mui/icons-material/Search';
import TelegramIcon from '@mui/icons-material/Telegram';
import { BrowseSettings } from '~/modules/browse/BrowseSettings';
import { ElevenlabsSettings } from '~/modules/elevenlabs/ElevenlabsSettings';
import { GoogleSearchSettings } from '~/modules/google/GoogleSearchSettings';
import { ProdiaSettings } from '~/modules/prodia/ProdiaSettings';
import { GoodModal } from '~/common/components/GoodModal';
import { closeLayoutPreferences, openLayoutModelsSetup, openLayoutPreferences, useLayoutPreferencesTab } from '~/common/layout/store-applayout';
import { closeLayoutPreferences, openLayoutShortcuts, useLayoutPreferencesTab } from '~/common/layout/store-applayout';
import { settingsGap } from '~/common/app.theme';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { AppChatSettingsAI } from './AppChatSettingsAI';
import { AppChatSettingsUI } from './AppChatSettingsUI';
@@ -44,14 +43,17 @@ const Topics = styled(AccordionGroup)(({ theme }) => ({
},
}));
function Topic(props: { title: string, icon?: string | React.ReactNode, startCollapsed?: boolean, children?: React.ReactNode }) {
function Topic(props: { title?: string, icon?: string | React.ReactNode, startCollapsed?: boolean, children?: React.ReactNode }) {
// state
const [expanded, setExpanded] = React.useState(props.startCollapsed !== true);
// derived state
const hideTitleBar = !props.title && !props.icon;
return (
<Accordion
expanded={expanded}
expanded={expanded || hideTitleBar}
onChange={(_event, expanded) => setExpanded(expanded)}
sx={{
'&:not(:last-child)': {
@@ -63,23 +65,25 @@ function Topic(props: { title: string, icon?: string | React.ReactNode, startCol
}}
>
<AccordionSummary
color='primary'
variant={expanded ? 'plain' : 'soft'}
indicator={<AddIcon />}
>
{!!props.icon && (
<Avatar
color='primary'
variant={expanded ? 'soft' : 'plain'}
>
{props.icon}
</Avatar>
)}
<ListItemContent>
{props.title}
</ListItemContent>
</AccordionSummary>
{!hideTitleBar && (
<AccordionSummary
color='primary'
variant={expanded ? 'plain' : 'soft'}
indicator={<AddIcon />}
>
{!!props.icon && (
<Avatar
color='primary'
variant={expanded ? 'soft' : 'plain'}
>
{props.icon}
</Avatar>
)}
<ListItemContent>
{props.title}
</ListItemContent>
</AccordionSummary>
)}
<AccordionDetails>
<Stack sx={{ gap: settingsGap, border: 'none' }}>
@@ -99,8 +103,8 @@ function Topic(props: { title: string, icon?: string | React.ReactNode, startCol
export function SettingsModal() {
// external state
const isMobile = useIsMobile();
const settingsTabIndex = useLayoutPreferencesTab();
useGlobalShortcut('p', true, true, false, openLayoutPreferences);
const tabFixSx = { fontFamily: 'body', flex: 1, p: 0, m: 0 };
@@ -108,13 +112,11 @@ export function SettingsModal() {
<GoodModal
title='Preferences' strongerTitle
open={!!settingsTabIndex} onClose={closeLayoutPreferences}
startButton={
<Button variant='soft' color='success' onClick={openLayoutModelsSetup} startDecorator={<BuildCircleIcon />} sx={{
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
}}>
Models
startButton={isMobile ? undefined : (
<Button variant='soft' onClick={openLayoutShortcuts}>
👉 See Shortcuts
</Button>
}
)}
sx={{
'--Card-padding': { xs: '8px', sm: '16px', lg: '24px' },
}}
@@ -143,7 +145,7 @@ export function SettingsModal() {
},
}}
>
<Tab disableIndicator value={1} sx={tabFixSx}>UX</Tab>
<Tab disableIndicator value={1} sx={tabFixSx}>Chat</Tab>
<Tab disableIndicator value={3} sx={tabFixSx}>Voice</Tab>
<Tab disableIndicator value={2} sx={tabFixSx}>Draw</Tab>
<Tab disableIndicator value={4} sx={tabFixSx}>Tools</Tab>
@@ -151,7 +153,7 @@ export function SettingsModal() {
<TabPanel value={1} sx={{ p: 'var(--Tabs-gap)' }}>
<Topics>
<Topic icon={<TelegramIcon />} title='User Interface'>
<Topic>
<AppChatSettingsUI />
</Topic>
<Topic icon='🧠' title='Chat AI' startCollapsed>
@@ -184,7 +186,10 @@ export function SettingsModal() {
<TabPanel value={4} sx={{ p: 'var(--Tabs-gap)' }}>
<Topics>
<Topic icon={<SearchIcon />} title='Google Search API'>
<Topic icon={<SearchIcon />} title='Browsing' startCollapsed>
<BrowseSettings />
</Topic>
<Topic icon={<SearchIcon />} title='Google Search API' startCollapsed>
<GoogleSearchSettings />
</Topic>
{/*<Topic icon='🛠' title='Other tools...' />*/}
+36 -23
View File
@@ -3,38 +3,51 @@ import * as React from 'react';
import { ChatMessage } from '../chat/components/message/ChatMessage';
import { GoodModal } from '~/common/components/GoodModal';
import { closeLayoutShortcuts, useLayoutShortcuts } from '~/common/layout/store-applayout';
import { createDMessage } from '~/common/state/store-chats';
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
const shortcutsMd = `
| Shortcut | Description |
|------------------|-------------------------------------------------|
| **Edit** | |
| Shift + Enter | Newline (don't send) |
| Alt + Enter | Append message (don't send) |
| Ctrl + Shift + R | Regenerate answer |
| Ctrl + Shift + V | Attach clipboard (better than Ctrl + V) |
| Ctrl + M | Microphone (voice typing) |
| **Chats** | |
| Ctrl + Alt + N | **New** chat |
| Ctrl + Alt + X | **Reset** chat |
| Ctrl + Alt + D | **Delete** chat |
| Ctrl + Alt + F | **Clone** chat |
| **Settings** | |
| Ctrl + Shift + M | 🧠 Models |
| Ctrl + Shift + P | ⚙️ Preferences |
| Shortcut | Description |
|---------------------|-------------------------------------------------|
| **Edit** | |
| Shift + Enter | Newline |
| Alt + Enter | Append (no response) |
| Ctrl + Shift + R | Regenerate answer |
| Ctrl + Shift + V | Attach clipboard (better than Ctrl + V) |
| Ctrl + M | Microphone (voice typing) |
| **Chats** | |
| Ctrl + Alt + Left | **Previous** chat (in history) |
| Ctrl + Alt + Right | **Next** chat (in history) |
| Ctrl + Alt + N | **New** chat |
| Ctrl + Alt + X | **Reset** chat |
| Ctrl + Alt + D | **Delete** chat |
| Ctrl + Alt + B | **Branch** chat |
| **Settings** | |
| Ctrl + Shift + P | ⚙️ Preferences |
| Ctrl + Shift + M | 🧠 Models |
| Ctrl + Shift + O | Options (current Chat Model) |
| Ctrl + Shift + ? | Shortcuts |
`.trim();
const shortcutsMessage = createDMessage('assistant', platformAwareKeystrokes(shortcutsMd));
export const ShortcutsModal = (props: { onClose: () => void }) =>
<GoodModal
open title='Desktop Shortcuts'
onClose={props.onClose}
>
<ChatMessage message={shortcutsMessage} hideAvatars noBottomBorder sx={{ p: 0, m: 0 }} />
</GoodModal>;
export function ShortcutsModal() {
// external state
const showShortcuts = useLayoutShortcuts();
return (
<GoodModal
open={showShortcuts}
title='Desktop Shortcuts'
onClose={closeLayoutShortcuts}
>
<ChatMessage message={shortcutsMessage} hideAvatars noBottomBorder sx={{ p: 0, m: 0 }} />
</GoodModal>
);
}
+50 -25
View File
@@ -1,41 +1,66 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Button, FormControl, Switch } from '@mui/joy';
import { FormControl, Typography } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import CallIcon from '@mui/icons-material/Call';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import YouTubeIcon from '@mui/icons-material/YouTube';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { closeLayoutPreferences } from '~/common/layout/store-applayout';
import { navigateToLabs } from '~/common/app.routes';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
import { Link } from '~/common/components/Link';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
export function UxLabsSettings() {
// external state
const isMobile = useIsMobile();
const {
experimentalLabs, setExperimentalLabs,
} = useUIPreferencesStore(state => ({
experimentalLabs: state.experimentalLabs, setExperimentalLabs: state.setExperimentalLabs,
}), shallow);
const handleExperimentalLabsChange = (event: React.ChangeEvent<HTMLInputElement>) => setExperimentalLabs(event.target.checked);
labsCalling, labsCameraDesktop, /*labsEnhancedUI,*/ labsMagicDraw, labsPersonaYTCreator, labsSplitBranching,
setLabsCalling, setLabsCameraDesktop, /*setLabsEnhancedUI,*/ setLabsMagicDraw, setLabsPersonaYTCreator, setLabsSplitBranching,
} = useUXLabsStore();
return <>
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
<FormLabelStart title='Experiments'
description={experimentalLabs ? 'Enabled' : 'Disabled'} />
<Switch checked={experimentalLabs} onChange={handleExperimentalLabsChange}
endDecorator={experimentalLabs ? 'On' : 'Off'}
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
</FormControl>
<FormSwitchControl
title={<><YouTubeIcon color={labsPersonaYTCreator ? 'primary' : undefined} sx={{ mr: 0.25 }} /> YouTube Personas</>} description={labsPersonaYTCreator ? 'Creator Enabled' : 'Disabled'}
checked={labsPersonaYTCreator} onChange={setLabsPersonaYTCreator}
/>
<Button variant='soft' onClick={() => {
closeLayoutPreferences();
void navigateToLabs();
}} sx={{ ml: 'auto' }}>
👉 See Experiments
</Button>
<FormSwitchControl
title={<><FormatPaintIcon color={labsMagicDraw ? 'primary' : undefined} sx={{ mr: 0.25 }} />Assisted Draw</>} description={labsMagicDraw ? 'Enabled' : 'Disabled'}
checked={labsMagicDraw} onChange={setLabsMagicDraw}
/>
<FormSwitchControl
title={<><CallIcon color={labsCalling ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Voice Calls</>} description={labsCalling ? 'Call AGI' : 'Disabled'}
checked={labsCalling} onChange={setLabsCalling}
/>
{!isMobile && <FormSwitchControl
title={<><AddAPhotoIcon color={labsCameraDesktop ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Webcam</>} description={labsCameraDesktop ? 'Enabled' : 'Disabled'}
checked={labsCameraDesktop} onChange={setLabsCameraDesktop}
/>}
<FormSwitchControl
title={<><VerticalSplitIcon color={labsSplitBranching ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Split Branching</>} description={labsSplitBranching ? 'Enabled' : 'Disabled'} disabled
checked={labsSplitBranching} onChange={setLabsSplitBranching}
/>
{/*<FormSwitchControl*/}
{/* title='Enhanced UI' description={labsEnhancedUI ? 'Enabled' : 'Disabled'}*/}
{/* checked={labsEnhancedUI} onChange={setLabsEnhancedUI}*/}
{/*/>*/}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='Graduated' />
<Typography level='body-xs'>
<Link href='https://github.com/enricoros/big-agi/issues/192' target='_blank'>Auto Diagrams</Link> · Relative chat size · Text Tools · LLM Overheat
</Typography>
</FormControl>
</>;
}
+32 -13
View File
@@ -1,23 +1,29 @@
import * as React from 'react';
import { FormControl, Radio, RadioGroup } from '@mui/joy';
import { FormControl } from '@mui/joy';
import { ChatAutoSpeakType, useChatAutoAI } from '../chat/store-app-chat';
import { useChatAutoAI, useChatMicTimeoutMs } from '../chat/store-app-chat';
import { useElevenLabsVoices } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { FormRadioControl } from '~/common/components/forms/FormRadioControl';
import { LanguageSelect } from '~/common/components/LanguageSelect';
import { useIsMobile } from '~/common/components/useMatchMedia';
export function VoiceSettings() {
// external state
const isMobile = useIsMobile();
const { autoSpeak, setAutoSpeak } = useChatAutoAI();
const { hasVoices } = useElevenLabsVoices();
const [chatTimeoutMs, setChatTimeoutMs] = useChatMicTimeoutMs();
const handleAutoSpeakChange = (e: React.ChangeEvent<HTMLInputElement>) => setAutoSpeak((e.target.value || 'off') as ChatAutoSpeakType);
// this converts from string keys to numbers and vice versa
const chatTimeoutValue: string = '' + chatTimeoutMs;
const setChatTimeoutValue = (value: string) => value && setChatTimeoutMs(parseInt(value));
return <>
@@ -29,16 +35,29 @@ export function VoiceSettings() {
<LanguageSelect />
</FormControl>
<FormControl orientation='horizontal' sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<FormLabelStart title='Speak Responses'
description={autoSpeak === 'off' ? 'Off' : 'First paragraph'}
tooltip={!hasVoices ? 'No voices available, please configure a voice synthesis service' : undefined} />
<RadioGroup orientation='horizontal' value={autoSpeak} onChange={handleAutoSpeakChange}>
<Radio disabled={!hasVoices} value='off' label='Off' />
<Radio disabled={!hasVoices} value='firstLine' label='Start' />
<Radio disabled={!hasVoices} value='all' label='Full' />
</RadioGroup>
</FormControl>
{!isMobile && <FormRadioControl
title='Mic Timeout'
description={chatTimeoutMs < 1000 ? 'Best for quick calls' : chatTimeoutMs > 5000 ? 'Best for thinking' : 'Standard'}
options={[
{ value: '600', label: '.6s' },
{ value: '2000', label: '2s' },
{ value: '15000', label: '15s' },
]}
value={chatTimeoutValue} onChange={setChatTimeoutValue}
/>}
<FormRadioControl
title='Speak Responses'
description={autoSpeak === 'off' ? 'Off' : 'First paragraph'}
tooltip={!hasVoices ? 'No voices available, please configure a voice synthesis service' : undefined}
disabled={!hasVoices}
options={[
{ value: 'off', label: 'Off' },
{ value: 'firstLine', label: 'Start' },
{ value: 'all', label: 'Full' },
]}
value={autoSpeak} onChange={setAutoSpeak}
/>
</>;
}
+28 -7
View File
@@ -6,17 +6,38 @@
import Router from 'next/router';
const APP_CHAT = '/';
const APP_LINK_CHAT = '/link/chat/:linkId';
const APP_LABS = '/labs';
import type { DConversationId } from '~/common/state/store-chats';
export const getHomeLink = () => APP_CHAT;
export const getChatLinkRelativePath = (chatLinkId: string) => APP_LINK_CHAT.replace(':linkId', chatLinkId);
export const ROUTE_INDEX = '/';
export const ROUTE_APP_CHAT = '/';
export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId';
export const ROUTE_APP_NEWS = '/news';
export const navigateToChat = async () => await Router.push(APP_CHAT);
export const getIndexLink = () => ROUTE_INDEX;
export const navigateToLabs = async () => await Router.push(APP_LABS);
export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT.replace(':linkId', chatLinkId);
const navigateFn = (path: string) => (replace?: boolean): Promise<boolean> =>
Router[replace ? 'replace' : 'push'](path);
export const navigateToIndex = navigateFn(ROUTE_INDEX);
export const navigateToChat = async (conversationId?: DConversationId) => {
if (conversationId) {
await Router.push(
{
pathname: ROUTE_APP_CHAT,
query: {
conversationId,
},
},
ROUTE_APP_CHAT,
);
} else {
await Router.push(ROUTE_APP_CHAT, ROUTE_APP_CHAT);
}
};
export const navigateToNews = navigateFn(ROUTE_APP_NEWS);
export const navigateBack = Router.back;
+7 -7
View File
@@ -2,8 +2,8 @@ import * as React from 'react';
import { KeyboardEvent } from 'react';
import { ClickAwayListener, Popper, PopperPlacementType } from '@mui/base';
import { MenuList, styled, VariantProp } from '@mui/joy';
import { SxProps } from '@mui/system';
import { MenuList, styled } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
// adds the 'sx' prop to the Popper, and defaults zIndex to 1000
@@ -23,7 +23,8 @@ const Popup = styled(Popper)({
*/
export function CloseableMenu(props: {
open: boolean, anchorEl: HTMLElement | null, onClose: () => void,
variant?: VariantProp,
dense?: boolean,
// variant?: VariantProp,
// color?: ColorPaletteProp,
// size?: 'sm' | 'md' | 'lg',
placement?: PopperPlacementType,
@@ -71,13 +72,12 @@ export function CloseableMenu(props: {
>
<ClickAwayListener onClickAway={handleClose}>
<MenuList
variant={props.variant}
// color={props.color}
// variant={props.variant} color={props.color}
onKeyDown={handleListKeyDown}
sx={{
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
'--ListItem-minHeight': '3rem',
'--ListItemDecorator-size': '2.75rem',
'--ListItem-minHeight': props.dense ? '2.5rem' : '3rem',
'--ListItemDecorator-size': '2.75rem', // icon width
backgroundColor: 'background.popup',
boxShadow: 'md',
...(props.maxHeightGapPx !== undefined ? { maxHeight: `calc(100dvh - ${props.maxHeightGapPx}px)`, overflowY: 'auto' } : {}),
+24 -22
View File
@@ -1,39 +1,41 @@
import * as React from 'react';
import { Box, Button, Divider, Modal, ModalDialog, Typography } from '@mui/joy';
import { Box, Button, Divider, Typography } from '@mui/joy';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { GoodModal } from '~/common/components/GoodModal';
/**
* A confirmation dialog (Joy Modal)
* Pass the question and the positive answer, and get called when it's time to close the dialog, or when the positive action is taken
*/
export function ConfirmationModal(props: {
open: boolean, onClose: () => void, onPositive: () => void,
open?: boolean, onClose: () => void, onPositive: () => void,
title?: string | React.JSX.Element,
confirmationText: string | React.JSX.Element,
positiveActionText: string
}) {
return (
<Modal open={props.open} onClose={props.onClose}>
<ModalDialog variant='outlined' color='neutral'>
<Typography component='h2' startDecorator={<WarningRoundedIcon />}>
{props.title || 'Confirmation'}
</Typography>
{/*<ModalClose/>*/}
<Divider sx={{ my: 2 }} />
<Typography level='body-md'>
{props.confirmationText}
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
<Button variant='plain' color='neutral' onClick={props.onClose}>
Cancel
</Button>
<Button variant='solid' color='danger' onClick={props.onPositive} sx={{ lineHeight: '1.5em' }}>
{props.positiveActionText}
</Button>
</Box>
</ModalDialog>
</Modal>
<GoodModal
open={props.open === undefined ? true : props.open}
title={props.title || 'Confirmation'}
titleStartDecorator={<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />}
onClose={props.onClose}
hideBottomClose
>
<Divider />
<Typography level='body-md'>
{props.confirmationText}
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
<Button autoFocus variant='plain' color='neutral' onClick={props.onClose}>
Cancel
</Button>
<Button variant='solid' color='danger' onClick={props.onPositive} sx={{ lineHeight: '1.5em' }}>
{props.positiveActionText}
</Button>
</Box>
</GoodModal>
);
}
+8 -4
View File
@@ -9,14 +9,18 @@ import { SxProps } from '@mui/joy/styles/types';
*/
export function GoodModal(props: {
title?: string | React.JSX.Element,
strongerTitle?: boolean, noTitleBar?: boolean,
titleStartDecorator?: React.JSX.Element,
strongerTitle?: boolean,
noTitleBar?: boolean,
dividers?: boolean,
open: boolean,
onClose?: () => void,
hideBottomClose?: boolean,
startButton?: React.JSX.Element,
sx?: SxProps,
children: React.ReactNode,
}) {
const showBottomClose = !!props.onClose && props.hideBottomClose !== true;
return (
<Modal open={props.open} onClose={props.onClose}>
<ModalOverflow>
@@ -29,7 +33,7 @@ export function GoodModal(props: {
}}>
{!props.noTitleBar && <Box sx={{ mb: -1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography level={props.strongerTitle !== true ? 'title-md' : 'title-lg'}>
<Typography level={props.strongerTitle !== true ? 'title-md' : 'title-lg'} startDecorator={props.titleStartDecorator}>
{props.title || ''}
</Typography>
{!!props.onClose && <ModalClose sx={{ position: 'static', mr: -1 }} />}
@@ -41,9 +45,9 @@ export function GoodModal(props: {
{props.dividers === true && <Divider />}
{(!!props.startButton || !!props.onClose) && <Box sx={{ mt: 'auto', display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'space-between' }}>
{(!!props.startButton || showBottomClose) && <Box sx={{ mt: 'auto', display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'space-between' }}>
{props.startButton}
{!!props.onClose && <Button variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
{showBottomClose && <Button variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
Close
</Button>}
</Box>}
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react';
import { Tooltip } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
/**
* Tooltip with text that wraps to multiple lines (doesn't go too long)
*/
export const GoodTooltip = (props: {
title: string | React.JSX.Element | null,
placement?: 'top' | 'bottom' | 'top-start',
isError?: boolean, isWarning?: boolean,
children: React.JSX.Element,
sx?: SxProps
}) =>
<Tooltip
title={props.title}
placement={props.placement}
variant={(props.isError || props.isWarning) ? 'soft' : undefined}
color={props.isError ? 'danger' : props.isWarning ? 'warning' : undefined}
sx={{ maxWidth: { sm: '50vw', md: '25vw' }, ...props.sx }}
>
{props.children}
</Tooltip>;
+5 -3
View File
@@ -1,11 +1,13 @@
import * as React from 'react';
import { Alert, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
export function InlineError(props: { error: React.JSX.Element | null | any, severity?: 'warning' | 'danger' }) {
export function InlineError(props: { error: React.JSX.Element | null | any, severity?: 'warning' | 'danger' | 'info', sx?: SxProps }) {
const color = props.severity === 'info' ? 'primary' : props.severity || 'warning';
return (
<Alert variant='soft' color={props.severity || 'warning'} sx={{ mt: 1 }}>
<Typography level='body-sm' color={props.severity || 'warning'}>
<Alert variant='soft' color={color} sx={{ mt: 1, ...props.sx }}>
<Typography level='body-sm' color={color}>
{props.error?.message || props.error || 'Unknown error'}
</Typography>
</Alert>
+3 -3
View File
@@ -1,12 +1,12 @@
import * as React from 'react';
import { Textarea } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { ColorPaletteProp, SxProps } from '@mui/joy/styles/types';
import { useUIPreferencesStore } from '~/common/state/store-ui';
export function InlineTextarea(props: { initialText: string, onEdit: (text: string) => void, sx?: SxProps }) {
export function InlineTextarea(props: { initialText: string, color?: ColorPaletteProp, onEdit: (text: string) => void, sx?: SxProps }) {
const [text, setText] = React.useState(props.initialText);
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
@@ -27,7 +27,7 @@ export function InlineTextarea(props: { initialText: string, onEdit: (text: stri
return (
<Textarea
variant='soft' color='warning' autoFocus minRows={1}
variant='soft' color={props.color || 'warning'} autoFocus minRows={1}
value={text} onChange={handleEditTextChanged}
onKeyDown={handleEditKeyDown} onBlur={handleEditBlur}
slotProps={{
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Chip } from '@mui/joy';
import { SxProps } from '@mui/system';
import { SxProps } from '@mui/joy/styles/types';
import { hideOnMobile } from '~/common/app.theme';
import { isMacUser } from '~/common/util/pwaUtils';
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from 'react';
import { SvgIcon } from '@mui/joy';
import { SxProps } from '@mui/system';
import { SxProps } from '@mui/joy/styles/types';
export const LogoSquircle = (props: {
sx?: SxProps
@@ -1,9 +1,10 @@
import * as React from 'react';
import { Box, FormHelperText, FormLabel, Tooltip } from '@mui/joy';
import { Box, FormHelperText, FormLabel } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import InfoIcon from '@mui/icons-material/Info';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { settingsCol1Width } from '~/common/app.theme';
@@ -22,15 +23,15 @@ export const FormLabelStart = (props: {
<FormLabel
onClick={props.onClick}
sx={{
width: settingsCol1Width,
minWidth: settingsCol1Width,
...(!!props.onClick && { cursor: 'pointer', textDecoration: 'underline' }),
...props.sx,
}}
>
{props.title} {props.tooltip && (
<Tooltip title={props.tooltip} sx={{ maxWidth: { sm: '50vw', md: '25vw' } }}>
<GoodTooltip title={props.tooltip}>
<InfoIcon sx={{ mx: 0.5, cursor: 'pointer', fontSize: 'md', color: 'primary.solidBg' }} />
</Tooltip>
</GoodTooltip>
)}
</FormLabel>
@@ -0,0 +1,36 @@
import * as React from 'react';
import { FormControl, Radio, RadioGroup } from '@mui/joy';
import { FormLabelStart } from './FormLabelStart';
export type FormRadioOption<T extends string> = {
value: T,
label: string | React.JSX.Element,
disabled?: boolean
};
export const FormRadioControl = <TValue extends string>(props: {
title: string | React.JSX.Element,
description?: string | React.JSX.Element,
tooltip?: string | React.JSX.Element,
disabled?: boolean;
options: FormRadioOption<TValue>[];
value: TValue;
onChange: (value: TValue) => void;
}) =>
<FormControl orientation='horizontal' disabled={props.disabled} sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
{(!!props.title || !!props.description) && <FormLabelStart title={props.title} description={props.description} tooltip={props.tooltip} />}
<RadioGroup
orientation='horizontal'
value={props.value}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => event.target.value && props.onChange(event.target.value as TValue)}
>
{props.options.map((option) =>
<Radio key={'opt-' + option.value} value={option.value} label={option.label} disabled={option.disabled || props.disabled} />,
)}
</RadioGroup>
</FormControl>;
@@ -13,6 +13,7 @@ export function FormSliderControl(props: {
min?: number, max?: number, step?: number, defaultValue?: number,
valueLabelDisplay?: 'on' | 'auto' | 'off',
value: number, onChange: (value: number) => void,
endAdornment?: React.ReactNode,
}) {
return (
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
@@ -24,6 +25,7 @@ export function FormSliderControl(props: {
valueLabelDisplay={props.valueLabelDisplay}
// sx={{ py: 1, mt: 1.1 }}
/>
{props.endAdornment}
</FormControl>
);
}
@@ -10,16 +10,19 @@ import { FormLabelStart } from './FormLabelStart';
*/
export function FormSwitchControl(props: {
title: string | React.JSX.Element, description?: string | React.JSX.Element,
value: boolean, onChange: (on: boolean) => void,
on?: string, off?: string, fullWidth?: boolean,
checked: boolean, onChange: (on: boolean) => void,
disabled?: boolean,
}) {
return (
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
<FormControl orientation='horizontal' disabled={props.disabled} sx={{ flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title={props.title} description={props.description} />
<Switch
checked={props.value}
checked={props.checked}
onChange={event => props.onChange(event.target.checked)}
endDecorator={props.value ? 'Enabled' : 'Off'}
sx={{ flexGrow: 1 }}
endDecorator={props.checked ? props.on || 'On' : props.off || 'Off'}
sx={props.fullWidth ? { flexGrow: 1 } : undefined}
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }}
/>
</FormControl>
);

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