Compare commits

...

356 Commits

Author SHA1 Message Date
Enrico Ros 6053636f66 OpenRouter: OAuth login support 2023-12-11 22:35:40 -08:00
Enrico Ros f2e2aee672 1.7.2: Stable Patch Version 2023-12-11 21:22:31 -08:00
Enrico Ros 11cbb2bbf0 OpenRouter: update models 2023-12-11 21:21:22 -08:00
Enrico Ros 30bd19d6ce HTML Table to Markdown Table: improve reliability and ignore hidden data 2023-12-11 20:46:34 -08:00
Enrico Ros d0b5c02062 Improve how Stream errors are shown 2023-12-11 18:22:15 -08:00
Enrico Ros 771192e406 Ollama: support ollama errors via API 2023-12-11 18:19:38 -08:00
Enrico Ros 13f502bd76 1.7.1: Release (Ollama chat). #270 2023-12-10 22:17:35 -08:00
Enrico Ros 11055b12ca Ollama: use the new Chat endpoint. Closes #270 2023-12-10 22:12:51 -08:00
Enrico Ros d0ea96eec0 Ollama: Admin: optional sort by Pulls, and UI link to the Model page 2023-12-10 22:03:55 -08:00
Enrico Ros 02eafc03f1 Ollama: update models, and sort by Featured 2023-12-10 22:01:50 -08:00
Enrico Ros 33d07a0313 Ollama: update documentation 2023-12-10 21:30:30 -08:00
Enrico Ros 763b852148 Ollama: administration: external link 2023-12-10 20:24:20 -08:00
Enrico Ros d5b0617fd7 Comment for now 2023-12-10 06:14:49 -08:00
Enrico Ros e3ce83674c Update Ollama 2023-12-10 06:09:54 -08:00
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
Enrico Ros 9ad92c19a6 1.5.0 Update Version 2023-11-18 21:09:27 -08:00
Enrico Ros c54185e6eb 1.5.0 Update README 2023-11-18 21:09:26 -08:00
Enrico Ros 42fae2f915 1.5.0 News page 2023-11-18 21:09:25 -08:00
Enrico Ros 48f4dd8573 Lint fixes 2023-11-18 21:09:16 -08:00
Enrico Ros 396e3a4625 Update issue templates 2023-11-18 20:29:10 -08:00
Enrico Ros 348915c420 AppNews: fix layouting 2023-11-18 19:07:38 -08:00
Enrico Ros 157dadcae6 Update README.md 2023-11-18 17:27:34 -08:00
Enrico Ros 89b39b4bec Play mic off sound only when not manually initiated. #226 2023-11-18 16:31:15 -08:00
Enrico Ros c42625c8aa SpeechRecognition: add done 'reason' 2023-11-18 16:26:45 -08:00
Enrico Ros ac0e7ad738 Keystrokes: fix platform 2023-11-18 16:10:32 -08:00
Enrico Ros bdd92e69fc Mic: louder click 2023-11-18 15:30:22 -08:00
Enrico Ros f65178c08a Mic: play sound when it stops recording. Closes #226 2023-11-18 15:23:38 -08:00
Enrico Ros 3df40f18f8 Shortcuts: show shortcuts modal. Fixes #195 2023-11-18 14:40:59 -08:00
Enrico Ros af007699ce Shortcuts: delete and clone conversation 2023-11-18 14:36:50 -08:00
Enrico Ros b8537bc4e7 Fix stored states 2023-11-18 14:07:27 -08:00
Enrico Ros a4c3e57899 Auto title chat: true by default 2023-11-18 13:55:46 -08:00
Enrico Ros 065069426b ElevenLabs: cleanup state store, move config options around, and enable to speak the full sentence. Fixes #225 2023-11-18 00:15:47 -08:00
Enrico Ros 0d1cd45813 Remove follow-up mode, and instead add it as an option on the chat menu. Fixes #224 2023-11-17 22:45:02 -08:00
Enrico Ros 090032dccd Roll Next and Prisma 2023-11-17 20:53:25 -08:00
Enrico Ros 987458ed63 Separate Chat menus, part of #224 2023-11-17 20:51:20 -08:00
Enrico Ros 32bc46c46b Merge pull request #220 from llegomark/main
Great update, thanks for the PR. Approved.
2023-11-17 17:18:36 -08:00
Mark Anthony Llego f3a39ad5d2 Refactor pdfToText function to improve readability
and performance
2023-11-17 19:37:28 +08:00
Mark Anthony Llego 98c95bf436 Update pdfjs-dist version to 4.0.189 2023-11-17 19:30:58 +08:00
Enrico Ros a687ddd2a0 Downgrade the UI if the browser does not support clipboard read. Closes #124 2023-11-17 00:45:55 -08:00
Enrico Ros 2bce8dc31e Bugfix: when no model is selected, composer shouldn't send (should not actually clear) 2023-11-17 00:33:41 -08:00
Enrico Ros 2c3597f0dd Merge branch 'edmondop-issue-191' 2023-11-16 23:08:35 -08:00
Enrico Ros 3570d9e9cf Auto-title: moved to a non-reactive check, UI: cleanup text for mobile
Closes #191
2023-11-16 23:08:04 -08:00
Enrico Ros cb8fab47af Merge branch 'issue-191' of https://github.com/edmondop/big-agi into edmondop-issue-191 2023-11-16 17:34:40 -08:00
Enrico Ros 58cfff3912 README: Update the Roadmap, latest features, development and deployment instructions 2023-11-16 17:31:03 -08:00
Edmondo Porcu d2cdf36186 Missing newline 2023-11-16 17:18:39 -08:00
Edmondo Porcu 9237fbaad5 Exposing UI for disabling auto title in the chats 2023-11-16 17:16:56 -08:00
Enrico Ros c6a20c475f Add the "BUG" issue template 2023-11-16 16:52:49 -08:00
Enrico Ros 6e0bb6260e Adding the "Roadmap request" issue template 2023-11-16 16:48:52 -08:00
Enrico Ros 321c52351e As per request, enable sponsorship 2023-11-16 13:38:29 -08:00
Enrico Ros 13d91508c9 Diagrams: show text if no code 2023-11-16 02:06:10 -08:00
Enrico Ros 7a770659f3 Docker: update instructions 2023-11-15 20:10:23 -08:00
Enrico Ros b734087d85 Settings Menu overhaul 2023-11-15 16:26:05 -08:00
Enrico Ros ae354434e2 Auto-focus composer after mic input 2023-11-15 14:00:33 -08:00
Enrico Ros ae16b03c7f Auto-focus composer on ctrl+alt+n 2023-11-15 13:58:04 -08:00
Enrico Ros a1ac12761d Cleanup: appearance 2023-11-15 04:03:12 -08:00
Enrico Ros 1aabdd4394 Mermaid: final cleanups 2023-11-15 03:50:26 -08:00
Enrico Ros 0548f6b863 Disable --turbo until https://github.com/vercel/next.js/issues/57581 is resolved 2023-11-15 03:38:08 -08:00
Enrico Ros 65fc40796b Mermaid: switch to CDN operation, to speed up development again
We are loading Mermaid from the CDN (and spending all the work to dynamically load it
and strong type it), because the Mermaid dependencies (npm i mermaid) are too heavy
and would slow down development for everyone.

Looking forward for feedback on this.
2023-11-15 03:35:43 -08:00
Enrico Ros 48af71d5f1 Mermaid: vast improvement 2023-11-14 20:08:27 -08:00
Enrico Ros cafcafb582 Escape to toggle declutter mode 2023-11-14 20:08:19 -08:00
Enrico Ros 29da5383ed Ollama: enable deletion. See #186 2023-11-14 19:21:40 -08:00
Enrico Ros ba50ff3b90 On a second thought, trying this with
OpenAI replacement as well.
2023-11-14 19:21:09 -08:00
Enrico Ros 63a7dd1ce9 Replace models (don't append) by default.
On all Vendors, aside OpenAI, replace the models, so if a model is deleted from the server,
it won't show up in the list. This has multiple advantages, including not keeping stray configuration.

On a second thought, trying this with
OpenAI replacement as well.

Fixes #186
2023-11-14 19:20:28 -08:00
Enrico Ros 552ffb4257 Fix page 2023-11-14 03:59:37 -08:00
Enrico Ros 87461fb73e Emergency fix - final.r2.reallyfinal.r42-draft-clean_copy 2023-11-14 03:35:28 -08:00
Enrico Ros 22fac6f3c1 Emergency fix2 2023-11-14 03:33:11 -08:00
Enrico Ros 2932e8e89d Emergency fix 2023-11-14 03:29:36 -08:00
Enrico Ros b7ea52701a [*] Full dynamic backend configuration. Allows for runtime env vars, especially on Docker. 2023-11-14 03:25:07 -08:00
Enrico Ros 6d8aa3e989 Dynamic backend feature presence: move all apart from llm 2023-11-14 03:09:29 -08:00
Enrico Ros 5a158155c5 Backend: fetch capabilities 2023-11-14 02:30:46 -08:00
Enrico Ros a30ec5d023 Env-vars: build time validation
Note: build time env vars are not needed, as we're transitioning at
runtime variables.
However if they are set at build time, then validation would happen right then and there.
2023-11-14 01:42:52 -08:00
Enrico Ros eff9be3c99 Env-vars: server side strict checking 2023-11-14 01:15:45 -08:00
Enrico Ros 5a17801c8e Remove some process.env refs 2023-11-14 00:13:44 -08:00
Enrico Ros 76651be12c OpenAILLMOptions: show the temperature value, always 2023-11-13 23:29:28 -08:00
Enrico Ros 5c93af6cdc Restructure the App wrappers in Providers 2023-11-13 23:04:53 -08:00
Enrico Ros 3dbd5158c0 Shortcuts: display the main Send shortcut 2023-11-13 21:04:00 -08:00
Enrico Ros 233d92b69d Docker: fix - thanks @fredliubojin 2023-11-13 20:17:58 -08:00
Enrico Ros bc6bf3195e Visualization: copy 2023-11-13 18:29:02 -08:00
Enrico Ros a71588777a Visualization: disable copy button 2023-11-13 18:13:53 -08:00
Enrico Ros 8c9445d800 fixed hardcoding 2023-11-13 18:08:59 -08:00
Enrico Ros 3cecf7c0b5 removed useTheme from Layout 2023-11-13 18:08:09 -08:00
Enrico Ros e1128fa38f Composer: Extract some buttons, and support the useIsMobile() hook 2023-11-13 17:50:19 -08:00
Enrico Ros 140412cb8b pwaUtils: core for isBrowser, and reduce all platform checks to static client-side 2023-11-13 17:49:38 -08:00
Enrico Ros 882b8629d7 Reduce settings gap 2023-11-13 17:48:34 -08:00
Enrico Ros 7056866841 Improve Keystrokes 2023-11-13 17:48:25 -08:00
Enrico Ros cc6afa9190 Rationalize Settings Labels 2023-11-13 16:27:20 -08:00
Enrico Ros 93f075c270 Cleanup settings code 2023-11-13 15:01:33 -08:00
Enrico Ros c2f991678c App files: start rationalizing 2023-11-13 14:49:38 -08:00
Enrico Ros b8c2f1b73b Dockerfile: cleanups 2023-11-13 13:33:05 -08:00
Enrico Ros 9b939c9a05 Dockerfile: improve and run as user 2023-11-13 13:24:26 -08:00
Enrico Ros 150c295370 Fix dependency 2023-11-13 12:40:10 -08:00
Enrico Ros c5f23ce7ca Docker deployments: add .dockerignore 2023-11-13 12:03:41 -08:00
Enrico Ros f7254fe8f6 Cleanup text-diff 2023-11-13 00:51:58 -08:00
Enrico Ros 32e3a4e547 Roll packages (note: mermaid brings in a lot?) 2023-11-12 23:13:12 -08:00
Enrico Ros 3622155881 ctrl + alt + n/x: new/reset conversation 2023-11-12 22:55:15 -08:00
Enrico Ros 77cc8272c5 ctrl + shift + x: clear conversation 2023-11-12 22:36:28 -08:00
Enrico Ros acff0d0ef5 Mermaid: improve with an example 2023-11-12 22:27:00 -08:00
Enrico Ros 47cf6fe688 Fix hook dependency 2023-11-12 22:01:21 -08:00
Enrico Ros 2b937719dd Mermaid: full support (gpt still makes many mistakes) 2023-11-12 21:57:37 -08:00
Enrico Ros 551faa47db RenderCode: make space for Mermaid 2023-11-12 17:23:25 -08:00
Enrico Ros 692c1ebfda Mermaid syntax highlighting 2023-11-12 17:18:16 -08:00
Enrico Ros 72c6f616f9 RenderCode: better explain the issue 2023-11-12 16:52:14 -08:00
Enrico Ros 1da4b3653e Diagrams: improve proompts 2023-11-12 16:50:46 -08:00
Enrico Ros 8ef6d1667e Diagrams: improve naming, hotfixing, remove title bar 2023-11-12 16:48:08 -08:00
Enrico Ros 961c0b581e Diagrams: improve naming, hotfixing 2023-11-12 16:38:31 -08:00
Enrico Ros 3118228a68 PlantUML: improve rendering, including Errors and syntax errors 2023-11-12 16:38:07 -08:00
Enrico Ros a47b9b0a55 Diagrams: mermaid support 2023-11-12 16:03:06 -08:00
Enrico Ros ae0b39c9c0 useFormRadio: easy memoized Radio drop in 2023-11-12 15:26:17 -08:00
Enrico Ros 2d90947cb9 Diagrams: hotfix code 2023-11-12 14:34:44 -08:00
Enrico Ros 78c1c3bece Images: smaller shadows 2023-11-12 14:34:19 -08:00
Enrico Ros bbce30b24f Improve consistency of Code, Html, Image blocks 2023-11-12 13:45:55 -08:00
Enrico Ros 92009ed6b4 Diagrams: toggling Options also hides the progress 2023-11-12 12:14:07 -08:00
Enrico Ros 54db3746c7 Diagrams: toggle Options 2023-11-12 12:11:00 -08:00
Enrico Ros 58c7012314 Bits 2023-11-12 04:37:14 -08:00
Enrico Ros baf0ca2682 Diagram Generator 2023-11-12 04:36:57 -08:00
Enrico Ros 191144b010 Shared Llm Type selector 2023-11-12 01:57:03 -08:00
Enrico Ros 65d085d169 ChatMessage: optional hide avatar 2023-11-12 01:21:04 -08:00
Enrico Ros a39e90003e ChatMessage: optional Edit callback 2023-11-12 01:06:32 -08:00
Enrico Ros 013186a1ad More GoodModals 2023-11-12 00:47:00 -08:00
Enrico Ros 6dd6fb0ce8 Diagrams: wire it up 2023-11-12 00:22:19 -08:00
Enrico Ros db590a2b76 Imagine and Speak: visible, and can configure 2023-11-11 22:47:28 -08:00
Enrico Ros e58088de24 Try to extend chrome to all desktops 2023-11-11 22:32:42 -08:00
Enrico Ros 88dfa60238 Chat messages: share isImagining / isEditing 2023-11-11 21:53:22 -08:00
Enrico Ros 03fca4b9f8 Hint at this being a selection 2023-11-11 21:40:47 -08:00
Enrico Ros c5f7b8e0d2 Remove the Red badge on share, not that new anymore 2023-11-11 21:38:36 -08:00
Enrico Ros 1d18c56810 Custom message context menu - supports custom actions on the selection 2023-11-11 21:38:19 -08:00
Enrico Ros e59e8780b6 small cleanup bits 2023-11-11 20:36:19 -08:00
Enrico Ros ea196bb22f MessagesList: cleanup code more 2023-11-11 19:11:36 -08:00
Enrico Ros 47c2d19a70 MessagesList: cleanup code 2023-11-11 19:08:42 -08:00
Enrico Ros a11ab7cd7c MessagesList: extract Tools panel 2023-11-11 18:51:45 -08:00
Enrico Ros b7b25688ac Fix built on a less configured eslint 2023-11-11 17:38:37 -08:00
Enrico Ros c77a6bb670 Roll next 2023-11-11 17:34:54 -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
232 changed files with 10230 additions and 4268 deletions
+38
View File
@@ -0,0 +1,38 @@
# big-AGI non-code files
/docs/
README.md
# Node build artifacts
/node_modules
/.pnp
.pnp.js
# next.js
/.next/
/out/
# production
/build
# versioning
.git/
.github/
# IDEs
.idea/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+13
View File
@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: enricoros # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+25
View File
@@ -0,0 +1,25 @@
---
name: Bug report
about: Omg what's happening?
title: "[BUG]"
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
Where is it happening?
- Which device [Mobile/Desktop, os version]:
- Which browser:
- Which website:
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots / context**
If applicable, please add screenshots or additional context
@@ -0,0 +1,75 @@
---
name: Maintainers-Release
about: Maintainers
title: Release 1.2.3
labels: ''
assignees: enricoros
---
## 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
- [ ] Assign this task
- [ ] 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': `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 the in-app News version number
- [ ] Update the readme with the new release
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
- Release:
- [ ] merge onto main
- [ ] verify deployment on Vercel
- [ ] verify container on GitHub Packages
- create a GitHub release
- [ ] name it 'vX.Y.Z'
- [ ] copy the release notes and link appropriate artifacts
- 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?
```
+17
View File
@@ -0,0 +1,17 @@
---
name: Roadmap request
about: Suggest a roadmap item
title: "[Roadmap]"
labels: ''
assignees: ''
---
**Why**
The reason behind the request - we love it to be framed for "users will be able to do x" rather than quick-aging hype-tech-of-the-day requests
**Concise description**
A clear and concise description of what you want to happen.
**Requirements**
If you can, please detail the changes you expect in UX, user workflows, technology, architecture (if not, the reviewers will do it for you)
+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 }}
+40 -26
View File
@@ -1,42 +1,56 @@
# Test
FROM node:18-alpine as test-target
ENV NODE_ENV=development
ENV PATH $PATH:/usr/src/app/node_modules/.bin
# Base
FROM node:18-alpine AS base
ENV NEXT_TELEMETRY_DISABLED 1
WORKDIR /usr/src/app
# Dependencies
FROM base AS deps
WORKDIR /app
COPY package*.json prisma/ ./
# Dependency files
COPY package*.json ./
COPY prisma ./prisma
# CI and release builds should use npm ci to fully respect the lockfile.
# Local development may use npm install for opportunistic package updates.
ARG npm_install_command=ci
RUN npm $npm_install_command
# Install dependencies, including dev (release builds should use npm ci)
ENV NODE_ENV development
RUN npm ci
# Builder
FROM base AS builder
WORKDIR /app
# Copy development deps and source
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build
FROM test-target as build-target
ENV NODE_ENV=production
# Use build tools, installed as development packages, to produce a release build.
# Build the application
ENV NODE_ENV production
RUN npm run build
# Reduce installed packages to production-only.
# Reduce installed packages to production-only
RUN npm prune --production
# Archive
FROM node:18-alpine as archive-target
ENV NODE_ENV=production
ENV PATH $PATH:/usr/src/app/node_modules/.bin
# Runner
FROM base AS runner
WORKDIR /app
WORKDIR /usr/src/app
# As user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Include only the release build and production packages.
COPY --from=build-target /usr/src/app/node_modules node_modules
COPY --from=build-target /usr/src/app/.next .next
COPY --from=build-target /usr/src/app/public public
# Copy Built app
COPY --from=builder --chown=nextjs:nodejs /app/public public
COPY --from=builder --chown=nextjs:nodejs /app/.next .next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules node_modules
# Minimal ENV for production
ENV NODE_ENV production
ENV PATH $PATH:/app/node_modules/.bin
# Run as non-root user
USER nextjs
# Expose port 3000 for the application to listen on
EXPOSE 3000
CMD ["next", "start"]
# Start the application
CMD ["next", "start"]
+95 -125
View File
@@ -1,27 +1,77 @@
# `BIG-AGI` 🤖💬
# BIG-AGI 🧠✨
Welcome to `big-AGI` 👋 your personal AGI application
powered by OpenAI GPT-4 and beyond. Designed for smart humans and super-heroes,
this responsive web app comes with Personas, Drawing, Code Execution, PDF imports, Voice support,
data Rendering, AGI functions, chats and much more. Comes with plenty of `#big-AGI-energy` 🚀
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.
[![Official Website](https://img.shields.io/badge/BIG--AGI.com-%23096bde?style=for-the-badge&logo=vercel&label=demo)](https://big-agi.com)
Pros use big-AGI. 🚀 Developers love big-AGI. 🤖
[![Official Website](https://img.shields.io/badge/BIG--AGI.com-%23096bde?style=for-the-badge&logo=vercel&label=launch)](https://big-agi.com)
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.)
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2)
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.7.2 · Dec 12, 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)
- [1.7.1]: Improved Ollama chats. [#270](https://github.com/enricoros/big-agi/issues/270)
- [1.7.2]: Updated OpenRouter models (incl. Mixtral 8x7B)
### 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
- **Ollama Local Models**: Leverage local models support with our comprehensive guide
- **Text Tools**: Enjoy tools including highlight differences to refine your content
- **Mermaid Diagramming**: Render complex diagrams with our Mermaid language support
- **OpenAI 1106 Chat Models**: Experience the cutting-edge capabilities of the latest OpenAI models
- **SDXL Support**: Enhance your image generation with SDXL support for Prodia
- **Cloudflare OpenAI API Gateway**: Integrate with Cloudflare for a robust API gateway
- **Helicone for Anthropic**: Utilize Helicone's tools for Anthropic models
Check out the [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), or
the [past releases changelog](docs/changelog.md).
## ✨ Key Features 👊
![Ask away, paste a ton, copy the gems](docs/pixels/big-AGI-compo1.png)
[More](docs/pixels/big-AGI-compo2b.png), [screenshots](docs/pixels).
- **AI Personas**
- **Polished UI**: installable web app, mobile-friendly, token counters, etc.
- **Fast UX**: Microphone, Camera OCR, Drag files, Voice Synthesis
- **Models**: [OpenAI](https://platform.openai.com/overview), [Anthropic](https://www.anthropic.com/product), [Azure](https://oai.azure.com/), [OpenRouter](https://openrouter.ai/), [Local models](https://github.com/oobabooga/text-generation-webui), and more
- **Private**: use your own API keys and self-host if you like
- **Advanced**: PDF import & Summarization, code execution
- **Integrations**: ElevenLabs, Helicone, Paste.gg, Prodia and more
- **AI Personas**: Tailor your AI interactions with customizable personas
- **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface
- **Efficient Interaction**: Voice commands, OCR, and drag-and-drop file uploads
- **Multiple AI Models**: Choose from a variety of leading AI providers
- **Privacy First**: Self-host and use your own API keys for full control
- **Advanced Tools**: Execute code, import PDFs, and summarize documents
- **Seamless Integrations**: Enhance functionality with various third-party services
- **Open Roadmap**: Contribute to the progress of big-AGI
## 💖 Support
@@ -39,101 +89,14 @@ Or fork & run on Vercel
<br/>
## 🧠 Latest Drops
#### Next
- **Cloudflare API Gateway** support
- **Helicone for Anthropic** support
- **Text Tools** - incl. highlight differences
#### 1.4.0: Sept/Oct: scale OUT
- **Expanded Model Support**: Azure and [OpenRouter](https://openrouter.ai/docs#models) models, including gpt-4-32k
- **Share and clone** conversations with public links
- Removed the 20 chats hard limit ([Ashesh3](https://github.com/enricoros/big-agi/pull/158))
- Latex Rendering
- Augmented Chat modes (Labs)
#### July/Aug: More Better Faster
- **Camera OCR** - real-world AI - take a picture of a text, and chat with it
- **Anthropic models** support, e.g. Claude
- **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
- 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
- Fixes on the HTML block - particularly useful to see error pages
#### June: scale UP
- **[New OpenAI Models](https://openai.com/blog/function-calling-and-other-api-updates) support** - 0613 models, including 16k and 32k
- **Cleaner UI** - with rationalized Settings, Modals, and Configurators
- **Dynamic Models Configurator** - easy connection with different model vendors
- **Multiple Model Vendors Support** framework to support many LLM vendors
- **Per-model Options** (temperature, tokens, etc.) for fine-tuning AI behavior to your needs
- Support for GPT-4-32k
- Improved Dialogs and Messages
- Much Enhanced DX: TRPC integration, modularization, pluggable UI, etc
#### April / May: more #big-agi-energy
- **[Google Search](docs/pixels/feature_react_google.png)** active in ReAct - add your keys to Settings > Google
Search
- **[Reason+Act](docs/pixels/feature_react_turn_on.png)** preview feature - activate with 2-taps on the 'Chat' button
- **[Image Generation](docs/pixels/feature_imagine_command.png)** using Prodia (BYO Keys) - /imagine - or menu option
- **[Voice Synthesis](docs/pixels/feature_voice_1.png)** 📣 with ElevenLabs, including selection of custom voices
- **[Precise Token Counter](docs/pixels/feature_token_counter.png)** 📈 extra-useful to pack the context window
- **[Install Mobile APP](docs/pixels/feature_pwa.png)** 📲 looks like native (@harlanlewis)
- **[UI language](docs/pixels/feature_language.png)** with auto-detect, and future app language! (@tbodyston)
- **PDF Summarization** 🧩🤯 - ask questions to a PDF! (@fredliubojin)
- **Code Execution: [Codepen](https://codepen.io/)/[Replit](https://replit.com/)** 💻 (@harlanlewis)
- **[SVG Drawing](docs/pixels/feature_svg_drawing.png)** - draw with AI 🎨
- Chats: multiple chats, AI titles, Import/Export, Selection mode
- Rendering: Markdown, SVG, improved Code blocks
- Integrations: OpenAI organization ID
- [Cloudflare deployment instructions](docs/deploy-cloudflare.md),
[awesome-agi](https://github.com/enricoros/awesome-agi)
- [Typing Avatars](docs/pixels/gif_typing_040123.gif) ⌨️
<!-- p><a href="docs/pixels/gif_typing_040123.gif"><img src="docs/pixels/gif_typing_040123.gif" width='700' alt="New Typing Avatars"/></a></p -->
#### March: first release
- **[AI Personas](docs/pixels/feature_purpose_two.png)** - including Code, Science, Corporate, and Chat 🎭
- **Privacy**: user-owned API keys 🔑 and localStorage 🛡️
- **Context** - Attach or [Drag & Drop files](docs/pixels/feature_drop_target.png) to add them to the prompt 📁
- **Syntax highlighting** - for multiple languages 🌈
- **Code Execution: Sandpack** -
[now on branch]((https://github.com/enricoros/big-agi/commit/f678a0d463d5e9cf0733f577e11bd612b7902d89)) `variant-code-execution`
- Chat with GPT-4 and 3.5 Turbo 🧠💨
- Real-time streaming of AI responses ⚡
- **Voice Input** 🎙️ - works great on Chrome / Windows
- Integration: **[Paste.gg](docs/pixels/feature_paste_gg.png)** integration for chat sharing 📥
- Integration: **[Helicone](https://www.helicone.ai/)** integration for API observability 📊
- 🌙 Dark model - Wide mode ⛶
<br/>
## Why this? 💡
Because the official Chat ___lacks important features___, is ___more limited than the api___, at times
___slow or unavailable___, and you cannot deploy it yourself, remix it, add features, or share it with
your friends.
Our users report that ___big-AGI is faster___, ___more reliable___, and ___features rich___
with features that matter to them.
![Much features, so fun](docs/pixels/big-AGI-compo2b.png)
## Develop 🧩
## 🧩 Develop
![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=&logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-61DAFB?style=&logo=react&logoColor=black)
![Next.js](https://img.shields.io/badge/Next.js-000000?style=&logo=vercel&logoColor=white)
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
@@ -142,50 +105,57 @@ npm install
npm run dev
```
Now the app should 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
* [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.
## Deploy with Docker 🐳
```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
For more detailed information on deploying with Docker, please refer to the [docker deployment documentation](docs/deploy-docker.md).
### 🔧 Locally built image
> Firstly, write all your API keys and env vars to an `.env` file, and make sure the env file is using *both build and run*.
> See [docs/environment-variables.md](docs/environment-variables.md) for a list of all environment variables.
```bash
Build and run:
```bash
docker build -t big-agi .
docker run --detach 'big-agi'
docker run -d -p 3000:3000 big-agi
```
### Pre-built image
Or run the official container:
> Warning: the UI will still be asking for keys, as the image was built without the API keys
- manually: `docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi`
- or, with docker-compose: `docker-compose up` or see [the documentation](docs/deploy-docker.md) for a composer file with integrated browsing
```bash
docker-compose up
```
## Deploy with Cloudflare Pages ☁️
## ☁️ Deploy on Cloudflare Pages
Please refer to the [Cloudflare deployment documentation](docs/deploy-cloudflare.md).
## Deploy with Vercel 🚀
## 🚀 Deploy on Vercel
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY,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:
+125
View File
@@ -0,0 +1,125 @@
## Changelog
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)
### 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.2 · Dec 11, 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)
- [1.7.1]: Improved Ollama chats. [#270](https://github.com/enricoros/big-agi/issues/270)
- [1.7.2]: Updated OpenRouter models (incl. Mixtral 8x7B)
### 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
- **Ollama Local Models**: Leverage local models support with our comprehensive guide
- **Text Tools**: Enjoy tools including highlight differences to refine your content
- **Mermaid Diagramming**: Render complex diagrams with our Mermaid language support
- **OpenAI 1106 Chat Models**: Experience the cutting-edge capabilities of the latest OpenAI models
- **SDXL Support**: Enhance your image generation with SDXL support for Prodia
- **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
- **Share and clone** conversations with public links
- Removed the 20 chats hard limit ([Ashesh3](https://github.com/enricoros/big-agi/pull/158))
- Latex Rendering
- Augmented Chat modes (Labs)
### July/Aug: More Better Faster
- **Camera OCR** - real-world AI - take a picture of a text, and chat with it
- **Anthropic models** support, e.g. Claude
- **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 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
- Fixes on the HTML block - particularly useful to see error pages
### June: scale UP
- **[New OpenAI Models](https://openai.com/blog/function-calling-and-other-api-updates) support** - 0613 models, including 16k and 32k
- **Cleaner UI** - with rationalized Settings, Modals, and Configurators
- **Dynamic Models Configurator** - easy connection with different model vendors
- **Multiple Model Vendors Support** framework to support many LLM vendors
- **Per-model Options** (temperature, tokens, etc.) for fine-tuning AI behavior to your needs
- Support for GPT-4-32k
- Improved Dialogs and Messages
- Much Enhanced DX: TRPC integration, modularization, pluggable UI, etc
### April / May: more #big-agi-energy
- **[Google Search](../docs/pixels/feature_react_google.png)** active in ReAct - add your keys to Settings > Google
Search
- **[Reason+Act](../docs/pixels/feature_react_turn_on.png)** preview feature - activate with 2-taps on the 'Chat' button
- **[Image Generation](../docs/pixels/feature_imagine_command.png)** using Prodia (BYO Keys) - /imagine - or menu option
- **[Voice Synthesis](../docs/pixels/feature_voice_1.png)** 📣 with ElevenLabs, including selection of custom voices
- **[Precise Token Counter](../docs/pixels/feature_token_counter.png)** 📈 extra-useful to pack the context window
- **[Install Mobile APP](../docs/pixels/feature_pwa.png)** 📲 looks like native (@harlanlewis)
- **[UI language](../docs/pixels/feature_language.png)** with auto-detect, and future app language! (@tbodyston)
- **PDF Summarization** 🧩🤯 - ask questions to a PDF! (@fredliubojin)
- **Code Execution: [Codepen](https://codepen.io/)/[Replit](https://replit.com/)** 💻 (@harlanlewis)
- **[SVG Drawing](../docs/pixels/feature_svg_drawing.png)** - draw with AI 🎨
- Chats: multiple chats, AI titles, Import/Export, Selection mode
- Rendering: Markdown, SVG, improved Code blocks
- Integrations: OpenAI organization ID
- [Cloudflare deployment instructions](../docs/deploy-cloudflare.md),
[awesome-agi](https://github.com/enricoros/awesome-agi)
- [Typing Avatars](../docs/pixels/gif_typing_040123.gif) ⌨️
<!-- p><a href="../docs/pixels/gif_typing_040123.gif"><img src="../docs/pixels/gif_typing_040123.gif" width='700' alt="New Typing Avatars"/></a></p -->
### March: first release
- **[AI Personas](../docs/pixels/feature_purpose_two.png)** - including Code, Science, Corporate, and Chat 🎭
- **Privacy**: user-owned API keys 🔑 and localStorage 🛡️
- **Context** - Attach or [Drag & Drop files](../docs/pixels/feature_drop_target.png) to add them to the prompt 📁
- **Syntax highlighting** - for multiple languages 🌈
- **Code Execution: Sandpack** -
[now on branch]((https://github.com/enricoros/big-agi/commit/f678a0d463d5e9cf0733f577e11bd612b7902d89)) `variant-code-execution`
- Chat with GPT-4 and 3.5 Turbo 🧠💨
- Real-time streaming of AI responses ⚡
- **Voice Input** 🎙️ - works great on Chrome / Windows
- Integration: **[Paste.gg](../docs/pixels/feature_paste_gg.png)** integration for chat sharing 📥
- Integration: **[Helicone](https://www.helicone.ai/)** integration for API observability 📊
- 🌙 Dark model - Wide mode ⛶
+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`!
+10 -5
View File
@@ -5,15 +5,20 @@ This guide helps you connect [Ollama](https://ollama.ai) [models](https://ollama
experience. The integration brings the popular big-AGI features to Ollama, including: voice chats,
editing tools, models switching, personas, and more.
_Last updated Dec 11, 2023_
![config-local-ollama-0-example.png](pixels/config-ollama-0-example.png)
## Quick Integration Guide
1. **Ensure Ollama API Server is Running**: Before starting, make sure your Ollama API server is up and running.
2. **Add Ollama as a Model Source**: In `big-AGI`, navigate to the **Models** section, select **Add a model source**, and choose **Ollama**.
3. **Enter Ollama Host URL**: Provide the Ollama Host URL where the API server is accessible (e.g., `http://localhost:11434`).
4. **Refresh Model List**: Once connected, refresh the list of available models to include the Ollama models.
5. **Start Using AI Personas**: Select an Ollama model and begin interacting with AI personas tailored to your needs.
1. **Ensure Ollama API Server is Running**: Follow the official instructions to get Ollama up and running on your machine
2. **Add Ollama as a Model Source**: In `big-AGI`, navigate to the **Models** section, select **Add a model source**, and choose **Ollama**
3. **Enter Ollama Host URL**: Provide the Ollama Host URL where the API server is accessible (e.g., `http://localhost:11434`)
4. **Refresh Model List**: Once connected, refresh the list of available models to include the Ollama models
> Optional: use the Ollama Admin interface to see which models are available and 'Pull' them in your local machine. Note
that this operation will likely timeout due to Edge Functions timeout on the big-AGI server while pulling, and
you'll have to press the 'Pull' button again, until a green message appears.
5. **Chat with Ollama models**: select an Ollama model and begin chatting with AI personas
### Ollama: installation and Setup
+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.)
+26 -13
View File
@@ -3,35 +3,48 @@
Utilize Docker containers to deploy the big-AGI application for an efficient and automated deployment process.
Docker ensures faster development cycles, easier collaboration, and seamless environment management.
## 🔧 Local Build & Deployment
## Build and run your container 🔧
1. **Clone big-AGI**
2. **Build the Docker Image**: Build a local docker image from the provided Dockerfile. The command is typically `docker build -t big-agi .`
3. **Run the Docker Container**: Start a Docker container using the built image with the command `docker run -d -p 3000:3000 big-agi`
> Note: 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.
> Therefore, ensure all necessary environment variables are set during the build process.
```bash
git clone https://github.com/enricoros/big-agi.git
cd big-agi
```
2. **Build the Docker Image**: Build a local docker image from the provided Dockerfile:
```bash
docker build -t big-agi .
```
3. **Run the Docker Container**: start a Docker container from the newly built image,
and expose its http port 3000 to your `localhost:3000` using:
```bash
docker run -d -p 3000:3000 big-agi
```
4. Browse to [http://localhost:3000](http://localhost:3000)
## Documentation
The big-AGI repository includes a Dockerfile and a GitHub Actions workflow for building and publishing a
Docker image of the application.
### Dockerfile: Containers
### Dockerfile
> A local build is recommended, as the 'ghcr' container is built without environment variables.
The [`Dockerfile`](../Dockerfile) is used to create a Docker image. It establishes a Node.js environment,
The [`Dockerfile`](../Dockerfile) describes how to create a Docker image. It establishes a Node.js environment,
installs dependencies, and creates a production-ready version of the application as a local container.
### GitHub Actions workflow
### Official container images
The [`.github/workflows/docker-image.yml`](../.github/workflows/docker-image.yml) file automates the
building and publishing of the Docker images to the GitHub Container Registry (ghcr) when changes are
pushed to the `main` branch.
### Docker Compose
Official pre-built containers: [ghcr.io/enricoros/big-agi](https://github.com/enricoros/big-agi/pkgs/container/big-agi)
Run official pre-built containers:
```bash
docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi
```
### Run official containers
In addition, the repository also includes a `docker-compose.yaml` file, configured to run the pre-built
'ghcr image'. This file is used to define the `big-agi` service, the ports to expose, and the command to run.
@@ -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
+29 -18
View File
@@ -3,16 +3,12 @@
This document provides an explanation of the environment variables used in the big-AGI application.
**All variables are optional**; and _UI options_ take precedence over _backend environment variables_,
which take place over _defaults_. This file is kept in sync with [`../src/common/types/env.d.ts`](../src/common/types/env.d.ts).
which take place over _defaults_. This file is kept in sync with [`../src/server/env.mjs`](../src/server/env.mjs).
### Setting Environment Variables
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
@@ -38,11 +34,20 @@ HELICONE_API_KEY=
ELEVENLABS_API_KEY=
ELEVENLABS_API_HOST=
ELEVENLABS_VOICE_ID=
# Text-To-Image
PRODIA_API_KEY=
# Google Custom Search
GOOGLE_CLOUD_API_KEY=
GOOGLE_CSE_ID=
# Text-To-Image
PRODIA_API_KEY=
# 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..
],
};
-39
View File
@@ -1,39 +0,0 @@
/** @type {import('next').NextConfig} */
let nextConfig = {
reactStrictMode: true,
modularizeImports: {
'@mui/icons-material': {
transform: '@mui/icons-material/{{member}}',
},
},
webpack: (config, _options) => {
// @mui/joy: anything material gets redirected to Joy
config.resolve.alias['@mui/material'] = '@mui/joy';
// @dqbd/tiktoken: enable asynchronous WebAssembly
config.experiments = {
asyncWebAssembly: true,
layers: true,
};
return config;
},
// NOTE: the following shall be replaced by runtime config
env: {
HAS_SERVER_DB_PRISMA: !!process.env.POSTGRES_PRISMA_URL && !!process.env.POSTGRES_URL_NON_POOLING,
HAS_SERVER_KEYS_GOOGLE_CSE: !!process.env.GOOGLE_CLOUD_API_KEY && !!process.env.GOOGLE_CSE_ID,
HAS_SERVER_KEY_ANTHROPIC: !!process.env.ANTHROPIC_API_KEY,
HAS_SERVER_KEY_AZURE_OPENAI: !!process.env.AZURE_OPENAI_API_KEY && !!process.env.AZURE_OPENAI_API_ENDPOINT,
HAS_SERVER_KEY_ELEVENLABS: !!process.env.ELEVENLABS_API_KEY,
HAS_SERVER_HOST_OLLAMA: !!process.env.OLLAMA_API_HOST,
HAS_SERVER_KEY_OPENAI: !!process.env.OPENAI_API_KEY,
HAS_SERVER_KEY_OPENROUTER: !!process.env.OPENROUTER_API_KEY,
HAS_SERVER_KEY_PRODIA: !!process.env.PRODIA_API_KEY,
},
};
// conditionally enable the nextjs bundle analyzer
if (process.env.ANALYZE_BUNDLE)
nextConfig = require('@next/bundle-analyzer')()(nextConfig);
module.exports = nextConfig;
+41
View File
@@ -0,0 +1,41 @@
/** @type {import('next').NextConfig} */
let nextConfig = {
reactStrictMode: true,
// Note: disabled to chech whether the project becomes slower with this
// modularizeImports: {
// '@mui/icons-material': {
// transform: '@mui/icons-material/{{member}}',
// },
// },
// [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';
// @dqbd/tiktoken: enable asynchronous WebAssembly
config.experiments = {
asyncWebAssembly: true,
layers: true,
};
return config;
},
};
// Validate environment variables, if set at build time. Will be actually read and used at runtime.
// This is the reason both this file and the servr/env.mjs files have this extension.
await import('./src/server/env.mjs');
// conditionally enable the nextjs bundle analyzer
if (process.env.ANALYZE_BUNDLE) {
const { default: withBundleAnalyzer } = await import('@next/bundle-analyzer');
nextConfig = withBundleAnalyzer({ openAnalyzer: true })(nextConfig);
}
export default nextConfig;
+478 -341
View File
File diff suppressed because it is too large Load Diff
+23 -21
View File
@@ -1,9 +1,9 @@
{
"name": "big-agi",
"version": "1.4.0",
"version": "1.7.2",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -18,28 +18,29 @@
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.16",
"@mui/joy": "^5.0.0-beta.14",
"@next/bundle-analyzer": "~14.0.2",
"@prisma/client": "^5.5.2",
"@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",
"idb-keyval": "^6.2.1",
"next": "~14.0.2",
"pdfjs-dist": "3.11.174",
"next": "^14.0.3",
"pdfjs-dist": "4.0.189",
"plantuml-encoder": "^1.4.0",
"prismjs": "^1.29.0",
"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",
@@ -49,19 +50,20 @@
"zustand": "~4.3.9"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@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",
"eslint": "^8.53.0",
"eslint-config-next": "~14.0.2",
"prettier": "^3.0.3",
"prisma": "^5.5.2",
"typescript": "^5.2.2"
"eslint": "^8.54.0",
"eslint-config-next": "^14.0.3",
"prettier": "^3.1.0",
"prisma": "^5.6.0",
"typescript": "^5.3.2"
},
"engines": {
"node": "^20.0.0 || ^18.0.0"
+27 -41
View File
@@ -1,56 +1,42 @@
import * as React from 'react';
import Head from 'next/head';
import { MyAppProps } from 'next/app';
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
import { AppProps } from 'next/app';
import { CacheProvider, EmotionCache } from '@emotion/react';
import { CssBaseline, CssVarsProvider } from '@mui/joy';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Brand } from '~/common/app.config';
import { apiQuery } from '~/common/util/trpc.client';
import 'katex/dist/katex.min.css';
import '~/common/styles/CodePrism.css'
import '~/common/styles/CodePrism.css';
import '~/common/styles/GithubMarkdown.css';
import { Brand } from '~/common/brand';
import { createEmotionCache, theme } from '~/common/theme';
import { ProviderBackend } from '~/common/state/ProviderBackend';
import { ProviderSnacks } from '~/common/state/ProviderSnacks';
import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient';
import { ProviderTheming } from '~/common/state/ProviderTheming';
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
<>
export interface MyAppProps extends AppProps {
emotionCache?: EmotionCache;
}
<Head>
<title>{Brand.Title.Common}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no' />
</Head>
<ProviderTheming emotionCache={emotionCache}>
<ProviderTRPCQueryClient>
<ProviderSnacks>
<ProviderBackend>
<Component {...pageProps} />
</ProviderBackend>
</ProviderSnacks>
</ProviderTRPCQueryClient>
</ProviderTheming>
function MyApp({ Component, emotionCache = clientSideEmotionCache, pageProps }: MyAppProps) {
const [queryClient] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
}));
return <>
<CacheProvider value={emotionCache}>
<Head>
<title>{Brand.Title.Common}</title>
<meta name='viewport' content='minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no' />
</Head>
{/* Rect-query provider */}
<QueryClientProvider client={queryClient}>
<CssVarsProvider defaultMode='light' theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<Component {...pageProps} />
</CssVarsProvider>
</QueryClientProvider>
</CacheProvider>
<VercelAnalytics debug={false} />
</>;
}
// enables the react-query api invocation
</>;
// enables the React Query API invocation
export default apiQuery.withTRPC(MyApp);
+3 -5
View File
@@ -1,13 +1,11 @@
import * as React from 'react';
import { AppType } from 'next/app';
import { AppType, MyAppProps } from 'next/app';
import { default as Document, DocumentContext, DocumentProps, Head, Html, Main, NextScript } from 'next/document';
import createEmotionServer from '@emotion/server/create-instance';
import { getInitColorSchemeScript } from '@mui/joy/styles';
import { Brand } from '~/common/brand';
import { bodyFontClassName, createEmotionCache } from '~/common/theme';
import { MyAppProps } from './_app';
import { Brand } from '~/common/app.config';
import { bodyFontClassName, createEmotionCache } from '~/common/app.theme';
interface MyDocumentProps extends DocumentProps {
-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>
);
}
+98
View File
@@ -0,0 +1,98 @@
import * as React from 'react';
import { useRouter } from 'next/router';
import { Box, Typography } from '@mui/joy';
import { useModelsStore } from '~/modules/llms/store-llms';
import { AppLayout } from '~/common/layout/AppLayout';
import { InlineError } from '~/common/components/InlineError';
import { apiQuery } from '~/common/util/trpc.client';
import { navigateToIndex } from '~/common/app.routes';
import { openLayoutModelsSetup } from '~/common/layout/store-applayout';
function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
// external state
const { data, isError, error, isLoading } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
enabled: !!props.openRouterCode,
refetchOnWindowFocus: false,
staleTime: Infinity,
});
// derived state
const isErrorInput = !props.openRouterCode;
const openRouterKey = data?.key ?? undefined;
const isSuccess = !!openRouterKey;
// Success: save the key and redirect to the chat app
React.useEffect(() => {
if (!isSuccess)
return;
// 1. Save the key as the client key
useModelsStore.getState().setOpenRoutersKey(openRouterKey);
// 2. Navigate to the chat app
navigateToIndex(true).then(() => openLayoutModelsSetup());
}, [isSuccess, openRouterKey]);
return (
<Box sx={{
flexGrow: 1,
backgroundColor: 'background.level1',
overflowY: 'auto',
display: 'flex', justifyContent: 'center',
p: { xs: 3, md: 6 },
}}>
<Box sx={{
// my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 4,
}}>
<Typography level='title-lg'>
Welcome Back
</Typography>
{isLoading && <Typography level='body-sm'>Loading...</Typography>}
{isErrorInput && <InlineError error='There was an issue retrieving the code from OpenRouter.' />}
{isError && <InlineError error={error} />}
{data && (
<Typography level='body-md'>
Success! You can now close this window.
</Typography>
)}
</Box>
</Box>
);
}
/**
* This page will be invoked by OpenRouter as a Callback
*
* Docs: https://openrouter.ai/docs#oauth
* Example URL: https://localhost:3000/link/callback_openrouter?code=SomeCode
*/
export default function Page() {
// get the 'code=...' from the URL
const { query } = useRouter();
const { code: openRouterCode } = query;
return (
<AppLayout suspendAutoModelsSetup>
<CallbackOpenRouterPage openRouterCode={openRouterCode as (string | undefined)} />
</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": {
Binary file not shown.
Binary file not shown.
+1 -2
View File
File diff suppressed because one or more lines are too long
+1 -4
View File
@@ -3,16 +3,13 @@ import { useRouter } from 'next/router';
import { Container, Sheet } from '@mui/joy';
import { AppCallQueryParams } from '~/common/routes';
import { AppCallQueryParams } from '~/common/app.routes';
import { InlineError } from '~/common/components/InlineError';
import { CallUI } from './CallUI';
import { CallWizard } from './CallWizard';
export const APP_CALL_ENABLED = false;
export function AppCall() {
// external state
const { query } = useRouter();
+2 -2
View File
@@ -17,7 +17,7 @@ import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.cl
import { SystemPurposeId, SystemPurposes } from '../../data';
import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
import { streamChat } from '~/modules/llms/transports/streamChat';
import { useVoiceDropdown } from '~/modules/elevenlabs/useVoiceDropdown';
import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
import { Link } from '~/common/components/Link';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
@@ -39,7 +39,7 @@ function CallMenuItems(props: {
}) {
// external state
const { voicesDropdown } = useVoiceDropdown(false, !props.override);
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
+1 -1
View File
@@ -10,7 +10,7 @@ import MicIcon from '@mui/icons-material/Mic';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import WarningIcon from '@mui/icons-material/Warning';
import { navigateBack } from '~/common/routes';
import { navigateBack } from '~/common/app.routes';
import { openLayoutPreferences } from '~/common/layout/store-applayout';
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useChatStore } from '~/common/state/store-chats';
+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';
+328 -123
View File
@@ -1,63 +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, 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,
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;
@@ -75,21 +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':
case 'immediate-follow-up':
return await runAssistantUpdatingState(conversationId, history, chatLLMId, systemPurposeId, true, chatModeId === 'immediate-follow-up');
return await runAssistantUpdatingState(conversationId, history, chatLLMId, focusedSystemPurposeId);
case 'write-user':
return setMessages(conversationId, history);
case 'react':
@@ -115,127 +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 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;
};
useGlobalShortcut('r', true, true, handleRegenerateAssistant);
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
await _handleExecute('immediate', conversationId, history);
const handleClearConversation = (conversationId: string) => setClearConfirmationId(conversationId);
const handleConfirmedClearConversation = () => {
if (clearConfirmationId) {
setMessages(clearConfirmationId, []);
setAutoTitle(clearConfirmationId, '');
setClearConfirmationId(null);
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 handleDeleteAllConversations = () => setDeleteConfirmationId(SPECIAL_ID_ALL_CHATS);
const handleTextSpeak = async (text: string) => {
await speakText(text);
};
// Chat actions
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 handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
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 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);
}
};
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
const handleImportConversation = () => setTradeConfig({ dir: 'import' });
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
if (bypassConfirmation)
setFocusedConversationId(deleteConversation(conversationId));
else
setDeleteConversationId(conversationId);
}, [deleteConversation, setFocusedConversationId]);
const handleExportConversation = (conversationId: string | null) => setTradeConfig({ dir: 'export', conversationId });
const handleFlattenConversation = (conversationId: string) => setFlattenConversationId(conversationId);
// 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}
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'}
onNewMessage={handleComposerNewMessage}
chatLLM={chatLLM}
composerTextAreaRef={composerTextAreaRef}
conversationId={focusedConversationId}
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
onAction={handleComposerAction}
sx={{
zIndex: 21, // position: 'sticky', bottom: 0,
backgroundColor: 'background.surface',
@@ -245,25 +440,35 @@ export function AppChat() {
}} />
{/* Import / Export */}
{!!tradeConfig && <TradeModal config={tradeConfig} onClose={() => setTradeConfig(null)} />}
{/* Diagrams */}
{!!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} 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'}
/>}
+142 -128
View File
@@ -1,168 +1,176 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, List, Sheet, Switch, Tooltip, Typography } from '@mui/joy';
import { Box, List } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { useChatLLM } from '~/modules/llms/store-llms';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
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';
/**
* [Experimental] A panel with tools for the chat
*/
function ToolsPanel(props: { showDiff: boolean, setShowDiff: (showDiff: boolean) => void }) {
return (
<Sheet
variant='outlined' invertedColors
sx={{
position: 'fixed', top: 64, left: 8, zIndex: 101,
boxShadow: 'md', borderRadius: '100px',
p: 2,
display: 'flex', flexFlow: 'row wrap', alignItems: 'center', justifyContent: 'space-between', gap: 2,
}}
>
<Typography level='title-md'>
🪛
</Typography>
<Tooltip title='Highlight differences'>
<Switch
checked={props.showDiff} onChange={() => props.setShowDiff(!props.showDiff)}
startDecorator={<Typography level='title-md'>Diff</Typography>}
/>
</Tooltip>
</Sheet>
);
}
import { useChatShowSystemMessages } from '../store-app-chat';
/**
* A list of ChatMessages
*/
export function ChatMessageList(props: {
conversationId: string | null,
showTools?: boolean,
conversationId: DConversationId | null,
chatLLMContextTokens?: number,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onExecuteChatHistory: (conversationId: string, history: DMessage[]) => void,
onImagineFromText: (conversationId: string, userText: 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
const [diffing, setDiffing] = React.useState<boolean>(false);
const [isImagining, setIsImagining] = React.useState(false);
const [isSpeaking, setIsSpeaking] = React.useState(false);
const [selectedMessages, setSelectedMessages] = React.useState<Set<string>>(new Set());
// external state
const { experimentalLabs, showSystemMessages } = useUIPreferencesStore(state => ({
experimentalLabs: state.experimentalLabs,
showSystemMessages: state.showSystemMessages,
}));
const { messages, editMessage, deleteMessage, historyTokenCount } = useChatStore(state => {
const [showSystemMessages] = useChatShowSystemMessages();
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();
const handleMessageDelete = (messageId: string) =>
props.conversationId && deleteMessage(props.conversationId, messageId);
// derived state
const { conversationId, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
const handleMessageEdit = (messageId: string, newText: string) =>
props.conversationId && editMessage(props.conversationId, messageId, { text: newText }, true);
const handleImagineFromText = (messageText: string): Promise<any> => {
if (props.conversationId)
return props.onImagineFromText(props.conversationId, messageText);
else
return Promise.reject('No conversation');
};
const handleRestartFromMessage = (messageId: string, offset: number) => {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
props.conversationId && props.onExecuteChatHistory(props.conversationId, truncatedHistory);
};
// text actions
const handleRunExample = (text: string) =>
props.conversationId && props.onExecuteChatHistory(props.conversationId, [...messages, createDMessage('user', text)]);
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
// hide system messages if the user chooses so
// NOTE: reverse is because we'll use flexDirection: 'column-reverse' to auto-snap-to-bottom
const filteredMessages = messages.filter(m => m.role !== 'system' || showSystemMessages).reverse();
// message menu methods proxy
// when there are no messages, show the purpose selector
if (!filteredMessages.length)
return props.conversationId ? (
<Box sx={props.sx || {}}>
<PersonaSelector conversationId={props.conversationId} runExample={handleRunExample} />
</Box>
) : null;
const handleConversationBranch = React.useCallback((messageId: string) => {
conversationId && onConversationBranch(conversationId, messageId);
}, [conversationId, onConversationBranch]);
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 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]);
const handleToggleSelected = (messageId: string, selected: boolean) => {
// operate on the local selection set
const handleSelectAll = (selected: boolean) => {
const newSelected = new Set<string>();
if (selected)
for (const message of conversationMessages)
newSelected.add(message.id);
setSelectedMessages(newSelected);
};
const handleSelectMessage = (messageId: string, selected: boolean) => {
const newSelected = new Set(selectedMessages);
selected ? newSelected.add(messageId) : newSelected.delete(messageId);
setSelectedMessages(newSelected);
};
const handleSelectAllMessages = (selected: boolean) => {
const newSelected = new Set<string>();
if (selected)
for (const message of messages)
newSelected.add(message.id);
setSelectedMessages(newSelected);
};
const handleDeleteSelectedMessages = () => {
if (props.conversationId)
const handleSelectionDelete = () => {
if (conversationId)
for (const selectedMessage of selectedMessages)
deleteMessage(props.conversationId, selectedMessage);
deleteMessage(conversationId, selectedMessage);
setSelectedMessages(new Set());
};
// scrollbar style
// const scrollbarStyle: SxProps = {
// '&::-webkit-scrollbar': {
// md: {
// width: 8,
// background: theme.palette.neutral.plainHoverBg,
// },
// },
// '&::-webkit-scrollbar-thumb': {
// background: theme.palette.neutral.solidBg,
// borderRadius: 6,
// },
// '&::-webkit-scrollbar-thumb:hover': {
// background: theme.palette.neutral.solidHoverBg,
// },
// };
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
props.setIsMessageSelectionMode(false);
});
// pass the diff text to most recent assistant message, once done
const showTextTools = !!props.showTools || experimentalLabs;
let diffMessage: DMessage | undefined;
let diffText: string | undefined;
if (diffing && showTextTools) {
const [msgB, msgA] = filteredMessages.filter(m => m.role === 'assistant');
if (!msgB.typing && msgB?.text && msgA?.text) {
// text-diff functionality, find the messages to diff with
const { diffMessage, diffText } = React.useMemo(() => {
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
if (msgB?.text && msgA?.text && !msgB?.typing) {
const textA = msgA.text, textB = msgB.text;
const lenA = textA.length, lenB = textB.length;
if (lenA > 80 && lenB > 80 && lenA > lenB / 2 && lenB > lenA / 2) {
diffMessage = msgB;
diffText = textA;
}
if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
return { diffMessage: msgB, diffText: textA };
}
}
return { diffMessage: undefined, diffText: undefined };
}, [conversationMessages]);
// no content: show the persona selector
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
if (!filteredMessages.length)
return (
<Box sx={{ ...props.sx }}>
{conversationId
? <PersonaSelector conversationId={conversationId} runExample={handleRunExample} />
: <InlineError severity='info' error='Select a conversation' sx={{ m: 2 }} />}
</Box>
);
return (
<List sx={{
@@ -170,34 +178,40 @@ 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) =>
props.isMessageSelectionMode ? (
<CleanerMessage
key={'sel-' + message.id} message={message}
isBottom={idx === 0} remainingTokens={(chatLLM ? chatLLM.contextTokens : 0) - historyTokenCount}
selected={selectedMessages.has(message.id)} onToggleSelected={handleToggleSelected}
key={'sel-' + message.id}
message={message}
isBottom={idx === 0} remainingTokens={(props.chatLLMContextTokens || 0) - historyTokenCount}
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
/>
) : (
<ChatMessage
key={'msg-' + message.id} message={message} diffText={message === diffMessage ? diffText : undefined}
<ChatMessageMemo
key={'msg-' + message.id}
message={message}
diffPreviousText={message === diffMessage ? diffText : undefined}
isBottom={idx === 0}
onMessageDelete={() => handleMessageDelete(message.id)}
onMessageEdit={newText => handleMessageEdit(message.id, newText)}
onMessageRunFrom={(offset: number) => handleRestartFromMessage(message.id, offset)}
onImagine={handleImagineFromText}
isImagining={isImagining} isSpeaking={isSpeaking}
onConversationBranch={handleConversationBranch}
onConversationRestartFrom={handleConversationRestartFrom}
onConversationTruncate={handleConversationTruncate}
onMessageDelete={handleMessageDelete}
onMessageEdit={handleMessageEdit}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
/>
),
)}
{showTextTools && <ToolsPanel showDiff={diffing} setShowDiff={setDiffing} />}
{/* Header at the bottom because of 'row-reverse' */}
{props.isMessageSelectionMode && (
<MessagesSelectionHeader
@@ -205,8 +219,8 @@ export function ChatMessageList(props: {
isBottom={filteredMessages.length === 0}
sumTokens={historyTokenCount}
onClose={() => props.setIsMessageSelectionMode(false)}
onSelectAll={handleSelectAllMessages}
onDeleteMessages={handleDeleteSelectedMessages}
onSelectAll={handleSelectAll}
onDeleteMessages={handleSelectionDelete}
/>
)}
+2 -4
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 }) => ({
@@ -32,8 +32,6 @@ function PrimitiveRender({ name, value }: { name: string, value: string | number
return <StateLine><b>{name}</b>: <b>{value}</b></StateLine>;
else if (typeof value === 'boolean')
return <StateLine><b>{name}</b>: <b>{value ? 'true' : 'false'}</b></StateLine>;
else if (typeof value === 'symbol')
return <StateLine><b>{name}</b>: <b>{value.toString()}</b></StateLine>;
else
return <StateLine><b>{name}</b>: unknown?</StateLine>;
}
@@ -126,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,67 +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, createConversation, 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,
createConversation: state.createConversation,
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 = () => {
// if the first in the stack is a new conversation, just activate it
if (topNewConversationId)
setActiveConversationId(topNewConversationId);
else
createConversation();
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;
@@ -83,7 +80,7 @@ export function ChatDrawerItems(props: {
// flatten grouped conversations
sortedIds = Object.values(groupedConversations).flat();
}
}*/
return <>
@@ -93,9 +90,12 @@ export function ChatDrawerItems(props: {
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem disabled={!!topNewConversationId && topNewConversationId === props.conversationId} onClick={handleNew}>
<MenuItem disabled={props.disableNewButton} onClick={handleButtonNew}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
New
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
New
{/*<KeyStroke combo='Ctrl + Alt + N' />*/}
</Box>
</MenuItem>
<ListDivider sx={{ mb: 0 }} />
@@ -115,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>
@@ -138,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
@@ -1,7 +1,6 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Badge, ListDivider, ListItemDecorator, MenuItem, Switch } from '@mui/joy';
import { Box, ListDivider, ListItemDecorator, MenuItem, Switch } from '@mui/joy';
import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlineBlankOutlined';
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
import ClearIcon from '@mui/icons-material/Clear';
@@ -10,59 +9,67 @@ 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, useUIPreferencesStore } from '~/common/state/store-ui';
import { useUICounter } from '~/common/state/store-ui';
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
const { novel: shareBadge, touch: shareTouch } = useUICounter('export-share');
const { showSystemMessages, setShowSystemMessages } = useUIPreferencesStore(state => ({
showSystemMessages: state.showSystemMessages, setShowSystemMessages: state.setShowSystemMessages,
}), shallow);
const { touch: shareTouch } = useUICounter('export-share');
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
// 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>
@@ -109,16 +108,17 @@ export function ChatMenuItems(props: {
<MenuItem disabled={!props.hasConversations} onClick={handleConversationExport}>
<ListItemDecorator>
<Badge color='danger' invisible={!shareBadge || !props.hasConversations}>
<FileDownloadIcon />
</Badge>
<FileDownloadIcon />
</ListItemDecorator>
Share / Export ...
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Reset
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Reset
{!disabled && <KeyStroke combo='Ctrl + Alt + X' />}
</Box>
</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>
</>}
@@ -9,14 +9,13 @@ import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/sto
import { AppBarDropdown, DropdownItems } from '~/common/layout/AppBarDropdown';
import { KeyStroke } from '~/common/components/KeyStroke';
import { hideOnMobile } from '~/common/theme';
import { openLayoutLLMOptions, openLayoutModelsSetup } from '~/common/layout/store-applayout';
function AppBarLLMDropdown(props: {
llms: DLLM[],
llmId: DLLMId | null,
setLlmId: (llmId: DLLMId | null) => void,
chatLlmId: DLLMId | null,
setChatLlmId: (llmId: DLLMId | null) => void,
placeholder?: string,
}) {
@@ -24,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 };
@@ -34,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>
)}
@@ -57,7 +59,7 @@ function AppBarLLMDropdown(props: {
<ListItemDecorator><BuildCircleIcon color='success' /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Models
<KeyStroke light combo='Ctrl + Shift + M' sx={hideOnMobile} />
<KeyStroke combo='Ctrl + Shift + M' />
</Box>
</ListItemButton>
@@ -75,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 { launchAppCall } from '~/common/routes';
import { useChatStore } from '~/common/state/store-chats';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { launchAppCall } from '~/common/app.routes';
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>
);
}
@@ -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,35 +0,0 @@
import * as React from 'react';
import { Button, IconButton } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import { hideOnDesktop, hideOnMobile } from '~/common/theme';
import { CameraCaptureModal } from './CameraCaptureModal';
const showOnDesktop = false; // process.env.NODE_ENV === 'development';
export function CameraCaptureButton(props: { onOCR: (ocrText: string) => void }) {
// state
const [open, setOpen] = React.useState(false);
return <>
{/* The Button */}
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)} sx={hideOnDesktop}>
<AddAPhotoIcon />
</IconButton>
{/* Also show a button on desktop while in development */}
{showOnDesktop && <Button
fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
sx={{ ...hideOnMobile, justifyContent: 'flex-start' }}>
OCR
</Button>}
{/* The actual capture dialog, which will stream the video */}
{open && <CameraCaptureModal onCloseModal={() => setOpen(false)} onOCR={props.onOCR} />}
</>;
}
@@ -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>
@@ -3,12 +3,59 @@ import * as React from 'react';
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';
export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClose: () => void, experimental: boolean, chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void }) =>
<CloseableMenu
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) {
if (shortcut === 'ENTER')
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
return shortcut;
}
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 }}
open anchorEl={props.anchorEl} onClose={props.onClose}
>
@@ -20,15 +67,21 @@ export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClos
{/*<ListDivider />*/}
{/* ChatMode items */}
{Object.entries(ChatModeItems).filter(([, { experimental }]) => props.experimental || !experimental).map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio checked={key === props.chatModeId} />
<Box>
<Typography>{data.label}</Typography>
<Typography level='body-sm'>{data.description}</Typography>
{Object.entries(ChatModeItems)
.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 }}>
<Radio checked={key === props.chatModeId} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<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)} />
)}
</Box>
</Box>
</MenuItem>)}
</MenuItem>)}
</CloseableMenu>;
</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!, false);
} 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,40 +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' | 'immediate-follow-up' | '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; experimental?: boolean } } = {
'immediate': {
label: 'Chat',
description: 'Persona answers',
},
'immediate-follow-up': {
label: 'Chat Plus',
description: 'Augmented chat (diagrams)',
},
'write-user': {
label: 'Write',
description: 'Just append a message',
},
'react': {
label: 'Reason+Act',
description: 'Answer your questions with ReAct and search',
},
'draw-imagine': {
label: 'Draw',
description: 'AI Image Generation',
},
'draw-imagine-plus': {
label: 'Assisted Draw',
description: 'Assisted Image Generation',
},
};
/// Composer Store
interface ComposerStore {
@@ -54,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);
+304 -138
View File
@@ -3,13 +3,15 @@ import TimeAgo from 'react-timeago';
import { shallow } from 'zustand/shallow';
import { cleanupEfficiency, Diff as TextDiff, makeDiff } from '@sanity/diff-match-patch';
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Stack, Tooltip, Typography, useTheme } from '@mui/joy';
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Stack, Switch, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import ClearIcon from '@mui/icons-material/Clear';
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';
@@ -17,9 +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 { canUseElevenLabs, speakText } from '~/modules/elevenlabs/elevenlabs.client';
import { canUseProdia } from '~/modules/prodia/prodia.client';
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';
@@ -28,11 +29,13 @@ import { InlineTextarea } from '~/common/components/InlineTextarea';
import { KeyStroke } from '~/common/components/KeyStroke';
import { Link } from '~/common/components/Link';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { copyToClipboard } from '~/common/util/copyToClipboard';
import { cssRainbowColorKeyframes, hideOnMobile } from '~/common/theme';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
import { prettyBaseModel } from '~/common/util/modelUtils';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useChatShowTextDiff } from '../../store-app-chat';
import { RenderCode } from './RenderCode';
import { RenderHtml } from './RenderHtml';
import { RenderImage } from './RenderImage';
@@ -43,8 +46,14 @@ import { RenderTextDiff } from './RenderTextDiff';
import { parseBlocks } from './blocks';
// How long is the user collapsed message
const USER_COLLAPSED_LINES: number = 8;
// Enable the automatic menu on text selection
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
// Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu.
const ENABLE_COPY_MESSAGE: boolean = false;
const ENABLE_COPY_MESSAGE_OVERLAY: boolean = false;
export function messageBackground(messageRole: DMessage['role'] | string, wasEdited: boolean, unknownAssistantIssue: boolean): string {
@@ -162,6 +171,24 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
return { errorMessage, isAssistantError };
}
function useSanityTextDiffs(text: string, diffText: string | undefined, enabled: boolean) {
const [diffs, setDiffs] = React.useState<TextDiff[] | null>(null);
React.useEffect(() => {
if (!diffText || !enabled)
return setDiffs(null);
setDiffs(
cleanupEfficiency(makeDiff(diffText, text, {
timeout: 1,
checkLines: true,
}), 4),
);
}, [text, diffText, enabled]);
return diffs;
}
export const ChatMessageMemo = React.memo(ChatMessage);
/**
* The Message component is a customizable chat message UI component that supports
* different roles (user, assistant, and system), text editing, syntax highlighting,
@@ -170,8 +197,44 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
* or collapsing long user messages.
*
*/
export function ChatMessage(props: { message: DMessage, diffText?: string, showDate?: boolean, isBottom?: boolean, noBottomBorder?: boolean, onMessageDelete?: () => void, onMessageEdit: (text: string) => void, onMessageRunFrom?: (offset: number) => void, onImagine?: (messageText: string) => Promise<void> }) {
export function ChatMessage(props: {
message: DMessage,
showDate?: boolean, diffPreviousText?: string,
hideAvatars?: boolean, codeBackground?: string,
noMarkdown?: boolean, diagramMode?: boolean,
isBottom?: boolean, noBottomBorder?: boolean,
isImagining?: boolean, isSpeaking?: boolean,
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,
}) {
// state
const [forceUserExpanded, setForceUserExpanded] = React.useState(false);
const [isHovering, setIsHovering] = React.useState(false);
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
const [selMenuAnchor, setSelMenuAnchor] = React.useState<HTMLElement | null>(null);
const [selMenuText, setSelMenuText] = React.useState<string | null>(null);
const [isEditing, setIsEditing] = React.useState(false);
// external state
const { cleanerLooks, renderMarkdown, doubleClickToEdit } = useUIPreferencesStore(state => ({
cleanerLooks: state.zenMode === 'cleaner',
renderMarkdown: state.renderMarkdown,
doubleClickToEdit: state.doubleClickToEdit,
}), shallow);
const [showDiff, setShowDiff] = useChatShowTextDiff();
const textDiffs = useSanityTextDiffs(props.message.text, props.diffPreviousText, showDiff);
// derived state
const {
id: messageId,
text: messageText,
sender: messageSender,
avatar: messageAvatar,
@@ -182,99 +245,148 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
created: messageCreated,
updated: messageUpdated,
} = props.message;
const fromAssistant = messageRole === 'assistant';
const fromSystem = messageRole === 'system';
const fromUser = messageRole === 'user';
const wasEdited = !!messageUpdated;
// state
const [diffs, setDiffs] = React.useState<TextDiff[] | null>(null);
const [forceExpanded, setForceExpanded] = React.useState(false);
const [isHovering, setIsHovering] = React.useState(false);
const [menuAnchor, setMenuAnchor] = React.useState<HTMLElement | null>(null);
const [isEditing, setIsEditing] = React.useState(false);
const [isImagining, setIsImagining] = React.useState(false);
const [isSpeaking, setIsSpeaking] = React.useState(false);
const showAvatars = props.hideAvatars !== true && !cleanerLooks;
// external state
const theme = useTheme();
const { showAvatars, renderMarkdown: _renderMarkdown, doubleClickToEdit } = useUIPreferencesStore(state => ({
showAvatars: state.zenMode !== 'cleaner',
renderMarkdown: state.renderMarkdown,
doubleClickToEdit: state.doubleClickToEdit,
}), shallow);
const renderMarkdown = _renderMarkdown && !fromSystem;
const isImaginable = canUseProdia() && !!props.onImagine;
const isImaginableEnabled = messageText?.length >= 2 && !messageText.startsWith('https://images.prodia.xyz/') && !(messageText.startsWith('/imagine') || messageText.startsWith('/img'));
const isSpeakable = canUseElevenLabs();
const isSpeakableEnabled = isImaginableEnabled;
const textSel = selMenuText ? selMenuText : messageText;
const isSpecialProdia = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/imagine') || textSel.startsWith('/img');
const couldDiagram = textSel?.length >= 100 && !isSpecialProdia;
const couldImagine = textSel?.length >= 2 && !isSpecialProdia;
const couldSpeak = couldImagine;
// Effect: text diffing vs the former message
React.useEffect(() => {
if (!props.diffText)
return setDiffs(null);
setDiffs(
cleanupEfficiency(makeDiff(props.diffText, messageText, {
timeout: 1,
checkLines: true,
}), 4),
);
}, [messageText, props.diffText]);
const closeOperationsMenu = () => setMenuAnchor(null);
const handleMenuCopy = (e: React.MouseEvent) => {
copyToClipboard(messageText);
e.preventDefault();
closeOperationsMenu();
const handleTextEdited = (editedText: string) => {
setIsEditing(false);
if (props.onMessageEdit && editedText?.trim() && editedText !== messageText)
props.onMessageEdit(messageId, editedText);
};
const handleMenuEdit = (e: React.MouseEvent) => {
const handleUncollapse = () => setForceUserExpanded(true);
// Operations Menu
const closeOperationsMenu = () => setOpsMenuAnchor(null);
const handleOpsCopy = (e: React.MouseEvent) => {
copyToClipboard(textSel, 'Text');
e.preventDefault();
closeOperationsMenu();
closeSelectionMenu();
};
const handleOpsEdit = (e: React.MouseEvent) => {
if (messageTyping && !isEditing) return; // don't allow editing while typing
setIsEditing(!isEditing);
e.preventDefault();
closeOperationsMenu();
};
const handleMenuImagine = async (e: React.MouseEvent) => {
const handleOpsConversationBranch = (e: React.MouseEvent) => {
e.preventDefault();
if (props.onImagine) {
setIsImagining(true);
await props.onImagine(messageText);
setIsImagining(false);
closeOperationsMenu();
}
};
const handleMenuSpeak = async (e: React.MouseEvent) => {
e.preventDefault();
setIsSpeaking(true);
await speakText(messageText);
setIsSpeaking(false);
props.onConversationBranch && props.onConversationBranch(messageId);
closeOperationsMenu();
};
const handleMenuRunAgain = (e: React.MouseEvent) => {
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
e.preventDefault();
if (props.onMessageRunFrom) {
props.onMessageRunFrom(fromAssistant ? -1 : 0);
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(messageId, textSel);
closeOperationsMenu();
closeSelectionMenu();
}
};
const handleTextEdited = (editedText: string) => {
setIsEditing(false);
if (editedText?.trim() && editedText !== messageText)
props.onMessageEdit(editedText);
const handleOpsImagine = async (e: React.MouseEvent) => {
e.preventDefault();
if (props.onTextImagine) {
await props.onTextImagine(textSel);
closeOperationsMenu();
closeSelectionMenu();
}
};
const handleExpand = () => setForceExpanded(true);
const handleOpsSpeak = async (e: React.MouseEvent) => {
e.preventDefault();
if (props.onTextSpeak) {
await props.onTextSpeak(textSel);
closeOperationsMenu();
closeSelectionMenu();
}
};
const handleOpsTruncate = (_e: React.MouseEvent) => {
props.onConversationTruncate && props.onConversationTruncate(messageId);
closeOperationsMenu();
};
const handleOpsDelete = (_e: React.MouseEvent) => {
props.onMessageDelete && props.onMessageDelete(messageId);
};
// soft error handling
// Selection Menu
const removeSelectionAnchor = React.useCallback(() => {
if (selMenuAnchor) {
try {
document.body.removeChild(selMenuAnchor);
} catch (e) {
// ignore...
}
}
}, [selMenuAnchor]);
const openSelectionMenu = React.useCallback((event: MouseEvent, selectedText: string) => {
event.stopPropagation();
event.preventDefault();
// remove any stray anchor
removeSelectionAnchor();
// create a temporary fixed anchor element to position the menu
const anchorEl = document.createElement('div');
anchorEl.style.position = 'fixed';
anchorEl.style.left = `${event.clientX}px`;
anchorEl.style.top = `${event.clientY}px`;
document.body.appendChild(anchorEl);
setSelMenuAnchor(anchorEl);
setSelMenuText(selectedText);
}, [removeSelectionAnchor]);
const closeSelectionMenu = React.useCallback(() => {
// window.getSelection()?.removeAllRanges?.();
removeSelectionAnchor();
setSelMenuAnchor(null);
setSelMenuText(null);
}, [removeSelectionAnchor]);
const handleMouseUp = React.useCallback((event: MouseEvent) => {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const selectedText = range.toString().trim();
if (selectedText.length > 0)
openSelectionMenu(event, selectedText);
}
}, [openSelectionMenu]);
// prettier upstream errors
const { isAssistantError, errorMessage } = React.useMemo(
() => explainErrorInMessage(messageText, fromAssistant, messageOriginLLM),
[messageText, fromAssistant, messageOriginLLM],
@@ -295,9 +407,9 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
};
const codeSx: SxProps = {
// backgroundColor: fromAssistant ? 'background.level1' : 'background.level1',
backgroundColor: fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
backgroundColor: props.codeBackground ? props.codeBackground : fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
boxShadow: 'xs',
fontFamily: theme.fontFamily.code,
fontFamily: 'code',
fontSize: '14px',
fontVariantLigatures: 'none',
lineHeight: 1.75,
@@ -307,10 +419,10 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
// user message truncation
let collapsedText = messageText;
let isCollapsed = false;
if (fromUser && !forceExpanded) {
if (fromUser && !forceUserExpanded) {
const lines = messageText.split('\n');
if (lines.length > 10) {
collapsedText = lines.slice(0, 10).join('\n');
if (lines.length > USER_COLLAPSED_LINES) {
collapsedText = lines.slice(0, USER_COLLAPSED_LINES).join('\n');
isCollapsed = true;
}
}
@@ -318,8 +430,6 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
return (
<ListItem
// [alpha] Right-click menu: still in early development
// onContextMenu={event => setMenuAnchor(event.currentTarget)}
sx={{
display: 'flex', flexDirection: !fromAssistant ? 'row-reverse' : 'row', alignItems: 'flex-start',
gap: { xs: 0, md: 1 }, px: { xs: 1, md: 2 }, py: 2,
@@ -328,9 +438,10 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
borderBottom: '1px solid',
borderBottomColor: 'divider',
}),
...(ENABLE_COPY_MESSAGE && { position: 'relative' }),
...(ENABLE_COPY_MESSAGE_OVERLAY && { position: 'relative' }),
...(props.isBottom === true && { mb: 'auto' }),
'&:hover > button': { opacity: 1 },
...props.sx,
}}
>
@@ -338,7 +449,7 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
{showAvatars && <Stack
sx={{ alignItems: 'center', minWidth: { xs: 50, md: 64 }, maxWidth: 80, textAlign: 'center' }}
onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)}
onClick={event => setMenuAnchor(event.currentTarget)}>
onClick={event => setOpsMenuAnchor(event.currentTarget)}>
{isHovering ? (
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'}>
@@ -365,14 +476,21 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
{/* Edit / Blocks */}
{!isEditing ? (
{isEditing
<Box
onDoubleClick={(e) => doubleClickToEdit ? handleMenuEdit(e) : null}
? <InlineTextarea initialText={messageText} onEdit={handleTextEdited} sx={{ ...blockSx, lineHeight: 1.75, flexGrow: 1 }} />
: <Box
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
sx={{
...blockSx,
flexGrow: 0,
overflowX: 'auto',
...(!!props.diagramMode && {
// width: '100%',
boxShadow: 'md',
}),
}}>
{props.showDate === true && (
@@ -386,30 +504,33 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
<Typography level='body-sm' color='warning' sx={{ mt: 1, mx: 1.5 }}>modified by user - auto-update disabled</Typography>
)}
{!errorMessage && parseBlocks(collapsedText, fromSystem, diffs).map((block, index) =>
block.type === 'html'
? <RenderHtml key={'html-' + index} htmlBlock={block} sx={codeSx} />
: block.type === 'code'
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} />
: block.type === 'image'
? <RenderImage key={'image-' + index} imageBlock={block} allowRunAgain={props.isBottom === true} onRunAgain={handleMenuRunAgain} />
: block.type === 'latex'
? <RenderLatex key={'latex-' + index} latexBlock={block} />
: block.type === 'diff'
? <RenderTextDiff key={'latex-' + index} diffBlock={block} />
: renderMarkdown
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
: <RenderText key={'text-' + index} textBlock={block} />,
)}
{errorMessage && (
<Tooltip title={<Typography sx={{ maxWidth: 800 }}>{collapsedText}</Typography>} variant='soft'>
<InlineError error={errorMessage} />
</Tooltip>
)}
{/* sequence of render components, for each Block */}
{!errorMessage && parseBlocks(collapsedText, fromSystem, textDiffs)
.filter((block, _, blocks) => !props.diagramMode || block.type === 'code' || blocks.length === 1)
.map(
(block, index) =>
block.type === 'html'
? <RenderHtml key={'html-' + index} htmlBlock={block} sx={codeSx} />
: block.type === 'code'
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} noCopyButton={props.diagramMode} />
: block.type === 'image'
? <RenderImage key={'image-' + index} imageBlock={block} 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 && !(fromUser && block.content.startsWith('/')))
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
: <RenderText key={'text-' + index} textBlock={block} />)}
{isCollapsed && (
<Button variant='plain' color='neutral' onClick={handleExpand}>... expand ...</Button>
<Button variant='plain' color='neutral' onClick={handleUncollapse}>... expand ...</Button>
)}
{/* import VisibilityIcon from '@mui/icons-material/Visibility'; */}
@@ -419,19 +540,14 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
{/*</Chip>*/}
</Box>
) : (
<InlineTextarea initialText={messageText} onEdit={handleTextEdited} sx={{ ...blockSx, lineHeight: 1.75, flexGrow: 1 }} />
)}
}
{/* Copy message */}
{ENABLE_COPY_MESSAGE && !fromSystem && !isEditing && (
{/* Overlay copy icon */}
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
<Tooltip title={fromAssistant ? 'Copy message' : 'Copy input'} variant='solid'>
<IconButton
variant='outlined' color='neutral' onClick={handleMenuCopy}
variant='outlined' color='neutral' onClick={handleOpsCopy}
sx={{
position: 'absolute', ...(fromAssistant ? { right: { xs: 12, md: 28 } } : { left: { xs: 12, md: 28 } }), zIndex: 10,
opacity: 0, transition: 'opacity 0.3s',
@@ -442,60 +558,110 @@ export function ChatMessage(props: { message: DMessage, diffText?: string, showD
)}
{/* Message Operations menu */}
{!!menuAnchor && (
{/* Operations Menu (3 dots) */}
{!!opsMenuAnchor && (
<CloseableMenu
placement='bottom-end' sx={{ minWidth: 280 }}
open anchorEl={menuAnchor} onClose={closeOperationsMenu}
dense placement='bottom-end' sx={{ minWidth: 280 }}
open anchorEl={opsMenuAnchor} onClose={closeOperationsMenu}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<MenuItem variant='plain' disabled={messageTyping} onClick={handleMenuEdit} sx={{ flex: 1 }}>
<ListItemDecorator><EditIcon /></ListItemDecorator>
{isEditing ? 'Discard' : 'Edit'}
{/*{!isEditing && <span style={{ opacity: 0.5, marginLeft: '8px' }}>{doubleClickToEdit ? '(double-click)' : ''}</span>}*/}
</MenuItem>
<MenuItem onClick={handleMenuCopy} sx={{ flex: 1 }}>
{!!props.onMessageEdit && (
<MenuItem variant='plain' disabled={messageTyping} onClick={handleOpsEdit} sx={{ flex: 1 }}>
<ListItemDecorator><EditIcon /></ListItemDecorator>
{isEditing ? 'Discard' : 'Edit'}
{/*{!isEditing && <span style={{ opacity: 0.5, marginLeft: '8px' }}>{doubleClickToEdit ? '(double-click)' : ''}</span>}*/}
</MenuItem>
)}
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
Copy
</MenuItem>
</Box>
{!!props.diffPreviousText && <ListDivider />}
{!!props.diffPreviousText && (
<MenuItem onClick={handleOpsToggleShowDiff}>
<ListItemDecorator><DifferenceIcon /></ListItemDecorator>
Show difference
<Switch checked={showDiff} onChange={handleOpsToggleShowDiff} sx={{ ml: 'auto' }} />
</MenuItem>
)}
<ListDivider />
{!!props.onMessageRunFrom && (
<MenuItem onClick={handleMenuRunAgain}>
<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 light combo='Ctrl + Shift + R' sx={hideOnMobile} />
<KeyStroke combo='Ctrl + Shift + R' />
</Box>
}
</MenuItem>
)}
{isImaginable && isImaginableEnabled && (
<MenuItem onClick={handleMenuImagine} disabled={!isImaginableEnabled || isImagining}>
<ListItemDecorator>{isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
Imagine
{!!props.onConversationBranch && (
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
<ListItemDecorator>
<ForkRightIcon />
</ListItemDecorator>
Branch {!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
</MenuItem>
)}
{isSpeakable && isSpeakableEnabled && (
<MenuItem onClick={handleMenuSpeak} disabled={isSpeaking}>
<ListItemDecorator>{isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
{!!props.onConversationBranch && <ListDivider />}
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Visualize ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
Imagine
</MenuItem>}
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
</MenuItem>}
{!!props.onConversationRestartFrom && <ListDivider />}
{!!props.onConversationTruncate && (
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Truncate <span style={{ opacity: 0.5 }}>after</span>
</MenuItem>
)}
{!!props.onMessageRunFrom && <ListDivider />}
{!!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>
)}
{/* Selection (Contextual) Menu */}
{!!selMenuAnchor && (
<CloseableMenu
dense placement='bottom-start' sx={{ minWidth: 220 }}
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
>
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
Copy <span style={{ opacity: 0.5 }}>selection</span>
</MenuItem>
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Visualize ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
Imagine
</MenuItem>}
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
</MenuItem>}
</CloseableMenu>
)}
</ListItem>
);
}
@@ -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';
@@ -85,7 +85,7 @@ export function CleanerMessage(props: { message: DMessage, isBottom: boolean, se
<Checkbox size='md' checked={props.selected} onChange={handleCheckedChange} />
</Box>}
<Box sx={{ display: 'flex', minWidth: { xs: 40, sm: 48 }, justifyContent: 'center' }}>
<Box sx={{ display: { xs: 'none', sm: 'flex' }, minWidth: { xs: 40, sm: 48 }, justifyContent: 'center' }}>
{avatarEl}
</Box>
@@ -94,10 +94,18 @@ 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 sx={{ flexGrow: 1, textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap' }}>
<Typography level='body-md' sx={{
flexGrow: 1,
textOverflow: 'ellipsis', overflow: 'hidden',
// whiteSpace: 'nowrap',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
maxHeight: '2.9em',
}}>
{messageText}
</Typography>
+153 -115
View File
@@ -8,66 +8,39 @@ import HtmlIcon from '@mui/icons-material/Html';
import SchemaIcon from '@mui/icons-material/Schema';
import ShapeLineOutlinedIcon from '@mui/icons-material/ShapeLineOutlined';
import { copyToClipboard } from '~/common/util/copyToClipboard';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { CodeBlock } from './blocks';
import { OpenInCodepen } from './OpenInCodepen';
import { OpenInReplit } from './OpenInReplit';
import { RenderCodeMermaid } from './RenderCodeMermaid';
import { heuristicIsHtml, IFrameComponent } from './RenderHtml';
export const overlayButtonsSx: SxProps = {
position: 'absolute', top: 0, right: 0, zIndex: 10,
display: 'flex', flexDirection: 'row', gap: 1,
opacity: 0, transition: 'opacity 0.2s',
'& > button': { backdropFilter: 'blur(12px)' },
};
function RenderCodeImpl(props: {
codeBlock: CodeBlock, sx?: SxProps,
codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps,
highlightCode: (inferredCodeLanguage: string | null, blockCode: string) => string,
inferCodeLanguage: (blockTitle: string, code: string) => string | null,
}) {
// state
const [showHTML, setShowHTML] = React.useState(false);
const [showSVG, setShowSVG] = React.useState(true);
const [showMermaid, setShowMermaid] = React.useState(true);
const [showPlantUML, setShowPlantUML] = React.useState(true);
const [showSVG, setShowSVG] = React.useState(true);
// derived props
const { codeBlock: { blockTitle, blockCode }, highlightCode, inferCodeLanguage } = props;
const isHTML = heuristicIsHtml(blockCode);
const renderHTML = isHTML && showHTML;
const isSVG = blockCode.startsWith('<svg') && blockCode.endsWith('</svg>');
const renderSVG = isSVG && showSVG;
const isPlantUML =
(blockCode.startsWith('@startuml') && blockCode.endsWith('@enduml'))
|| (blockCode.startsWith('@startmindmap') && blockCode.endsWith('@endmindmap'))
|| (blockCode.startsWith('@startsalt') && blockCode.endsWith('@endsalt'))
|| (blockCode.startsWith('@startwbs') && blockCode.endsWith('@endwbs'))
|| (blockCode.startsWith('@startgantt') && blockCode.endsWith('@endgantt'));
let renderPlantUML = isPlantUML && showPlantUML;
const { data: plantUmlHtmlData } = useQuery({
enabled: renderPlantUML,
queryKey: ['plantuml', blockCode],
queryFn: async () => {
try {
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
const { encode: plantUmlEncode } = await import('plantuml-encoder');
// retrieve and manually adapt the SVG, to remove the background
const encodedPlantUML: string = plantUmlEncode(blockCode);
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
const svg = await response.text();
const start = svg.indexOf('<svg ');
const end = svg.indexOf('</svg>');
if (start < 0 || end <= start)
return null;
return svg.slice(start, end + 6).replace('background:#FFFFFF;', '');
} catch (e) {
// ignore errors, and disable the component in that case
return null;
}
},
staleTime: 24 * 60 * 60 * 1000, // 1 day
});
renderPlantUML = renderPlantUML && !!plantUmlHtmlData;
const {
codeBlock: { blockTitle, blockCode, complete: blockComplete },
highlightCode, inferCodeLanguage,
} = props;
// heuristic for language, and syntax highlight
const { highlightedCode, inferredCodeLanguage } = React.useMemo(
@@ -78,6 +51,62 @@ function RenderCodeImpl(props: {
}, [inferCodeLanguage, blockTitle, blockCode, highlightCode]);
// heuristics for specialized rendering
const isHTML = heuristicIsHtml(blockCode);
const renderHTML = isHTML && showHTML;
const isMermaid = blockTitle === 'mermaid' && blockComplete;
const renderMermaid = isMermaid && showMermaid;
const isPlantUML =
(blockCode.startsWith('@startuml') && blockCode.endsWith('@enduml'))
|| (blockCode.startsWith('@startmindmap') && blockCode.endsWith('@endmindmap'))
|| (blockCode.startsWith('@startsalt') && blockCode.endsWith('@endsalt'))
|| (blockCode.startsWith('@startwbs') && blockCode.endsWith('@endwbs'))
|| (blockCode.startsWith('@startgantt') && blockCode.endsWith('@endgantt'));
let renderPlantUML = isPlantUML && showPlantUML;
const { data: plantUmlHtmlData, error: plantUmlError } = useQuery({
enabled: renderPlantUML,
queryKey: ['plantuml', blockCode],
queryFn: async () => {
// fetch the PlantUML SVG
let text: string = '';
try {
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
const { encode: plantUmlEncode } = await import('plantuml-encoder');
// retrieve and manually adapt the SVG, to remove the background
const encodedPlantUML: string = plantUmlEncode(blockCode);
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
text = await response.text();
} catch (e) {
return null;
}
// validate/extract the SVG
const start = text.indexOf('<svg ');
const end = text.indexOf('</svg>');
if (start < 0 || end <= start)
throw new Error('Could not render PlantUML');
const svg = text
.slice(start, end + 6) // <svg ... </svg>
.replace('background:#FFFFFF;', ''); // transparent background
// check for syntax errors
if (svg.includes('>Syntax Error?</text>'))
throw new Error('syntax issue (it happens!). Please regenerate or change generator model.');
return svg;
},
staleTime: 24 * 60 * 60 * 1000, // 1 day
});
renderPlantUML = renderPlantUML && (!!plantUmlHtmlData || !!plantUmlError);
const isSVG = blockCode.startsWith('<svg') && blockCode.endsWith('</svg>');
const renderSVG = isSVG && showSVG;
const languagesCodepen = ['html', 'css', 'javascript', 'json', 'typescript'];
const canCodepen = isSVG || (!!inferredCodeLanguage && languagesCodepen.includes(inferredCodeLanguage));
@@ -86,84 +115,93 @@ function RenderCodeImpl(props: {
const handleCopyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation();
copyToClipboard(blockCode);
copyToClipboard(blockCode, 'Code');
};
return (
<Box
component='code'
className={`language-${inferredCodeLanguage || 'unknown'}`}
sx={{
position: 'relative', mx: 0, p: 1.5, // this block gets a thicker border
display: 'block', fontWeight: 500,
whiteSpace: 'pre', // was 'break-spaces' before we implmented per-block scrolling
overflowX: 'auto',
'&:hover > .code-buttons': { opacity: 1 },
...(props.sx || {}),
}}>
{/* Overlay Buttons */}
<Box sx={{ position: 'relative' /* for overlay buttons to stick properly */ }}>
<Box
className='code-buttons'
component='code'
className={`language-${inferredCodeLanguage || 'unknown'}`}
sx={{
backdropFilter: 'blur(8px)', // '... grayscale(0.8)
position: 'absolute', top: 0, right: 0, zIndex: 10, p: 0.5,
display: 'flex', flexDirection: 'row', gap: 1,
opacity: 0, transition: 'opacity 0.3s',
// '& > button': { backdropFilter: 'blur(6px)' },
fontWeight: 500, whiteSpace: 'pre', // was 'break-spaces' before we implemented per-block scrolling
mx: 0, p: 1.5, // this block gets a thicker border
display: 'block',
overflowX: 'auto',
'&:hover > .overlay-buttons': { opacity: 1 },
...(props.sx || {}),
}}>
{isSVG && (
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'} variant='solid'>
<IconButton variant={renderSVG ? 'solid' : 'soft'} color='neutral' onClick={() => setShowSVG(!showSVG)}>
<ShapeLineOutlinedIcon />
</IconButton>
</Tooltip>
{/* Markdown Title (File/Type) */}
{blockTitle != inferredCodeLanguage && blockTitle.includes('.') && (
<Sheet sx={{ boxShadow: 'sm', borderRadius: 'sm', mb: 1 }}>
<Typography level='title-sm' sx={{ px: 1, py: 0.5 }}>
{blockTitle}
{/*{inferredCodeLanguage}*/}
</Typography>
</Sheet>
)}
{isHTML && (
<Tooltip title={renderHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
<IconButton variant={renderHTML ? 'solid' : 'soft'} color='danger' onClick={() => setShowHTML(!showHTML)}>
<HtmlIcon />
{/* Renders HTML, or inline SVG, inline plantUML rendered, or highlighted code */}
{renderHTML
? <IFrameComponent htmlString={blockCode} />
: renderMermaid
? <RenderCodeMermaid mermaidCode={blockCode} />
: <Box component='div'
dangerouslySetInnerHTML={{
__html:
renderSVG
? blockCode
: renderPlantUML
? (plantUmlHtmlData || (plantUmlError as string) || 'No PlantUML rendering.')
: highlightedCode,
}}
sx={{
...(renderSVG ? { lineHeight: 0 } : {}),
...(renderPlantUML ? { textAlign: 'center' } : {}),
}}
/>}
{/* Code Buttons */}
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, p: 0.5 }}>
{isHTML && (
<Tooltip title={renderHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
<IconButton variant={renderHTML ? 'solid' : 'outlined'} color='danger' onClick={() => setShowHTML(!showHTML)}>
<HtmlIcon />
</IconButton>
</Tooltip>
)}
{isMermaid && (
<Tooltip title={renderMermaid ? 'Show Code' : 'Render Mermaid'} variant='solid'>
<IconButton variant={renderMermaid ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowMermaid(!showMermaid)}>
<SchemaIcon />
</IconButton>
</Tooltip>
)}
{isPlantUML && (
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'} variant='solid'>
<IconButton variant={renderPlantUML ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowPlantUML(!showPlantUML)}>
<SchemaIcon />
</IconButton>
</Tooltip>
)}
{isSVG && (
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'} variant='solid'>
<IconButton variant={renderSVG ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowSVG(!showSVG)}>
<ShapeLineOutlinedIcon />
</IconButton>
</Tooltip>
)}
{canCodepen && <OpenInCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{canReplit && <OpenInReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{props.noCopyButton !== true && <Tooltip title='Copy Code' variant='solid'>
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>
)}
{isPlantUML && (
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'} variant='solid'>
<IconButton variant={renderPlantUML ? 'solid' : 'soft'} color='neutral' onClick={() => setShowPlantUML(!showPlantUML)}>
<SchemaIcon />
</IconButton>
</Tooltip>
)}
{canCodepen && <OpenInCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{canReplit && <OpenInReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
<Tooltip title='Copy Code' variant='solid'>
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>
</Tooltip>}
</Box>
</Box>
{/* Title (highlighted code) */}
{blockTitle != inferredCodeLanguage && blockTitle.includes('.') && <Sheet sx={{ boxShadow: 'sm', borderRadius: 'sm', mb: 1 }}>
<Typography level='title-sm' sx={{ px: 1, py: 0.5 }}>
{blockTitle}
{/*{inferredCodeLanguage}*/}
</Typography>
</Sheet>}
{/* Renders HTML, or inline SVG, inline plantUML rendered, or highlighted code */}
{renderHTML ? <IFrameComponent htmlString={blockCode} />
: <Box
dangerouslySetInnerHTML={{
__html:
renderSVG ? blockCode
: (renderPlantUML && plantUmlHtmlData) ? plantUmlHtmlData
: highlightedCode,
}}
sx={{
...(renderSVG ? { lineHeight: 0 } : {}),
...(renderPlantUML ? { textAlign: 'center' } : {}),
}}
/>}
</Box>
);
}
@@ -175,12 +213,12 @@ const RenderCodeDynamic = React.lazy(async () => {
const { highlightCode, inferCodeLanguage } = await import('./codePrism');
return {
default: (props: { codeBlock: CodeBlock, sx?: SxProps }) =>
default: (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
<RenderCodeImpl highlightCode={highlightCode} inferCodeLanguage={inferCodeLanguage} {...props} />,
};
});
export const RenderCode = (props: { codeBlock: CodeBlock, sx?: SxProps }) =>
export const RenderCode = (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
<React.Suspense fallback={<Box component='code' sx={{ p: 1.5, display: 'block', ...(props.sx || {}) }} />}>
<RenderCodeDynamic {...props} />
</React.Suspense>;
@@ -0,0 +1,164 @@
import * as React from 'react';
import { create } from 'zustand';
import { Box } from '@mui/joy';
import { appTheme } from '~/common/app.theme';
import { isBrowser } from '~/common/util/pwaUtils';
/**
* We are loading Mermaid from the CDN (and spending all the work to dynamically load it
* and strong type it), because the Mermaid dependencies (npm i mermaid) are too heavy
* and would slow down development for everyone.
*
* If you update this file, also make sure the interfaces/type definitions and initialization
* options are updated accordingly.
*/
const MERMAID_CDN_FILE: string = 'https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js';
interface MermaidAPI {
initialize: (config: any) => void;
render: (id: string, text: string, svgContainingElement?: Element) => Promise<{ svg: string, bindFunctions?: (element: Element) => void }>;
}
// extend the Window interface, to allow for the mermaid API to be found
declare global {
// noinspection JSUnusedGlobalSymbols
interface Window {
mermaid: MermaidAPI;
}
}
interface MermaidAPIStore {
mermaidAPI: MermaidAPI | null,
loadingError: string | null,
}
const useMermaidStore = create<MermaidAPIStore>()(
() => ({
mermaidAPI: null,
loadingError: null,
}),
);
let loadingStarted: boolean = false;
let loadingError: string | null = null;
function loadMermaidFromCDN() {
if (isBrowser && !loadingStarted) {
loadingStarted = true;
const script = document.createElement('script');
script.src = MERMAID_CDN_FILE;
script.defer = true;
script.onload = () => {
useMermaidStore.setState({
mermaidAPI: initializeMermaid(window.mermaid),
loadingError: null,
});
};
script.onerror = () => {
useMermaidStore.setState({
mermaidAPI: null,
loadingError: `Script load error for ${script.src}`,
});
};
document.head.appendChild(script);
}
}
function initializeMermaid(mermaidAPI: MermaidAPI): MermaidAPI {
mermaidAPI.initialize({
startOnLoad: false,
// gfx options
fontFamily: appTheme.fontFamily.code,
altFontFamily: appTheme.fontFamily.body,
// style configuration
htmlLabels: true,
securityLevel: 'loose',
theme: 'forest',
// per-chart configuration
mindmap: { useMaxWidth: false },
flowchart: { useMaxWidth: false },
sequence: { useMaxWidth: false },
timeline: { useMaxWidth: false },
class: { useMaxWidth: false },
state: { useMaxWidth: false },
pie: { useMaxWidth: false },
er: { useMaxWidth: false },
gantt: { useMaxWidth: false },
gitGraph: { useMaxWidth: false },
});
return mermaidAPI;
}
function useMermaidLoader() {
const { mermaidAPI } = useMermaidStore();
React.useEffect(() => {
if (!mermaidAPI)
loadMermaidFromCDN();
}, [mermaidAPI]);
return { mermaidAPI, isSuccess: !!mermaidAPI, isLoading: loadingStarted, error: loadingError };
}
export function RenderCodeMermaid(props: { mermaidCode: string }) {
// state
const [svgCode, setSvgCode] = React.useState<string | null>(null);
const hasUnmounted = React.useRef(false);
const mermaidContainerRef = React.useRef<HTMLDivElement>(null);
// external state
const { mermaidAPI, error: mermaidError } = useMermaidLoader();
// [effect] re-render on code changes
React.useEffect(() => {
if (!mermaidAPI)
return;
const updateSvgCode = () => {
const elementId = `mermaid-${Math.random().toString(36).substring(2, 9)}`;
mermaidAPI
.render(elementId, props.mermaidCode, mermaidContainerRef.current!)
.then(({ svg }) => {
if (mermaidContainerRef.current && !hasUnmounted.current) {
setSvgCode(svg);
// bindFunctions?.(mermaidContainerRef.current);
}
})
.catch((error) =>
console.warn('The AI-generated Mermaid code is invalid, please try again. Details below:\n >>', error.message),
);
};
// strict-mode de-bounce, plus watch for unmounts
hasUnmounted.current = false;
const timeout = setTimeout(updateSvgCode, 0);
return () => {
hasUnmounted.current = true;
clearTimeout(timeout);
};
}, [mermaidAPI, props.mermaidCode]);
// render errors when loading Mermaid. for syntax errors, the Error SVG will be rendered in-place
if (mermaidError)
return <div>Error: {mermaidError}</div>;
return (
<Box
component='div'
ref={mermaidContainerRef}
dangerouslySetInnerHTML={{ __html: svgCode || 'Loading Diagram...' }}
/>
);
}
+52 -38
View File
@@ -2,9 +2,13 @@ import * as React from 'react';
import { Box, Button, IconButton, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import WebIcon from '@mui/icons-material/Web';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { HtmlBlock } from './blocks';
import { overlayButtonsSx } from './RenderCode';
// this is used by the blocks parser (for full text detection) and by the Code component (for inline rendering)
@@ -53,50 +57,60 @@ export function RenderHtml(props: { htmlBlock: HtmlBlock, sx?: SxProps }) {
if (key.startsWith('font'))
delete sx[key];
const handleCopyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation();
copyToClipboard(props.htmlBlock.html, 'HTML');
};
return (
<Box
sx={{
position: 'relative', mx: 0, p: 1.5, // this block gets a thicker border
minWidth: { xs: '300px', md: '750px', lg: '900px', xl: '1100px' },
'&:hover > .code-buttons': { opacity: 1 },
...sx,
}}>
{/* Buttons */}
<Box sx={{ position: 'relative' /* for overlay buttons to stick properly */ }}>
<Box
className='code-buttons'
sx={{
position: 'absolute', top: 0, right: 0, zIndex: 10, mr: 7,
display: 'flex', flexDirection: 'row', gap: 1,
opacity: 0, transition: 'opacity 0.3s',
}}>
<Tooltip title={showHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
<IconButton variant={showHTML ? 'solid' : 'soft'} color='danger' onClick={() => setShowHTML(!showHTML)}>
<WebIcon />
</IconButton>
</Tooltip>
</Box>
minWidth: { sm: '480px', md: '750px', lg: '950px', xl: '1200px' },
mx: 0, p: 1.5, // this block gets a thicker border
display: 'block',
overflowX: 'auto',
'&:hover > .overlay-buttons': { opacity: 1 },
...sx,
}}
>
{/* Highlighted Code / SVG render */}
{showHTML
? <IFrameComponent htmlString={props.htmlBlock.html} />
: <Box>
<Typography>
<b>CAUTION</b> - The content you are about to access is an HTML page. It is possible that an
unauthorized entity is monitoring this connection and has generated this content.
Please exercise caution and do not trust the contents blindly. Be aware that proceeding
may pose potential risks. Click the button to view the content, if you wish to proceed.
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
<Button variant='plain' color='neutral' onClick={() => setShowHTML(false)}>
Ignore
</Button>
<Button variant='solid' color='danger' onClick={() => setShowHTML(true)}>
Show Web Page
</Button>
{/* Highlighted Code / SVG render */}
{showHTML
? <IFrameComponent htmlString={props.htmlBlock.html} />
: <Box>
<Typography>
<b>CAUTION</b> - The content you are about to access is an HTML page. It is possible that an
unauthorized entity is monitoring this connection and has generated this content.
Please exercise caution and do not trust the contents blindly. Be aware that proceeding
may pose potential risks. Click the button to view the content, if you wish to proceed.
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
<Button variant='plain' color='neutral' onClick={() => setShowHTML(false)}>
Ignore
</Button>
<Button variant='solid' color='danger' onClick={() => setShowHTML(true)}>
Show Web Page
</Button>
</Box>
</Box>
}
{/* External HTML Buttons */}
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, p: 1.5 }}>
<Tooltip title={showHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
<IconButton variant={showHTML ? 'solid' : 'outlined'} color='danger' onClick={() => setShowHTML(!showHTML)}>
<WebIcon />
</IconButton>
</Tooltip>
<Tooltip title='Copy Code' variant='solid'>
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>
</Box>
}
</Box>
</Box>
);
}
@@ -7,6 +7,7 @@ import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
import { Link } from '~/common/components/Link';
import { ImageBlock } from './blocks';
import { overlayButtonsSx } from './RenderCode';
export const RenderImage = (props: { imageBlock: ImageBlock, allowRunAgain: boolean, onRunAgain?: (e: React.MouseEvent) => void }) => {
@@ -19,22 +20,18 @@ export const RenderImage = (props: { imageBlock: ImageBlock, allowRunAgain: bool
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
mx: 1.5, mt: index > 0 ? 1.5 : 0,
// p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1,
minWidth: 32, minHeight: 32, boxShadow: 'md',
minWidth: 64, minHeight: 64, boxShadow: 'lg',
backgroundColor: 'neutral.solidBg',
'& picture': { display: 'flex' },
'& img': { maxWidth: '100%', maxHeight: '100%' },
'&:hover > .image-buttons': { opacity: 1 },
'&:hover > .overlay-buttons': { opacity: 1 },
}}>
{/* External Image */}
<picture><img src={url} alt='Generated Image' /></picture>
{/* Image Buttons */}
<Box
className='image-buttons'
sx={{
position: 'absolute', top: 0, right: 0, zIndex: 10, pt: 0.5, px: 0.5,
display: 'flex', flexDirection: 'row', gap: 0.5,
opacity: 0, transition: 'opacity 0.3s',
}}>
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, pt: 0.5, px: 0.5, gap: 0.5 }}>
{props.allowRunAgain && !!props.onRunAgain && (
<Tooltip title='Draw again' variant='solid'>
<IconButton variant='solid' color='neutral' onClick={props.onRunAgain}>
@@ -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,16 +7,17 @@ import 'prismjs/components/prism-java';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-markdown';
import 'prismjs/components/prism-mermaid';
import 'prismjs/components/prism-plant-uml';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-typescript';
// NOTE: must match Prism components imports
const hPrismLanguages = ['bash', 'css', 'java', 'javascript', 'json', 'markdown', 'plant-uml', 'python', 'typescript'];
const hPrismLanguages = ['bash', 'css', 'java', 'javascript', 'json', 'markdown', 'mermaid', 'plant-uml', 'python', 'typescript'];
const hFileExtensionsMap: { [key: string]: string } = {
cs: 'csharp', html: 'html', java: 'java', js: 'javascript', json: 'json', jsx: 'javascript',
md: 'markdown', py: 'python', sh: 'bash', ts: 'typescript', tsx: 'typescript', xml: 'xml',
md: 'markdown', mmd: 'mermaid', py: 'python', sh: 'bash', ts: 'typescript', tsx: 'typescript', xml: 'xml',
};
const hCodeIncipitMap: { starts: string[], language: string }[] = [
@@ -77,6 +78,7 @@ export function inferCodeLanguage(blockTitle: string, code: string): string | nu
}
export function highlightCode(inferredCodeLanguage: string | null, blockCode: string): string {
// NOTE: to save power, we could skip highlighting until the block is complete (future feature)
const safeHighlightLanguage = inferredCodeLanguage || 'typescript';
return Prism.highlight(
blockCode,
@@ -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,
});
}
};
+49 -32
View File
@@ -1,23 +1,26 @@
import { SystemPurposeId } from '../../../data';
import { DLLMId } from '~/modules/llms/store-llms';
import { SystemPurposeId } from '../../../data';
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
import { autoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import { streamChat } from '~/modules/llms/transports/streamChat';
import { useElevenlabsStore } from '~/modules/elevenlabs/store-elevenlabs';
import { DMessage, useChatStore } from '~/common/state/store-chats';
import { ChatAutoSpeakType, getChatAutoAI } from '../store-app-chat';
import { createAssistantTypingMessage, updatePurposeInHistory } from './editors';
/**
* The main "chat" function. TODO: this is here so we can soon move it to the data model.
*/
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, systemPurpose: SystemPurposeId, _autoTitle: boolean, enableFollowUps: boolean) {
export async function runAssistantUpdatingState(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, systemPurpose: SystemPurposeId) {
// ai follow-up operations (fire/forget)
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
// update the system message from the active Purpose, if not manually edited
history = updatePurposeInHistory(conversationId, history, systemPurpose);
history = updatePurposeInHistory(conversationId, history, assistantLlmId, systemPurpose);
// create a blank and 'typing' message for the assistant
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantLlmId, history[0].purposeId, '...');
@@ -28,51 +31,61 @@ export async function runAssistantUpdatingState(conversationId: string, history:
startTyping(conversationId, controller);
// stream the assistant's messages
await streamAssistantMessage(assistantLlmId, history, controller.signal, (updatedMessage) =>
editMessage(conversationId, assistantMessageId, updatedMessage, false));
await streamAssistantMessage(
assistantLlmId, history,
autoSpeak,
(updatedMessage) => editMessage(conversationId, assistantMessageId, updatedMessage, false),
controller.signal,
);
// clear to send, again
startTyping(conversationId, null);
// auto-suggestions (fire/forget)
if (enableFollowUps)
autoSuggestions(conversationId, assistantMessageId);
// update text, if needed (fire/forget)
if (_autoTitle)
if (autoTitleChat)
autoTitle(conversationId);
if (autoSuggestDiagrams || autoSuggestQuestions)
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
}
async function streamAssistantMessage(
llmId: DLLMId, history: DMessage[],
abortSignal: AbortSignal,
autoSpeak: ChatAutoSpeakType,
editMessage: (updatedMessage: Partial<DMessage>) => void,
abortSignal: AbortSignal,
) {
// 📢 TTS: speak the first line, if configured
const speakFirstLine = useElevenlabsStore.getState().elevenLabsAutoSpeak === 'firstLine';
let firstLineSpoken = false;
// speak once
let spokenText = '';
let spokenLine = false;
const messages = history.map(({ role, text }) => ({ role, content: text }));
try {
const messages = history.map(({ role, text }) => ({ role, content: text }));
await streamChat(llmId, messages, abortSignal, (updatedMessage: Partial<DMessage>) => {
// update the message in the store (and thus schedule a re-render)
editMessage(updatedMessage);
await streamChat(llmId, messages, abortSignal,
(updatedMessage: Partial<DMessage>) => {
// update the message in the store (and thus schedule a re-render)
editMessage(updatedMessage);
// 📢 TTS
if (updatedMessage?.text && speakFirstLine && !firstLineSpoken) {
let cutPoint = updatedMessage.text.lastIndexOf('\n');
if (cutPoint < 0)
cutPoint = updatedMessage.text.lastIndexOf('. ');
if (cutPoint > 100 && cutPoint < 400) {
firstLineSpoken = true;
const firstParagraph = updatedMessage.text.substring(0, cutPoint);
// fire/forget: we don't want to stall this loop
void speakText(firstParagraph);
// 📢 TTS: first-line
if (updatedMessage?.text) {
spokenText = updatedMessage.text;
if (autoSpeak === 'firstLine' && !spokenLine) {
let cutPoint = spokenText.lastIndexOf('\n');
if (cutPoint < 0)
cutPoint = spokenText.lastIndexOf('. ');
if (cutPoint > 100 && cutPoint < 400) {
spokenLine = true;
const firstParagraph = spokenText.substring(0, cutPoint);
// fire/forget: we don't want to stall this loop
void speakText(firstParagraph);
}
}
}
}
});
},
);
} catch (error: any) {
if (error?.name !== 'AbortError') {
console.error('Fetch request error:', error);
@@ -80,6 +93,10 @@ async function streamAssistantMessage(
}
}
// 📢 TTS: all
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && spokenText && !spokenLine && !abortSignal.aborted)
void speakText(spokenText);
// finally, stop the typing animation
editMessage({ typing: false });
}
@@ -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 });
+113
View File
@@ -0,0 +1,113 @@
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { persist } from 'zustand/middleware';
export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
// Chat Settings (Chat AI & Chat UI)
interface AppChatStore {
autoSpeak: ChatAutoSpeakType;
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void;
autoSuggestDiagrams: boolean,
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => void;
autoSuggestQuestions: boolean,
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void;
autoTitleChat: boolean;
setAutoTitleChat: (autoTitleChat: boolean) => void;
micTimeoutMs: number;
setMicTimeoutMs: (micTimeoutMs: number) => void;
showTextDiff: boolean;
setShowTextDiff: (showTextDiff: boolean) => void;
showSystemMessages: boolean;
setShowSystemMessages: (showSystemMessages: boolean) => void;
}
const useAppChatStore = create<AppChatStore>()(persist(
(_set, _get) => ({
autoSpeak: 'off',
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => _set({ autoSpeak }),
autoSuggestDiagrams: false,
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => _set({ autoSuggestDiagrams }),
autoSuggestQuestions: false,
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => _set({ autoSuggestQuestions }),
autoTitleChat: true,
setAutoTitleChat: (autoTitleChat: boolean) => _set({ autoTitleChat }),
micTimeoutMs: 2000,
setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }),
showTextDiff: false,
setShowTextDiff: (showTextDiff: boolean) => _set({ showTextDiff }),
showSystemMessages: false,
setShowSystemMessages: (showSystemMessages: boolean) => _set({ showSystemMessages }),
}), {
name: 'app-app-chat',
version: 1,
onRehydrateStorage: () => (state) => {
if (!state) return;
// for now, let text diff be off by default
state.showTextDiff = false;
},
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;
return state;
},
},
));
export const useChatAutoAI = () => useAppChatStore(state => ({
autoSpeak: state.autoSpeak,
autoSuggestDiagrams: state.autoSuggestDiagrams,
autoSuggestQuestions: state.autoSuggestQuestions,
autoTitleChat: state.autoTitleChat,
setAutoSpeak: state.setAutoSpeak,
setAutoSuggestDiagrams: state.setAutoSuggestDiagrams,
setAutoSuggestQuestions: state.setAutoSuggestQuestions,
setAutoTitleChat: state.setAutoTitleChat,
}), shallow);
export const getChatAutoAI = (): {
autoSpeak: ChatAutoSpeakType,
autoSuggestDiagrams: boolean,
autoSuggestQuestions: boolean,
autoTitleChat: boolean,
} => useAppChatStore.getState();
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 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>
);
}
+3 -3
View File
@@ -4,10 +4,10 @@ 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/brand';
import { Brand } from '~/common/app.config';
import { InlineError } from '~/common/components/InlineError';
import { LogoProgress } from '~/common/components/LogoProgress';
import { apiAsyncNode } from '~/common/util/trpc.client';
+4 -4
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/brand';
import { Brand } from '~/common/app.config';
import { Link } from '~/common/components/Link';
import { closeLayoutDrawer } from '~/common/layout/store-applayout';
import { getChatLinkRelativePath, getHomeLink } from '~/common/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}
+3 -2
View File
@@ -5,6 +5,8 @@ import { MenuItem, Switch, Typography } from '@mui/joy';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useChatShowSystemMessages } from '../chat/store-app-chat';
/**
* Menu Items are the settings for the chat.
@@ -12,12 +14,11 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
export function AppChatLinkMenuItems() {
// external state
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
const {
showSystemMessages, setShowSystemMessages,
renderMarkdown, setRenderMarkdown,
zenMode, setZenMode,
} = useUIPreferencesStore(state => ({
showSystemMessages: state.showSystemMessages, setShowSystemMessages: state.setShowSystemMessages,
renderMarkdown: state.renderMarkdown, setRenderMarkdown: state.setRenderMarkdown,
zenMode: state.zenMode, setZenMode: state.setZenMode,
}), shallow);
+6 -5
View File
@@ -5,10 +5,11 @@ import { Box, Button, Card, List, ListItem, Tooltip, Typography } from '@mui/joy
import TelegramIcon from '@mui/icons-material/Telegram';
import { ChatMessage } from '../chat/components/message/ChatMessage';
import { useChatShowSystemMessages } from '../chat/store-app-chat';
import { Brand } from '~/common/brand';
import { Brand } from '~/common/app.config';
import { conversationTitle, DConversation, useChatStore } from '~/common/state/store-chats';
import { navigateToChat } from '~/common/routes';
import { navigateToChat } from '~/common/app.routes';
import { useUIPreferencesStore } from '~/common/state/store-ui';
@@ -22,7 +23,7 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
const listBottomRef = React.useRef<HTMLDivElement>(null);
// external state
const showSystemMessages = useUIPreferencesStore(state => state.showSystemMessages);
const [showSystemMessages] = useChatShowSystemMessages();
const hasExistingChat = useChatStore(state => state.conversations.some(c => c.id === props.conversation.id));
// derived state
@@ -56,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);
};
+26 -18
View File
@@ -1,17 +1,33 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Button, ButtonGroup, Divider, FormControl, FormLabel, Input, Switch, Typography } from '@mui/joy';
import { Box, Button, ButtonGroup, Divider, FormControl, Input, Switch, Typography } from '@mui/joy';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { DLLMId, useModelsStore } from '~/modules/llms/store-llms';
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { GoodModal } from '~/common/components/GoodModal';
import { closeLayoutLLMOptions } from '~/common/layout/store-applayout';
import { settingsGap } from '~/common/app.theme';
import { VendorLLMOptions } from './VendorLLMOptions';
function VendorLLMOptions(props: { llmId: DLLMId }) {
// get LLM (warning: this will refresh all children components on every change of any LLM field)
const llm = useModelsStore(state => state.llms.find(llm => llm.id === props.llmId), shallow);
if (!llm)
return 'Options issue: LLM not found for id ' + props.llmId;
// get vendor
const vendor = findVendorById(llm._source.vId);
if (!vendor)
return 'Options issue: Vendor not found for LLM ' + props.llmId + ', source ' + llm._source.id;
return <vendor.LLMOptionsComponent llm={llm} />;
}
export function LLMOptionsModal(props: { id: DLLMId }) {
@@ -63,21 +79,19 @@ export function LLMOptionsModal(props: { id: DLLMId }) {
}
>
<VendorLLMOptions id={props.id} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: settingsGap }}>
<VendorLLMOptions llmId={props.id} />
</Box>
<Divider />
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<FormLabel sx={{ minWidth: 80 }}>
Name
</FormLabel>
<FormLabelStart title='Name' sx={{ minWidth: 80 }} />
<Input variant='outlined' value={llm.label} onChange={handleLlmLabelSet} />
</FormControl>
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<FormLabel sx={{ minWidth: 80 }}>
Defaults
</FormLabel>
<FormLabelStart title='Defaults' sx={{ minWidth: 80 }} />
<ButtonGroup orientation='horizontal' size='sm' variant='outlined'>
<Button variant={isChatLLM ? 'solid' : undefined} onClick={() => setChatLLMId(isChatLLM ? null : props.id)}>Chat</Button>
<Button variant={isFastLLM ? 'solid' : undefined} onClick={() => setFastLLMId(isFastLLM ? null : props.id)}>Fast</Button>
@@ -86,9 +100,7 @@ export function LLMOptionsModal(props: { id: DLLMId }) {
</FormControl>
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>
<FormLabel sx={{ minWidth: 80 }}>
Visible
</FormLabel>
<FormLabelStart title='Visible' sx={{ minWidth: 80 }} />
<Switch checked={!llm.hidden} onChange={handleLlmVisibilityToggle}
endDecorator={!llm.hidden ? <VisibilityIcon /> : <VisibilityOffIcon />}
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }}
@@ -96,17 +108,13 @@ export function LLMOptionsModal(props: { id: DLLMId }) {
</FormControl>
{/*<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', alignItems: 'center' }}>*/}
{/* <FormLabel sx={{ minWidth: 80 }}>*/}
{/* Flags*/}
{/* </FormLabel>*/}
{/* <FormLabelStart title='Flags' sx={{ minWidth: 80 }} /> >*/}
{/* <Checkbox color='neutral' checked={llm.tags?.includes('chat')} readOnly disabled label='Chat' sx={{ ml: 4 }} />*/}
{/* <Checkbox color='neutral' checked={llm.tags?.includes('stream')} readOnly disabled label='Stream' sx={{ ml: 4 }} />*/}
{/*</FormControl>*/}
<FormControl orientation='horizontal' sx={{ flexWrap: 'nowrap' }}>
<FormLabel onClick={() => setShowDetails(!showDetails)} sx={{ minWidth: 80, cursor: 'pointer', textDecoration: 'underline' }}>
Details
</FormLabel>
<FormLabelStart title='Details' sx={{ minWidth: 80 }} onClick={() => setShowDetails(!showDetails)} />
{showDetails && <Typography level='body-sm' sx={{ display: 'block' }}>
[{llm.id}]: {llm.options.llmRef && `${llm.options.llmRef} · `}
{llm.contextTokens && `context tokens: ${llm.contextTokens.toLocaleString()} · `}
+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 }} />
+18 -8
View File
@@ -1,19 +1,26 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Checkbox, Divider } from '@mui/joy';
import { Box, Checkbox, Divider } from '@mui/joy';
import { DModelSource, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
import { createModelSourceForDefaultVendor, findVendorById } from '~/modules/llms/vendors/vendor.registry';
import { GoodModal } from '~/common/components/GoodModal';
import { closeLayoutModelsSetup, openLayoutModelsSetup, useLayoutModelsSetup } from '~/common/layout/store-applayout';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
import { createModelSourceForDefaultVendor } from '~/modules/llms/vendors/vendor.registry';
import { settingsGap } from '~/common/app.theme';
import { LLMOptionsModal } from './LLMOptionsModal';
import { ModelsList } from './ModelsList';
import { ModelsSourceSelector } from './ModelsSourceSelector';
import { VendorSourceSetup } from './VendorSourceSetup';
function VendorSourceSetup(props: { source: DModelSource }) {
const vendor = findVendorById(props.source.vId);
if (!vendor)
return 'Configuration issue: Vendor not found for Source ' + props.source.id;
return <vendor.SourceSetupComponent sourceId={props.source.id} />;
}
export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
@@ -28,7 +35,6 @@ export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
modelSources: state.sources,
llmCount: state.llms.length,
}), shallow);
useGlobalShortcut('m', true, true, 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
@@ -70,7 +76,11 @@ export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
{!!activeSource && <Divider />}
{!!activeSource && <VendorSourceSetup source={activeSource} />}
{!!activeSource && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: settingsGap }}>
<VendorSourceSetup source={activeSource} />
</Box>
)}
{!!llmCount && <Divider />}
+16 -12
View File
@@ -11,11 +11,11 @@ import { createModelSourceForVendor, findAllVendors, findVendorById } from '~/mo
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { hideOnDesktop, hideOnMobile } from '~/common/theme';
import { useIsMobile } from '~/common/components/useMatchMedia';
/*function locationIcon(vendor?: IModelVendor | null) {
if (vendor && vendor.id === 'openai' && ModelVendorOpenAI.hasServerKey)
if (vendor && vendor.id === 'openai' && ModelVendorOpenAI.hasBackendCap?.())
return <CloudDoneOutlinedIcon />;
return !vendor ? null : vendor.location === 'local' ? <ComputerIcon /> : <CloudOutlinedIcon />;
}*/
@@ -43,6 +43,7 @@ export function ModelsSourceSelector(props: {
const [confirmDeletionSourceId, setConfirmDeletionSourceId] = React.useState<DModelSourceId | null>(null);
// external state
const isMobile = useIsMobile();
const { modelSources, addModelSource, removeModelSource } = useModelsStore(state => ({
modelSources: state.sources,
addModelSource: state.addSource, removeModelSource: state.removeSource,
@@ -63,7 +64,7 @@ export function ModelsSourceSelector(props: {
}, [addModelSource, props]);
const enableDeleteButton = !!props.selectedSourceId && (modelSources.length > 1 /*|| (process.env.NODE_ENV === 'development')*/);
const enableDeleteButton = !!props.selectedSourceId && modelSources.length > 1;
const handleDeleteSource = (id: DModelSourceId) => setConfirmDeletionSourceId(id);
@@ -89,7 +90,7 @@ export function ModelsSourceSelector(props: {
component: (
<MenuItem key={vendor.id} disabled={!enabled} onClick={() => handleAddSourceFromVendor(vendor.id)}>
<ListItemDecorator>
{vendorIcon(vendor, !!vendor.hasServerKey)}
{vendorIcon(vendor, !!vendor.hasBackendCap && vendor.hasBackendCap())}
</ListItemDecorator>
{vendor.name}{/*{sourceCount > 0 && ` (added)`}*/}
</MenuItem>
@@ -115,9 +116,9 @@ export function ModelsSourceSelector(props: {
<Box sx={{ display: 'flex', flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
{/* Models: [Select] Add Delete */}
<Typography sx={{ mr: 1, ...hideOnMobile }}>
{!isMobile && <Typography sx={{ mr: 1 }}>
Service:
</Typography>
</Typography>}
<Select
variant='outlined'
@@ -133,12 +134,15 @@ export function ModelsSourceSelector(props: {
{sourceItems.map(item => item.component)}
</Select>
<IconButton variant={noSources ? 'solid' : 'plain'} color='primary' onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} sx={{ ...hideOnDesktop }}>
<AddIcon />
</IconButton>
<Button variant={noSources ? 'solid' : 'plain'} onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} startDecorator={<AddIcon />} sx={{ ...hideOnMobile }}>
Add
</Button>
{isMobile ? (
<IconButton variant={noSources ? 'solid' : 'plain'} color='primary' onClick={handleShowVendors} disabled={!!vendorsMenuAnchor}>
<AddIcon />
</IconButton>
) : (
<Button variant={noSources ? 'solid' : 'plain'} onClick={handleShowVendors} disabled={!!vendorsMenuAnchor} startDecorator={<AddIcon />}>
Add
</Button>
)}
<IconButton
variant='plain' color='neutral' disabled={!enableDeleteButton} sx={{ ml: 'auto' }}
@@ -1,21 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { DLLMId, useModelsStore } from '~/modules/llms/store-llms';
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
export function VendorLLMOptions(props: { id: DLLMId }) {
// get LLM (warning: this will refresh all children components on every change of any LLM field)
const llm = useModelsStore(state => state.llms.find(llm => llm.id === props.id), shallow);
if (!llm)
return <>Configuration issue: LLM not found for id {props.id}</>;
// get vendor
const vendor = findVendorById(llm._source.vId);
if (!vendor)
return <>Configuration issue: Vendor not found for LLM {llm.id}, source: {llm.sId}</>;
const LLMOptionsComponent = vendor.LLMOptionsComponent;
return <LLMOptionsComponent llm={llm} />;
}
@@ -1,14 +0,0 @@
import * as React from 'react';
import { DModelSource } from '~/modules/llms/store-llms';
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
export function VendorSourceSetup(props: { source: DModelSource }) {
const vendor = findVendorById(props.source.vId);
if (!vendor)
return <>Configuration issue: Vendor not found for Source {props.source.id}</>;
const SourceSetupComponent = vendor.SourceSetupComponent;
return <SourceSetupComponent sourceId={props.source.id} />;
}
+96 -57
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/brand';
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 { NewsItems } from './news.data';
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);
@@ -21,73 +42,91 @@ export function AppNews() {
return (
<Box sx={{
backgroundColor: 'background.level1',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
backgroundColor: 'background.level1',
overflowY: 'auto',
minHeight: 96,
display: 'flex', justifyContent: 'center',
p: { xs: 3, md: 6 },
gap: 4,
}}>
<Typography level='h1' sx={{fontSize: '3.6rem'}}>
New updates!
</Typography>
<Box sx={{
my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 4,
}}>
<Typography>
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionName}. Enjoy what&apos;s new:
</Typography>
<Typography level='h1' sx={{ fontSize: '3rem' }}>
Welcome to {Brand.Title.Base} <Box component='span' sx={{ animation: `${cssColorKeyframes} 10s infinite` }}>{firstNews?.versionCode}</Box>!
</Typography>
{!!news && <Container disableGutters maxWidth='sm'>
{news?.map((ni, idx) => {
const firstCard = idx === 0;
const hasCardAfter = news.length < NewsItems.length;
const showExpander = hasCardAfter && (idx === news.length - 1);
const addPadding = !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>}
<Typography>
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionCode}
</Typography>
{!!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'>
{item.text}
</Typography>
</li>)}
</ul>}
<Box>
<Button
variant='solid' color='neutral' size='lg'
component={Link} href={ROUTE_INDEX} noLinkStyle
endDecorator='✨'
sx={{ minWidth: 200 }}
>
Sweet
</Button>
</Box>
{/*!firstCard &&*/ (
<Typography level='body-sm' sx={{ position: 'absolute', right: 0, top: 0 }}>
{ni.versionName}
</Typography>
)}
{!!newsCallout && <Container disableGutters maxWidth='sm'>{newsCallout}</Container>}
{showExpander && (
<IconButton
variant='plain' size='sm'
onClick={() => setLastNewsIdx(idx + 1)}
sx={{ position: 'absolute', right: 0, bottom: 0, mr: -1, mb: -1 }}
>
<ExpandMoreIcon />
</IconButton>
)}
{!!news && <Container disableGutters maxWidth='sm'>
{news?.map((ni, idx) => {
const firstCard = idx === 0;
const hasCardAfter = news.length < NewsItems.length;
const showExpander = hasCardAfter && (idx === news.length - 1);
const addPadding = false; //!firstCard; // || showExpander;
return <Card key={'news-' + idx} sx={{ mb: 2, minHeight: 32 }}>
<CardContent sx={{ position: 'relative', pr: addPadding ? 4 : 0 }}>
<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>
</CardContent>
</Card>;
})}
</Container>}
{!!ni.items && (ni.items.length > 0) && <ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 24 }}>
{ni.items.filter(item => item.dev !== true).map((item, idx) => <li key={idx}>
<Typography component='div' level='body-sm'>
{item.text}
</Typography>
</li>)}
</ul>}
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
Got it!
</Button>
{showExpander && (
<IconButton
variant='plain' size='sm'
onClick={() => setLastNewsIdx(idx + 1)}
sx={{ position: 'absolute', right: 0, bottom: 0, mr: -1, mb: -1 }}
>
<ExpandMoreIcon />
</IconButton>
)}
{/*<Typography sx={{ textAlign: 'center' }}>*/}
{/* Enjoy!*/}
{/* <br /><br />*/}
{/* -- The {Brand.Title.Base} Team*/}
{/*</Typography>*/}
</CardContent>
</Card>;
})}
</Container>}
{/*<Typography sx={{ textAlign: 'center' }}>*/}
{/* Enjoy!*/}
{/* <br /><br />*/}
{/* -- The {Brand.Title.Base} Team*/}
{/*</Typography>*/}
</Box>
</Box>
);
+111 -16
View File
@@ -1,40 +1,132 @@
import * as React from 'react';
import { Box, 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/brand';
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 = 5;
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>;
return props.href ?
<Link href={props.href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>{boldText} <LaunchIcon sx={{ ml: 1 }} /></Link> :
boldText;
};
const { OpenRepo, OpenProject } = Brand.URIs;
const RCode = `${OpenRepo}/blob/main`;
const RIssues = `${OpenRepo}/issues`;
// callout, for special occasions
export const newsCallout =
<Card>
<CardContent sx={{ gap: 2 }}>
<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!
</Typography>
<Grid container spacing={1}>
<Grid xs={12} sm={7}>
<Button
fullWidth variant='soft' color='primary' endDecorator={<LaunchIcon />}
component={Link} href={OpenProject} noLinkStyle target='_blank'
>
Explore the Roadmap
</Button>
</Grid>
<Grid xs={12} sm={5} sx={{ display: 'flex', flexAlign: 'center', justifyContent: 'center' }}>
<Button
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
</Button>
</Grid>
</Grid>
</CardContent>
</Card>;
const B = (props: { children: React.ReactNode }) => <Typography color='danger' sx={{ fontWeight: 600 }}>{props.children}</Typography>;
// 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: <>CloudFlare OpenAI API Gateway</> },
{ text: <>Helicone Anthropic support</> },
{ text: <>Highlight differneces (Labs)</> },
{ text: <>(Labs mode) YouTube personas creator</> },
// multi-window support
// phone calls
],
},*/
{
versionName: '1.4.0',
versionCode: '1.7.2',
versionName: 'Attachment Theory',
versionDate: new Date('2023-12-11T06:00:00Z'), // new Date().toISOString()
// versionDate: new Date('2023-12-10T12:00:00Z'), // 1.7.0
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</> },
{ text: <>1.7.1: Improved <B href={RIssues + '/270'}>Ollama chats</B></> },
{ text: <>1.7.2: Updated OpenRouter 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</> },
{ text: <><B href={RCode + '/docs/config-ollama.md'}>Ollama (guide)</B> local models support</> },
{ text: <><B href={RIssues + '/194'}>Text Tools</B> including highlight differences</> },
{ text: <><B href='https://mermaid.js.org/'>Mermaid</B> Diagramming Rendering</> },
{ text: <><B>OpenAI 1106</B> Chat Models</> },
{ text: <><B>SDXL</B> support with Prodia</> },
{ text: <>Cloudflare OpenAI API Gateway</> },
{ text: <>Helicone for Anthropic</> },
],
},
{
versionCode: '1.4.0',
items: [
{ text: <><B>Share and clone</B> conversations, with public links</> },
{ text: <><B>Azure</B> models <Link href='https://github.com/enricoros/big-agi/blob/main/docs/config-azure-openai.md' target='_blank'>full support</Link>, incl. gpt-4-32k</> },
{ text: <><B href={RCode + '/docs/config-azure-openai.md'}>Azure</B> models, incl. gpt-4-32k</> },
{ text: <><B>OpenRouter</B> models full support, incl. gpt-4-32k</> },
{ text: <>Latex Rendering</> },
{ text: <>Augmented Chat modes (Labs)</> },
],
},
{
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</> },
@@ -45,17 +137,17 @@ 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</> },
{ text: <><B>/s</B> and <B>/a</B> to append a <i>system</i> or <i>assistant</i> message</> },
{ text: <>Local LLMs with <Link href='https://github.com/enricoros/big-agi/blob/main/docs/config-local-oobabooga.md' target='_blank'>Oobabooga server</Link></> },
{ text: <>Local LLMs with <Link href={RCode + '/docs/config-local-oobabooga.md'} target='_blank'>Oobabooga server</Link></> },
{ text: 'NextJS STOP bug.. squashed, with Vercel!' },
],
},
{
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></> },
@@ -67,9 +159,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;
}[];
}
+28 -52
View File
@@ -1,15 +1,14 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Alert, Box, Button, Card, CardContent, CircularProgress, Grid, IconButton, Input, LinearProgress, Modal, ModalDialog, Radio, RadioGroup, Tooltip, Typography } from '@mui/joy';
import { Alert, Box, Button, Card, CardContent, CircularProgress, Grid, IconButton, Input, LinearProgress, Tooltip, Typography } from '@mui/joy';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import WhatshotIcon from '@mui/icons-material/Whatshot';
import YouTubeIcon from '@mui/icons-material/YouTube';
import { GoodModal } from '~/common/components/GoodModal';
import { apiQuery } from '~/common/util/trpc.client';
import { useModelsStore } from '~/modules/llms/store-llms';
import { copyToClipboard } from '~/common/util/copyToClipboard';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { useFormRadioLlmType } from '~/common/components/forms/useFormRadioLlmType';
import { LLMChainStep, useLLMChain } from './useLLMChain';
@@ -66,21 +65,11 @@ const YouTubePersonaSteps: LLMChainStep[] = [
export function YTPersonaCreator() {
// state
const [videoURL, setVideoURL] = React.useState('');
const [selectedModelType, setSelectedModelType] = React.useState<'chat' | 'fast'>('fast');
// const [selectedLLMLabel, setSelectedLLMLabel] = React.useState<string | null>(null);
const [videoID, setVideoID] = React.useState('');
const [personaTranscript, setPersonaTranscript] = React.useState<string | null>(null);
// external state
const { chatLLM, fastLLM } = useModelsStore(state => {
const { chatLLMId, fastLLMId } = state;
const chatLLM = state.llms.find(llm => llm.id === chatLLMId) ?? null;
const fastLLM = state.llms.find(llm => llm.id === fastLLMId) ?? null;
return {
chatLLM: chatLLM,
fastLLM: /*chatLLM === fastLLM ? null :*/ fastLLM,
};
}, shallow);
const [diagramLlm, llmComponent] = useFormRadioLlmType();
// fetch transcript when the Video ID is ready, then store it
const { transcript, thumbnailUrl, title, isFetching, isError, error: transcriptError } =
@@ -88,9 +77,8 @@ export function YTPersonaCreator() {
React.useEffect(() => setPersonaTranscript(transcript), [transcript]);
// use the transformation sequence to create a persona
const llm = selectedModelType === 'chat' ? chatLLM : fastLLM;
const { isFinished, isTransforming, chainProgress, chainIntermediates, chainStepName, chainOutput, chainError, abortChain } =
useLLMChain(YouTubePersonaSteps, llm?.id, personaTranscript ?? undefined);
useLLMChain(YouTubePersonaSteps, diagramLlm?.id, personaTranscript ?? undefined);
const handleVideoIdChange = (e: React.ChangeEvent<HTMLInputElement>) => setVideoURL(e.target.value);
@@ -142,17 +130,7 @@ export function YTPersonaCreator() {
</form>
{/* LLM selector (chat vs fast) */}
{!isTransforming && !isFinished && !!chatLLM && !!fastLLM && (
<RadioGroup
orientation='horizontal'
value={selectedModelType}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setSelectedModelType(event.target.value as 'chat' | 'fast')}
>
<Radio value='chat' label={chatLLM.label.startsWith('GPT-4') ? chatLLM.label + ' (slow, accurate)' : chatLLM.label} />
<Radio value='fast' label={fastLLM.label} />
</RadioGroup>
)}
{!isTransforming && !isFinished && llmComponent}
{/* 1. Transcript*/}
{personaTranscript && (
@@ -199,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',
@@ -239,28 +217,26 @@ export function YTPersonaCreator() {
{/* Embodiment Progress */}
{isTransforming && <Modal open>
<ModalDialog>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 2 }}>
<CircularProgress color='primary' value={Math.max(10, 100 * chainProgress)} />
</Box>
<Typography color='success' level='title-lg' sx={{ mt: 1 }}>
Embodying Persona ...
</Typography>
<Typography color='success' level='title-sm' sx={{ mt: 1, fontWeight: 600 }}>
{chainStepName}
</Typography>
<LinearProgress color='success' determinate value={Math.max(10, 100 * chainProgress)} sx={{ mt: 1, mb: 2 }} />
<Typography level='title-sm'>
This may take 1-2 minutes. Do not close this window or the progress will be lost.
If you experience any errors (e.g. LLM timeouts, or context overflows for larger videos)
please try again with faster/smaller models.
</Typography>
<Button variant='soft' color='neutral' onClick={abortChain} sx={{ ml: 'auto', minWidth: 100, mt: 5 }}>
Cancel
</Button>
</ModalDialog>
</Modal>}
{isTransforming && <GoodModal open>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 2 }}>
<CircularProgress color='primary' value={Math.max(10, 100 * chainProgress)} />
</Box>
<Typography color='success' level='title-lg' sx={{ mt: 1 }}>
Embodying Persona ...
</Typography>
<Typography color='success' level='title-sm' sx={{ mt: 1, fontWeight: 600 }}>
{chainStepName}
</Typography>
<LinearProgress color='success' determinate value={Math.max(10, 100 * chainProgress)} sx={{ mt: 1, mb: 2 }} />
<Typography level='title-sm'>
This may take 1-2 minutes. Do not close this window or the progress will be lost.
If you experience any errors (e.g. LLM timeouts, or context overflows for larger videos)
please try again with faster/smaller models.
</Typography>
<Button variant='soft' color='neutral' onClick={abortChain} sx={{ ml: 'auto', minWidth: 100, mt: 5 }}>
Cancel
</Button>
</GoodModal>}
</>;
}

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