Compare commits

..

385 Commits

Author SHA1 Message Date
Enrico Ros 5c44804d50 Desktop: innovate 2024-07-08 15:48:26 -07:00
Enrico Ros 8589376c66 Desktop: improve looks 2024-07-08 15:33:31 -07:00
Enrico Ros d53a8b4941 Desktop: move files around 2024-07-08 15:08:16 -07:00
Enrico Ros af819da623 Desktop: electron build 2024-07-08 15:02:02 -07:00
Enrico Ros 3addc4e2ac AIX: fix build 2024-07-07 05:02:37 -07:00
Enrico Ros 7ff7e489ab Merge branch 'refs/heads/main' into feature-multipart
# Conflicts:
#	src/apps/chat/AppChat.tsx
#	src/apps/chat/components/ChatMessageList.tsx
#	src/apps/personas/creator/Creator.tsx
2024-07-07 04:43:21 -07:00
Enrico Ros 95aa0da014 Merge branch 'fork/mapringg/mac-shortcuts' 2024-07-07 04:21:24 -07:00
Enrico Ros b12637267b Small cleanups with shortcut fixes 2024-07-07 04:20:23 -07:00
Enrico Ros 3a44f70db9 AI Persona Creator: Update the react state after 575 2024-07-07 03:55:55 -07:00
Enrico Ros 92206d9740 Merge pull request #575
[BUG] Fixes text on AI Personas Creator panel
2024-07-07 03:52:30 -07:00
Enrico Ros bddd91df2a Merge branch 'fork/mapringg/feature/deepseek-vendor' 2024-07-07 03:36:51 -07:00
Enrico Ros 144ead8cfe Deepseek: cleanups
Mostly reordered the properties in alphabetical order
and made sure that models are listed dynamically (for future changes)
2024-07-07 03:34:45 -07:00
Enrico Ros 185f8e7f44 roll tRPC w/ #5851 2024-07-07 02:52:39 -07:00
Enrico Ros 1538cd83af roll packages 2024-07-07 02:49:06 -07:00
Sorawit Kongnurat 027f7deb3a feat: implement deepseek vendor 2024-07-07 16:02:08 +07:00
Enrico Ros 4043a6098b Aix: handle abort signals on both server and client 2024-07-06 06:24:01 -07:00
Enrico Ros 92b913be98 Aix: use backend fetchers 2024-07-06 05:52:11 -07:00
Enrico Ros 8505ba6b84 Backend Fetchers: improve 2024-07-06 05:38:26 -07:00
Enrico Ros c6973f6b4e OpenRouter: remove non-free 2024-07-06 05:21:10 -07:00
Enrico Ros 94eddaff3f Backend fetchers: allow signals 2024-07-06 04:35:58 -07:00
Sorawit Kongnurat f38be4aff3 feat: replace useGlobalShortcut with useGlobalShortcuts
Ignore alt key for mac users.
2024-07-06 15:41:45 +07:00
Sorawit Kongnurat 3ea78fcf9f Merge branch 'main' into mac-shortcuts 2024-07-06 14:31:58 +07:00
Enrico Ros 78cfcc6206 AIX: improve Gemini and issue reporting 2024-07-05 18:45:23 -07:00
Enrico Ros 9c5d4a18ce Audio: Wire Chime (disabled) 2024-07-05 18:31:05 -07:00
Enrico Ros aa48b4d596 Audio: Add Generator 2024-07-05 17:11:10 -07:00
Enrico Ros 265acd9345 Audio: Add Generator 2024-07-05 16:38:41 -07:00
Enrico Ros 34ec1d5671 Audio: Port Player 2024-07-05 16:33:19 -07:00
Enrico Ros 4a1f4f0a01 Bits 2024-07-05 13:42:47 -07:00
Enrico Ros 850528820f AIX: define new low-level APIs 2024-07-05 02:55:34 -07:00
Enrico Ros 4dc8197c51 paste over 2024-07-04 16:28:04 -07:00
Enrico Ros 42e97eed4c Persona: port Throttle 2024-07-04 03:18:07 -07:00
Enrico Ros 065f30ac38 Persona: port AutoSpeak 2024-07-04 02:55:10 -07:00
Enrico Ros 9e705a12b1 Bits 2024-07-04 02:09:43 -07:00
Enrico Ros b8144f0748 AIX: flow cleanup 1 2024-07-04 01:29:56 -07:00
Enrico Ros e5b5faad3e Style2 2024-07-03 22:36:11 -07:00
Enrico Ros f840c1d424 Style 2024-07-03 20:30:36 -07:00
Enrico Ros eabd268874 AIX: client-side wire 2024-07-03 20:19:52 -07:00
Enrico Ros 06aadc543a AIX: redo all uplink parsers 2024-07-03 19:58:11 -07:00
Enrico Ros 2a410f52b5 AIX: improved all uplinks 2024-07-03 19:57:09 -07:00
Enrico Ros eb7a32ed16 AIX: redo the Upstream engine 2024-07-03 15:55:19 -07:00
Enrico Ros 14118d3056 Tokens: rationalize 2024-07-03 11:42:46 -07:00
Enrico Ros c8b3d8ad9b AIX: exception trap 2024-07-03 01:52:15 -07:00
Enrico Ros a097b32d5c AIX: types migration 2024-07-03 01:31:13 -07:00
Enrico Ros 0a88a9cee6 Cleanup Execute Mode labels 2024-07-03 00:17:36 -07:00
Enrico Ros bef1c0c5fc Extract ChatExecuteMode 2024-07-02 23:51:24 -07:00
Enrico Ros 52e6ef436f change ChatModeIds 2024-07-02 22:48:17 -07:00
Enrico Ros ad0617de90 roll PDFjs 2024-07-02 21:40:09 -07:00
Enrico Ros 1753c1a40a AutoSuggestions: controllable LLM 2024-07-02 21:37:49 -07:00
Enrico Ros 13b7004959 roll packages 2024-07-02 21:33:25 -07:00
Enrico Ros 3b9a21bbf7 AIX: remove tRPC router: not ready - will hand-roll it 2024-07-02 21:32:02 -07:00
Enrico Ros 5f0beb9d00 AIX: move stream debug 2024-07-02 00:38:03 -07:00
Enrico Ros 8411a73589 AIX: test router and client 2024-07-01 23:29:24 -07:00
Enrico Ros 009a3751c0 AIX: update fc schema 2024-07-01 23:28:52 -07:00
Enrico Ros adef88e358 roll packages 2024-07-01 22:44:14 -07:00
Enrico Ros f8b9df7bf0 AIX: function calling types 2024-07-01 21:58:18 -07:00
Enrico Ros c6fa3e1d24 Export: update backup file name 2024-07-01 21:26:31 -07:00
Enrico Ros ae24dd1e28 Bits 2024-07-01 21:26:31 -07:00
Enrico Ros 1efca7dd48 Ollama: update models 2024-06-28 02:32:35 -07:00
Jason Baker 3178f4e7e9 Fixes text on AI Personas Creator panel 2024-06-28 00:12:20 -07:00
Enrico Ros e00f61dcd0 MP: bits 2024-06-25 12:18:02 -07:00
Enrico Ros 6a5774aae7 MP: bifurcate persona generation 2024-06-25 03:27:38 -07:00
Enrico Ros 5119061861 MP: improve history editing 2024-06-25 03:11:52 -07:00
Enrico Ros fdfbae334a MP: re-enable reply to... 2024-06-25 03:03:59 -07:00
Enrico Ros e3fce43e62 Merge pull request #572 from JeremiLorenti/patch-1
Update DallESettings.tsx
2024-06-25 02:15:25 -07:00
Enrico Ros 9251f8ff0e MP: re-enable auto-draw... 2024-06-25 01:49:16 -07:00
Enrico Ros 18ef40f6f4 MP: bifurcate generate-text 2024-06-25 01:42:32 -07:00
Enrico Ros 46887d1d9f MP: reuse more fragment functions 2024-06-25 01:28:57 -07:00
Enrico Ros 632d10e9e3 MP: fix the execution pipeline 2024-06-25 01:08:56 -07:00
Enrico Ros 9fa33eea73 MP: fix editing 2024-06-24 22:29:21 -07:00
Enrico Ros 2c4c13bc2c MP: fix 'ph' reducing and token counting 2024-06-24 22:16:03 -07:00
Enrico Ros 33f8a4eb3a Message pricing: show min, and max on hover 2024-06-24 22:15:29 -07:00
Jeremi Lorenti aa7959a970 Update DallESettings.tsx
Changed the label for the "Hyper-Real" setting from "Relistic" to "Realistic" to correct the spelling mistake and improve clarity in the UI.
2024-06-25 01:12:42 -04:00
Enrico Ros 7471bc0bb2 roll packages 2024-06-24 21:41:22 -07:00
Enrico Ros b257f75e53 Screen Capture: enable for everyone 2024-06-24 20:25:13 -07:00
Enrico Ros 455e279216 MP: enter Docs 2024-06-24 20:19:43 -07:00
Enrico Ros 7fd359852a Browse: get page title too 2024-06-24 18:44:34 -07:00
Enrico Ros 82ecfdbd37 DMessage: remove Sender 2024-06-24 17:41:50 -07:00
Enrico Ros 478452983f MP: port to Embeds 2024-06-24 17:19:13 -07:00
Enrico Ros 5c1a7d485f Merge pull request #570 from blakkd/patch-1
Correct LocalAI URL
2024-06-24 04:39:56 -07:00
Enrico Ros 39c4ce9240 bits 2024-06-24 03:16:58 -07:00
Enrico Ros da49585df5 MP: improve Ego-Fragments 2024-06-24 01:34:54 -07:00
Enrico Ros 0b9bee02fe MP: begin converting Attachments 2024-06-23 21:33:40 -07:00
Enrico Ros 00e5d1ae27 MP: move towards typed parts 2024-06-23 21:16:03 -07:00
blakkd b290d63926 Correct LocalAI URL 2024-06-24 06:10:30 +02:00
Enrico Ros 1b5438cc6c MP: renames 2024-06-23 20:44:48 -07:00
Enrico Ros 17323facce Blocks: cleanups 2024-06-23 20:24:27 -07:00
Enrico Ros bc9dedeea4 MP: cleanup Content fragments 2024-06-23 20:22:05 -07:00
Enrico Ros 1b3a383b53 Blocks: rename to AutoBlocksRenderer 2024-06-23 20:09:27 -07:00
Enrico Ros 4e0a535402 Blocks: cleanups 2024-06-23 19:56:55 -07:00
Enrico Ros 0005db1b33 Blocks: extract iframe rendering 2024-06-23 19:18:28 -07:00
Enrico Ros 5cd74031be Blocks: rename 2024-06-23 19:07:24 -07:00
Enrico Ros facb85b5da Rename to an Anthropic-compatible naming (part 1) 2024-06-23 18:46:41 -07:00
Enrico Ros 5f97d17837 anthropic model naming 2024-06-23 01:10:53 -07:00
Enrico Ros af722e09f8 bits 2024-06-23 00:55:57 -07:00
Enrico Ros 959edf6010 MP: cleanups/enablers 2024-06-22 23:58:15 -07:00
Enrico Ros d08f183394 roll trpc 2024-06-22 23:25:20 -07:00
Enrico Ros da541ae182 MP: Extract Fragments to own file 2024-06-22 23:12:12 -07:00
Enrico Ros 4582c4c03d MP: Typesystem Sentinels 2024-06-22 21:52:30 -07:00
Enrico Ros 8c7d70d434 Debug 2024-06-22 18:01:59 -07:00
Enrico Ros fcf9f9e562 MP: disable images editing 2024-06-22 17:31:21 -07:00
Enrico Ros 7bb0fb294a MP: full document editing 2024-06-22 17:01:50 -07:00
Enrico Ros 2e7b5ba5f0 Fix enter is newline on editing 2024-06-22 16:41:08 -07:00
Enrico Ros 6b017f3678 style 2024-06-22 16:34:41 -07:00
Enrico Ros a303d00900 style 2024-06-22 16:34:26 -07:00
Enrico Ros aaa351dca4 Revert "code block detection: assume the ``` has a newline before the end of block"
Because the Diagrams stopped working in the modal.

This reverts commit ee5fb5361c.
2024-06-22 16:25:17 -07:00
Enrico Ros ee5fb5361c code block detection: assume the ``` has a newline before the end of block 2024-06-22 16:18:37 -07:00
Enrico Ros aaffcdbfeb Style 2024-06-22 16:13:09 -07:00
Enrico Ros a8fefb5a90 Document Editor: inline editing 2024-06-22 16:05:54 -07:00
Enrico Ros 8e3b07fa49 Document Fragment Button: variable height 2024-06-22 15:57:35 -07:00
Enrico Ros 36ac618e88 bits 2024-06-22 15:56:24 -07:00
Enrico Ros ab0eeae1e3 BlocksRenderer: plain code rendering mode 2024-06-22 15:55:32 -07:00
Enrico Ros f74adffa12 MP: Fix overall layout 2024-06-22 15:08:52 -07:00
Enrico Ros 8f23f41e2f bits 2024-06-22 13:20:05 -07:00
Enrico Ros 7d04844c6a DocumentFragment: improve 2024-06-21 03:47:53 -07:00
Enrico Ros c301dcc226 TextAttachmentFragment: works 2024-06-21 03:13:30 -07:00
Enrico Ros 8dd4ece730 PartImageRefDBlob: extract 2024-06-21 02:49:27 -07:00
Enrico Ros 75bd68f9fe PartImageRef: improve bifurcation 2024-06-21 02:18:58 -07:00
Enrico Ros 96af022afa BlocksRenderer: remove isBottom 2024-06-21 02:18:58 -07:00
Enrico Ros c570c68f1b TextAttachments: begin 2024-06-21 02:18:58 -07:00
Enrico Ros 21a226a486 Restore the Bubble menu 2024-06-21 00:06:01 -07:00
Enrico Ros 2695cb8e46 MP: align items correctly 2024-06-21 00:06:01 -07:00
Enrico Ros 2207405ebc MP: improve text part addition 2024-06-20 23:34:42 -07:00
Enrico Ros 3802123147 Double-click to edit: on again (differentiator) 2024-06-20 20:24:05 -07:00
Enrico Ros c6c630f5c6 Auto-UI: add one more message 2024-06-20 20:22:11 -07:00
Enrico Ros 7c76a17c08 RenderCode: borderless 2024-06-20 20:08:43 -07:00
Enrico Ros 5ba7723fa0 shift to delete without confirmation 2024-06-20 19:12:35 -07:00
Enrico Ros 87ff07c850 Debug: print DMessage 2024-06-20 19:05:34 -07:00
Enrico Ros 71e1a2eeec MP: render Image Attachments 2024-06-20 18:48:32 -07:00
Enrico Ros 88fba0f53a MP: cluster fragments 2024-06-20 17:40:54 -07:00
Enrico Ros 07260a8e06 ChatMessage: pass mobile 2024-06-20 17:37:26 -07:00
Enrico Ros c1d155b569 ImageRender(DataRef): Change buttons order 2024-06-20 17:36:10 -07:00
Enrico Ros 7e7cfe1db1 ContentPartImage: introduce a lighter variant 2024-06-20 17:35:27 -07:00
Enrico Ros d27a44ab7f TS 5.5.2 fixes 2024-06-20 14:55:02 -07:00
Enrico Ros 2adcca1cda roll packages 2024-06-20 14:43:43 -07:00
Enrico Ros cf854b7262 Merge branch 'refs/heads/main' into feature-multipart 2024-06-20 12:43:06 -07:00
Enrico Ros ecb0e07312 Merge branch 'refs/heads/main-stable' 2024-06-20 12:42:51 -07:00
Enrico Ros 8b2b88c7cb Merge branch 'refs/heads/main' into feature-multipart 2024-06-20 12:28:53 -07:00
Enrico Ros 9af1a6a16b Merge branch 'refs/heads/main-stable' 2024-06-20 12:28:36 -07:00
Enrico Ros d1ac9adc7e executor: bits 2024-06-20 11:41:31 -07:00
Enrico Ros 513edf90f7 executor: begin cleanups 2024-06-19 19:48:48 -07:00
Enrico Ros 60d47510ab Auto-UI: further improve 2024-06-19 19:33:31 -07:00
Enrico Ros 5b7b9837f0 Chats: group by conversation size 2024-06-19 19:27:29 -07:00
Enrico Ros 333c3327c4 Text areas: deprecate enterIsNewline on text edits that have buttons 2024-06-19 18:59:55 -07:00
Enrico Ros 9723c98940 Auto-suggestions: cleanups 2024-06-19 18:47:38 -07:00
Enrico Ros 97604f3c5b Auto-UI: update 2024-06-19 18:40:30 -07:00
Enrico Ros 044f18da46 bits 2024-06-19 18:08:44 -07:00
Enrico Ros 53946b9523 Llms: begin cleaning up 2024-06-19 18:07:16 -07:00
Enrico Ros fd8f88c5e4 Chat: reorg 2024-06-19 13:29:01 -07:00
Enrico Ros e7d15ce2b0 bits 2024-06-19 12:55:02 -07:00
Enrico Ros ff1d98a87e MP: Improve ownership, restore GC 2024-06-19 11:58:09 -07:00
Enrico Ros accc68cd28 Attachments: cleanup of ownership 2024-06-19 11:57:45 -07:00
Enrico Ros b2c7bc980f DBlobs: type Context and Scope 2024-06-19 11:51:49 -07:00
Enrico Ros 75fbe8d5d8 PDFUtils: skip load in dev 2024-06-19 11:34:18 -07:00
Enrico Ros 13ebf3b3aa Composer: cleanups 2024-06-19 11:13:44 -07:00
Enrico Ros 916d3812db MP: Update follow-up prompts 2024-06-19 09:44:04 -07:00
Enrico Ros 90610c819b MP: AppChat: send logic 2024-06-19 09:22:23 -07:00
Enrico Ros a5f6f62559 Hide the block title when rendering html. 2024-06-19 08:49:16 -07:00
Enrico Ros bfb3501dec MP: Composer: go out in multipart 2024-06-18 16:30:26 -07:00
Enrico Ros c0513c50b1 MP: Attach: cleanups 2024-06-18 15:54:16 -07:00
Enrico Ros bcf4baf004 MP: Attach: rationalize 2024-06-18 15:35:20 -07:00
Enrico Ros 53bf948a04 ChatMessage: extract avatars 2024-06-18 14:47:13 -07:00
Enrico Ros 2186d91f89 MP: ChatMessage: Attachments begin 2024-06-18 14:47:13 -07:00
Enrico Ros aaf856a503 MP: ChatMessage: extract Fragments list 2024-06-18 14:19:57 -07:00
Enrico Ros 8af625b7dc Code: fix render lines suppression 2024-06-18 14:03:30 -07:00
Enrico Ros 4690891757 Code: render lines 2024-06-18 12:57:24 -07:00
Enrico Ros bb3e17c0fa Cleanups 2024-06-18 11:31:49 -07:00
Enrico Ros 7965df5ff2 Text Parts Editor: buttons to edit 2024-06-18 03:55:34 -07:00
Enrico Ros 5b5f0a5a8d Text Parts Editor: style 2024-06-18 02:56:32 -07:00
Enrico Ros fdb087a39b Text Parts Editor: works! 2024-06-18 02:43:34 -07:00
Enrico Ros 97749378d6 Text Parts Editor: begin 2024-06-18 02:43:26 -07:00
Enrico Ros 63dc2301ff Bits 2024-06-18 01:13:00 -07:00
Enrico Ros 5659c0bc70 Auto-UI: Remove bloat 2024-06-18 00:11:32 -07:00
Enrico Ros 1e288ab0fd Auto-UI: Update prompts 2024-06-17 21:37:04 -07:00
Enrico Ros 4f058a0174 RenderHTML: use a simple CSS reset and coalesce timers 2024-06-17 21:06:52 -07:00
Enrico Ros 7284114565 Code render: improve fallback while loading 2024-06-17 20:13:07 -07:00
Enrico Ros 0b2592dbd7 tRPC: un-batch for now 2024-06-17 19:53:38 -07:00
Enrico Ros edfaf6f002 Cleaner errors 2024-06-17 19:49:20 -07:00
Enrico Ros da3990b614 Save toolbar space 2024-06-17 19:49:16 -07:00
Enrico Ros 25740ae13c MP: Image parts support deletion 2024-06-17 19:48:37 -07:00
Enrico Ros fb4c05f698 Bits 2024-06-17 18:48:22 -07:00
Enrico Ros a0c4e37c94 MP: Remove duplicate Placeholdering 2024-06-17 18:23:17 -07:00
Enrico Ros 278caf6f0c MP: Rename PH 2024-06-17 16:33:43 -07:00
Enrico Ros 2ce0c61f83 MP: Add Placeholder and Error parts 2024-06-17 16:23:10 -07:00
Enrico Ros afb25324a7 DMessage: logic to index by fragmentIds 2024-06-17 11:23:47 -07:00
Enrico Ros ba1b761c08 DMessage: add fragment Id (not unique) and a type sentinel 2024-06-17 09:04:56 -07:00
Enrico Ros 0e2d4af617 tRPC-11: workaround a transformation bug in SuperJSON/tRPC/NextJS
May be related to:
- https://github.com/blitz-js/superjson/issues/242
- https://github.com/blitz-js/superjson/issues/283
2024-06-17 00:36:08 -07:00
Enrico Ros 1b0b54a072 tRPC-11: Loading states with React-Query 2024-06-17 00:12:47 -07:00
Enrico Ros 9c629d3c5c tRPC-11: Server 2024-06-17 00:12:47 -07:00
Enrico Ros 173af4e459 Bits 2024-06-17 00:12:47 -07:00
Enrico Ros c0f12c0a5d tRPC-11: Client 2024-06-17 00:12:47 -07:00
Enrico Ros 390605fe66 Roll packages 2024-06-16 17:42:26 -07:00
Enrico Ros e4bd5f865c Migrate to scoped nanoid() 2024-06-16 17:33:46 -07:00
Enrico Ros b31c891772 Fix compile 2024-06-16 17:26:32 -07:00
Enrico Ros 08e4016972 Auto-ui: include prompt auto-mix-in 2024-06-15 17:38:37 -07:00
Enrico Ros aea7eb6ba3 Auto-ui: improve rendering and integration 2024-06-15 17:24:10 -07:00
Enrico Ros 5496750085 Auto-ui: better execution security 2024-06-15 17:23:54 -07:00
Enrico Ros 4b9709898c Auto-ui: improve settings 2024-06-15 16:49:35 -07:00
Enrico Ros 705daac737 RenderHTML: do not scroll the parent frame when keys are pressed within 2024-06-15 02:51:09 -07:00
Enrico Ros a802b32f47 Auto-HTML UI: auto-render
Added DANGER disclaimers in the code and UI. Just a testing mode for fun.
2024-06-15 01:09:32 -07:00
Enrico Ros 8b8db5e447 Auto-HTML UI test 2024-06-15 00:15:58 -07:00
Enrico Ros 3ee44599c7 MP: Auto-diagrams (chat) fix. 2024-06-14 23:52:20 -07:00
Enrico Ros 2955a41ed5 Attachments: lower the image quality a tad (very large files) 2024-06-14 22:46:23 -07:00
Enrico Ros a52802c882 Attachments: when resizing (openai-*), force format conversion 2024-06-14 22:46:10 -07:00
Enrico Ros b46c70512a Beam: fix #552 2024-06-14 18:29:12 -07:00
Enrico Ros 18f91e2eeb Beam: flatter design 2024-06-14 17:53:52 -07:00
Enrico Ros 9296984569 Layout: preserve state on resizes 2024-06-14 15:24:14 -07:00
Enrico Ros 7b835d9855 Draw: use the queue 2024-06-14 08:52:40 -07:00
Enrico Ros ce23b9169b Misc 2024-06-14 01:16:45 -07:00
Enrico Ros 47a535d309 Chat: show has drawings icon 2024-06-13 23:17:53 -07:00
Enrico Ros 6342801aa0 Bits 2024-06-13 22:55:46 -07:00
Enrico Ros 50c00f5516 Bootup to the right drawer opening state 2024-06-13 22:53:15 -07:00
Enrico Ros 4a49678fb6 T2I: rename providerId 2024-06-13 22:34:54 -07:00
Enrico Ros 0f10b8f677 MP: filter chats for images 2024-06-13 22:21:45 -07:00
Enrico Ros d8433b79cc MP: regenerate in place 2024-06-13 21:40:49 -07:00
Enrico Ros f94f640212 MP: share common draw/store logic 2024-06-13 18:19:02 -07:00
Enrico Ros 5cf779757f MP: improve fetch logic? 2024-06-13 17:40:19 -07:00
Enrico Ros d49acf379e DBlobs: show usage if storage persistence fails 2024-06-13 16:36:06 -07:00
Enrico Ros b9bff4abc0 DBlobs: try to persist with the browser 2024-06-13 16:24:06 -07:00
Enrico Ros 6fc4dbe9d1 CSS fix 2024-06-13 16:09:31 -07:00
Enrico Ros cca8132a2c DBlobs: rationalize image usage and GC 2024-06-13 16:02:21 -07:00
Enrico Ros 91654ca219 Chat: option to regenerate images in-place 2024-06-13 03:57:48 -07:00
Enrico Ros 547d7eca59 Draw: gallery empty state 2024-06-13 03:13:41 -07:00
Enrico Ros b86bf31baa DBlob: fixup post-rename 2024-06-13 02:54:43 -07:00
Enrico Ros 5b5b4efe42 Chat symbol: render support 2024-06-13 02:47:21 -07:00
Enrico Ros e9fb65edba DBlobs: refactor DBlobAssetId 2024-06-13 02:42:43 -07:00
Enrico Ros cc1cba9aa8 DBlobs: refactor 2024-06-13 02:06:10 -07:00
Enrico Ros a765c566c8 DBlobs: simplify 2024-06-13 01:19:13 -07:00
Enrico Ros 63e9022b84 add Nanoid 2024-06-13 00:29:16 -07:00
Enrico Ros 368a995e7f DBlobs: rename the table 2024-06-13 00:29:16 -07:00
Enrico Ros c844c66b5a DBlobs: survive hot reloads 2024-06-12 23:27:24 -07:00
Enrico Ros 73b18313e9 Draw: layout 2024-06-12 23:09:48 -07:00
Enrico Ros bdd68dc6c9 DBlobs: sanity fix 2024-06-12 23:08:08 -07:00
Enrico Ros 3901b94382 Draw: add rounded 2024-06-12 22:11:06 -07:00
Enrico Ros 82ac276338 Draw: fix layout 2024-06-12 22:07:16 -07:00
Enrico Ros 02c9f3ebdb Draw: bare bones enhancer 2024-06-12 15:52:10 -07:00
Enrico Ros 364ad63877 Draw: Merge T2I back into DrawCreate 2024-06-12 02:43:44 -07:00
Enrico Ros 5fc4196d01 Draw: fix fallbacks 2024-06-12 02:24:56 -07:00
Enrico Ros 3a1e10bd21 Fix wrapping of radios 2024-06-12 02:10:59 -07:00
Enrico Ros 73519ec562 Draw: configure provider opens up top 2024-06-12 01:15:33 -07:00
Enrico Ros bf9c9916b1 Draw: improve prompt composer 2024-06-12 01:15:04 -07:00
Enrico Ros 01d017c6cd Draw: dynamic header 2024-06-12 00:37:44 -07:00
Enrico Ros ca98ab02d8 Draw: improve numbers 2024-06-12 00:37:36 -07:00
Enrico Ros 347804a02e Draw: fix dom 2024-06-11 23:43:47 -07:00
Enrico Ros 4c80f8dbf4 Draw: improve 2024-06-11 23:41:16 -07:00
Enrico Ros 73ee96040f Draw: heading 2024-06-11 22:09:04 -07:00
Enrico Ros 6180da1333 MP: cleanups 2024-06-11 19:53:44 -07:00
Enrico Ros 2756ff6ad0 MP: fix 2024-06-11 19:50:03 -07:00
Enrico Ros e57491b812 MP: DBlob GC on message/chat deletion 2024-06-11 19:50:00 -07:00
Enrico Ros 9d8ae538d9 Draw: thumbnail in gallery 2024-06-11 19:41:12 -07:00
Enrico Ros dd7defd2c7 DBlobs: auto-gen image thumbnail 2024-06-11 19:41:04 -07:00
Enrico Ros e79ec45b5b Draw: bare bones gallery 2024-06-11 19:13:48 -07:00
Enrico Ros 1a138bbc16 DesktopDrawer: do not get in the way when closed 2024-06-11 19:11:09 -07:00
Enrico Ros b067165471 Draw: cleans 2024-06-11 18:31:37 -07:00
Enrico Ros 6fbcbb9399 Draw: renames 2024-06-11 15:11:22 -07:00
Enrico Ros aaf77b4e20 Draw: renames 2024-06-11 15:06:18 -07:00
Enrico Ros f5cc2e952b Draw: sections 2024-06-11 14:50:02 -07:00
Enrico Ros eeab362567 cleanups 2024-06-11 14:45:49 -07:00
Enrico Ros 834205c426 DBlobs: renames 2024-06-11 14:13:17 -07:00
Enrico Ros fbad8ca62e DBlobs: gc on chat images 2024-06-11 13:48:21 -07:00
Enrico Ros 1e4c6f13c5 MP: show images 2024-06-11 12:56:12 -07:00
Enrico Ros b7c2b3d4cb RenderImageURL: improve greatly 2024-06-11 12:53:55 -07:00
Enrico Ros 0d5b7d36f1 Message Fragments: update v-layout 2024-06-11 12:10:47 -07:00
Enrico Ros 059886fede Mobile menu: shrink a bit 2024-06-11 12:08:22 -07:00
Enrico Ros db7dd0ca43 DBlobs: 'ot' for the origin type 2024-06-11 12:07:53 -07:00
Enrico Ros f4c611b47d DBlobs: reactive live hooks 2024-06-11 01:51:08 -07:00
Enrico Ros 39c32646c5 Merge branch 'refs/heads/main' into feature-multipart 2024-06-10 23:57:05 -07:00
Enrico Ros 1720fffbdc Merge remote-tracking branch 'refs/remotes/opensource/main-stable' 2024-06-10 23:56:43 -07:00
Enrico Ros 6c51cd0d1d Fix Tooltip on errors 2024-06-10 23:54:59 -07:00
Enrico Ros cb9cdc508a MP: rename Content Parts 2024-06-10 23:35:53 -07:00
Enrico Ros 7d037a206f MP: enable multipart edits 2024-06-10 22:41:46 -07:00
Enrico Ros ace10ab4be MP: begin Fragment extraction and simplify BlockRenderer 2024-06-10 21:46:45 -07:00
Enrico Ros bc0a7b6ac3 Rename RenderImageURL 2024-06-10 21:45:40 -07:00
Enrico Ros e77e2045e3 MP: Message porting skel 2024-06-10 19:29:01 -07:00
Enrico Ros abbd55c740 Message: renames 2024-06-09 19:16:46 -07:00
Enrico Ros bf5e80a462 MP: adapt ego attachment, messageSingleTextOrThrow-- 2024-06-09 19:00:01 -07:00
Enrico Ros 121deaae5f bits 2024-06-09 18:01:49 -07:00
Enrico Ros 80317232ba MP: improve fragment typings 2024-06-09 17:44:25 -07:00
Enrico Ros 22f815dcd1 do not load 'pdfjs-dist' during development, to make '--turbo' work 2024-06-09 17:07:36 -07:00
Enrico Ros fb96c3ab47 Merge branch 'refs/heads/main' into feature-multipart
# Conflicts:
#	src/modules/aifn/autosuggestions/autoSuggestions.ts
#	src/modules/aifn/autotitle/autoTitle.ts
2024-06-07 14:24:03 -07:00
Enrico Ros 3b15ad51a1 Merge branch 'refs/heads/main-stable' 2024-06-07 14:23:21 -07:00
Enrico Ros 5066336c75 Merge branch 'refs/heads/main-stable' 2024-06-07 12:16:49 -07:00
Enrico Ros 0807744577 Option to see model interfaces 2024-06-07 12:09:38 -07:00
Enrico Ros 59f871d3ec Merge branch 'refs/heads/main' into feature-multipart
# Conflicts:
#	src/apps/chat/editors/chat-stream.ts
#	src/modules/beam/gather/instructions/ChatGenerateInstruction.tsx
#	src/modules/beam/scatter/beam.scatter.ts
2024-06-06 22:15:17 -07:00
Enrico Ros fed351a2fc Merge branch 'refs/heads/main-stable'
# Conflicts:
#	package-lock.json
#	package.json
#	src/common/util/token-counter.ts
2024-06-06 22:11:43 -07:00
Enrico Ros aeb129e422 roll: minor packages 2024-06-06 11:21:23 -07:00
Enrico Ros 3050b546ac Gemini: update 2024-06-06 10:13:37 -07:00
Enrico Ros 1429726ba6 Gemini: update 2024-06-06 10:10:06 -07:00
Enrico Ros 4075581acd Add Codestral - Fixes #558 2024-06-06 09:59:31 -07:00
Enrico Ros 56774fd974 roll: mermaid 10.9.1 from CDN 2024-06-06 09:36:08 -07:00
Enrico Ros 5e674d2299 Dynamic won't be required from Next 15 2024-06-04 20:49:05 -07:00
Enrico Ros 06f5b6d6ff Silence warnings by declaring asyncFunction available on the browsers side 2024-06-04 20:44:01 -07:00
Enrico Ros b25b4e6c8f roll: next 14.2 2024-06-04 20:05:06 -07:00
Enrico Ros 645e07dba8 roll: pdfjs-dist 2024-06-04 20:04:51 -07:00
Enrico Ros 46181fcaa2 roll: misc 2024-06-04 19:32:08 -07:00
Enrico Ros 8d7ae425f9 roll: cloudflare/puppeteer 2024-06-04 18:34:56 -07:00
Enrico Ros 7d572334a1 roll: misc 2024-06-04 18:19:38 -07:00
Enrico Ros 5dab6f68e6 roll: mui 2024-06-04 18:18:04 -07:00
Enrico Ros d1c595d8db Text 2024-06-04 07:09:56 -07:00
Enrico Ros eaa2635b51 T2I: port TextToImage (partial) 2024-06-03 13:55:11 -07:00
Enrico Ros dc2d226ddb T2I: port image-generate 2024-06-03 13:41:57 -07:00
Enrico Ros 336a4e1f35 T2I: separate data/mime 2024-06-03 13:40:45 -07:00
Enrico Ros 4d3b6b4f43 MP: cleaner contentFragments additions 2024-06-03 12:57:59 -07:00
Enrico Ros a12601b49c Merge remote-tracking branch 'feature-t2i-b64' into feature-multipart
# Conflicts:
#	src/apps/chat/editors/image-generate.ts
2024-06-03 12:05:48 -07:00
Enrico Ros 15a895064e ChatMessage: bits 2024-06-03 12:02:35 -07:00
Enrico Ros 8bd1507ace ChatMessage: Fix Avatar on Mobile 2024-06-03 11:42:46 -07:00
Enrico Ros 89d7ec5d0b ChatMessage: disable diffing 2024-06-03 10:16:55 -07:00
Enrico Ros 670e57735a MP: relax singleTextOrThrow 2024-06-03 10:10:54 -07:00
Enrico Ros fa703c25e8 Attachments: disable GC drafts for now 2024-06-03 09:34:57 -07:00
Enrico Ros f58161b1d1 Attachments: GC drafts 2024-06-03 09:33:53 -07:00
Enrico Ros 8db2a37a59 Attachments: remove DBlobs when setting outputFragments, until GC comes 2024-06-03 09:21:58 -07:00
Enrico Ros bfdb9c2624 Attachments: remove race condition on conversion 2024-06-03 09:15:11 -07:00
Enrico Ros 240e984737 DBlob: types 2024-06-03 09:05:46 -07:00
Enrico Ros fe128c18b1 MP: improve dmessage part/reference functions and cloning 2024-06-03 08:48:42 -07:00
Enrico Ros b208d8c40d MP: fix streaming text 2024-05-26 02:55:55 -07:00
Enrico Ros 556641e1f4 MP: fix V3 chats porting 2024-05-26 02:47:11 -07:00
Enrico Ros 464eb671db MP: upgrade V3 to V4 (store, links, jsons) 2024-05-25 21:13:45 -07:00
Enrico Ros 12b8f1e3ef MP: refine DMessage/DMessageFragment
Once again, large change.
2024-05-25 20:17:43 -07:00
Enrico Ros ab199afe0d Beam: reminder the save/restore on a per-message basis, #483 2024-05-25 15:53:56 -07:00
Enrico Ros fe1a498da0 Beam: also enable method selection on AutoStart 2024-05-25 15:51:37 -07:00
Enrico Ros 4f9d55eb42 Attachments: +Plain Mime 2024-05-25 03:49:21 -07:00
Enrico Ros 70f450f547 Attachments: restored take and copy 2024-05-25 02:56:17 -07:00
Enrico Ros 28fc7deefc MP: rename to contentRef 2024-05-25 00:40:39 -07:00
Enrico Ros 428babf856 Tokens: estimate images tokens 2024-05-25 00:28:15 -07:00
Enrico Ros b824ddf2e3 Attachments: uniform remove 2024-05-24 22:33:33 -07:00
Enrico Ros 2396966740 Tokens: rename the text methods 2024-05-24 22:25:14 -07:00
Enrico Ros 23ca49128a Attachments: consolidation 2024-05-24 19:35:26 -07:00
Enrico Ros ec6bdede20 Attachments: improvements 2024-05-24 18:06:45 -07:00
Enrico Ros 4ada2013d2 Attachments: correct rescaling 2024-05-24 17:31:36 -07:00
Enrico Ros 79afef6bc1 Attachments: more rescaling pains 2024-05-24 16:52:49 -07:00
Enrico Ros e7000df89f Attachments: no upsizing of small images 2024-05-24 16:30:10 -07:00
Enrico Ros 59f77a64ea Attachments: open images from the menu in new tabs 2024-05-24 16:13:29 -07:00
Enrico Ros 8be152666e Attachments: improve conversions/quality 2024-05-24 15:14:50 -07:00
Enrico Ros 10488854ce Attachments: low/high detail modes 2024-05-24 03:44:59 -07:00
Enrico Ros 6586aafed8 Attachments: resize modes for OpenAI, Google, Anthropic 2024-05-24 03:32:25 -07:00
Enrico Ros 4568a60be3 Attachments: image resize 2024-05-24 03:18:12 -07:00
Enrico Ros 193bc8bb8e Attachments: fix multi-image pdf 2024-05-24 02:20:53 -07:00
Enrico Ros ce381b7690 Attachments: rename main to AttachmentDraft, convert to (overlayed) Store Slice 2024-05-24 01:52:25 -07:00
Enrico Ros b238428816 Chat search: sort by date by default 2024-05-24 00:27:31 -07:00
Enrico Ros 0ac37f50cf Attachments: remove old code 2024-05-23 22:29:44 -07:00
Enrico Ros 54b9389b77 MP: Attachments full port, incl. Storage 2024-05-22 02:56:23 -07:00
Enrico Ros a183c26e51 PDFUtils: improve PDF to image 2024-05-22 02:56:23 -07:00
Enrico Ros 01a03d164c ImageUtils: add dimension guessers and converters 2024-05-22 02:56:23 -07:00
Enrico Ros cdff1fde2d TextUtils: update to new uuid 2024-05-22 02:56:23 -07:00
Enrico Ros c38b9998a6 TextUtils: add uuidv4 base64 2024-05-22 02:56:23 -07:00
Enrico Ros 77c1a335ad MP: data at rest cleanup 2024-05-22 02:56:23 -07:00
Enrico Ros 07a0fe6249 MP: text separation 2024-05-22 02:56:23 -07:00
Enrico Ros 204bc46976 MP: append parts to messages 2024-05-22 02:56:23 -07:00
Enrico Ros b910506519 MP: cleanup MP Beam 2024-05-22 02:56:23 -07:00
Enrico Ros 3cef39da17 MP: fix Beam, Rays and Fusions 2024-05-22 02:56:23 -07:00
Enrico Ros 3aea29bcb5 MP: remove typing, support placeholder, improve streaming of updates 2024-05-22 02:56:23 -07:00
Enrico Ros dd0d19168b MP: cleanup conversions 2024-05-22 02:56:22 -07:00
Enrico Ros 6727fcd111 MP: cleanup conversions 2024-05-22 02:56:22 -07:00
Enrico Ros 9d347f4a5a Multi-Part refactor
Partial still. Does not build.
2024-05-22 02:56:22 -07:00
Enrico Ros 084e48ddc2 Desktop Nav: improve menu 2024-05-22 02:55:04 -07:00
Enrico Ros 31e89ce9a1 App: Tokenizer 2024-05-22 02:54:56 -07:00
Enrico Ros baad3ae1c3 Fix Domino issue (crash) by upgrading Turndown to 7.2.0
See:
https://github.com/mixmark-io/turndown/issues/439
https://github.com/fgnass/domino/issues/146
2024-05-21 23:50:32 -07:00
Enrico Ros 7c099cab94 Fix TimeoutError issue 2024-05-18 03:07:04 -07:00
Enrico Ros 811875dd2e idb-keyval: bits 2024-05-16 18:04:27 -07:00
Enrico Ros 127443d550 idb-keyval: crud console fn 2024-05-16 18:03:20 -07:00
Enrico Ros d2064605bf Bits 2024-05-16 04:14:41 -07:00
Enrico Ros 4c6fb61ca8 Draw: enable nav 2024-05-16 04:11:04 -07:00
Enrico Ros 608ba8bcb4 T2I: Adapt callers (fixme) 2024-05-16 04:11:04 -07:00
Enrico Ros b53c054dee T2I: Enrich Generated Output 2024-05-16 04:11:04 -07:00
Enrico Ros 05aa4b547f Mistral: update pricing 2024-05-16 04:02:04 -07:00
Enrico Ros 6afb61d25d Mistral: update
#518
2024-05-16 04:02:03 -07:00
Enrico Ros a7ce5c1ca6 Already Set 2024-05-16 03:25:25 -07:00
Enrico Ros 952bd2bd93 Start opened 2024-05-16 03:03:57 -07:00
Enrico Ros f9d33d4888 Page download: improve 2024-05-16 02:57:50 -07:00
Enrico Ros 81d99f19d4 Beam: bits 2024-05-16 01:41:44 -07:00
Enrico Ros 454a4257da Beam: recall importing rays 2024-05-16 01:35:04 -07:00
Enrico Ros e513b42786 Beam: fix reactive bug 2024-05-16 01:30:37 -07:00
Enrico Ros b607e3c034 Beam: if auto-start, give the chance to change merge model 2024-05-16 01:00:21 -07:00
Enrico Ros d5c3f5012b Tiktoken: in the future, show tokens 2024-05-16 00:55:58 -07:00
Enrico Ros 21d045be59 Update TikToken for perfect token computation on 'o' models. 2024-05-16 00:53:08 -07:00
Enrico Ros a9c1c34dc9 DBlobs: subsystem for storing blobs
Uses Dexie.js fro IndexedDB access.
2024-05-15 23:44:33 -07:00
Enrico Ros 44ab0483b6 DChat: remove IDB migration 2024-05-15 23:43:31 -07:00
Enrico Ros 9eb0cc0b62 Gemini: improve support (incl. interfaces, cost, visibility) 2024-05-14 15:15:53 -07:00
Enrico Ros 2db74867f5 (bits) 2024-05-13 16:24:50 -07:00
Enrico Ros fd30baafb8 Default to the full context window 2024-05-13 16:24:30 -07:00
Enrico Ros 3623eef47f Browse: improve markdown transform 2024-05-13 15:59:46 -07:00
Enrico Ros 7b07bb7884 Browse: full support for markdown transform 2024-05-13 15:38:08 -07:00
Enrico Ros 7946cd6614 Browse: markdown transform as default 2024-05-13 14:49:29 -07:00
Enrico Ros 51b6e30986 Browse: support transform (skel) 2024-05-13 14:34:25 -07:00
Enrico Ros 002df7b0f9 Hold Shift to delete without confirmation: fixes #537 2024-05-13 14:00:43 -07:00
Sorawit Kongnurat 2ac1789312 Use ctrl and remove alt usage with certain hotkeys for mac shortcuts 2024-05-06 16:34:06 +07:00
305 changed files with 19297 additions and 7193 deletions
+2 -1
View File
@@ -51,7 +51,8 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/v1-stable' }}
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
type=sha # Just in case none of the above applies
+5 -22
View File
@@ -11,35 +11,18 @@ Stay ahead of the curve with big-AGI. 🚀 Pros & Devs 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)
> 🚀 Big-AGI 2 is launching Q4 2024. Be the first to experience it before the public release.
>
> 👉 [Apply for Early Access](https://y2rjg0zillz.typeform.com/to/ZSADpr5u?utm_source=gh-stable&utm_medium=readme&utm_campaign=ea2)
Or fork & run on Vercel
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
### New Version
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [installation](docs/installation.md) 👉 [documentation](docs/README.md)
This repository contains two main versions:
> Note: bigger better features (incl. Beam-2) are being cooked outside of `main`.
- Big-AGI 2: next-generation, bringing the most advanced AI experience
- `v2-dev`: V2 development branch, the exciting one, future default
- Big-AGI Stable: as deployed on big-agi.com
- `v1-stable`: Current stable version & Docker 'latest' tag
[//]: # (big-AGI is an open book; see the **[ready-to-ship and future ideas](https://github.com/users/enricoros/projects/4/views/2)** in our open roadmap)
Note: After the V2 release in Q4, `v2/dev` will become the default branch and `v1/dev` will reach EOL.
### What's New in 1.16.1...1.16.3 · Jun 20, 2024 (patch releases)
### Quick links: 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [installation](docs/installation.md) 👉 [documentation](docs/README.md)
### What's New in 1.16.1...1.16.9 · Jan 21, 2025 (patch releases)
- 1.16.9: Docker Gemini fix (R1 models are supported in Big-AGI 2)
- 1.16.8: OpenAI ChatGPT-4o Latest (o1 models are supported in Big-AGI 2)
- 1.16.7: OpenAI support for GPT-4o 2024-08-06
- 1.16.6: Groq support for Llama 3.1 models
- 1.16.5: GPT-4o Mini support
- 1.16.4: 8192 tokens support for Claude 3.5 Sonnet
- 1.16.3: Anthropic Claude 3.5 Sonnet model support
- 1.16.2: Improve web downloads, as text, markdwon, or HTML
- 1.16.2: Proper support for Gemini models
@@ -160,7 +143,7 @@ You can easily configure 100s of AI models in big-AGI:
| **AI models** | _supported vendors_ |
|:--------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Opensource Servers | [LocalAI](https://localai.com) (multimodal) · [Ollama](https://ollama.com/) · [Oobabooga](https://github.com/oobabooga/text-generation-webui) |
| Opensource Servers | [LocalAI](https://localai.io/) (multimodal) · [Ollama](https://ollama.com/) · [Oobabooga](https://github.com/oobabooga/text-generation-webui) |
| Local Servers | [LM Studio](https://lmstudio.ai/) |
| Multimodal services | [Azure](https://azure.microsoft.com/en-us/products/ai-services/openai-service) · [Google Gemini](https://ai.google.dev/) · [OpenAI](https://platform.openai.com/docs/overview) |
| Language services | [Anthropic](https://anthropic.com) · [Groq](https://wow.groq.com/) · [Mistral](https://mistral.ai/) · [OpenRouter](https://openrouter.ai/) · [Perplexity](https://www.perplexity.ai/) · [Together AI](https://www.together.ai/) |
+2 -2
View File
@@ -5,13 +5,13 @@ import { createTRPCFetchContext } from '~/server/api/trpc.server';
const handlerEdgeRoutes = (req: Request) =>
fetchRequestHandler({
router: appRouterEdge,
endpoint: '/api/trpc-edge',
router: appRouterEdge,
req,
createContext: createTRPCFetchContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? "<no-path>"}: ${error.message}`)
? ({ path, error }) => console.error(`❌ tRPC-edge failed on ${path ?? 'unk-path'}: ${error.message}`)
: undefined,
});
+5 -3
View File
@@ -5,19 +5,21 @@ import { createTRPCFetchContext } from '~/server/api/trpc.server';
const handlerNodeRoutes = (req: Request) =>
fetchRequestHandler({
router: appRouterNode,
endpoint: '/api/trpc-node',
router: appRouterNode,
req,
createContext: createTRPCFetchContext,
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? '<no-path>'}: ${error.message}`)
? ({ path, error }) => console.error(`❌ tRPC-node failed on ${path ?? 'unk-path'}: ${error.message}`)
: undefined,
});
export const runtime = 'nodejs';
// NOTE: the following statement breaks the build on non-pro deployments, and conditionals don't work either
// so we resorted to raising the timeout from 10s to 25s in the vercel.json file instead
// export const maxDuration = 25;
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export { handlerNodeRoutes as GET, handlerNodeRoutes as POST };
+1 -7
View File
@@ -10,14 +10,8 @@ by release.
- milestone: [1.17.0](https://github.com/enricoros/big-agi/milestone/17)
- work in progress: [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), [help here](https://github.com/users/enricoros/projects/4/views/4)
### What's New in 1.16.1...1.16.9 · Jan 21, 2025 (patch releases)
### What's New in 1.16.1...1.16.3 · Jun 20, 2024 (patch releases)
- 1.16.9: Docker Gemini fix (R1 models are supported in Big-AGI 2)
- 1.16.8: OpenAI ChatGPT-4o Latest (o1 models are supported in Big-AGI 2)
- 1.16.7: OpenAI support for GPT-4o 2024-08-06
- 1.16.6: Groq support for Llama 3.1 models
- 1.16.5: GPT-4o Mini support
- 1.16.4: 8192 tokens support for Claude 3.5 Sonnet
- 1.16.3: Anthropic Claude 3.5 Sonnet model support
- 1.16.2: Improve web downloads, as text, markdwon, or HTML
- 1.16.2: Proper support for Gemini models
+5
View File
@@ -0,0 +1,5 @@
From root:
```bash
BIG_AGI_BUILD=standalone next build
electron . --enable-logging
```
+61
View File
@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
background: #2e2c29;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: Arial, sans-serif;
}
.loader-container {
position: relative;
width: 100px;
height: 100px;
}
.spinner {
position: absolute;
top: 0;
left: 0;
border: 5px solid rgba(255, 255, 255, 0.3);
border-top: 5px solid #3498db;
border-radius: 50%;
width: 100px;
height: 100px;
animation: spin 2s linear infinite;
}
.logo {
position: absolute;
top: 15px;
left: 15px;
width: 80px;
height: 80px;
background: url('tray-icon.png') no-repeat center center;
background-size: contain;
animation: counter-spin 3.33s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes counter-spin {
0% { transform: rotate(360deg); }
100% { transform: rotate(0deg); }
}
</style>
</head>
<body>
<div class="loader-container">
<div class="spinner"></div>
<div class="logo"></div>
</div>
</body>
</html>
+178
View File
@@ -0,0 +1,178 @@
const { app, BrowserWindow, Tray, Menu, ipcMain, screen, nativeTheme, shell } = require('electron');
const path = require('path');
const startServer = require('./server.js');
const { autoUpdater } = require('electron-updater');
let mainWindow;
let tray;
const port = 3000;
async function createWindow() {
try {
console.log('Starting server...');
await startServer(port);
console.log('Server started successfully');
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
// // Set up a loading screen
// loadingScreen = new BrowserWindow({
// // width: 150,
// // height: 150,
// frame: false,
// transparent: false,
// alwaysOnTop: true,
// webPreferences: {
// nodeIntegration: true,
// },
// backgroundColor: '#2e2c29',
// });
//
// loadingScreen.loadFile(path.join(__dirname, 'loading.html'));
// loadingScreen.center();
// console.log('Loading screen created');
console.log('Preload script path:', path.join(__dirname, 'preload.js'));
mainWindow = new BrowserWindow({
width: Math.min(1280, width * 0.8),
height: Math.min(800, height * 0.8),
minWidth: 430,
minHeight: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
sandbox: false,
devTools: false,
},
backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff',
show: true,
frame: false,
titleBarStyle: 'hidden',
icon: path.join(__dirname, 'tray-icon.png'),
// New "insane" features:
// transparent: true, // Enable window transparency
vibrancy: 'under-window', // Add vibrancy effect (macOS only)
visualEffectState: 'active', // Keep vibrancy active even when not focused (macOS only)
roundedCorners: true, // Enable rounded corners (macOS only)
// thickFrame: false, // Use a thinner frame on Windows
autoHideMenuBar: true, // Auto-hide the menu bar, press Alt to show it
scrollBounce: true, // Enable bounce effect when scrolling (macOS only)
});
mainWindow.removeMenu();
mainWindow.setTitle('Your Professional App Name');
console.log('Attempting to load main window URL...');
await mainWindow.loadURL(`http://localhost:${port}`);
console.log('Main window URL loaded successfully');
mainWindow.once('ready-to-show', () => {
console.log('Main window ready to show');
// if (loadingScreen) {
// loadingScreen.close();
// }
mainWindow.show();
mainWindow.focus();
});
createTray();
autoUpdater.checkForUpdatesAndNotify();
// Handle window state
let isQuitting = false;
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
app.on('before-quit', () => {
isQuitting = true;
});
// Adjust window behavior
mainWindow.on('maximize', () => {
mainWindow.webContents.send('window-maximized');
});
mainWindow.on('unmaximize', () => {
mainWindow.webContents.send('window-unmaximized');
});
// Warn if preloads fail
mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
console.error('Preload error:', preloadPath, error);
});
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('Failed to load:', errorCode, errorDescription);
});
// Handle external links
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
} catch (err) {
console.error('Error in createWindow:', err);
app.quit();
}
}
function createTray() {
tray = new Tray(path.join(__dirname, 'tray-icon.png'));
const contextMenu = Menu.buildFromTemplate([
{ label: 'Show App', click: () => mainWindow.show() },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
]);
tray.setToolTip('Your Professional App Name');
tray.setContextMenu(contextMenu);
tray.on('click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
});
}
app.whenReady().then(() => {
console.log('App is ready, creating window...');
createWindow().catch((err) => {
console.error('Failed to create window:', err);
app.quit();
});
app.on('activate', function() {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', function() {
if (process.platform !== 'darwin') app.quit();
});
// IPC handlers for window controls
ipcMain.on('minimize-window', () => mainWindow.minimize());
ipcMain.on('maximize-window', () => {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
});
ipcMain.on('close-window', () => mainWindow.close());
// Auto-updater events
autoUpdater.on('update-available', () => {
mainWindow.webContents.send('update_available');
});
autoUpdater.on('update-downloaded', () => {
mainWindow.webContents.send('update_downloaded');
});
+36
View File
@@ -0,0 +1,36 @@
const { contextBridge, desktopCapturer, ipcRenderer } = require('electron');
const { readFileSync } = require('fs');
const { join } = require('path');
// Main bridge
contextBridge.exposeInMainWorld('electron', {
sendEvent: (event) => ipcRenderer.send('app-event', event),
onUpdateAvailable: (callback) => ipcRenderer.on('update_available', callback),
onUpdateDownloaded: (callback) => ipcRenderer.on('update_downloaded', callback),
});
// Screen Capture: inject renderer.js into the web page
window.addEventListener('DOMContentLoaded', () => {
console.log('Screen Capture: Injecting renderer.js into the web page');
const rendererScript = document.createElement('script');
rendererScript.text = readFileSync(join(__dirname, 'renderer.js'), 'utf8');
document.body.appendChild(rendererScript);
});
// Screen Capture: expose desktopCapturer to the web page
contextBridge.exposeInMainWorld('myCustomGetDisplayMedia', async () => {
console.log('Screen Capture: Calling desktopCapturer.getSources');
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
});
console.log('Available sources:', sources);
// you should create some kind of UI to prompt the user
// to select the correct source like Google Chrome does
// this is just for testing purposes
return sources[0];
});
console.log('Preload script loaded');
+30
View File
@@ -0,0 +1,30 @@
// https://github.com/aabuhijleh/override-getDisplayMedia/blob/main/renderer.js
// This file is required by the index.html file and will
// be executed in the renderer process for that window.
// No Node.js APIs are available in this process because
// `nodeIntegration` is turned off. Use `preload.js` to
// selectively enable features needed in the rendering
// process.
// override getDisplayMedia
navigator.mediaDevices.getDisplayMedia = async () => {
const selectedSource = await globalThis.myCustomGetDisplayMedia();
// create MediaStream
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selectedSource.id,
minWidth: 1280,
maxWidth: 1280,
minHeight: 720,
maxHeight: 720,
},
},
});
return stream;
};
+71
View File
@@ -0,0 +1,71 @@
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const path = require('path');
// const dev = process.env.NODE_ENV !== 'production';
const dir = path.join(__dirname, '..'); // This points to the root of your project
const app = next({ dev: false, dir });
const handle = app.getRequestHandler();
function startServer(port) {
return new Promise((resolve, reject) => {
app.prepare()
.then(() => {
const server = createServer((req, res) => {
// Basic request logging
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
// Simple rate limiting
if (rateLimiter(req)) {
res.statusCode = 429;
res.end('Too Many Requests');
return;
}
// Handle the request
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
server.listen(port, (err) => {
if (err) reject(err);
console.log(`> Ready on http://localhost:${port}`);
resolve(server);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
server.close(() => {
console.log('HTTP server closed');
});
});
})
.catch(err => reject(err));
});
}
// Simple in-memory rate limiter
const MAX_REQUESTS_PER_MINUTE = 100;
const requestCounts = new Map();
function rateLimiter(req) {
const ip = req.socket.remoteAddress;
const now = Date.now();
const windowStart = now - 60000; // 1 minute ago
const requestTimestamps = requestCounts.get(ip) || [];
const requestsInWindow = requestTimestamps.filter(timestamp => timestamp > windowStart);
if (requestsInWindow.length >= MAX_REQUESTS_PER_MINUTE) {
return true; // Rate limit exceeded
}
requestTimestamps.push(now);
requestCounts.set(ip, requestTimestamps);
return false; // Rate limit not exceeded
}
module.exports = startServer;
Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

+11 -3
View File
@@ -13,7 +13,7 @@ let nextConfig = {
// [exports] https://nextjs.org/docs/advanced-features/static-html-export
...buildType && {
output: buildType,
distDir: 'dist',
// distDir: 'dist',
// disable image optimization for exports
images: { unoptimized: true },
@@ -27,7 +27,7 @@ let nextConfig = {
serverComponentsExternalPackages: ['puppeteer-core'],
},
webpack: (config, _options) => {
webpack: (config, { isServer }) => {
// @mui/joy: anything material gets redirected to Joy
config.resolve.alias['@mui/material'] = '@mui/joy';
@@ -37,9 +37,17 @@ let nextConfig = {
layers: true,
};
// fix warnings for async functions in the browser (https://github.com/vercel/next.js/issues/64792)
if (!isServer) {
config.output.environment = { ...config.output.environment, asyncFunction: true };
}
// prevent too many small chunks (40kb min) on 'client' packs (not 'server' or 'edge-server')
if (typeof config.optimization.splitChunks === 'object' && config.optimization.splitChunks.minSize)
// noinspection JSUnresolvedReference
if (typeof config.optimization.splitChunks === 'object' && config.optimization.splitChunks.minSize) {
// noinspection JSUnresolvedReference
config.optimization.splitChunks.minSize = 40 * 1024;
}
return config;
},
+4371 -831
View File
File diff suppressed because it is too large Load Diff
+58 -32
View File
@@ -4,15 +4,20 @@
"private": true,
"author": "Enrico Ros <enrico.ros@gmail.com>",
"repository": "https://github.com/enricoros/big-agi",
"main": "electron/main.js",
"scripts": {
"dev": "next dev",
"dev": "node electron/server.js",
"build": "next build",
"start": "next start",
"start": "NODE_ENV=production node electron/server.js",
"lint": "next lint",
"postinstall": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"vercel:env:pull": "npx vercel env pull .env.development.local"
"vercel:env:pull": "npx vercel env pull .env.development.local",
"electron": "electron .",
"electron-dev": "concurrently \"npm run dev\" \"electron .\"",
"electron-build": "next build && electron-builder",
"electron-start": "npm run build && electron ."
},
"prisma": {
"schema": "src/server/prisma/schema.prisma"
@@ -22,28 +27,32 @@
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.17",
"@mui/joy": "^5.0.0-beta.36",
"@mui/material": "^5.15.17",
"@next/bundle-analyzer": "^14.2.3",
"@next/third-parties": "^14.2.3",
"@prisma/client": "^5.13.0",
"@mui/icons-material": "^5.16.0",
"@mui/joy": "^5.0.0-beta.47",
"@mui/material": "^5.16.0",
"@next/bundle-analyzer": "^14.2.4",
"@next/third-parties": "^14.2.4",
"@prisma/client": "^5.16.1",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "~4.36.1",
"@trpc/client": "10.44.1",
"@trpc/next": "10.44.1",
"@trpc/react-query": "10.44.1",
"@trpc/server": "10.44.1",
"@vercel/analytics": "^1.2.2",
"@vercel/speed-insights": "^1.0.10",
"@tanstack/react-query": "^5.50.1",
"@trpc/client": "11.0.0-alpha-tmp-issues-5851-take-two.496",
"@trpc/next": "11.0.0-alpha-tmp-issues-5851-take-two.496",
"@trpc/react-query": "11.0.0-alpha-tmp-issues-5851-take-two.496",
"@trpc/server": "11.0.0-alpha-tmp-issues-5851-take-two.496",
"@vercel/analytics": "^1.3.1",
"@vercel/speed-insights": "^1.0.12",
"browser-fs-access": "^0.35.0",
"cheerio": "^1.0.0-rc.12",
"dexie": "^4.0.7",
"dexie-react-hooks": "^1.1.7",
"electron-updater": "^6.2.1",
"eventsource-parser": "^1.1.2",
"idb-keyval": "^6.2.1",
"next": "~14.1.4",
"nanoid": "^5.0.7",
"next": "~14.2.4",
"nprogress": "^0.2.0",
"pdfjs-dist": "4.2.67",
"pdfjs-dist": "4.4.168",
"plantuml-encoder": "^1.4.0",
"prismjs": "^1.29.0",
"react": "^18.3.1",
@@ -53,41 +62,58 @@
"react-katex": "^3.0.1",
"react-markdown": "^9.0.1",
"react-player": "^2.16.0",
"react-resizable-panels": "^2.0.19",
"react-resizable-panels": "^2.0.20",
"react-timeago": "^7.2.0",
"rehype-katex": "^7.0.0",
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sharp": "^0.33.3",
"sharp": "^0.33.4",
"superjson": "^2.2.1",
"tesseract.js": "^5.1.0",
"tiktoken": "^1.0.15",
"turndown": "^7.2.0",
"uuid": "^9.0.1",
"zod": "^3.23.8",
"zustand": "^4.5.2"
"zustand": "^4.5.4"
},
"devDependencies": {
"@cloudflare/puppeteer": "0.0.5",
"@types/node": "^20.12.11",
"@cloudflare/puppeteer": "0.0.11",
"@types/node": "^20.14.10",
"@types/nprogress": "^0.2.3",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.1",
"@types/react": "^18.3.3",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-csv": "^1.1.10",
"@types/react-dom": "^18.3.0",
"@types/react-katex": "^3.0.4",
"@types/react-timeago": "^4.1.7",
"@types/turndown": "^5.0.4",
"@types/uuid": "^9.0.8",
"concurrently": "^8.2.2",
"electron": "^31.1.0",
"electron-builder": "^24.13.3",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.3",
"prettier": "^3.2.5",
"prisma": "^5.13.0",
"typescript": "^5.4.5"
"eslint-config-next": "^14.2.4",
"prettier": "^3.3.2",
"prisma": "^5.16.1",
"typescript": "^5.5.3"
},
"engines": {
"node": "^22.0.0 || ^20.0.0 || ^18.0.0"
"node": "^20.0.0 || ^18.0.0"
},
"build": {
"appId": "com.yourcompany.yourappname",
"productName": "Your App Name",
"files": [
"electron/**/*",
".next/**/*",
"public/**/*",
"next.config.js"
],
"directories": {
"buildResources": "electron"
},
"extraMetadata": {
"main": "electron/main.js"
}
}
}
}
+1
View File
@@ -11,6 +11,7 @@ import 'katex/dist/katex.min.css';
import '~/common/styles/CodePrism.css';
import '~/common/styles/GithubMarkdown.css';
import '~/common/styles/NProgress.css';
import '~/common/styles/agi.effects.css';
import '~/common/styles/app.styles.css';
import { ProviderBackendCapabilities } from '~/common/providers/ProviderBackendCapabilities';
+1 -1
View File
@@ -25,7 +25,7 @@ import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapa
// stores access
import { getLLMsDebugInfo } from '~/modules/llms/store-llms';
import { useAppStateStore } from '~/common/state/store-appstate';
import { useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useFolderStore } from '~/common/state/store-folders';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
+2 -2
View File
@@ -13,7 +13,7 @@ import { withLayout } from '~/common/layout/withLayout';
function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
// external state
const { data, isError, error, isLoading } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
const { data, isError, error, isPending } = apiQuery.backend.exchangeOpenRouterKey.useQuery({ code: props.openRouterCode || '' }, {
enabled: !!props.openRouterCode,
refetchOnWindowFocus: false,
staleTime: Infinity,
@@ -56,7 +56,7 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
Welcome Back
</Typography>
{isLoading && <Typography level='body-sm'>Loading...</Typography>}
{isPending && <Typography level='body-sm'>Loading...</Typography>}
{isErrorInput && <InlineError error='There was an issue retrieving the code from OpenRouter.' />}
+10
View File
@@ -0,0 +1,10 @@
import * as React from 'react';
import { AppTokens } from '../src/apps/tokens/AppTokens';
import { withLayout } from '~/common/layout/withLayout';
export default function PersonasPage() {
return withLayout({ type: 'optima' }, <AppTokens />);
}
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -8,20 +8,21 @@ import { BeamView } from '~/modules/beam/BeamView';
import { createBeamVanillaStore } from '~/modules/beam/store-beam-vanilla';
import { useModelsStore } from '~/modules/llms/store-llms';
import { createDConversation, createDMessage, DConversation, DMessage } from '~/common/state/store-chats';
import { createDConversation, DConversation } from '~/common/stores/chat/chat.conversation';
import { createDMessageTextContent, DMessage } from '~/common/stores/chat/chat.message';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
function initTestConversation(): DConversation {
const conversation = createDConversation();
conversation.messages.push(createDMessage('system', 'You are a helpful assistant.'));
conversation.messages.push(createDMessage('user', 'Hello, who are you? (please expand...)'));
conversation.messages.push(createDMessageTextContent('system', 'You are a helpful assistant.')); // Beam Test - seed1
conversation.messages.push(createDMessageTextContent('user', 'Hello, who are you? (please expand...)')); // Beam Test - seed2
return conversation;
}
function initTestBeamStore(messages: DMessage[], beamStore: BeamStoreApi = createBeamVanillaStore()): BeamStoreApi {
beamStore.getState().open(messages, useModelsStore.getState().chatLLMId, (text) => alert(text));
beamStore.getState().open(messages, useModelsStore.getState().chatLLMId, (content) => alert(content));
return beamStore;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
import { Container, Sheet } from '@mui/joy';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { useRouterQuery } from '~/common/app.routes';
import { CallWizard } from './CallWizard';
+1 -1
View File
@@ -13,7 +13,7 @@ import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptim
import { animationColorRainbow } from '~/common/util/animUtils';
import { navigateBack } from '~/common/app.routes';
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useUICounter } from '~/common/state/store-ui';
+4 -4
View File
@@ -1,13 +1,13 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, Card, CardContent, Chip, IconButton, Link as MuiLink, ListDivider, MenuItem, Sheet, Switch, Typography } from '@mui/joy';
import CallIcon from '@mui/icons-material/Call';
import { DConversation, DConversationId, conversationTitle } from '~/common/stores/chat/chat.conversation';
import { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
import { animationShadowRingLimey } from '~/common/util/animUtils';
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
@@ -60,7 +60,7 @@ const ContactCardConversationCall = (props: { conversation: DConversation, onCon
function CallContactCard(props: {
persona: MockPersona,
callGrayUI: boolean,
conversations: DConversation[],
conversations: Readonly<DConversation[]>,
setCallIntent: (intent: AppCallIntent) => void,
}) {
@@ -189,7 +189,7 @@ function CallContactCard(props: {
function useConversationsByPersona() {
const conversations = useChatStore(state => state.conversations, shallow);
const conversations = useChatStore(state => state.conversations);
return React.useMemo(() => {
// group by personaId
+26 -15
View File
@@ -11,18 +11,20 @@ import RecordVoiceOverTwoToneIcon from '@mui/icons-material/RecordVoiceOverTwoTo
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
import { useChatLLMDropdown } from '../chat/components/useLLMDropdown';
import { useChatLLMDropdown } from '../chat/components/layout-bar/useLLMDropdown';
import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client';
import { SystemPurposeId, SystemPurposes } from '../../data';
import { llmStreamingChatGenerate, VChatMessageIn } from '~/modules/llms/llm.client';
import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVoiceDropdown';
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
import { Link } from '~/common/components/Link';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { conversationTitle } from '~/common/stores/chat/chat.conversation';
import { createDMessageTextContent, DMessage, messageFragmentsReduceText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message';
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
@@ -118,9 +120,9 @@ export function Telephone(props: {
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
setSpeechInterim(result.done ? null : { ...result });
if (result.done) {
const transcribed = result.transcript.trim();
if (transcribed.length >= 1)
setCallMessages(messages => [...messages, createDMessage('user', transcribed)]);
const userSpeechTranscribed = result.transcript.trim();
if (userSpeechTranscribed.length >= 1)
setCallMessages(messages => [...messages, createDMessageTextContent('user', userSpeechTranscribed)]); // [state] append user:speech
}
}, []);
const { isSpeechEnabled, isRecording, isRecordingAudio, isRecordingSpeech, startRecording, stopRecording, toggleRecording } = useSpeechRecognition(onSpeechResultCallback, 1000);
@@ -136,11 +138,11 @@ export function Telephone(props: {
// pickup / hangup
React.useEffect(() => {
!isRinging && playSoundUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
!isRinging && AudioPlayer.playUrl(isConnected ? '/sounds/chat-begin.mp3' : '/sounds/chat-end.mp3');
}, [isRinging, isConnected]);
// ringtone
usePlaySoundUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
AudioPlayer.usePlayUrl(isRinging ? '/sounds/chat-ringtone.mp3' : null, 300, 2800 * 2);
/// CONNECTED
@@ -169,7 +171,8 @@ export function Telephone(props: {
const phoneMessages = personaCallStarters || ['Hello?', 'Hey!'];
const firstMessage = phoneMessages[Math.floor(Math.random() * phoneMessages.length)];
setCallMessages([createDMessage('assistant', firstMessage)]);
setCallMessages([createDMessageTextContent('assistant', firstMessage)]); // [state] set assistant:hello message
// fire/forget
void EXPERIMENTAL_speakTextStream(firstMessage, personaVoiceId);
@@ -179,22 +182,30 @@ export function Telephone(props: {
// [E] persona streaming response - upon new user message
React.useEffect(() => {
// only act when we have a new user message
if (!isConnected || callMessages.length < 1 || callMessages[callMessages.length - 1].role !== 'user')
if (!isConnected || callMessages.length < 1)
return;
switch (callMessages[callMessages.length - 1].text) {
// Voice commands
const lastUserMessage = callMessages[callMessages.length - 1];
if (lastUserMessage.role !== 'user')
return;
switch (messageFragmentsReduceText(lastUserMessage.fragments)) {
// do not respond
case 'Stop.':
return;
// command: close the call
case 'Goodbye.':
setStage('ended');
setTimeout(launchAppChat, 2000);
return;
// command: regenerate answer
case 'Retry.':
case 'Try again.':
setCallMessages(messages => messages.slice(0, messages.length - 2));
return;
// command: restart chat
case 'Restart.':
setCallMessages([]);
@@ -206,7 +217,7 @@ export function Telephone(props: {
// temp fix: when the chat has no messages, only assume a single system message
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = (reMessages && reMessages.length > 0)
? reMessages
? reMessages.map(message => ({ role: message.role, text: messageSingleTextOrThrow(message) }))
: personaSystemMessage
? [{ role: 'system', text: personaSystemMessage }]
: [];
@@ -217,7 +228,7 @@ export function Telephone(props: {
{ role: 'system', content: 'You are having a phone call. Your response style is brief and to the point, and according to your personality, defined below.' },
...chatMessages.map(message => ({ role: message.role, content: message.text })),
{ role: 'system', content: 'You are now on the phone call related to the chat above. Respect your personality and answer with short, friendly and accurate thoughtful lines.' },
...callMessages.map(message => ({ role: message.role, content: message.text })),
...callMessages.map(message => ({ role: message.role, content: messageSingleTextOrThrow(message) })),
];
// perform completion
@@ -237,7 +248,7 @@ export function Telephone(props: {
}).finally(() => {
setPersonaTextInterim(null);
if (finalText || error)
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
setCallMessages(messages => [...messages, createDMessageTextContent('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]); // [state] append assistant:call_response
// fire/forget
if (finalText?.length >= 1)
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
@@ -339,7 +350,7 @@ export function Telephone(props: {
{callMessages.map((message) =>
<CallMessage
key={message.id}
text={message.text}
text={messageSingleTextOrThrow(message)}
variant={message.role === 'assistant' ? 'solid' : 'soft'}
color={message.role === 'assistant' ? 'neutral' : 'primary'}
role={message.role}
+77 -79
View File
@@ -17,13 +17,16 @@ import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
import { DConversation, DConversationId } from '~/common/stores/chat/chat.conversation';
import { DMessageAttachmentFragment, DMessageContentFragment, duplicateDMessageFragments } from '~/common/stores/chat/chat.fragments';
import { GlobalShortcutDefinition, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
import { PanelResizeInset } from '~/common/components/panes/GoodPanelResizeHandler';
import { PreferencesTab, useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, DMessageMetadata, getConversation, getConversationSystemPurposeId, useConversation } from '~/common/state/store-chats';
import { createDMessageFromFragments, createDMessageTextContent, DMessageMetadata, duplicateDMessageMetadata } from '~/common/stores/chat/chat.message';
import { getConversation, getConversationSystemPurposeId, useConversation } from '~/common/stores/chat/store-chats';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { useFolderStore } from '~/common/state/store-folders';
import { useIsMobile } from '~/common/components/useMatchMedia';
@@ -31,35 +34,26 @@ import { useRouterQuery } from '~/common/app.routes';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
import { ChatBarAltBeam } from './components/ChatBarAltBeam';
import { ChatBarAltTitle } from './components/ChatBarAltTitle';
import { ChatBarDropdowns } from './components/ChatBarDropdowns';
import { ChatBarAltBeam } from './components/layout-bar/ChatBarAltBeam';
import { ChatBarAltTitle } from './components/layout-bar/ChatBarAltTitle';
import { ChatBarDropdowns } from './components/layout-bar/ChatBarDropdowns';
import { ChatBeamWrapper } from './components/ChatBeamWrapper';
import { ChatDrawerMemo } from './components/ChatDrawer';
import { ChatDrawerMemo } from './components/layout-drawer/ChatDrawer';
import { ChatMessageList } from './components/ChatMessageList';
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
import { ChatPageMenuItems } from './components/layout-menu/ChatPageMenuItems';
import { Composer } from './components/composer/Composer';
import { usePanesManager } from './components/panes/usePanesManager';
import type { ChatExecuteMode } from './execute-mode/execute-mode.types';
import { _handleExecute } from './editors/_handleExecute';
import { gcChatImageAssets } from './editors/image-generate';
// what to say when a chat is new and has no title
export const CHAT_NOVEL_TITLE = 'Chat';
/**
* Mode: how to treat the input from the Composer
*/
export type ChatModeId =
| 'generate-text'
| 'generate-text-beam'
| 'append-user'
| 'generate-image'
| 'generate-react';
export interface AppChatIntent {
initialConversationId: string | null;
}
@@ -118,19 +112,23 @@ export function AppChat() {
setFocusedPaneIndex,
} = usePanesManager();
const chatHandlers = React.useMemo(() => chatPanes.map(pane => {
return pane.conversationId ? ConversationsManager.getHandler(pane.conversationId) : null;
}), [chatPanes]);
const { paneUniqueConversationIds, paneHandlers, paneBeamStores } = React.useMemo(() => {
const paneConversationIds: (DConversationId | null)[] = chatPanes.map(pane => pane.conversationId || null);
const paneHandlers = paneConversationIds.map(cId => cId ? ConversationsManager.getHandler(cId) : null);
const paneBeamStores = paneHandlers.map(handler => handler?.getBeamStore() ?? null);
const paneUniqueConversationIds = Array.from(new Set(paneConversationIds.filter(Boolean))) as DConversationId[];
return {
paneHandlers: paneHandlers,
paneBeamStores: paneBeamStores,
paneUniqueConversationIds: paneUniqueConversationIds,
};
}, [chatPanes]);
const beamsStores = React.useMemo(() => chatHandlers.map(handler => {
return handler?.getBeamStore() ?? null;
}), [chatHandlers]);
const beamsOpens = useAreBeamsOpen(beamsStores);
const beamsOpens = useAreBeamsOpen(paneBeamStores);
const beamOpenStoreInFocusedPane = React.useMemo(() => {
const open = focusedPaneIndex !== null ? (beamsOpens?.[focusedPaneIndex] ?? false) : false;
return open ? beamsStores?.[focusedPaneIndex!] ?? null : null;
}, [beamsOpens, beamsStores, focusedPaneIndex]);
return open ? paneBeamStores?.[focusedPaneIndex!] ?? null : null;
}, [beamsOpens, focusedPaneIndex, paneBeamStores]);
const {
// focused
@@ -162,7 +160,7 @@ export function AppChat() {
const isMultiPane = chatPanes.length >= 2;
const isMultiAddable = chatPanes.length < 4;
const isMultiConversationId = isMultiPane && new Set(chatPanes.map((pane) => pane.conversationId)).size >= 2;
const isMultiConversationId = paneUniqueConversationIds.length >= 2;
const willMulticast = isComposerMulticast && isMultiConversationId;
const disableNewButton = isFocusedChatEmpty && !isMultiPane;
@@ -197,8 +195,8 @@ export function AppChat() {
// Execution
const handleExecuteAndOutcome = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
const outcome = await _handleExecute(chatModeId, conversationId, history);
const handleExecuteAndOutcome = React.useCallback(async (chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, callerNameDebug: string) => {
const outcome = await _handleExecute(chatExecuteMode, conversationId, callerNameDebug);
if (outcome === 'err-no-chatllm')
openModelsSetup();
else if (outcome === 'err-t2i-unconfigured')
@@ -207,55 +205,52 @@ export function AppChat() {
addSnackbar({ key: 'chat-no-persona', message: 'No persona selected.', type: 'issue' });
else if (outcome === 'err-no-conversation')
addSnackbar({ key: 'chat-no-conversation', message: 'No active conversation.', type: 'issue' });
else if (outcome === 'err-no-last-message')
addSnackbar({ key: 'chat-no-conversation', message: 'No conversation history.', type: 'issue' });
return outcome === true;
}, [openModelsSetup, openPreferencesTab]);
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: ComposerOutputMultiPart, metadata?: DMessageMetadata): boolean => {
// 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;
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => {
// multicast: send the message to all the panes
const uniqueConversationIds = new Set([conversationId]);
if (willMulticast)
chatPanes.forEach(pane => pane.conversationId && uniqueConversationIds.add(pane.conversationId));
// [multicast] send the message to all the panes
const uniqueConversationIds = willMulticast
? Array.from(new Set([conversationId, ...paneUniqueConversationIds]))
: [conversationId];
// validate conversation existence
const uniqueConverations = uniqueConversationIds.map(cId => getConversation(cId)).filter(Boolean) as DConversation[];
if (!uniqueConverations.length)
return false;
// we loop to handle both the normal and multicast modes
let enqueuedAny = false;
for (const _cId of uniqueConversationIds) {
const history = getConversation(_cId)?.messages;
if (!history) continue;
for (const conversation of uniqueConverations) {
const newUserMessage = createDMessage('user', userText);
if (metadata) newUserMessage.metadata = metadata;
// create the user:message
// NOTE: this can lead to multiple chat messages with data refs that are referring to the same dblobs,
// however, we already got transferred ownership of the dblobs at this point.
const userMessage = createDMessageFromFragments('user', duplicateDMessageFragments(fragments)); // [chat] create user:message
if (metadata) userMessage.metadata = duplicateDMessageMetadata(metadata);
ConversationsManager.getHandler(conversation.id).messageAppend(userMessage); // [chat] append user message in each conversation
// fire/forget
void handleExecuteAndOutcome(chatModeId, _cId, [...history, newUserMessage]);
enqueuedAny = true;
void handleExecuteAndOutcome(chatExecuteMode /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*'
}
return enqueuedAny;
}, [chatPanes, handleExecuteAndOutcome, willMulticast]);
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]) => {
await handleExecuteAndOutcome('generate-text', conversationId, history);
return true;
}, [paneUniqueConversationIds, handleExecuteAndOutcome, willMulticast]);
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId) => {
await handleExecuteAndOutcome('generate-content', conversationId, 'chat-execute-history'); // replace with 'history', then 'generate-text'
}, [handleExecuteAndOutcome]);
const handleMessageRegenerateLastInFocusedPane = React.useCallback(async () => {
const focusedConversation = getConversation(focusedPaneConversationId);
if (focusedConversation?.messages?.length) {
if (focusedPaneConversationId && focusedConversation?.messages?.length) {
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
const history = lastMessage.role === 'assistant' ? focusedConversation.messages.slice(0, -1) : [...focusedConversation.messages];
await handleExecuteAndOutcome('generate-text', focusedConversation.id, history);
if (lastMessage.role === 'assistant')
ConversationsManager.getHandler(focusedPaneConversationId).historyTruncateTo(lastMessage.id, -1);
await handleExecuteAndOutcome('generate-content', focusedConversation.id, 'chat-regenerate-last'); // truncate if assistant, then gen-text
}
}, [focusedPaneConversationId, handleExecuteAndOutcome]);
@@ -273,15 +268,14 @@ export function AppChat() {
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string) => {
const handleImagineFromText = React.useCallback(async (conversationId: DConversationId, messageText: string) => {
const conversation = getConversation(conversationId);
if (!conversation)
return;
const imaginedPrompt = await imaginePromptFromText(messageText, conversationId) || 'An error sign.';
await handleExecuteAndOutcome('generate-image', conversationId, [
...conversation.messages,
createDMessage('user', imaginedPrompt),
]);
const imaginePrompMessage = createDMessageTextContent('user', imaginedPrompt);
ConversationsManager.getHandler(conversationId).messageAppend(imaginePrompMessage); // [chat] append user:imagine prompt
await handleExecuteAndOutcome('generate-image', conversationId, 'chat-imagine-from-text'); // append message for 'imagine', then generate-image
}, [handleExecuteAndOutcome]);
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
@@ -364,7 +358,7 @@ export function AppChat() {
const handleConfirmedClearConversation = React.useCallback(() => {
if (clearConversationId) {
ConversationsManager.getHandler(clearConversationId).messagesReplace([]);
ConversationsManager.getHandler(clearConversationId).historyClear();
setClearConversationId(null);
}
}, [clearConversationId]);
@@ -382,6 +376,9 @@ export function AppChat() {
handleOpenConversationInFocusedPane(nextConversationId);
setDeleteConversationIds(null);
// run GC for dblobs in this conversation
void gcChatImageAssets(); // fire/forget
}, [deleteConversations, handleOpenConversationInFocusedPane]);
const handleConfirmedDeleteConversations = React.useCallback(() => {
@@ -397,7 +394,7 @@ export function AppChat() {
openLlmOptions(chatLLMId);
}, [openLlmOptions]);
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
const shortcuts = React.useMemo((): GlobalShortcutDefinition[] => [
// focused conversation
['b', true, true, false, handleMessageBeamLastInFocusedPane],
['r', true, true, false, handleMessageRegenerateLastInFocusedPane],
@@ -434,7 +431,7 @@ export function AppChat() {
isMobile={isMobile}
activeConversationId={focusedPaneConversationId}
activeFolderId={activeFolderId}
chatPanesConversationIds={chatPanes.map(pane => pane.conversationId).filter(Boolean) as DConversationId[]}
chatPanesConversationIds={paneUniqueConversationIds}
disableNewButton={disableNewButton}
onConversationActivate={handleOpenConversationInFocusedPane}
onConversationBranch={handleConversationBranch}
@@ -444,7 +441,7 @@ export function AppChat() {
onConversationsImportDialog={handleConversationImportDialog}
setActiveFolderId={setActiveFolderId}
/>,
[activeFolderId, chatPanes, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile],
[activeFolderId, disableNewButton, focusedPaneConversationId, handleConversationBranch, handleConversationExport, handleConversationImportDialog, handleConversationNewInFocusedPane, handleDeleteConversations, handleOpenConversationInFocusedPane, isMobile, paneUniqueConversationIds],
);
const focusedMenuItems = React.useMemo(() =>
@@ -476,8 +473,8 @@ export function AppChat() {
{chatPanes.map((pane, idx) => {
const _paneIsFocused = idx === focusedPaneIndex;
const _paneConversationId = pane.conversationId;
const _paneChatHandler = chatHandlers[idx] ?? null;
const _paneBeamStore = beamsStores[idx] ?? null;
const _paneChatHandler = paneHandlers[idx] ?? null;
const _paneBeamStore = paneBeamStores[idx] ?? null;
const _paneBeamIsOpen = !!beamsOpens?.[idx] && !!_paneBeamStore;
const _panesCount = chatPanes.length;
const _keyAndId = `chat-pane-${pane.paneId}`;
@@ -536,12 +533,13 @@ export function AppChat() {
capabilityHasT2I={capabilityHasT2I}
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
fitScreen={isMobile || isMultiPane}
isMobile={isMobile}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextImagine={handleImagineFromText}
onTextSpeak={handleTextSpeak}
sx={{
flexGrow: 1,
@@ -583,12 +581,12 @@ export function AppChat() {
isMobile={isMobile}
chatLLM={chatLLM}
composerTextAreaRef={composerTextAreaRef}
conversationId={focusedPaneConversationId}
targetConversationId={focusedPaneConversationId}
capabilityHasT2I={capabilityHasT2I}
isMulticast={!isMultiConversationId ? null : isComposerMulticast}
isDeveloperMode={isFocusedChatDeveloper}
onAction={handleComposerAction}
onTextImagine={handleTextImagine}
onTextImagine={handleImagineFromText}
setIsMulticast={setIsComposerMulticast}
sx={beamOpenStoreInFocusedPane ? composerClosedSx : composerOpenSx}
/>
+3 -3
View File
@@ -3,18 +3,18 @@ import ClearIcon from '@mui/icons-material/Clear';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsAlter: ICommandsProvider = {
id: 'chat-alter',
id: 'cmd-chat-alter',
rank: 25,
getCommands: () => [{
primary: '/assistant',
alternatives: ['/a'],
arguments: ['text'],
arguments: ['text...'],
description: 'Injects assistant response',
}, {
primary: '/system',
alternatives: ['/s'],
arguments: ['text'],
arguments: ['text...'],
description: 'Injects system message',
}, {
primary: '/clear',
+1 -1
View File
@@ -3,7 +3,7 @@ import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsBeam: ICommandsProvider = {
id: 'mode-beam',
id: 'cmd-mode-beam',
rank: 9,
getCommands: () => [{
+1 -1
View File
@@ -3,7 +3,7 @@ import LanguageIcon from '@mui/icons-material/Language';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsBrowse: ICommandsProvider = {
id: 'ass-browse',
id: 'cmd-ass-browse',
rank: 20,
getCommands: () => [{
+5 -1
View File
@@ -2,8 +2,12 @@ import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
import type { ICommandsProvider } from './ICommandsProvider';
export function textToDrawCommand(text: string): string {
return `/draw ${text}`;
}
export const CommandsDraw: ICommandsProvider = {
id: 'ass-t2i',
id: 'cmd-ass-t2i',
rank: 10,
getCommands: () => [{
+1 -1
View File
@@ -3,7 +3,7 @@ import PsychologyIcon from '@mui/icons-material/Psychology';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsReact: ICommandsProvider = {
id: 'ass-react',
id: 'cmd-mode-react',
rank: 15,
getCommands: () => [{
+18 -12
View File
@@ -8,20 +8,20 @@ import { CommandsHelp } from './CommandsHelp';
import { CommandsReact } from './CommandsReact';
export type CommandsProviderId = 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help' | 'mode-beam';
export type CommandsProviderId = 'cmd-ass-browse' | 'cmd-ass-t2i' | 'cmd-chat-alter' | 'cmd-help' | 'cmd-mode-beam' | 'cmd-mode-react';
type TextCommandPiece =
| { type: 'text'; value: string; }
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isError?: boolean };
| { type: 'nocmd'; value: string; }
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isErrorNoArgs?: boolean };
const ChatCommandsProviders: Record<CommandsProviderId, ICommandsProvider> = {
'ass-browse': CommandsBrowse,
'ass-react': CommandsReact,
'ass-t2i': CommandsDraw,
'chat-alter': CommandsAlter,
'cmd-ass-browse': CommandsBrowse,
'cmd-ass-t2i': CommandsDraw,
'cmd-chat-alter': CommandsAlter,
'cmd-help': CommandsHelp,
'mode-beam': CommandsBeam,
'cmd-mode-beam': CommandsBeam,
'cmd-mode-react': CommandsReact,
};
export function findAllChatCommands(): ChatCommand[] {
@@ -31,12 +31,18 @@ export function findAllChatCommands(): ChatCommand[] {
.flat();
}
export function helpPrettyChatCommands() {
return findAllChatCommands()
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
.join('\n');
}
export function extractChatCommand(input: string): TextCommandPiece[] {
const inputTrimmed = input.trim();
// quick exit: command does not start with '/'
if (!inputTrimmed.startsWith('/'))
return [{ type: 'text', value: input }];
return [{ type: 'nocmd', value: input }];
// Find the first space to separate the command from its parameters (if any)
const firstSpaceIndex = inputTrimmed.indexOf(' ');
@@ -56,7 +62,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
providerId: provider.id,
command: potentialCommand,
params: textAfterCommand || undefined,
isError: !textAfterCommand || undefined,
isErrorNoArgs: !textAfterCommand,
}];
// command without arguments, treat any text after as a separate text piece
@@ -67,7 +73,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
params: undefined,
}];
textAfterCommand && pieces.push({
type: 'text',
type: 'nocmd',
value: textAfterCommand,
});
return pieces;
@@ -77,7 +83,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
// No command found, return the entire input as text
return [{
type: 'text',
type: 'nocmd',
value: input,
}];
}
+74 -63
View File
@@ -7,10 +7,13 @@ import { Box, List } from '@mui/joy';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import type { DMessageFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
import { InlineError } from '~/common/components/InlineError';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { createDMessage, DConversationId, DMessage, DMessageUserFlag, getConversation, messageToggleUserFlag, useChatStore } from '~/common/state/store-chats';
import { ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
import { createDMessageTextContent, DMessageId, DMessageUserFlag, messageToggleUserFlag } from '~/common/stores/chat/chat.message';
import { getConversation, useChatStore } from '~/common/stores/chat/store-chats';
import { useBrowserTranslationWarning } from '~/common/components/useIsBrowserTranslating';
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useEphemerals } from '~/common/chats/EphemeralsStore';
@@ -20,7 +23,7 @@ import { ChatMessage, ChatMessageMemo } from './message/ChatMessage';
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
import { Ephemerals } from './Ephemerals';
import { PersonaSelector } from './persona-selector/PersonaSelector';
import { useChatShowSystemMessages } from '../store-app-chat';
import { useChatAutoSuggestHTMLUI, useChatShowSystemMessages } from '../store-app-chat';
/**
@@ -32,9 +35,10 @@ export function ChatMessageList(props: {
capabilityHasT2I: boolean,
chatLLMContextTokens: number | null,
fitScreen: boolean,
isMobile: boolean,
isMessageSelectionMode: boolean,
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
onConversationExecuteHistory: (conversationId: DConversationId) => Promise<void>,
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
onTextSpeak: (selectedText: string) => Promise<void>,
@@ -50,43 +54,43 @@ export function ChatMessageList(props: {
// external state
const { notifyBooting } = useScrollToBottom();
const { openPreferencesTab } = useOptimaLayout();
const danger_experimentalHtmlWebUi = useChatAutoSuggestHTMLUI();
const [showSystemMessages] = useChatShowSystemMessages();
const optionalTranslationWarning = useBrowserTranslationWarning();
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(useShallow(state => {
const { conversationMessages, historyTokenCount } = useChatStore(useShallow(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
conversationMessages: conversation ? conversation.messages : [],
historyTokenCount: conversation ? conversation.tokenCount : 0,
deleteMessage: state.deleteMessage,
editMessage: state.editMessage,
setMessages: state.setMessages,
};
}));
const ephemerals = useEphemerals(props.conversationHandler);
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
// derived state
const { conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
const { conversationHandler, conversationId, capabilityHasT2I, onConversationBranch, onConversationExecuteHistory, onTextDiagram, onTextImagine, onTextSpeak } = props;
// text actions
const handleRunExample = React.useCallback(async (examplePrompt: string) => {
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', examplePrompt)]);
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
if (conversationId && conversationHandler) {
conversationHandler.messageAppend(createDMessageTextContent('user', examplePrompt)); // [chat] append user:persona question
await onConversationExecuteHistory(conversationId);
}
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
// message menu methods proxy
const handleMessageAssistantFrom = React.useCallback(async (messageId: string, offset: number) => {
const messages = getConversation(conversationId)?.messages;
if (messages) {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
conversationId && await onConversationExecuteHistory(conversationId, truncatedHistory);
const handleMessageAssistantFrom = React.useCallback(async (messageId: DMessageId, offset: number) => {
if (conversationId && conversationHandler) {
conversationHandler.historyTruncateTo(messageId, offset);
await onConversationExecuteHistory(conversationId);
}
}, [conversationId, onConversationExecuteHistory]);
}, [conversationHandler, conversationId, onConversationExecuteHistory]);
const handleMessageBeam = React.useCallback(async (messageId: string) => {
const handleMessageBeam = React.useCallback(async (messageId: DMessageId) => {
// Right-click menu Beam
if (!conversationId || !props.conversationHandler) return;
const messages = getConversation(conversationId)?.messages;
@@ -110,37 +114,41 @@ export function ChatMessageList(props: {
}
}, [conversationId, props.conversationHandler]);
const handleMessageBranch = React.useCallback((messageId: string) => {
const handleMessageBranch = React.useCallback((messageId: DMessageId) => {
conversationId && onConversationBranch(conversationId, messageId);
}, [conversationId, onConversationBranch]);
const handleMessageTruncate = 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 handleMessageTruncate = React.useCallback((messageId: DMessageId) => {
props.conversationHandler?.historyTruncateTo(messageId, 0);
}, [props.conversationHandler]);
const handleMessageDelete = React.useCallback((messageId: string) => {
conversationId && deleteMessage(conversationId, messageId);
}, [conversationId, deleteMessage]);
const handleMessageDelete = React.useCallback((messageId: DMessageId) => {
props.conversationHandler?.messagesDelete([messageId]);
}, [props.conversationHandler]);
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
}, [conversationId, editMessage]);
const handleMessageAppendFragment = React.useCallback((messageId: DMessageId, fragment: DMessageFragment) => {
props.conversationHandler?.messageFragmentAppend(messageId, fragment, false, false);
}, [props.conversationHandler]);
const handleMessageToggleUserFlag = React.useCallback((messageId: string, userFlag: DMessageUserFlag) => {
conversationId && editMessage(conversationId, messageId, (message) => ({
const handleMessageDeleteFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId) => {
props.conversationHandler?.messageFragmentDelete(messageId, fragmentId, false, true);
}, [props.conversationHandler]);
const handleMessageReplaceFragment = React.useCallback((messageId: DMessageId, fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => {
props.conversationHandler?.messageFragmentReplace(messageId, fragmentId, newFragment, false);
}, [props.conversationHandler]);
const handleMessageToggleUserFlag = React.useCallback((messageId: DMessageId, userFlag: DMessageUserFlag) => {
props.conversationHandler?.messageEdit(messageId, (message) => ({
userFlags: messageToggleUserFlag(message, userFlag),
}), false);
}, [conversationId, editMessage]);
}), false, false);
}, [props.conversationHandler]);
const handleReplyTo = React.useCallback((_messageId: string, text: string) => {
const handleReplyTo = React.useCallback((_messageId: DMessageId, text: string) => {
props.conversationHandler?.getOverlayStore().getState().setReplyToText(text);
}, [props.conversationHandler]);
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
const handleTextDiagram = React.useCallback(async (messageId: DMessageId, text: string) => {
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
}, [conversationId, onTextDiagram]);
@@ -173,36 +181,35 @@ export function ChatMessageList(props: {
setSelectedMessages(newSelected);
};
const handleSelectMessage = (messageId: string, selected: boolean) => {
const handleSelectMessage = (messageId: DMessageId, selected: boolean) => {
const newSelected = new Set(selectedMessages);
selected ? newSelected.add(messageId) : newSelected.delete(messageId);
setSelectedMessages(newSelected);
};
const handleSelectionDelete = () => {
if (conversationId)
for (const selectedMessage of selectedMessages)
deleteMessage(conversationId, selectedMessage);
const handleSelectionDelete = React.useCallback(() => {
props.conversationHandler?.messagesDelete(Array.from(selectedMessages));
setSelectedMessages(new Set());
};
}, [props.conversationHandler, selectedMessages]);
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
useGlobalShortcuts([[props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
props.setIsMessageSelectionMode(false);
});
}]]);
// text-diff functionality: only diff the last message and when it's complete (not typing), and they're similar in size
// text-diff functionality: only diff the last complete message, and they're similar in size
const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
if (msgB?.text && msgA?.text && !msgB?.typing) {
const textA = msgA.text, textB = msgB.text;
const lenA = textA.length, lenB = textB.length;
if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
return { diffTargetMessage: msgB, diffPrevText: textA };
}
return { diffTargetMessage: undefined, diffPrevText: undefined };
}, [conversationMessages]);
// const { diffTargetMessage, diffPrevText } = React.useMemo(() => {
// const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
// const textB = msgB ? singleTextOrThrow(msgB) : undefined;
// const textA = msgA ? singleTextOrThrow(msgA) : undefined;
// if (textB && textA && !msgB?.pendingIncomplete) {
// const lenA = textA.length, lenB = textB.length;
// if (lenA > 80 && lenB > 80 && lenA > lenB / 3 && lenB > lenA / 3)
// return { diffTargetMessage: msgB, diffPrevText: textA };
// }
// return { diffTargetMessage: undefined, diffPrevText: undefined };
// }, [conversationMessages]);
// scroll to the very bottom of a new chat
@@ -228,7 +235,7 @@ export function ChatMessageList(props: {
);
return (
<List sx={{
<List role='chat-messages-list' sx={{
p: 0,
...(props.sx || {}),
@@ -254,8 +261,8 @@ export function ChatMessageList(props: {
{filteredMessages.map((message, idx, { length: count }) => {
// Optimization: if the component is going to change (e.g. the message is typing), we don't want to memoize it to not throw garbage in memory
const ChatMessageMemoOrNot = message.typing ? ChatMessage : ChatMessageMemo;
// Optimization: only memo complete components, or we'd be memoizing garbage
const ChatMessageMemoOrNot = !message.pendingIncomplete ? ChatMessageMemo : ChatMessage;
return props.isMessageSelectionMode ? (
@@ -271,19 +278,23 @@ export function ChatMessageList(props: {
<ChatMessageMemoOrNot
key={'msg-' + message.id}
message={message}
diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
// diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
fitScreen={props.fitScreen}
isMobile={props.isMobile}
isBottom={idx === count - 1}
isImagining={isImagining}
isSpeaking={isSpeaking}
showUnsafeHtml={danger_experimentalHtmlWebUi}
onMessageAssistantFrom={handleMessageAssistantFrom}
onMessageBeam={handleMessageBeam}
onMessageBranch={handleMessageBranch}
onMessageDelete={handleMessageDelete}
onMessageEdit={handleMessageEdit}
onMessageFragmentAppend={handleMessageAppendFragment}
onMessageFragmentDelete={handleMessageDeleteFragment}
onMessageFragmentReplace={handleMessageReplaceFragment}
onMessageToggleUserFlag={handleMessageToggleUserFlag}
onMessageTruncate={handleMessageTruncate}
// onReplyTo={handleReplyTo}
onReplyTo={handleReplyTo}
onTextDiagram={handleTextDiagram}
onTextImagine={capabilityHasT2I ? handleTextImagine : undefined}
onTextSpeak={isSpeakable ? handleTextSpeak : undefined}
+2 -2
View File
@@ -4,9 +4,9 @@ import { Box, Grid, IconButton, Sheet, styled, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import type { DEphemeral } from '~/common/chats/EphemeralsStore';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { DConversationId } from '~/common/state/store-chats';
import { DEphemeral } from '~/common/chats/EphemeralsStore';
import { lineHeightChatTextMd } from '~/common/app.theme';
@@ -1,99 +0,0 @@
import * as React from 'react';
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ChatModeId } from '../../AppChat';
interface ChatModeDescription {
label: string;
description: string | React.JSX.Element;
highlight?: boolean;
shortcut?: string;
hideOnDesktop?: boolean;
requiresTTI?: boolean;
}
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
'generate-text': {
label: 'Chat',
description: 'Persona replies',
},
'generate-text-beam': {
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
description: 'Combine multiple models', // Smarter: combine...
shortcut: 'Ctrl + Enter',
hideOnDesktop: true,
},
'append-user': {
label: 'Write',
description: 'Append a message',
shortcut: 'Alt + Enter',
},
'generate-image': {
label: 'Draw',
description: 'AI Image Generation',
requiresTTI: true,
},
'generate-react': {
label: 'Reason + Act', // · α
description: 'Answer questions in multiple steps',
},
};
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
if (shortcut === 'ENTER')
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
return shortcut;
}
export function ChatModeMenu(props: {
isMobile: boolean,
anchorEl: HTMLAnchorElement | null,
onClose: () => void,
chatModeId: ChatModeId,
onSetChatModeId: (chatMode: ChatModeId) => void,
capabilityHasTTI: boolean,
}) {
// external state
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
return (
<CloseableMenu
placement='top-end'
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/* ChatMode items */}
{Object.entries(ChatModeItems)
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
.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 color={data.highlight ? 'success' : undefined} checked={key === props.chatModeId} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
</Box>
{(key === props.chatModeId || !!data.shortcut) && (
<KeyStroke combo={platformAwareKeystrokes(fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline))} />
)}
</Box>
</MenuItem>)}
</CloseableMenu>
);
}
+238 -226
View File
@@ -15,31 +15,33 @@ import SendIcon from '@mui/icons-material/Send';
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
import TelegramIcon from '@mui/icons-material/Telegram';
import type { ChatModeId } from '../../AppChat';
import { useChatMicTimeoutMsValue } from '../../store-app-chat';
import type { DLLM } from '~/modules/llms/store-llms';
import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vendor';
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
import { AudioPlayer } from '~/common/util/audio/AudioPlayer';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { DMessageMetadata, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { animationEnterBelow } from '~/common/util/animUtils';
import { conversationTitle, DConversationId, DMessageMetadata, getConversation, useChatStore } from '~/common/state/store-chats';
import { countModelTokens } from '~/common/util/token-counter';
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
import { copyToClipboard, supportsClipboardRead } from '~/common/util/clipboardUtils';
import { createTextContentFragment, DMessageAttachmentFragment, DMessageContentFragment, duplicateDMessageFragments, isContentFragment } from '~/common/stores/chat/chat.fragments';
import { estimateTextTokens, glueForMessageTokens, marshallWrapDocFragments } from '~/common/stores/chat/chat.tokens';
import { getConversation, isValidConversation, useChatStore } from '~/common/stores/chat/store-chats';
import { isMacUser } from '~/common/util/pwaUtils';
import { launchAppCall } from '~/common/app.routes';
import { lineHeightTextareaMd } from '~/common/app.theme';
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
import { playSoundUrl } from '~/common/util/audioUtils';
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
import { useAppStateStore } from '~/common/state/store-appstate';
import { useChatOverlayStore } from '~/common/chats/store-chat-overlay-vanilla';
import { useChatOverlayStore } from '~/common/chats/store-chat-overlay';
import { useDebouncer } from '~/common/components/useDebouncer';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
@@ -48,12 +50,14 @@ import { providerCommands } from './actile/providerCommands';
import { providerStarredMessage, StarredMessageItem } from './actile/providerStarredMessage';
import { useActileManager } from './actile/useActileManager';
import type { AttachmentId } from './attachments/store-attachments';
import { Attachments } from './attachments/Attachments';
import { getSingleTextBlockText, useLLMAttachments } from './attachments/useLLMAttachments';
import { useAttachments } from './attachments/useAttachments';
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
import { LLMAttachmentDraftsAction, LLMAttachmentsList } from './llmattachments/LLMAttachmentsList';
import { useAttachmentDrafts } from '~/common/attachment-drafts/useAttachmentDrafts';
import { useLLMAttachmentDrafts } from './llmattachments/useLLMAttachmentDrafts';
import type { ChatExecuteMode } from '../../execute-mode/execute-mode.types';
import { chatExecuteModeCanAttach, useChatExecuteMode } from '../../execute-mode/useChatExecuteMode';
import type { ComposerOutputMultiPart } from './composer.types';
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
@@ -64,7 +68,6 @@ import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
import { ButtonMicMemo } from './buttons/ButtonMic';
import { ButtonMultiChatMemo } from './buttons/ButtonMultiChat';
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
import { ChatModeMenu } from './ChatModeMenu';
import { ReplyToBubble } from '../message/ReplyToBubble';
import { TokenBadgeMemo } from './TokenBadge';
import { TokenProgressbarMemo } from './TokenProgressbar';
@@ -97,23 +100,26 @@ export function Composer(props: {
isMobile?: boolean;
chatLLM: DLLM | null;
composerTextAreaRef: React.RefObject<HTMLTextAreaElement>;
conversationId: DConversationId | null;
targetConversationId: DConversationId | null;
capabilityHasT2I: boolean;
isMulticast: boolean | null;
isDeveloperMode: boolean;
onAction: (conversationId: DConversationId, chatModeId: ChatModeId, multiPartMessage: ComposerOutputMultiPart, metadata?: DMessageMetadata) => boolean;
onAction: (conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata) => boolean;
onTextImagine: (conversationId: DConversationId, text: string) => void;
setIsMulticast: (on: boolean) => void;
sx?: SxProps;
}) {
// state
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
const [composeText, debouncedText, setComposeText] = useDebouncer('', 300, 1200, true);
const [micContinuation, setMicContinuation] = React.useState(false);
const [speechInterimResult, setSpeechInterimResult] = React.useState<SpeechResult | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
const {
chatExecuteMode,
chatExecuteModeSendColor, chatExecuteModeSendLabel,
chatExecuteMenuComponent, chatExecuteMenuShown, showChatExecuteMenu,
} = useChatExecuteMode(props.capabilityHasT2I, !!props.isMobile);
// external state
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
@@ -129,47 +135,60 @@ export function Composer(props: {
const [startupText, setStartupText] = useComposerStartupText();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(useShallow(state => {
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, abortConversationTemp } = useChatStore(useShallow(state => {
const conversation = state.conversations.find(_c => _c.id === props.targetConversationId);
return {
assistantAbortible: conversation ? !!conversation.abortController : false,
systemPurposeId: conversation?.systemPurposeId ?? null,
tokenCount: conversation ? conversation.tokenCount : 0,
stopTyping: state.stopTyping,
abortConversationTemp: state.abortConversationTemp,
};
}));
const { inComposer: browsingInComposer } = useBrowseCapability();
const { attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoMessage, attachAppendFile, attachments: _attachments, clearAttachments, removeAttachment } =
useAttachments(browsingInComposer && !composeText.startsWith('/'));
// external overlay state (extra conversationId-dependent state)
const conversationHandler = props.conversationId ? ConversationsManager.getHandler(props.conversationId) : null;
const conversationOverlayStore = conversationHandler?.getOverlayStore() ?? null;
const conversationOverlayStore = props.targetConversationId
? ConversationsManager.getHandler(props.targetConversationId)?.getOverlayStore() || null
: null;
// composer-overlay: for the reply-to state, comes from the conversation overlay
const { replyToGenerateText } = useChatOverlayStore(conversationOverlayStore, useShallow(store => ({
replyToGenerateText: chatModeId === 'generate-text' ? store.replyToText?.trim() || null : null,
replyToGenerateText: (chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1') ? store.replyToText?.trim() || null : null,
})));
// don't load URLs if the user is typing a command or there's no capability
const enableLoadURLsInComposer = useBrowseCapability().inComposer && !composeText.startsWith('/');
// attachments-overlay: comes from the attachments slice of the conversation overlay
const {
/* items */ attachmentDrafts,
/* append */ attachAppendClipboardItems, attachAppendDataTransfer, attachAppendEgoFragments, attachAppendFile,
/* take */ attachmentsRemoveAll, attachmentsTakeAllFragments, attachmentsTakeFragmentsByType,
} = useAttachmentDrafts(conversationOverlayStore, enableLoadURLsInComposer);
// attachments derived state
const llmAttachmentDrafts = useLLMAttachmentDrafts(attachmentDrafts, props.chatLLM);
// derived state
const { composerTextAreaRef, targetConversationId, onAction, onTextImagine } = props;
const isMobile = !!props.isMobile;
const isDesktop = !props.isMobile;
const chatLLMId = props.chatLLM?.id || null;
const noConversation = !targetConversationId;
const noLLM = !props.chatLLM;
const showLLMAttachments = chatExecuteModeCanAttach(chatExecuteMode);
// attachments derived state
const llmAttachments = useLLMAttachments(_attachments, chatLLMId);
// tokens derived state
const tokensComposerText = React.useMemo(() => {
if (!debouncedText || !chatLLMId)
return 0;
return countModelTokens(debouncedText, chatLLMId, 'composer text') ?? 0;
}, [chatLLMId, debouncedText]);
let tokensComposer = tokensComposerText + llmAttachments.tokenCountApprox;
if (tokensComposer > 0)
tokensComposer += 4; // every user message has this many surrounding tokens (note: shall depend on llm..)
const tokensComposerTextDebounced = React.useMemo(() => {
return (debouncedText && props.chatLLM)
? estimateTextTokens(debouncedText, props.chatLLM, 'composer text')
: 0;
}, [props.chatLLM, debouncedText]);
let tokensComposer = tokensComposerTextDebounced + (llmAttachmentDrafts.llmTokenCountApprox || 0);
if (props.chatLLM && tokensComposer > 0)
tokensComposer += glueForMessageTokens(props.chatLLM);
const tokensHistory = _historyTokenCount;
const tokensReponseMax = (props.chatLLM?.options as LLMOptionsOpenAI /* FIXME: BIG ASSUMPTION */)?.llmResponseTokens || 0;
const tokenLimit = props.chatLLM?.contextTokens || 0;
@@ -188,95 +207,94 @@ export function Composer(props: {
// Overlay actions
const handleReplyToCleared = React.useCallback(() => {
const handleReplyToClear = React.useCallback(() => {
conversationOverlayStore?.getState().setReplyToText(null);
}, [conversationOverlayStore]);
React.useEffect(() => {
if (replyToGenerateText)
setTimeout(() => props.composerTextAreaRef.current?.focus(), 1 /* prevent focus theft */);
}, [replyToGenerateText, props.composerTextAreaRef]);
setTimeout(() => composerTextAreaRef.current?.focus(), 1 /* prevent focus theft */);
}, [composerTextAreaRef, replyToGenerateText]);
// Primary button
const { conversationId, onAction } = props;
const handleClear = React.useCallback(() => {
setComposeText('');
attachmentsRemoveAll();
handleReplyToClear();
}, [attachmentsRemoveAll, handleReplyToClear, setComposeText]);
const handleSendAction = React.useCallback((_chatModeId: ChatModeId, composerText: string): boolean => {
if (!conversationId)
const handleSendAction = React.useCallback(async (_chatExecuteMode: ChatExecuteMode, composerText: string): Promise<boolean> => {
if (!isValidConversation(targetConversationId)) return false;
// validate some chat mode inputs
const isDraw = _chatExecuteMode === 'generate-image';
const isBlank = !composerText.trim();
if (isDraw && isBlank)
return false;
// get the multipart output including all attachments
const multiPartMessage = llmAttachments.collapseWithAttachments(composerText || null);
if (!multiPartMessage.length)
return false;
// prepare the fragments: content (if any) and attachments (if allowed, and any)
const fragments: (DMessageContentFragment | DMessageAttachmentFragment)[] = [];
if (composerText)
fragments.push(createTextContentFragment(composerText));
// metadata
const metadata = replyToGenerateText ? { inReplyToText: replyToGenerateText } : undefined;
// send the message
const enqueued = onAction(conversationId, _chatModeId, multiPartMessage, metadata);
if (enqueued) {
clearAttachments();
handleReplyToCleared();
setComposeText('');
const canAttach = chatExecuteModeCanAttach(_chatExecuteMode);
if (canAttach) {
const attachmentFragments = await attachmentsTakeAllFragments('global', 'app-chat');
fragments.push(...attachmentFragments);
}
if (!fragments.length) {
// addSnackbar({ key: 'chat-composer-empty', message: 'Nothing to send', type: 'info' });
return false;
}
// send the message - NOTE: if successful, the ownership of the fragments is transferred to the receiver, so we just clear them
const metadata = replyToGenerateText ? { inReplyToText: replyToGenerateText } : undefined;
const enqueued = onAction(targetConversationId, _chatExecuteMode, fragments, metadata);
if (enqueued)
handleClear();
return enqueued;
}, [clearAttachments, conversationId, handleReplyToCleared, llmAttachments, onAction, replyToGenerateText, setComposeText]);
}, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]);
const handleSendClicked = React.useCallback(() => {
handleSendAction(chatModeId, composeText);
}, [chatModeId, composeText, handleSendAction]);
const handleSendTextBeamClicked = React.useCallback(() => {
handleSendAction('generate-text-beam', composeText);
const handleSendClicked = React.useCallback(async () => {
await handleSendAction(chatExecuteMode, composeText); // 'chat/write/...' button
}, [chatExecuteMode, composeText, handleSendAction]);
const handleSendTextBeamClicked = React.useCallback(async () => {
await handleSendAction('beam-content', composeText); // 'beam' button
}, [composeText, handleSendAction]);
const handleStopClicked = React.useCallback(() => {
!!props.conversationId && stopTyping(props.conversationId);
}, [props.conversationId, stopTyping]);
targetConversationId && abortConversationTemp(targetConversationId);
}, [abortConversationTemp, targetConversationId]);
// Secondary buttons
const handleCallClicked = React.useCallback(() => {
props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
}, [props.conversationId, systemPurposeId]);
targetConversationId && systemPurposeId && launchAppCall(targetConversationId, systemPurposeId);
}, [systemPurposeId, targetConversationId]);
const handleDrawOptionsClicked = React.useCallback(() => {
openPreferencesTab(PreferencesTab.Draw);
}, [openPreferencesTab]);
const handleTextImagineClicked = React.useCallback(() => {
if (!composeText || !props.conversationId)
return;
props.onTextImagine(props.conversationId, composeText);
if (!composeText || !targetConversationId) return;
onTextImagine(targetConversationId, composeText);
setComposeText('');
}, [composeText, props, setComposeText]);
// Mode menu
const handleModeSelectorHide = React.useCallback(() => {
setChatModeMenuAnchor(null);
}, []);
const handleModeSelectorShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
}, []);
const handleModeChange = React.useCallback((_chatModeId: ChatModeId) => {
handleModeSelectorHide();
setChatModeId(_chatModeId);
}, [handleModeSelectorHide]);
}, [composeText, onTextImagine, setComposeText, targetConversationId]);
// Actiles
const onActileCommandPaste = React.useCallback((item: ActileItem) => {
if (props.composerTextAreaRef.current) {
const textArea = props.composerTextAreaRef.current;
if (composerTextAreaRef.current) {
const textArea = composerTextAreaRef.current;
const currentText = textArea.value;
const cursorPos = textArea.selectionStart;
@@ -293,36 +311,39 @@ export function Composer(props: {
const newCursorPos = commandStart + item.label.length + 1;
textArea.setSelectionRange(newCursorPos, newCursorPos);
}
}, [props.composerTextAreaRef, setComposeText]);
}, [composerTextAreaRef, setComposeText]);
const onActileMessageAttach = React.useCallback((item: StarredMessageItem) => {
const onActileEmbedMessage = React.useCallback(async ({ conversationId, messageId }: StarredMessageItem) => {
// get the message
const conversation = getConversation(item.conversationId);
const messageToAttach = conversation?.messages.find(m => m.id === item.messageId);
if (conversation && messageToAttach && messageToAttach.text) {
// Testing with this serialization for LLM. Note it will still be within a multi-part message,
// this could be in a titled markdown block. Don't know yet how this fares with different LLMs.
const chatTitle = conversationTitle(conversation);
const textPlain = `---\nitem id: ${messageToAttach.id}\ncontext title: ${chatTitle}\n---\n${messageToAttach.text.trim()}\n`;
void attachAppendEgoMessage('context-item', textPlain, `${chatTitle} > ${messageToAttach.text.slice(0, 10)}...`);
const conversation = getConversation(conversationId);
const messageToEmbed = conversation?.messages.find(m => m.id === messageId);
if (conversation && messageToEmbed) {
const fragmentsCopy = duplicateDMessageFragments(messageToEmbed.fragments)
.filter(isContentFragment);
if (fragmentsCopy.length) {
const chatTitle = conversationTitle(conversation);
const messageText = messageFragmentsReduceText(fragmentsCopy);
const label = `${chatTitle} > ${messageText.slice(0, 10)}...`;
await attachAppendEgoFragments(fragmentsCopy, label, chatTitle, conversationId, messageId);
}
}
}, [attachAppendEgoMessage]);
}, [attachAppendEgoFragments]);
const actileProviders = React.useMemo(() => {
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileMessageAttach)];
}, [onActileCommandPaste, onActileMessageAttach]);
return [providerCommands(onActileCommandPaste), providerStarredMessage(onActileEmbedMessage)];
}, [onActileCommandPaste, onActileEmbedMessage]);
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, composerTextAreaRef);
// Text typing
// Type...
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setComposeText(e.target.value);
isMobile && actileInterceptTextChange(e.target.value);
}, [actileInterceptTextChange, isMobile, setComposeText]);
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const handleTextareaKeyDown = React.useCallback(async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// disable keyboard handling if the actile is visible
if (actileInterceptKeydown(e))
return;
@@ -332,14 +353,14 @@ export function Composer(props: {
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
if (e.altKey) {
if (handleSendAction('append-user', composeText))
if (await handleSendAction('append-user', composeText)) // 'alt+enter' -> write
touchAltEnter();
return e.preventDefault();
}
// Ctrl (Windows) or Command (Mac) + Enter: send for beaming
if ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey)) {
if (handleSendAction('generate-text-beam', composeText))
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
touchCtrlEnter();
return e.preventDefault();
}
@@ -349,12 +370,12 @@ export function Composer(props: {
touchShiftEnter();
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (!assistantAbortible)
handleSendAction(chatModeId, composeText);
await handleSendAction(chatExecuteMode, composeText); // enter -> send
return e.preventDefault();
}
}
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
// Focus mode
@@ -380,26 +401,26 @@ export function Composer(props: {
nextText = nextText ? nextText + ' ' + transcript : transcript;
// auto-send (mic continuation mode) if requested
const autoSend = micContinuation && nextText.length >= 1 && !!props.conversationId; //&& assistantAbortible;
const autoSend = micContinuation && nextText.length >= 1 && !noConversation; //&& assistantAbortible;
const notUserStop = result.doneReason !== 'manual';
if (autoSend) {
if (notUserStop)
playSoundUrl('/sounds/mic-off-mid.mp3');
handleSendAction(chatModeId, nextText);
void AudioPlayer.playUrl('/sounds/mic-off-mid.mp3');
void handleSendAction(chatExecuteMode, nextText); // fire/forget
} else {
if (!micContinuation && notUserStop)
playSoundUrl('/sounds/mic-off-mid.mp3');
void AudioPlayer.playUrl('/sounds/mic-off-mid.mp3');
if (nextText) {
props.composerTextAreaRef.current?.focus();
composerTextAreaRef.current?.focus();
setComposeText(nextText);
}
}
}, [chatModeId, composeText, handleSendAction, micContinuation, props.composerTextAreaRef, props.conversationId, setComposeText]);
}, [chatExecuteMode, composeText, composerTextAreaRef, handleSendAction, micContinuation, noConversation, setComposeText]);
const { isSpeechEnabled, isSpeechError, isRecordingAudio, isRecordingSpeech, toggleRecording } =
useSpeechRecognition(onSpeechResultCallback, chatMicTimeoutMs || 2000);
useGlobalShortcut('m', true, false, false, toggleRecording);
useGlobalShortcuts([['m', true, false, false, toggleRecording]]);
const micIsRunning = !!speechInterimResult;
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible && !isSpeechError;
@@ -423,7 +444,7 @@ export function Composer(props: {
}, [toggleRecording, micContinuationTrigger]);
// Attachments
// Attachment Up
const handleAttachCtrlV = React.useCallback((event: React.ClipboardEvent) => {
if (attachAppendDataTransfer(event.clipboardData, 'paste', false) === 'as_files')
@@ -434,12 +455,12 @@ export function Composer(props: {
void attachAppendFile('camera', file);
}, [attachAppendFile]);
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
const handleAttachScreenCapture = React.useCallback((file: File) => {
void attachAppendFile('screencapture', file);
}, [attachAppendFile]);
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
const handleAttachFilePicker = React.useCallback(async () => {
try {
const selectedFiles: FileWithHandle[] = await fileOpen({ multiple: true });
@@ -451,25 +472,24 @@ export function Composer(props: {
}
}, [attachAppendFile]);
useGlobalShortcut(supportsClipboardRead ? 'v' : false, true, true, false, attachAppendClipboardItems);
useGlobalShortcuts([[supportsClipboardRead ? 'v' : false, true, true, false, attachAppendClipboardItems]]);
const handleAttachmentInlineText = React.useCallback((attachmentId: AttachmentId) => {
setComposeText(currentText => {
const inlinedMultiPart = llmAttachments.collapseWithAttachment(currentText, attachmentId);
const inlinedText = getSingleTextBlockText(inlinedMultiPart) || '';
removeAttachment(attachmentId);
return inlinedText;
});
}, [llmAttachments, removeAttachment, setComposeText]);
const handleAttachmentsInlineText = React.useCallback(() => {
setComposeText(currentText => {
const inlinedMultiPart = llmAttachments.collapseWithAttachments(currentText);
const inlinedText = getSingleTextBlockText(inlinedMultiPart) || '';
clearAttachments();
return inlinedText;
});
}, [clearAttachments, llmAttachments, setComposeText]);
// Attachments Down
const handleAttachmentDraftsAction = React.useCallback((attachmentDraftIdOrAll: AttachmentDraftId | null, action: LLMAttachmentDraftsAction) => {
switch (action) {
case 'copy-text':
const copyFragments = attachmentsTakeFragmentsByType('doc', attachmentDraftIdOrAll, false);
const copyString = marshallWrapDocFragments(null, copyFragments, false, '\n\n---\n\n');
copyToClipboard(copyString, attachmentDraftIdOrAll ? 'Attachment Text' : 'Attachments Text');
break;
case 'inline-text':
const inlineFragments = attachmentsTakeFragmentsByType('doc', attachmentDraftIdOrAll, true);
setComposeText(currentText => marshallWrapDocFragments(currentText, inlineFragments, 'markdown-code', '\n\n'));
break;
}
}, [attachmentsTakeFragmentsByType, setComposeText]);
// Drag & Drop
@@ -498,7 +518,7 @@ export function Composer(props: {
const handleOverlayDragOver = React.useCallback((e: React.DragEvent) => {
eatDragEvent(e);
// this makes sure we don't "transfer" (or move) the attachment, but we tell the sender we'll copy it
// this makes sure we don't "transfer" (or move) the item, but we tell the sender we'll copy it
e.dataTransfer.dropEffect = 'copy';
}, [eatDragEvent]);
@@ -517,32 +537,22 @@ export function Composer(props: {
}, [attachAppendDataTransfer, eatDragEvent, setComposeText]);
const isText = chatModeId === 'generate-text';
const isTextBeam = chatModeId === 'generate-text-beam';
const isAppend = chatModeId === 'append-user';
const isReAct = chatModeId === 'generate-react';
const isDraw = chatModeId === 'generate-image';
const isText = chatExecuteMode === 'generate-content' || chatExecuteMode === 'generate-text-v1';
const isTextBeam = chatExecuteMode === 'beam-content';
const isAppend = chatExecuteMode === 'append-user';
const isReAct = chatExecuteMode === 'react-content';
const isDraw = chatExecuteMode === 'generate-image';
const showChatReplyTo = !!replyToGenerateText;
const showChatExtras = isText && !showChatReplyTo;
const buttonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
const sendButtonVariant: VariantProp = (isAppend || (isMobile && isTextBeam)) ? 'outlined' : 'solid';
const buttonColor: ColorPaletteProp =
assistantAbortible ? 'warning'
: isReAct ? 'success'
: isTextBeam ? 'primary'
: isDraw ? 'warning'
: 'primary';
const sendButtonColor: ColorPaletteProp = assistantAbortible ? 'warning' : chatExecuteModeSendColor;
const buttonText =
isAppend ? 'Write'
: isReAct ? 'ReAct'
: isTextBeam ? 'Beam'
: isDraw ? 'Draw'
: 'Chat';
const sendButtonLabel = chatExecuteModeSendLabel;
const buttonIcon =
const sendButtonIcon =
micContinuation ? <AutoModeIcon />
: isAppend ? <SendIcon sx={{ fontSize: 18 }} />
: isReAct ? <PsychologyIcon />
@@ -571,45 +581,50 @@ export function Composer(props: {
<Box aria-label='User Message' component='section' sx={props.sx}>
<Grid container spacing={{ xs: 1, md: 2 }}>
{/* [Mobile: top, Desktop: left] */}
<Grid xs={12} md={9}><Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'flex-start' }}>
{/* Start buttons column */}
<Box sx={{
flexGrow: 0,
display: 'grid', gap: 1,
}}>
{isMobile ? <>
{/* [Mobile, Col1] Mic, Insert Multi-modal content, and Broadcast buttons */}
{isMobile && (
<Box sx={{ flexGrow: 0, display: 'grid', gap: 1 }}>
{/* [mobile] Mic button */}
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
{/* [mobile] [+] button */}
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddCircleOutlineIcon />
</MenuButton>
<Menu>
{/* Responsive Camera OCR button */}
<MenuItem>
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
</MenuItem>
{showLLMAttachments && (
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddCircleOutlineIcon />
</MenuButton>
<Menu>
{/* Responsive Camera OCR button */}
<MenuItem>
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
</MenuItem>
{/* Responsive Open Files button */}
<MenuItem>
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
</MenuItem>
{/* Responsive Open Files button */}
<MenuItem>
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
</MenuItem>
{/* Responsive Paste button */}
{supportsClipboardRead && <MenuItem>
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
</MenuItem>}
</Menu>
</Dropdown>
{/* Responsive Paste button */}
{supportsClipboardRead && <MenuItem>
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
</MenuItem>}
</Menu>
</Dropdown>
)}
{/* [Mobile] MultiChat button */}
{props.isMulticast !== null && <ButtonMultiChatMemo isMobile multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
</> : <>
</Box>
)}
{/* [Desktop, Col1] Insert Multi-modal content buttons */}
{isDesktop && showLLMAttachments && (
<Box sx={{ flexGrow: 0, display: 'grid', gap: 1 }}>
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
{/* Attach*/}
@@ -627,22 +642,24 @@ export function Composer(props: {
{/* Responsive Camera OCR button */}
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
</>}
</Box>
</Box>)}
{/* [ Textarea + Overlays + Mic | Attachments ] */}
{/* Top: Textarea & Mic & Overlays, Bottom, Attachment Drafts */}
<Box sx={{
flexGrow: 1,
// layout
display: 'flex', flexDirection: 'column', gap: 1,
minWidth: 200, // flex: enable X-scrolling (resetting any possible minWidth due to the attachments)
display: 'flex',
flexDirection: 'column',
gap: 1,
minWidth: 200, // flex: enable X-scrolling (resetting any possible minWidth due to the attachment drafts)
}}>
{/* Textarea + Mic buttons + Mic/Drag overlay */}
<Box sx={{ position: 'relative' }}>
{/* Text Edit + Mic buttons + MicOverlay & DragOverlay */}
<Box sx={{ position: 'relative' /* for overlays */ }}>
{/* Edit box with inner Token Progress bar */}
<Box sx={{ position: 'relative' }}>
<Box sx={{ position: 'relative' /* for TokenBadge & TokenProgress */ }}>
<Textarea
variant='outlined'
@@ -659,7 +676,7 @@ export function Composer(props: {
onPasteCapture={handleAttachCtrlV}
// onFocusCapture={handleFocusModeOn}
// onBlurCapture={handleFocusModeOff}
endDecorator={showChatReplyTo && <ReplyToBubble replyToText={replyToGenerateText} onClear={handleReplyToCleared} className='reply-to-bubble' />}
endDecorator={showChatReplyTo && <ReplyToBubble replyToText={replyToGenerateText} onClear={handleReplyToClear} className='reply-to-bubble' />}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'send',
@@ -667,7 +684,7 @@ export function Composer(props: {
...(isSpeechEnabled && { pr: { md: 5 } }),
// mb: 0.5, // no need; the outer container already has enough p (for TokenProgressbar)
},
ref: props.composerTextAreaRef,
ref: composerTextAreaRef,
},
}}
sx={{
@@ -681,7 +698,7 @@ export function Composer(props: {
)}
{!showChatReplyTo && tokenLimit > 0 && (
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} showCost={labsShowCost} showExcess absoluteBottomRight />
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} showCost={labsShowCost} enableHover={!isMobile} showExcess absoluteBottomRight />
)}
</Box>
@@ -758,18 +775,20 @@ export function Composer(props: {
</Box>
{/* Render any Attachments & menu items */}
<Attachments
llmAttachments={llmAttachments}
onAttachmentInlineText={handleAttachmentInlineText}
onAttachmentsClear={clearAttachments}
onAttachmentsInlineText={handleAttachmentsInlineText}
/>
{!!conversationOverlayStore && showLLMAttachments && (
<LLMAttachmentsList
attachmentDraftsStoreApi={conversationOverlayStore}
llmAttachmentDrafts={llmAttachmentDrafts}
onAttachmentDraftsAction={handleAttachmentDraftsAction}
/>
)}
</Box>
</Box></Grid>
{/* [Mobile: bottom, Desktop: right] */}
<Grid xs={12} md={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, height: '100%' } as const}>
@@ -779,7 +798,7 @@ export function Composer(props: {
{/* [mobile] bottom-corner secondary button */}
{isMobile && (showChatExtras
? <ButtonCallMemo isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />
? <ButtonCallMemo isMobile disabled={noConversation || noLLM} onClick={handleCallClicked} />
: isDraw
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
@@ -787,28 +806,28 @@ export function Composer(props: {
{/* Responsive Send/Stop buttons */}
<ButtonGroup
variant={buttonVariant}
color={buttonColor}
variant={sendButtonVariant}
color={sendButtonColor}
sx={{
flexGrow: 1,
backgroundColor: (isMobile && buttonVariant === 'outlined') ? 'background.popup' : undefined,
boxShadow: (isMobile && buttonVariant !== 'outlined') ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${buttonColor}-mainChannel) / 20%)`,
backgroundColor: (isMobile && sendButtonVariant === 'outlined') ? 'background.popup' : undefined,
boxShadow: (isMobile && sendButtonVariant !== 'outlined') ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${sendButtonColor}-mainChannel) / 20%)`,
}}
>
{!assistantAbortible ? (
<Button
key='composer-act'
fullWidth disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
fullWidth disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
onClick={handleSendClicked}
endDecorator={buttonIcon}
endDecorator={sendButtonIcon}
sx={{ '--Button-gap': '1rem' }}
>
{micContinuation && 'Voice '}{buttonText}
{micContinuation && 'Voice '}{sendButtonLabel}
</Button>
) : (
<Button
key='composer-stop'
fullWidth variant='soft' disabled={!props.conversationId}
fullWidth variant='soft' disabled={noConversation}
onClick={handleStopClicked}
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
sx={{ animation: `${animationEnterBelow} 0.1s ease-out` }}
@@ -819,14 +838,14 @@ export function Composer(props: {
{/* [Beam] Open Beam */}
{/*{isText && <Tooltip title='Open Beam'>*/}
{/* <IconButton variant='outlined' disabled={!props.conversationId || !chatLLMId} onClick={handleSendTextBeamClicked}>*/}
{/* <IconButton variant='outlined' disabled={noConversation || noLLM} onClick={handleSendTextBeamClicked}>*/}
{/* <ChatBeamIcon />*/}
{/* </IconButton>*/}
{/*</Tooltip>}*/}
{/* [Draw] Imagine */}
{isDraw && !!composeText && <Tooltip title='Imagine a drawing prompt'>
<IconButton variant='outlined' disabled={!props.conversationId || !chatLLMId} onClick={handleTextImagineClicked}>
<IconButton variant='outlined' disabled={noConversation || noLLM} onClick={handleTextImagineClicked}>
<AutoAwesomeIcon />
</IconButton>
</Tooltip>}
@@ -834,8 +853,8 @@ export function Composer(props: {
{/* Mode expander */}
<IconButton
variant={assistantAbortible ? 'soft' : isDraw ? undefined : undefined}
disabled={!props.conversationId || !chatLLMId || !!chatModeMenuAnchor}
onClick={handleModeSelectorShow}
disabled={noConversation || noLLM || chatExecuteMenuShown}
onClick={showChatExecuteMenu}
>
<ExpandLessIcon />
</IconButton>
@@ -844,7 +863,7 @@ export function Composer(props: {
{/* [desktop] secondary-top buttons */}
{isDesktop && showChatExtras && !assistantAbortible && (
<ButtonBeamMemo
disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
hasContent={!!composeText}
onClick={handleSendTextBeamClicked}
/>
@@ -855,11 +874,11 @@ export function Composer(props: {
{/* [desktop] Multicast switch (under the Chat button) */}
{isDesktop && props.isMulticast !== null && <ButtonMultiChatMemo multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
{/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */}
{/* [desktop] secondary bottom-buttons (aligned to bottom for now, and mutually exclusive) */}
{isDesktop && <Box sx={{ mt: 'auto', display: 'grid', gap: 1 }}>
{/* [desktop] Call secondary button */}
{showChatExtras && <ButtonCallMemo disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
{showChatExtras && <ButtonCallMemo disabled={noConversation || noLLM} onClick={handleCallClicked} />}
{/* [desktop] Draw Options secondary button */}
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
@@ -871,20 +890,13 @@ export function Composer(props: {
</Grid>
{/* Mode selector */}
{!!chatModeMenuAnchor && (
<ChatModeMenu
isMobile={isMobile}
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
capabilityHasTTI={props.capabilityHasT2I}
/>
)}
{/* Execution Mode Menu */}
{chatExecuteMenuComponent}
{/* Camera */}
{/* Camera (when open) */}
{cameraCaptureComponent}
{/* Actile */}
{/* Actile (when open) */}
{actileComponent}
</Box>
@@ -122,20 +122,33 @@ function TokenBadge(props: {
tokenPriceIn?: number,
tokenPriceOut?: number,
enableHover?: boolean,
showCost?: boolean
showExcess?: boolean,
absoluteBottomRight?: boolean,
inline?: boolean,
}) {
// state
const [isHovering, setIsHovering] = React.useState(false);
const { message, color, remainingTokens, costMax, costMin } =
tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax, props.tokenPriceIn, props.tokenPriceOut);
// handlers
const handleHoverEnter = React.useCallback(() => setIsHovering(true), []);
const handleHoverLeave = React.useCallback(() => setIsHovering(false), []);
let badgeValue: string;
const showAltCosts = !!props.showCost && !!costMax && costMin !== undefined;
if (showAltCosts) {
badgeValue = '< ' + formatCost(costMax);
badgeValue = (!props.enableHover || isHovering)
? '< ' + formatCost(costMax)
: '> ' + formatCost(costMin);
} else {
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
@@ -154,6 +167,8 @@ function TokenBadge(props: {
<Badge
variant='soft' color={color} max={1000000}
// invisible={shallHide}
onMouseEnter={props.enableHover ? handleHoverEnter : undefined}
onMouseLeave={props.enableHover ? handleHoverLeave : undefined}
badgeContent={badgeValue}
slotProps={{
root: {
@@ -1,4 +1,6 @@
import { conversationTitle, DConversationId, messageHasUserFlag, useChatStore } from '~/common/state/store-chats';
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
import { messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { ActileItem, ActileProvider } from './ActileProvider';
@@ -27,7 +29,7 @@ export function providerStarredMessage(onMessageSeelect: (item: StarredMessageIt
messageId: message.id,
// looks
key: message.id,
label: conversationTitle(conversation) + ' - ' + message.text.slice(0, 32) + '...',
label: conversationTitle(conversation) + ' - ' + messageFragmentsReduceText(message.fragments).slice(0, 32) + '...',
// description: message.text.slice(32, 100),
Icon: undefined,
} satisfies StarredMessageItem);
@@ -1,190 +0,0 @@
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'
open anchorEl={props.menuAnchor} onClose={props.onClose}
sx={{ minWidth: 200 }}
>
{/* 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()
: output.type === 'image-part'
? output.base64Url.length.toLocaleString()
: '(other)'} 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>
);
}
@@ -1,172 +0,0 @@
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>) => {
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
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
onClick={handleOverallMenuToggle}
onContextMenu={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
dense placement='top-start'
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
>
<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{attachments.length > 5 ? <span style={{ opacity: 0.5 }}> {attachments.length} attachments</span> : null}
</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?`}
/>
)}
</>;
}
@@ -1,390 +0,0 @@
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { createBase36Uid } from '~/common/util/textUtils';
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
import { pdfToImageDataURLs, 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'];
// mimetypes to treat as plain text
const PLAIN_TEXT_MIMETYPES: string[] = [
'text/plain',
'text/html',
'text/markdown',
'text/csv',
'text/css',
'text/javascript',
'application/json',
];
/**
* 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);
edit(
page.content.markdown ? { input: { mimeType: 'text/markdown', data: page.content.markdown, dataSize: page.content.markdown.length } }
: page.content.text ? { input: { mimeType: 'text/plain', data: page.content.text, dataSize: page.content.text.length } }
: page.content.html ? { input: { mimeType: 'text/html', data: page.content.html, dataSize: page.content.html.length } }
: { 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;
case 'ego':
edit({
label: source.label,
ref: source.blockTitle,
input: {
mimeType: 'ego/message',
data: source.textPlain,
dataSize: source.textPlain.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 PLAIN_TEXT_MIMETYPES.includes(input.mimeType):
// handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read
const textOriginHtml = sourceType === 'text' && input.altMimeType === 'text/html' && !!input.altData;
const isHtmlTable = !!input.altData?.startsWith('<table');
// 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;
// EGO
case input.mimeType === 'ego/message':
converters.push({ id: 'ego-message-md', name: 'Message' });
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 || '\n<!DOCTYPE html>',
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 text 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':
if (!(input.data instanceof ArrayBuffer)) {
console.log('Expected ArrayBuffer for PDF images converter, got:', typeof input.data);
break;
}
// duplicate the ArrayBuffer to avoid mutation
const pdfData2 = new Uint8Array(input.data.slice(0));
try {
const imageDataURLs = await pdfToImageDataURLs(pdfData2);
imageDataURLs.forEach((pdfImg, index) => {
outputs.push({
type: 'image-part',
base64Url: pdfImg.base64Url,
metadata: {
title: `Page ${index + 1}`,
width: pdfImg.width,
height: pdfImg.height,
},
collapsible: false,
});
});
} catch (error) {
console.error('Error converting PDF to images:', error);
}
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 'ego-message-md':
outputs.push({
type: 'text-block',
text: inputDataToString(input.data),
title: ref,
collapsible: true,
});
break;
case 'unhandled':
// force the user to explicitly select 'as text' if they want to proceed
break;
}
// update
edit({
outputsConverting: false,
outputs,
});
}
@@ -1,42 +0,0 @@
/*
/// 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') ?? 0;
// 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));
*/
@@ -1,208 +0,0 @@
import { create } from 'zustand';
import type { FileWithHandle } from 'browser-fs-access';
import type { ComposerOutputMultiPart } from '../composer.types';
import { attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync, attachmentPerformConversion } from './pipeline';
// Attachment Types
export type AttachmentSourceOriginDTO = 'drop' | 'paste';
export type AttachmentSourceOriginFile = 'camera' | 'screencapture' | '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;
} | {
media: 'ego';
method: 'ego-message';
label: string;
blockTitle: string;
textPlain: 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'
| 'ego-message-md'
| '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; // will be used in ```ref\n...``` for instance
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),
}),
);
@@ -1,149 +0,0 @@
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[];
collapseWithAttachment: (initialTextBlockText: string | null, attachmentId: AttachmentId) => ComposerOutputMultiPart;
collapseWithAttachments: (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 collapseWithAttachment = (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 collapseWithAttachments = (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,
collapseWithAttachment,
collapseWithAttachments,
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 getSingleTextBlockText(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') ?? 0);
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') {
// THIS IS NOT CORRECT - we seem to be doing it just for downstream token counting - FIX IT
// Do not serialize here
accumulatedOutputs.push({
type: 'text-block',
text: `\n\n\`\`\`${output.title}\n${output.text}\n\`\`\``,
title: null,
collapsible: false, // Wrong
});
} else {
accumulatedOutputs.push(output);
}
}
}
return accumulatedOutputs;
}
@@ -1,22 +0,0 @@
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,
metadata: {
title?: string,
generatedBy?: string,
altText?: string,
width?: number,
height?: number,
},
collapsible: false,
};
export type ComposerOutputMultiPart = ComposerOutputPart[];
@@ -4,6 +4,9 @@ import { Box, Button, CircularProgress, ColorPaletteProp, Sheet, Typography } fr
import AbcIcon from '@mui/icons-material/Abc';
import CodeIcon from '@mui/icons-material/Code';
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
import PermMediaOutlinedIcon from '@mui/icons-material/PermMediaOutlined';
import PhotoSizeSelectLargeOutlinedIcon from '@mui/icons-material/PhotoSizeSelectLargeOutlined';
import PhotoSizeSelectSmallOutlinedIcon from '@mui/icons-material/PhotoSizeSelectSmallOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import PivotTableChartIcon from '@mui/icons-material/PivotTableChart';
import TelegramIcon from '@mui/icons-material/Telegram';
@@ -14,8 +17,8 @@ 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';
import type { AttachmentDraft, AttachmentDraftConverterType, AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
// default attachment width
@@ -66,20 +69,23 @@ const InputErrorIndicator = () =>
<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />;
const converterTypeToIconMap: { [key in AttachmentConverterType]: React.ComponentType<any> } = {
const converterTypeToIconMap: { [key in AttachmentDraftConverterType]: React.ComponentType<any> } = {
'text': TextFieldsIcon,
'rich-text': CodeIcon,
'rich-text-table': PivotTableChartIcon,
'pdf-text': PictureAsPdfIcon,
'pdf-images': PictureAsPdfIcon,
'image': ImageOutlinedIcon,
'pdf-images': PermMediaOutlinedIcon,
'image-original': ImageOutlinedIcon,
'image-resized-high': PhotoSizeSelectLargeOutlinedIcon,
'image-resized-low': PhotoSizeSelectSmallOutlinedIcon,
'image-to-default': ImageOutlinedIcon,
'image-ocr': AbcIcon,
'ego-message-md': TelegramIcon,
'ego-fragments-inlined': TelegramIcon,
'unhandled': TextureIcon,
};
function attachmentConverterIcon(attachment: Attachment) {
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
function attachmentConverterIcon(attachmentDraft: AttachmentDraft) {
const converter = attachmentDraft.converterIdx !== null ? attachmentDraft.converters[attachmentDraft.converterIdx] ?? null : null;
if (converter && converter.id) {
const Icon = converterTypeToIconMap[converter.id] ?? null;
if (Icon)
@@ -88,56 +94,51 @@ function attachmentConverterIcon(attachment: Attachment) {
return null;
}
function attachmentLabelText(attachment: Attachment): string {
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
if (converter && attachment.label === 'Rich Text') {
function attachmentLabelText(attachmentDraft: AttachmentDraft): string {
const converter = attachmentDraft.converterIdx !== null ? attachmentDraft.converters[attachmentDraft.converterIdx] ?? null : null;
if (converter && attachmentDraft.label === 'Rich Text') {
if (converter.id === 'rich-text-table')
return 'Rich Table';
if (converter.id === 'rich-text')
return 'Rich HTML';
}
return ellipsizeFront(attachment.label, 24);
return ellipsizeFront(attachmentDraft.label, 24);
}
export function AttachmentItem(props: {
llmAttachment: LLMAttachment,
export function LLMAttachmentItem(props: {
llmAttachment: LLMAttachmentDraft,
menuShown: boolean,
onItemMenuToggle: (attachmentId: AttachmentId, anchor: HTMLAnchorElement) => void,
onToggleMenu: (attachmentDraftId: AttachmentDraftId, anchor: HTMLAnchorElement) => void,
}) {
// derived state
const { attachmentDraft: draft, llmSupportsAllFragments } = props.llmAttachment;
const { onItemMenuToggle } = props;
const isInputLoading = draft.inputLoading;
const isInputError = !!draft.inputError;
const isUnconvertible = !draft.converters.length;
const isOutputLoading = draft.outputsConverting;
const isOutputMissing = !draft.outputFragments.length;
const {
attachment,
isUnconvertible,
isOutputMissing,
isOutputAttachable,
} = props.llmAttachment;
const showWarning = isUnconvertible || (isOutputMissing || !llmSupportsAllFragments);
const {
inputError,
inputLoading: isInputLoading,
outputsConverting: isOutputLoading,
} = attachment;
const isInputError = !!inputError;
const showWarning = isUnconvertible || isOutputMissing || !isOutputAttachable;
// handlers
const { onToggleMenu } = props;
const handleToggleMenu = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
onItemMenuToggle(attachment.id, event.currentTarget);
}, [attachment, onItemMenuToggle]);
onToggleMenu(draft.id, event.currentTarget);
}, [draft.id, onToggleMenu]);
// compose tooltip
let tooltip: string | null = '';
if (attachment.source.media !== 'text')
tooltip += attachment.source.media + ': ';
tooltip += attachment.label;
if (draft.source.media !== 'text')
tooltip += draft.source.media + ': ';
tooltip += draft.label;
// if (hasInput)
// tooltip += `\n(${aInput.mimeType}: ${aInput.dataSize.toLocaleString()} bytes)`;
// if (aOutputs && aOutputs.length >= 1)
@@ -149,15 +150,15 @@ export function AttachmentItem(props: {
if (isInputLoading || isOutputLoading) {
color = 'success';
} else if (isInputError) {
tooltip = `Issue loading the attachment: ${attachment.inputError}\n\n${tooltip}`;
color = 'danger';
tooltip = props.menuShown ? null
: `Issue loading the attachment: ${draft.inputError}\n\n${tooltip}`;
} 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';
tooltip = props.menuShown ? null
: isUnconvertible
? `Attachments of type '${draft.input?.mimeType}' are not supported yet. You can open a feature request on GitHub.\n\n${tooltip}`
: `Not compatible with the selected LLM or file not supported. Please try another format.\n\n${tooltip}`;
} else {
// all good
tooltip = null;
@@ -175,7 +176,7 @@ export function AttachmentItem(props: {
sx={{ p: 1, whiteSpace: 'break-spaces' }}
>
{isInputLoading
? <LoadingIndicator label={attachment.label} />
? <LoadingIndicator label={draft.label} />
: (
<Button
size='sm'
@@ -195,11 +196,11 @@ export function AttachmentItem(props: {
{isInputError
? <InputErrorIndicator />
: <>
{attachmentConverterIcon(attachment)}
{attachmentConverterIcon(draft)}
{isOutputLoading
? <>Converting <CircularProgress color='success' size='sm' /></>
: <Typography level='title-sm' sx={{ whiteSpace: 'nowrap' }}>
{attachmentLabelText(attachment)}
{attachmentLabelText(draft)}
</Typography>}
</>}
</Button>
@@ -0,0 +1,216 @@
import * as React from 'react';
import { Box, CircularProgress, Link, ListDivider, ListItem, 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 LaunchIcon from '@mui/icons-material/Launch';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
import { DMessageAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import type { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts-slice';
import type { LLMAttachmentDraft } from './useLLMAttachmentDrafts';
import type { LLMAttachmentDraftsAction } from './LLMAttachmentsList';
// enable for debugging
export const DEBUG_LLMATTACHMENTS = true;
export function LLMAttachmentMenu(props: {
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
llmAttachmentDraft: LLMAttachmentDraft,
menuAnchor: HTMLAnchorElement,
isPositionFirst: boolean,
isPositionLast: boolean,
onDraftAction: (attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => void,
onClose: () => void,
}) {
// derived state
const {
attachmentDraft: draft,
llmSupportsTextFragments,
llmTokenCountApprox,
} = props.llmAttachmentDraft;
const draftId = draft.id;
const draftInput = draft.input;
const isConverting = draft.outputsConverting;
const isUnconvertible = !draft.converters.length;
const isOutputMissing = !draft.outputFragments.length;
const isUnmoveable = props.isPositionFirst && props.isPositionLast;
// operations
const { attachmentDraftsStoreApi, onDraftAction, onClose } = props;
const handleMoveUp = React.useCallback(() => {
attachmentDraftsStoreApi.getState().moveAttachmentDraft(draftId, -1);
}, [draftId, attachmentDraftsStoreApi]);
const handleMoveDown = React.useCallback(() => {
attachmentDraftsStoreApi.getState().moveAttachmentDraft(draftId, 1);
}, [draftId, attachmentDraftsStoreApi]);
const handleRemove = React.useCallback(() => {
onClose();
attachmentDraftsStoreApi.getState().removeAttachmentDraft(draftId);
}, [draftId, attachmentDraftsStoreApi, onClose]);
const handleSetConverterIdx = React.useCallback(async (converterIdx: number | null) => {
return attachmentDraftsStoreApi.getState().setAttachmentDraftConverterIdxAndConvert(draftId, converterIdx);
}, [draftId, attachmentDraftsStoreApi]);
// const handleSummarizeText = React.useCallback(() => {
// onAttachmentDraftSummarizeText(draftId);
// }, [draftId, onAttachmentDraftSummarizeText]);
return (
<CloseableMenu
dense placement='top'
open anchorEl={props.menuAnchor} onClose={props.onClose}
sx={{ minWidth: 260 }}
>
{/* Move Arrows */}
{!isUnmoveable && <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>}
{!isUnmoveable && <ListDivider sx={{ mt: 0 }} />}
{/* Render Converters as menu items */}
{!isUnconvertible && (
<ListItem>
<Typography level='body-sm'>
Attach as:
</Typography>
</ListItem>
)}
{!isUnconvertible && draft.converters.map((c, idx) =>
<MenuItem
disabled={c.disabled || isConverting}
key={'c-' + c.id}
onClick={async () => idx !== draft.converterIdx && await handleSetConverterIdx(idx)}
>
<ListItemDecorator>
{(isConverting && idx === draft.converterIdx)
? <CircularProgress size='sm' sx={{ '--CircularProgress-size': '1.25rem' }} />
: <Radio checked={idx === draft.converterIdx} disabled={isConverting} />}
</ListItemDecorator>
{c.unsupported
? <Box>Unsupported 🤔 <Typography level='body-xs'>{c.name}</Typography></Box>
: c.name}
</MenuItem>,
)}
{!isUnconvertible && <ListDivider />}
{DEBUG_LLMATTACHMENTS && !!draftInput && !isConverting && (
<ListItem>
<ListItemDecorator />
<Box>
{!!draftInput && (
<Typography level='body-sm'>
🡐 {draftInput.mimeType} · {draftInput.dataSize.toLocaleString()}
</Typography>
)}
{!!draftInput?.altMimeType && (
<Typography level='body-sm'>
<span style={{ color: 'transparent' }}>🡐</span> {draftInput.altMimeType} · {draftInput.altData?.length.toLocaleString()}
</Typography>
)}
{/*<Typography level='body-sm'>*/}
{/* Converters: {aConverters.map(((converter, idx) => ` ${converter.id}${(idx === draft.converterIdx) ? '*' : ''}`)).join(', ')}*/}
{/*</Typography>*/}
<Box>
{isOutputMissing ? (
<Typography level='body-sm'>🡒 ...</Typography>
) : (
draft.outputFragments.map(({ part }, index) => {
if (isImageRefPart(part)) {
const resolution = part.width && part.height ? `${part.width} x ${part.height}` : 'unknown resolution';
const mime = part.dataRef.reftype === 'dblob' ? part.dataRef.mimeType : 'unknown image';
return (
<Typography key={index} level='body-sm'>
🡒 {mime/*unic.replace('image/', 'img: ')*/} · {resolution} · {part.dataRef.reftype === 'dblob' ? part.dataRef.bytesSize?.toLocaleString() : '(remote)'}
{' · '}
<Link onClick={() => showImageDataRefInNewTab(part.dataRef)}>
open <LaunchIcon sx={{ mx: 0.5, fontSize: 16 }} />
</Link>
</Typography>
);
} else if (part.pt === 'doc') {
return (
<Typography key={index} level='body-sm'>
🡒 text: {part.data.text.length.toLocaleString()} bytes
</Typography>
);
} else {
return (
<Typography key={index} level='body-sm'>
🡒 {(part as DMessageAttachmentFragment['part']).pt}: (other)
</Typography>
);
}
})
)}
{!!llmTokenCountApprox && (
<Typography level='body-sm' sx={{ ml: 1.75 }}>
~ {llmTokenCountApprox.toLocaleString()} tokens
</Typography>
)}
</Box>
</Box>
</ListItem>
)}
{DEBUG_LLMATTACHMENTS && !!draftInput && !isConverting && <ListDivider />}
{/* Destructive Operations */}
{/*<MenuItem onClick={handleCopyToClipboard} disabled={!isOutputTextInlineable}>*/}
{/* <ListItemDecorator><ContentCopyIcon /></ListItemDecorator>*/}
{/* Copy*/}
{/*</MenuItem>*/}
{/*<MenuItem onClick={handleSummarizeText} disabled={!isOutputTextInlineable}>*/}
{/* <ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>*/}
{/* Shrink*/}
{/*</MenuItem>*/}
<MenuItem onClick={() => onDraftAction(draftId, 'inline-text')} disabled={!llmSupportsTextFragments || isConverting}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Inline text
</MenuItem>
<MenuItem onClick={() => onDraftAction(draftId, 'copy-text')} disabled={!llmSupportsTextFragments || isConverting}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
Copy text
</MenuItem>
<ListDivider />
<MenuItem onClick={handleRemove}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Remove
</MenuItem>
</CloseableMenu>
);
}
@@ -0,0 +1,184 @@
import * as React from 'react';
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem } from '@mui/joy';
import ClearIcon from '@mui/icons-material/Clear';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
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 { AttachmentDraftId } from '~/common/attachment-drafts/attachment.types';
import type { AttachmentDraftsStoreApi } from '~/common/attachment-drafts/store-attachment-drafts-slice';
import type { LLMAttachmentDrafts } from './useLLMAttachmentDrafts';
import { LLMAttachmentItem } from './LLMAttachmentItem';
import { LLMAttachmentMenu } from './LLMAttachmentMenu';
export type LLMAttachmentDraftsAction = 'inline-text' | 'copy-text';
/**
* Renderer of attachment drafts, with menus, etc.
*/
export function LLMAttachmentsList(props: {
attachmentDraftsStoreApi: AttachmentDraftsStoreApi,
llmAttachmentDrafts: LLMAttachmentDrafts,
onAttachmentDraftsAction: (attachmentDraftId: AttachmentDraftId | null, actionId: LLMAttachmentDraftsAction) => void,
}) {
// state
const [confirmClearAttachmentDrafts, setConfirmClearAttachmentDrafts] = React.useState<boolean>(false);
const [draftMenu, setDraftMenu] = React.useState<{ anchor: HTMLAnchorElement, attachmentDraftId: AttachmentDraftId } | null>(null);
const [overallMenuAnchor, setOverallMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
// derived state
const { llmAttachmentDrafts, canInlineSomeFragments } = props.llmAttachmentDrafts;
const hasAttachments = llmAttachmentDrafts.length >= 1;
// derived item menu state
const itemMenuAnchor = draftMenu?.anchor;
const itemMenuAttachmentDraftId = draftMenu?.attachmentDraftId;
const itemMenuAttachmentDraft = itemMenuAttachmentDraftId ? llmAttachmentDrafts.find(la => la.attachmentDraft.id === draftMenu.attachmentDraftId) : undefined;
const itemMenuIndex = itemMenuAttachmentDraft ? llmAttachmentDrafts.indexOf(itemMenuAttachmentDraft) : -1;
// overall menu
const { onAttachmentDraftsAction } = props;
const handleOverallMenuHide = React.useCallback(() => setOverallMenuAnchor(null), []);
const handleOverallMenuToggle = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
event.shiftKey && console.log(llmAttachmentDrafts);
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
setOverallMenuAnchor(anchor => anchor ? null : event.currentTarget);
}, [llmAttachmentDrafts]);
const handleOverallCopyText = React.useCallback(() => {
handleOverallMenuHide();
onAttachmentDraftsAction(null, 'copy-text');
}, [handleOverallMenuHide, onAttachmentDraftsAction]);
const handleOverallInlineText = React.useCallback(() => {
handleOverallMenuHide();
onAttachmentDraftsAction(null, 'inline-text');
}, [handleOverallMenuHide, onAttachmentDraftsAction]);
const handleOverallClear = React.useCallback(() => setConfirmClearAttachmentDrafts(true), []);
const handleOverallClearConfirmed = React.useCallback(() => {
handleOverallMenuHide();
setConfirmClearAttachmentDrafts(false);
props.attachmentDraftsStoreApi.getState().removeAllAttachmentDrafts();
}, [handleOverallMenuHide, props.attachmentDraftsStoreApi]);
// item menu
const handleDraftMenuHide = React.useCallback(() => setDraftMenu(null), []);
const handleDraftMenuToggle = React.useCallback((attachmentDraftId: AttachmentDraftId, anchor: HTMLAnchorElement) => {
handleOverallMenuHide();
setDraftMenu(prev => prev?.attachmentDraftId === attachmentDraftId ? null : { anchor, attachmentDraftId });
}, [handleOverallMenuHide]);
const handleDraftAction = React.useCallback((attachmentDraftId: AttachmentDraftId, actionId: LLMAttachmentDraftsAction) => {
// pass-through, but close the menu as well, as the action is destructive for the caller
handleDraftMenuHide();
onAttachmentDraftsAction(attachmentDraftId, actionId);
}, [handleDraftMenuHide, onAttachmentDraftsAction]);
// no components without attachments
if (!hasAttachments)
return null;
return <>
{/* Attachment Drafts bar */}
<Box sx={{ position: 'relative' }}>
{/* Horizontally scrollable Attachments */}
<Box sx={{ display: 'flex', overflowX: 'auto', gap: 1, height: '100%', pr: 5 }}>
{llmAttachmentDrafts.map((llmAttachment) =>
<LLMAttachmentItem
key={llmAttachment.attachmentDraft.id}
llmAttachment={llmAttachment}
menuShown={llmAttachment.attachmentDraft.id === itemMenuAttachmentDraftId}
onToggleMenu={handleDraftMenuToggle}
/>,
)}
</Box>
{/* Overall Menu button */}
<IconButton
onClick={handleOverallMenuToggle}
onContextMenu={handleOverallMenuToggle}
sx={{
// borderRadius: 'sm',
borderRadius: 0,
position: 'absolute', right: 0, top: 0,
backgroundColor: 'neutral.softDisabledBg',
}}
>
<ExpandLessIcon />
</IconButton>
</Box>
{/* LLM Draft Menu */}
{!!itemMenuAnchor && !!itemMenuAttachmentDraft && !!props.attachmentDraftsStoreApi && (
<LLMAttachmentMenu
attachmentDraftsStoreApi={props.attachmentDraftsStoreApi}
llmAttachmentDraft={itemMenuAttachmentDraft}
menuAnchor={itemMenuAnchor}
isPositionFirst={itemMenuIndex === 0}
isPositionLast={itemMenuIndex === llmAttachmentDrafts.length - 1}
onDraftAction={handleDraftAction}
onClose={handleDraftMenuHide}
/>
)}
{/* All Drafts Menu */}
{!!overallMenuAnchor && (
<CloseableMenu
dense placement='top-start'
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
sx={{ minWidth: 200 }}
>
<MenuItem onClick={handleOverallInlineText} disabled={!canInlineSomeFragments}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Inline all text
</MenuItem>
<MenuItem onClick={handleOverallCopyText} disabled={!canInlineSomeFragments}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
Copy all text
</MenuItem>
<ListDivider />
<MenuItem onClick={handleOverallClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Remove All{llmAttachmentDrafts.length > 5 ? <span style={{ opacity: 0.5 }}> {llmAttachmentDrafts.length} attachments</span> : null}
</MenuItem>
</CloseableMenu>
)}
{/* 'Clear' Confirmation */}
{confirmClearAttachmentDrafts && (
<ConfirmationModal
open onClose={() => setConfirmClearAttachmentDrafts(false)} onPositive={handleOverallClearConfirmed}
title='Confirm Removal'
positiveActionText='Remove All'
confirmationText={`This action will remove all (${llmAttachmentDrafts.length}) attachments. Do you want to proceed?`}
/>
)}
</>;
}
@@ -0,0 +1,58 @@
import * as React from 'react';
import { DLLM, LLM_IF_OAI_Vision } from '~/modules/llms/store-llms';
import type { AttachmentDraft } from '~/common/attachment-drafts/attachment.types';
import type { DMessageAttachmentFragment } from '~/common/stores/chat/chat.fragments';
import { estimateTokensForFragments } from '~/common/stores/chat/chat.tokens';
export interface LLMAttachmentDrafts {
llmAttachmentDrafts: LLMAttachmentDraft[];
canAttachAllFragments: boolean;
canInlineSomeFragments: boolean;
llmTokenCountApprox: number | null;
}
export interface LLMAttachmentDraft {
attachmentDraft: AttachmentDraft;
llmSupportsAllFragments: boolean;
llmSupportsTextFragments: boolean;
llmTokenCountApprox: number | null;
}
export function useLLMAttachmentDrafts(attachmentDrafts: AttachmentDraft[], chatLLM: DLLM | null): LLMAttachmentDrafts {
return React.useMemo(() => {
// LLM-dependent multi-modal enablement
const supportsImages = !!chatLLM?.interfaces?.includes(LLM_IF_OAI_Vision);
const supportedTypes: DMessageAttachmentFragment['part']['pt'][] = supportsImages ? ['image_ref', 'doc'] : ['doc'];
const supportedTextTypes: DMessageAttachmentFragment['part']['pt'][] = supportedTypes.filter(pt => pt === 'doc');
// Add LLM-specific properties to each attachment draft
const llmAttachmentDrafts = attachmentDrafts.map((a): LLMAttachmentDraft => ({
attachmentDraft: a,
llmSupportsAllFragments: !a.outputFragments ? false : a.outputFragments.every(op => supportedTypes.includes(op.part.pt)),
llmSupportsTextFragments: !a.outputFragments ? false : a.outputFragments.some(op => supportedTextTypes.includes(op.part.pt)),
llmTokenCountApprox: chatLLM
? estimateTokensForFragments(a.outputFragments, chatLLM, true, 'useLLMAttachmentDrafts')
: null,
}));
// Calculate the overall properties
const canAttachAllFragments = llmAttachmentDrafts.every(a => a.llmSupportsAllFragments);
const canInlineSomeFragments = llmAttachmentDrafts.some(a => a.llmSupportsTextFragments);
const llmTokenCountApprox = chatLLM
? llmAttachmentDrafts.reduce((acc, a) => acc + (a.llmTokenCountApprox || 0), 0)
: null;
return {
llmAttachmentDrafts,
canAttachAllFragments,
canInlineSomeFragments,
llmTokenCountApprox,
};
}, [attachmentDrafts, chatLLM]);
}
@@ -10,7 +10,7 @@ import { BeamStoreApi, useBeamStore } from '~/modules/beam/store-beam.hooks';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { KeyStroke } from '~/common/components/KeyStroke';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcuts';
import { animationBackgroundBeamGather, animationColorBeamScatterINV, animationEnterBelow } from '~/common/util/animUtils';
@@ -59,7 +59,7 @@ export function ChatBarAltBeam(props: {
// intercept esc this beam is focused
useGlobalShortcut(ShortcutKeyName.Esc, false, false, false, handleCloseBeam);
useGlobalShortcuts([[ShortcutKeyName.Esc, false, false, false, handleCloseBeam]]);
return (
@@ -3,14 +3,14 @@ import * as React from 'react';
import { Box, Typography } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
import type { DConversationId } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { CHAT_NOVEL_TITLE } from '../AppChat';
import { CHAT_NOVEL_TITLE } from '../../AppChat';
import { FadeInButton } from './ChatDrawerItem';
import { FadeInButton } from '../layout-drawer/ChatDrawerItem';
export function ChatBarAltTitle(props: {
@@ -29,7 +29,7 @@ export function ChatBarAltTitle(props: {
const handleTitleEditAuto = React.useCallback(async () => {
if (!conversationId) return;
setIsEditingTitle(true);
await conversationAutoTitle(conversationId, true);
await autoConversationTitle(conversationId, true);
setIsEditingTitle(false);
}, [conversationId]);
@@ -1,10 +1,10 @@
import * as React from 'react';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { useChatLLMDropdown } from './useLLMDropdown';
import { usePersonaIdDropdown } from './usePersonaDropdown';
import { useFolderDropdown } from './folders/useFolderDropdown';
import { useFolderDropdown } from './useFolderDropdown';
export function ChatBarDropdowns(props: {
@@ -3,7 +3,7 @@ import * as React from 'react';
import ClearIcon from '@mui/icons-material/Clear';
import FolderIcon from '@mui/icons-material/Folder';
import type { DConversationId } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useFolderStore } from '~/common/state/store-folders';
@@ -1,13 +1,14 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { useShallow } from 'zustand/react/shallow';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { usePurposeStore } from './persona-selector/store-purposes';
import { usePurposeStore } from '../persona-selector/store-purposes';
function PersonaDropdown(props: {
@@ -17,9 +18,7 @@ function PersonaDropdown(props: {
// external state
const hiddenPurposeIDs = usePurposeStore(state => state.hiddenPurposeIDs);
const { zenMode } = useUIPreferencesStore(state => ({
zenMode: state.zenMode,
}), shallow);
const zenMode = useUIPreferencesStore(state => state.zenMode);
// filter by key in the object - must be missing the system purpose ids hidden by the user, or be the currently active one
@@ -54,12 +53,12 @@ function PersonaDropdown(props: {
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
// external state
const { systemPurposeId } = useChatStore(state => {
const { systemPurposeId } = useChatStore(useShallow(state => {
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
return {
systemPurposeId: conversation?.systemPurposeId ?? null,
};
}, shallow);
}));
const handleSetSystemPurposeId = React.useCallback((systemPurposeId: SystemPurposeId | null) => {
@@ -9,10 +9,11 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
import FolderIcon from '@mui/icons-material/Folder';
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { DebounceInputMemo } from '~/common/components/DebounceInput';
@@ -28,8 +29,8 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ChatDrawerItemMemo, FolderChangeRequest } from './ChatDrawerItem';
import { ChatFolderList } from './folders/ChatFolderList';
import { ChatNavGrouping, ChatSearchSorting, isDrawerSearching, useChatDrawerRenderItems } from './useChatDrawerRenderItems';
import { ClearFolderText } from './folders/useFolderDropdown';
import { useChatDrawerFilters } from '../store-app-chat';
import { ClearFolderText } from '../layout-bar/useFolderDropdown';
import { useChatDrawerFilters } from '../../store-app-chat';
// this is here to make shallow comparisons work on the next hook
@@ -75,7 +76,7 @@ function ChatDrawer(props: {
// local state
const [navGrouping, setNavGrouping] = React.useState<ChatNavGrouping>('date');
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('frequency');
const [searchSorting, setSearchSorting] = React.useState<ChatSearchSorting>('date');
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
@@ -83,12 +84,13 @@ function ChatDrawer(props: {
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
const {
filterHasStars, toggleFilterHasStars,
filterHasImageAssets, toggleFilterHasImageAssets,
showPersonaIcons, toggleShowPersonaIcons,
showRelativeSize, toggleShowRelativeSize,
} = useChatDrawerFilters();
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
const { filteredChatsCount, filteredChatIDs, filteredChatsAreEmpty, filteredChatsBarBasis, filteredChatsIncludeActive, renderNavItems } = useChatDrawerRenderItems(
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, navGrouping, searchSorting, showRelativeSize,
props.activeConversationId, props.chatPanesConversationIds, debouncedSearchQuery, activeFolder, allFolders, filterHasStars, filterHasImageAssets, navGrouping, searchSorting, showRelativeSize,
);
const { contentScaling, showSymbols } = useUIPreferencesStore(useShallow(state => ({
contentScaling: state.contentScaling,
@@ -159,11 +161,11 @@ function ChatDrawer(props: {
{!isSearching ? (
// Search/Filter default menu: Grouping, Filtering, ...
<Menu placement='bottom-start' sx={{ minWidth: 180, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
<Menu placement='bottom-start' sx={{ minWidth: 200, zIndex: themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */ }}>
<ListItem>
<Typography level='body-sm'>Group By</Typography>
</ListItem>
{(['date', 'persona'] as const).map(_gName => (
{(['date', 'persona', 'dimension'] as Exclude<ChatNavGrouping, false>[]).map(_gName => (
<MenuItem
key={'group-' + _gName}
aria-label={`Group by ${_gName}`}
@@ -183,6 +185,10 @@ function ChatDrawer(props: {
<ListItemDecorator>{filterHasStars && <CheckRoundedIcon />}</ListItemDecorator>
Starred <StarOutlineRoundedIcon />
</MenuItem>
<MenuItem onClick={toggleFilterHasImageAssets}>
<ListItemDecorator>{filterHasImageAssets && <CheckRoundedIcon />}</ListItemDecorator>
Has Images <FormatPaintOutlinedIcon />
</MenuItem>
<ListDivider />
<ListItem>
@@ -214,7 +220,10 @@ function ChatDrawer(props: {
</Menu>
)}
</Dropdown>
), [filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize]);
), [
filterHasImageAssets, filterHasStars, isSearching, navGrouping, searchSorting, showPersonaIcons, showRelativeSize,
toggleFilterHasImageAssets, toggleFilterHasStars, toggleShowPersonaIcons, toggleShowRelativeSize,
]);
return <>
@@ -11,17 +11,18 @@ import FolderIcon from '@mui/icons-material/Folder';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import type { DFolder } from '~/common/state/store-folders';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { isDeepEqual } from '~/common/util/jsUtils';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { CHAT_NOVEL_TITLE } from '../AppChat';
import { STREAM_TEXT_INDICATOR } from '../editors/chat-stream';
import { ANIM_BUSY_TYPING } from '../message/messageUtils';
import { CHAT_NOVEL_TITLE } from '../../AppChat';
// set to true to display the conversation IDs
@@ -54,11 +55,13 @@ export interface ChatNavigationItemData {
isAlsoOpen: string | false;
isEmpty: boolean;
title: string;
userSymbol: string | undefined;
userFlagsSummary: string | undefined;
containsImageAssets: boolean;
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
updatedAt: number;
messageCount: number;
assistantTyping: boolean;
beingGenerated: boolean;
systemPurposeId: SystemPurposeId;
searchFrequency: number;
}
@@ -88,7 +91,20 @@ function ChatDrawerItem(props: {
// derived state
const { onConversationBranch, onConversationExport, onConversationFolderChange } = props;
const { conversationId, isActive, isAlsoOpen, title, userFlagsSummary, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const {
conversationId,
isActive,
isAlsoOpen,
title,
userSymbol,
userFlagsSummary,
containsImageAssets,
folder,
messageCount,
beingGenerated,
systemPurposeId,
searchFrequency,
} = props.item;
const isNew = messageCount === 0;
@@ -148,7 +164,7 @@ function ChatDrawerItem(props: {
const handleTitleEditAuto = React.useCallback(async () => {
setIsAutoEditingTitle(true);
await conversationAutoTitle(conversationId, true);
await autoConversationTitle(conversationId, true);
setIsAutoEditingTitle(false);
}, [conversationId]);
@@ -158,7 +174,7 @@ function ChatDrawerItem(props: {
const { onConversationDeleteNoConfirmation } = props;
const handleDeleteButtonShow = React.useCallback((event: React.MouseEvent) => {
// special case: if 'Shift' is pressed, delete immediately
if (event.shiftKey) {
if (event.shiftKey) { // immediately delete:conversation
event.stopPropagation();
onConversationDeleteNoConfirmation(conversationId);
return;
@@ -177,7 +193,7 @@ function ChatDrawerItem(props: {
}, [conversationId, deleteArmed, onConversationDeleteNoConfirmation]);
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
const textSymbol = userSymbol || SystemPurposes[systemPurposeId]?.symbol || '❓';
const progress = props.bottomBarBasis ? 100 * (searchFrequency || messageCount) / props.bottomBarBasis : 0;
@@ -185,11 +201,11 @@ function ChatDrawerItem(props: {
{/* Symbol, if globally enabled */}
{props.showSymbols && <ListItemDecorator>
{assistantTyping
{beingGenerated
? (
<Avatar
alt='typing' variant='plain'
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
alt='activity' variant='plain'
src={ANIM_BUSY_TYPING}
sx={{
width: '1.5rem',
height: '1.5rem',
@@ -211,11 +227,12 @@ function ChatDrawerItem(props: {
onDoubleClick={handleTitleEditBegin}
sx={{
color: isActive ? 'text.primary' : 'text.secondary',
overflowWrap: 'anywhere',
flex: 1,
}}
>
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
{title.trim() ? title : CHAT_NOVEL_TITLE}{assistantTyping && STREAM_TEXT_INDICATOR}
{title.trim() ? title : CHAT_NOVEL_TITLE}{beingGenerated && ' ...'}
</Box>
) : (
<InlineTextarea
@@ -236,13 +253,20 @@ function ChatDrawerItem(props: {
<Typography level='body-sm'>
{searchFrequency}
</Typography>
) : (userFlagsSummary && props.showSymbols) ? (
<Typography sx={{ mr: '5px' }}>
{userFlagsSummary}
</Typography>
) : (props.showSymbols && (userFlagsSummary || containsImageAssets)) ? (
<Box sx={{
fontSize: 'xs',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}>
{userFlagsSummary}{containsImageAssets && '🖍️'}
</Box>
) : null}
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary]);
</>, [
beingGenerated, containsImageAssets, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive,
isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title, userFlagsSummary,
]);
const progressBarFixedComponent = React.useMemo(() =>
progress > 0 && (
@@ -273,6 +297,7 @@ function ChatDrawerItem(props: {
}),
// style
fontSize: 'inherit',
backgroundColor: isActive ? 'neutral.solidActiveBg' : 'neutral.softBg',
borderRadius: 'md',
mx: '0.25rem',
@@ -325,7 +350,7 @@ function ChatDrawerItem(props: {
</FadeInButton>
</Tooltip>
<Tooltip disableInteractive title='Branch'>
<Tooltip disableInteractive title='Duplicate (Branch)'>
<FadeInButton size='sm' onClick={handleConversationBranch}>
<ForkRightIcon />
</FadeInButton>
@@ -1,7 +1,11 @@
import { shallow } from 'zustand/shallow';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import type { DFolder } from '~/common/state/store-folders';
import { conversationTitle, DConversationId, DMessageUserFlag, messageHasUserFlag, messageUserFlagToEmoji, useChatStore } from '~/common/state/store-chats';
import { DMessage, DMessageUserFlag, messageFragmentsReduceText, messageHasUserFlag, messageUserFlagToEmoji } from '~/common/stores/chat/chat.message';
import { conversationTitle, DConversationId } from '~/common/stores/chat/chat.conversation';
import { isContentOrAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
import { useChatStore } from '~/common/stores/chat/store-chats';
import type { ChatNavigationItemData } from './ChatDrawerItem';
@@ -10,7 +14,7 @@ import type { ChatNavigationItemData } from './ChatDrawerItem';
const SEARCH_MIN_CHARS = 3;
export type ChatNavGrouping = false | 'date' | 'persona';
export type ChatNavGrouping = false | 'date' | 'persona' | 'dimension';
export type ChatSearchSorting = 'frequency' | 'date';
@@ -88,6 +92,7 @@ export function useChatDrawerRenderItems(
activeFolder: DFolder | null,
allFolders: DFolder[],
filterHasStars: boolean,
filterHasImageAssets: boolean,
grouping: ChatNavGrouping,
searchSorting: ChatSearchSorting,
showRelativeSize: boolean,
@@ -99,7 +104,7 @@ export function useChatDrawerRenderItems(
filteredChatsBarBasis: number,
filteredChatsIncludeActive: boolean,
} {
return useChatStore(({ conversations }) => {
return useStoreWithEqualityFn(useChatStore, ({ conversations }) => {
// filter 1: select all conversations or just the ones in the active folder
const selectedConversations = !activeFolder ? conversations : conversations.filter(_c => activeFolder.conversationIds.includes(_c.id));
@@ -107,9 +112,14 @@ export function useChatDrawerRenderItems(
// filter 2: preparation: lowercase the query
const { isSearching, lcTextQuery } = isDrawerSearching(filterByQuery);
function messageHasImageFragments(message: DMessage): boolean {
return message.fragments.some(fragment => isContentOrAttachmentFragment(fragment) && isImageRefPart(fragment.part) /*&& fragment.part.dataRef.reftype === 'dblob'*/);
}
// transform (the conversations into ChatNavigationItemData) + filter2 (if searching)
const chatNavItems = selectedConversations
.filter(_c => !filterHasStars || _c.messages.some(m => messageHasUserFlag(m, 'starred')))
.filter(_c => !filterHasImageAssets || _c.messages.some(messageHasImageFragments))
.map((_c): ChatNavigationItemData => {
// rich properties
const title = conversationTitle(_c);
@@ -119,7 +129,9 @@ export function useChatDrawerRenderItems(
let searchFrequency: number = 0;
if (isSearching) {
const titleFrequency = title.toLowerCase().split(lcTextQuery).length - 1;
const messageFrequency = _c.messages.reduce((count, message) => count + (message.text.toLowerCase().split(lcTextQuery).length - 1), 0);
const messageFrequency = _c.messages.reduce((count, message) => {
return count + messageFragmentsReduceText(message.fragments).toLowerCase().split(lcTextQuery).length - 1;
}, 0);
searchFrequency = titleFrequency + messageFrequency;
}
@@ -127,6 +139,7 @@ export function useChatDrawerRenderItems(
const allFlags = new Set<DMessageUserFlag>();
_c.messages.forEach(_m => _m.userFlags?.forEach(flag => allFlags.add(flag)));
const userFlagsSummary = !allFlags.size ? undefined : Array.from(allFlags).map(messageUserFlagToEmoji).join('');
const containsImageAssets = filterHasImageAssets || _c.messages.some(messageHasImageFragments);
// create the ChatNavigationData
return {
@@ -136,7 +149,9 @@ export function useChatDrawerRenderItems(
isAlsoOpen,
isEmpty: !_c.messages.length && !_c.userTitle,
title,
userSymbol: _c.userSymbol || undefined,
userFlagsSummary,
containsImageAssets,
folder: !allFolders.length
? undefined // don't show folder select if folders are disabled
: _c.id === activeConversationId // only show the folder for active conversation(s)
@@ -144,7 +159,7 @@ export function useChatDrawerRenderItems(
: null,
updatedAt: _c.updated || _c.created || 0,
messageCount: _c.messages.length,
assistantTyping: !!_c.abortController,
beingGenerated: !!_c.abortController, // FIXME: when the AbortController is moved at the message level, derive the state in the conv
systemPurposeId: _c.systemPurposeId,
searchFrequency,
};
@@ -173,25 +188,53 @@ export function useChatDrawerRenderItems(
// [grouping] group by date or persona
else if (grouping) {
// [grouping/date]: sort by update time
const midnightTime = getNextMidnightTime();
if (grouping === 'date')
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
switch (grouping) {
// [grouping/date or persona]: sort by last updated
case 'date':
case 'persona':
chatNavItems.sort((a, b) => b.updatedAt - a.updatedAt);
break;
// [grouping/dimension]: sort by message count
case 'dimension':
chatNavItems.sort((a, b) => b.messageCount - a.messageCount);
break;
}
// Array.groupBy(...)
const midnightTime = getNextMidnightTime();
const grouped = chatNavItems.reduce((acc, item) => {
const groupName = grouping === 'date'
? getTimeBucketEn(item.updatedAt || midnightTime, midnightTime)
: item.systemPurposeId;
// derive the bucket name
let bucket: string;
switch (grouping) {
case 'date':
bucket = getTimeBucketEn(item.updatedAt || midnightTime, midnightTime);
break;
case 'persona':
bucket = item.systemPurposeId;
break;
case 'dimension':
if (item.messageCount > 20)
bucket = 'Large chats';
else if (item.messageCount > 10)
bucket = 'Medium chats';
else if (item.messageCount > 5)
bucket = 'Small chats';
else if (item.messageCount > 1)
bucket = 'Tiny chats';
else if (item.messageCount === 1)
bucket = 'Single message';
else
bucket = 'Empty chats';
break;
}
if (!acc[groupName])
acc[groupName] = [];
acc[groupName].push(item);
if (!acc[bucket])
acc[bucket] = [];
acc[bucket].push(item);
return acc;
}, {} as { [groupName: string]: ChatNavigationItemData[] });
// prepend groups
// prepend group names as special items
renderNavItems = Object.entries(grouped).flatMap(([groupName, items]) => [
{ type: 'nav-item-group', title: groupName },
...items,
@@ -202,9 +245,11 @@ export function useChatDrawerRenderItems(
if (!renderNavItems.length)
renderNavItems.push({
type: 'nav-item-info-message',
message: filterHasStars ? 'No starred results'
: isSearching ? 'No results found'
: 'No conversations in folder',
message: (filterHasStars && filterHasImageAssets) ? 'No starred results with images'
: filterHasImageAssets ? 'No image results'
: filterHasStars ? 'No starred results'
: isSearching ? 'No results found'
: 'No conversations in folder',
});
// other derived state
@@ -13,12 +13,12 @@ import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutl
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import VerticalSplitOutlinedIcon from '@mui/icons-material/VerticalSplitOutlined';
import type { DConversationId } from '~/common/state/store-chats';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useChatShowSystemMessages } from '../store-app-chat';
import { usePaneDuplicateOrClose } from './panes/usePanesManager';
import { useChatShowSystemMessages } from '../../store-app-chat';
import { usePaneDuplicateOrClose } from '../panes/usePanesManager';
export function ChatPageMenuItems(props: {
+283 -299
View File
@@ -1,207 +1,57 @@
import * as React from 'react';
import { useShallow } from 'zustand/react/shallow';
import TimeAgo from 'react-timeago';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, ButtonGroup, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
import { Box, Button, ButtonGroup, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
import { ClickAwayListener, Popper } from '@mui/base';
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import ClearIcon from '@mui/icons-material/Clear';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DifferenceIcon from '@mui/icons-material/Difference';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import Face6Icon from '@mui/icons-material/Face6';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import RecordVoiceOverOutlinedIcon from '@mui/icons-material/RecordVoiceOverOutlined';
import ReplayIcon from '@mui/icons-material/Replay';
import ReplyRoundedIcon from '@mui/icons-material/ReplyRounded';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import StarOutlineRoundedIcon from '@mui/icons-material/StarOutlineRounded';
import StarRoundedIcon from '@mui/icons-material/StarRounded';
import TelegramIcon from '@mui/icons-material/Telegram';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { BlocksRenderer, editBlocksSx } from '~/modules/blocks/BlocksRenderer';
import { useSanityTextDiffs } from '~/modules/blocks/RenderTextDiff';
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DMessage, DMessageUserFlag, messageHasUserFlag } from '~/common/state/store-chats';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { DMessage, DMessageId, DMessageUserFlag, messageFragmentsReduceText, messageHasUserFlag } from '~/common/stores/chat/chat.message';
import { KeyStroke } from '~/common/components/KeyStroke';
import { Link } from '~/common/components/Link';
import { adjustContentScaling, themeScalingMap, themeZIndexPageBar } from '~/common/app.theme';
import { animationColorRainbow } from '~/common/util/animUtils';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { createTextContentFragment, DMessageAttachmentFragment, DMessageContentFragment, DMessageFragment, DMessageFragmentId, isAttachmentFragment, isContentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
import { prettyBaseModel } from '~/common/util/modelUtils';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ContentFragments } from './fragments-content/ContentFragments';
import { DocumentFragments } from './fragments-attachment-doc/DocumentFragments';
import { ImageAttachmentFragments } from './fragments-attachment-image/ImageAttachmentFragments';
import { ReplyToBubble } from './ReplyToBubble';
import { avatarIconSx, makeMessageAvatar, messageAsideColumnSx, messageBackground } from './messageUtils';
import { useChatShowTextDiff } from '../../store-app-chat';
// Enable the menu on text selection
const ENABLE_SELECTION_RIGHT_CLICK_MENU = false;
const ENABLE_SELECTION_TOOLBAR = true;
const SELECTION_TOOLBAR_MIN_LENGTH = 3;
const ENABLE_CONTEXT_MENU = false;
const ENABLE_BUBBLE = true;
const BUBBLE_MIN_TEXT_LENGTH = 3;
// 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_OVERLAY: boolean = false;
export function messageBackground(messageRole: DMessage['role'] | string, wasEdited: boolean, isAssistantIssue: boolean): string {
switch (messageRole) {
case 'user':
return 'primary.plainHoverBg'; // was .background.level1
case 'assistant':
return isAssistantIssue ? 'danger.softBg' : 'background.surface';
case 'system':
return wasEdited ? 'warning.softHoverBg' : 'neutral.softBg';
default:
return '#ff0000';
}
}
const avatarIconSx = {
width: 36,
height: 36,
};
const personaSx: SxProps = {
// make this stick to the top of the screen
position: 'sticky',
top: 0,
// flexBasis: 0, // this won't let the item grow
minWidth: { xs: 50, md: 64 },
maxWidth: 80,
textAlign: 'center',
// layout
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
};
export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['role'] | string, messageOriginLLM: string | undefined, messagePurposeId: SystemPurposeId | undefined, messageSender: string, messageTyping: boolean, size: 'sm' | undefined = undefined): React.JSX.Element {
if (typeof messageAvatar === 'string' && messageAvatar)
return <Avatar alt={messageSender} src={messageAvatar} />;
const mascotSx = size === 'sm' ? avatarIconSx : { width: 64, height: 64 };
switch (messageRole) {
case 'system':
return <SettingsSuggestIcon sx={avatarIconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
case 'user':
return <Face6Icon sx={avatarIconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
case 'assistant':
// typing gif (people seem to love this, so keeping it after april fools')
const isDownload = messageOriginLLM === 'web';
const isTextToImage = messageOriginLLM === 'DALL·E' || messageOriginLLM === 'Prodia';
const isReact = messageOriginLLM?.startsWith('react-');
// animation: message typing
if (messageTyping)
return <Avatar
alt={messageSender} variant='plain'
src={isDownload ? 'https://i.giphy.com/26u6dIwIphLj8h10A.webp' // hourglass: https://i.giphy.com/TFSxpAIYz5inJGuY8f.webp, small-lq: https://i.giphy.com/131tNuGktpXGhy.webp, floppy: https://i.giphy.com/RxR1KghIie2iI.webp
: isTextToImage ? 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp' // brush
: isReact ? 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp' // mind
: 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'} // typing
sx={{ ...mascotSx, borderRadius: 'sm' }}
/>;
// icon: text-to-image
if (isTextToImage)
return <FormatPaintOutlinedIcon sx={{
...avatarIconSx,
animation: `${animationColorRainbow} 1s linear 2.66`,
}} />;
// purpose symbol (if present)
const symbol = SystemPurposes[messagePurposeId!]?.symbol;
if (symbol)
return <Box sx={{
fontSize: '24px',
textAlign: 'center',
width: '100%',
minWidth: `${avatarIconSx.width}px`,
lineHeight: `${avatarIconSx.height}px`,
}}>
{symbol}
</Box>;
// default assistant avatar
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
}
return <Avatar alt={messageSender} />;
}
function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: string) {
const isAssistantError = isAssistant && (text.startsWith('[Issue] ') || text.startsWith('[OpenAI Issue]'));
let errorMessage: React.JSX.Element | null = null;
if (!isAssistantError)
return { errorMessage, isAssistantError };
// [OpenAI] "Service Temporarily Unavailable (503)", {"code":503,"message":"Service Unavailable.","param":null,"type":"cf_service_unavailable"}
if (text.includes('"cf_service_unavailable"')) {
errorMessage = <>
The OpenAI servers appear to be having trouble at the moment. Kindly follow
the <Link noLinkStyle href='https://status.openai.com/' target='_blank'>OpenAI Status</Link> page
for up to date information, and at your option try again.
</>;
}
// ...
else if (text.startsWith('OpenAI API error: 429 Too Many Requests')) {
// TODO: retry at the api/chat level a few times instead of showing this error
errorMessage = <>
The model appears to be occupied at the moment. Kindly select <b>GPT-3.5 Turbo</b>,
or give it another go by selecting <b>Run again</b> from the message menu.
</>;
} else if (text.includes('"model_not_found"')) {
// note that "model_not_found" is different than "The model `gpt-xyz` does not exist" message
errorMessage = <>
The API key appears to be unauthorized for {modelId || 'this model'}. You can change to <b>GPT-3.5
Turbo</b> and simultaneously <Link noLinkStyle href='https://openai.com/waitlist/gpt-4-api' target='_blank'>request
access</Link> to the desired model.
</>;
} else if (text.includes('"context_length_exceeded"')) {
// TODO: propose to summarize or split the input?
const pattern = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/;
const match = pattern.exec(text);
const usedText = match ? <b>{parseInt(match[2] || '0').toLocaleString()} tokens &gt; {parseInt(match[1] || '0').toLocaleString()}</b> : '';
errorMessage = <>
This thread <b>surpasses the maximum size</b> allowed for {modelId || 'this model'}. {usedText}.
Please consider removing some earlier messages from the conversation, start a new conversation,
choose a model with larger context, or submit a shorter new message.
{!usedText && ` -- ${text}`}
</>;
}
// [OpenAI] {"error":{"message":"Incorrect API key provided: ...","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}
else if (text.includes('"invalid_api_key"')) {
errorMessage = <>
The API key appears to be incorrect or to have expired.
Please <Link noLinkStyle href='https://platform.openai.com/account/api-keys' target='_blank'>check your
API key</Link> and update it in <b>Models</b>.
</>;
} else if (text.includes('"insufficient_quota"')) {
errorMessage = <>
The API key appears to have <b>insufficient quota</b>. Please
check <Link noLinkStyle href='https://platform.openai.com/account/usage' target='_blank'>your usage</Link> and
make sure the usage is under <Link noLinkStyle href='https://platform.openai.com/account/billing/limits' target='_blank'>the limits</Link>.
</>;
}
// else
// errorMessage = <>{text || 'Unknown error'}</>;
return { errorMessage, isAssistantError };
}
export type ChatMessageTextPartEditState = { [fragmentId: DMessageFragmentId]: string };
export const ChatMessageMemo = React.memo(ChatMessage);
@@ -217,6 +67,7 @@ export function ChatMessage(props: {
message: DMessage,
diffPreviousText?: string,
fitScreen: boolean,
isMobile?: boolean,
isBottom?: boolean,
isImagining?: boolean,
isSpeaking?: boolean,
@@ -229,24 +80,26 @@ export function ChatMessage(props: {
onMessageBeam?: (messageId: string) => Promise<void>,
onMessageBranch?: (messageId: string) => void,
onMessageDelete?: (messageId: string) => void,
onMessageEdit?: (messageId: string, text: string) => void,
onMessageFragmentAppend?: (messageId: DMessageId, fragment: DMessageFragment) => void
onMessageFragmentDelete?: (messageId: DMessageId, fragmentId: DMessageFragmentId) => void,
onMessageFragmentReplace?: (messageId: DMessageId, fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => void,
onMessageToggleUserFlag?: (messageId: string, flag: DMessageUserFlag) => void,
onMessageTruncate?: (messageId: string) => void,
onReplyTo?: (messageId: string, selectedText: string) => void,
onTextDiagram?: (messageId: string, text: string) => Promise<void>
onTextImagine?: (text: string) => Promise<void>
onTextSpeak?: (text: string) => Promise<void>
onTextDiagram?: (messageId: string, text: string) => Promise<void>,
onTextImagine?: (text: string) => Promise<void>,
onTextSpeak?: (text: string) => Promise<void>,
sx?: SxProps,
}) {
// state
const blocksRendererRef = React.useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = React.useState(false);
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
const [selMenuAnchor, setSelMenuAnchor] = React.useState<HTMLElement | null>(null);
const [selToolbarAnchor, setSelToolbarAnchor] = React.useState<HTMLElement | null>(null);
const [selText, setSelText] = React.useState<string | null>(null);
const [isEditing, setIsEditing] = React.useState(false);
const [bubbleAnchor, setBubbleAnchor] = React.useState<HTMLElement | null>(null);
const [contextMenuAnchor, setContextMenuAnchor] = React.useState<HTMLElement | null>(null);
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
const [textContentEditState, setTextContentEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
// external state
const { showAvatar, contentScaling, doubleClickToEdit, renderMarkdown } = useUIPreferencesStore(useShallow(state => ({
@@ -256,16 +109,15 @@ export function ChatMessage(props: {
renderMarkdown: state.renderMarkdown,
})));
const [showDiff, setShowDiff] = useChatShowTextDiff();
const textDiffs = useSanityTextDiffs(props.message.text, props.diffPreviousText, showDiff);
// derived state
const {
id: messageId,
text: messageText,
sender: messageSender,
avatar: messageAvatar,
typing: messageTyping,
role: messageRole,
fragments: messageFragments,
pendingIncomplete: messagePendingIncomplete,
avatar: messageAvatar,
purposeId: messagePurposeId,
originLLM: messageOriginLLM,
metadata: messageMetadata,
@@ -273,28 +125,75 @@ export function ChatMessage(props: {
updated: messageUpdated,
} = props.message;
// split the fragments: Image Attachments are rendered as cards, Content is the body (sequence of parts), and other attachment fragments as documents
const contentFragments: DMessageContentFragment[] = [];
const imageAttachments: DMessageAttachmentFragment[] = [];
const nonImageAttachments: DMessageAttachmentFragment[] = [];
messageFragments.forEach(fragment => {
if (isContentFragment(fragment)) contentFragments.push(fragment);
else if (isAttachmentFragment(fragment)) {
if (isImageRefPart(fragment.part)) imageAttachments.push(fragment);
else nonImageAttachments.push(fragment);
} else
console.warn('Unexpected fragment type:', fragment.ft);
});
const isUserStarred = messageHasUserFlag(props.message, 'starred');
const fromAssistant = messageRole === 'assistant';
const fromSystem = messageRole === 'system';
const wasEdited = !!messageUpdated;
const textSel = selText ? selText : messageText;
// WARNING: if you get an issue here, you're downgrading from the new Big-AGI 2 data format to 1.x.
const textSel = selText ? selText : messageFragmentsReduceText(contentFragments);
const isSpecialT2I = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/draw ') || textSel.startsWith('/imagine ') || textSel.startsWith('/img ');
const couldDiagram = textSel.length >= 100 && !isSpecialT2I;
const couldImagine = textSel.length >= 3 && !isSpecialT2I;
const couldSpeak = couldImagine;
const handleTextEdited = (editedText: string) => {
setIsEditing(false);
if (props.onMessageEdit && editedText?.trim() && editedText !== messageText)
props.onMessageEdit(messageId, editedText);
};
// TODO: fix the diffing
// const textDiffs = useSanityTextDiffs(messageText, props.diffPreviousText, showDiff);
// Operations Menu
const { onMessageFragmentAppend, onMessageFragmentDelete, onMessageFragmentReplace } = props;
const handleFragmentNew = React.useCallback(() => {
onMessageFragmentAppend?.(messageId, createTextContentFragment(''));
}, [messageId, onMessageFragmentAppend]);
const handleFragmentDelete = React.useCallback((fragmentId: DMessageFragmentId) => {
onMessageFragmentDelete?.(messageId, fragmentId);
}, [messageId, onMessageFragmentDelete]);
const handleFragmentReplace = React.useCallback((fragmentId: DMessageFragmentId, newFragment: DMessageFragment) => {
onMessageFragmentReplace?.(messageId, fragmentId, newFragment);
}, [messageId, onMessageFragmentReplace]);
// Text Editing
const isEditingText = !!textContentEditState;
const handleEditsApply = React.useCallback(() => {
const state = textContentEditState || {};
setTextContentEditState(null);
Object.entries(state).forEach(([fragmentId, editedText]) => {
if (editedText.length > 0)
handleFragmentReplace(fragmentId, createTextContentFragment(editedText));
else
handleFragmentDelete(fragmentId);
});
}, [handleFragmentDelete, handleFragmentReplace, textContentEditState]);
const handleEditsBegin = React.useCallback(() => setTextContentEditState({}), []);
const handleEditsCancel = React.useCallback(() => setTextContentEditState(null), []);
const handleEditSetText = React.useCallback((fragmentId: DMessageFragmentId, editedText: string) =>
setTextContentEditState((prev): ChatMessageTextPartEditState => ({ ...prev, [fragmentId]: editedText || '' })), []);
// Message Operations Menu
const { onMessageToggleUserFlag } = props;
@@ -309,16 +208,17 @@ export function ChatMessage(props: {
copyToClipboard(textSel, 'Text');
e.preventDefault();
handleCloseOpsMenu();
closeSelectionMenu();
closeToolbar();
closeContextMenu();
closeBubble();
};
const handleOpsEdit = React.useCallback((e: React.MouseEvent) => {
if (messageTyping && !isEditing) return; // don't allow editing while typing
setIsEditing(!isEditing);
const handleOpsEditToggle = React.useCallback((e: React.MouseEvent) => {
if (messagePendingIncomplete && !isEditingText) return; // don't allow editing while incomplete
if (isEditingText) handleEditsCancel();
else handleEditsBegin();
e.preventDefault();
handleCloseOpsMenu();
}, [handleCloseOpsMenu, isEditing, messageTyping]);
}, [handleCloseOpsMenu, handleEditsBegin, handleEditsCancel, isEditingText, messagePendingIncomplete]);
const handleOpsToggleStarred = React.useCallback(() => {
onMessageToggleUserFlag?.(messageId, 'starred');
@@ -350,8 +250,8 @@ export function ChatMessage(props: {
if (props.onTextDiagram) {
await props.onTextDiagram(messageId, textSel);
handleCloseOpsMenu();
closeSelectionMenu();
closeToolbar();
closeContextMenu();
closeBubble();
}
};
@@ -360,18 +260,18 @@ export function ChatMessage(props: {
if (props.onTextImagine) {
await props.onTextImagine(textSel);
handleCloseOpsMenu();
closeSelectionMenu();
closeToolbar();
closeContextMenu();
closeBubble();
}
};
const handleOpsReplyTo = (e: React.MouseEvent) => {
e.preventDefault();
if (props.onReplyTo && textSel.trim().length >= SELECTION_TOOLBAR_MIN_LENGTH) {
if (props.onReplyTo && textSel.trim().length >= BUBBLE_MIN_TEXT_LENGTH) {
props.onReplyTo(messageId, textSel.trim());
handleCloseOpsMenu();
closeSelectionMenu();
closeToolbar();
closeContextMenu();
closeBubble();
}
};
@@ -380,8 +280,8 @@ export function ChatMessage(props: {
if (props.onTextSpeak) {
await props.onTextSpeak(textSel);
handleCloseOpsMenu();
closeSelectionMenu();
closeToolbar();
closeContextMenu();
closeBubble();
}
};
@@ -395,24 +295,24 @@ export function ChatMessage(props: {
};
// Selection Menu
// Context Menu
const removeSelectionAnchor = React.useCallback(() => {
if (selMenuAnchor) {
const removeContextAnchor = React.useCallback(() => {
if (contextMenuAnchor) {
try {
document.body.removeChild(selMenuAnchor);
document.body.removeChild(contextMenuAnchor);
} catch (e) {
// ignore...
}
}
}, [selMenuAnchor]);
}, [contextMenuAnchor]);
const openSelectionMenu = React.useCallback((event: MouseEvent, selectedText: string) => {
const openContextMenu = React.useCallback((event: MouseEvent, selectedText: string) => {
event.stopPropagation();
event.preventDefault();
// remove any stray anchor
removeSelectionAnchor();
removeContextAnchor();
// create a temporary fixed anchor element to position the menu
const anchorEl = document.createElement('div');
@@ -421,16 +321,16 @@ export function ChatMessage(props: {
anchorEl.style.top = `${event.clientY}px`;
document.body.appendChild(anchorEl);
setSelMenuAnchor(anchorEl);
setContextMenuAnchor(anchorEl);
setSelText(selectedText);
}, [removeSelectionAnchor]);
}, [removeContextAnchor]);
const closeSelectionMenu = React.useCallback(() => {
const closeContextMenu = React.useCallback(() => {
// window.getSelection()?.removeAllRanges?.();
removeSelectionAnchor();
setSelMenuAnchor(null);
removeContextAnchor();
setContextMenuAnchor(null);
setSelText(null);
}, [removeSelectionAnchor]);
}, [removeContextAnchor]);
const handleContextMenu = React.useCallback((event: MouseEvent) => {
const selection = window.getSelection();
@@ -438,33 +338,34 @@ export function ChatMessage(props: {
const range = selection.getRangeAt(0);
const selectedText = range.toString().trim();
if (selectedText.length > 0)
openSelectionMenu(event, selectedText);
openContextMenu(event, selectedText);
}
}, [openSelectionMenu]);
}, [openContextMenu]);
// Selection Toolbar
// Bubble
const closeToolbar = React.useCallback((anchorEl?: HTMLElement) => {
const closeBubble = React.useCallback((anchorEl?: HTMLElement) => {
window.getSelection()?.removeAllRanges?.();
try {
const anchor = anchorEl || selToolbarAnchor;
const anchor = anchorEl || bubbleAnchor;
anchor && document.body.removeChild(anchor);
} catch (e) {
// ignore...
}
setSelToolbarAnchor(null);
setBubbleAnchor(null);
setSelText(null);
}, [selToolbarAnchor]);
}, [bubbleAnchor]);
const handleOpenToolbar = React.useCallback((_event: MouseEvent) => {
// restore blocksRendererRef
const handleOpenBubble = React.useCallback((_event: MouseEvent) => {
// check for selection
const selection = window.getSelection();
if (!selection || selection.rangeCount <= 0) return;
// check for enought selection
const selectionText = selection.toString().trim();
if (selectionText.length < SELECTION_TOOLBAR_MIN_LENGTH) return;
if (selectionText.length < BUBBLE_MIN_TEXT_LENGTH) return;
// check for the selection being inside the blocks renderer (core of the message)
const selectionRange = selection.getRangeAt(0);
@@ -486,15 +387,15 @@ export function ChatMessage(props: {
const closeOnUnselect = () => {
const selection = window.getSelection();
if (!selection || selection.toString().trim() === '') {
closeToolbar(anchorEl);
closeBubble(anchorEl);
document.removeEventListener('selectionchange', closeOnUnselect);
}
};
document.addEventListener('selectionchange', closeOnUnselect);
setSelToolbarAnchor(anchorEl);
setSelText(selectionText);
}, [closeToolbar]);
setBubbleAnchor(anchorEl);
setSelText(selectionText); /* TODO: operate on the underlying content, not the rendered text */
}, [closeBubble]);
// Blocks renderer
@@ -504,34 +405,28 @@ export function ChatMessage(props: {
}, [handleContextMenu]);
const handleBlocksDoubleClick = React.useCallback((event: React.MouseEvent) => {
doubleClickToEdit && props.onMessageEdit && handleOpsEdit(event);
}, [doubleClickToEdit, handleOpsEdit, props.onMessageEdit]);
doubleClickToEdit && props.onMessageFragmentReplace && handleOpsEditToggle(event);
}, [doubleClickToEdit, handleOpsEditToggle, props.onMessageFragmentReplace]);
const handleBlocksMouseUp = React.useCallback((event: React.MouseEvent) => {
handleOpenToolbar(event.nativeEvent);
}, [handleOpenToolbar]);
handleOpenBubble(event.nativeEvent);
}, [handleOpenBubble]);
// prettier upstream errors
const { isAssistantError, errorMessage } = React.useMemo(
() => explainErrorInMessage(messageText, fromAssistant, messageOriginLLM),
[messageText, fromAssistant, messageOriginLLM],
);
// style
const backgroundColor = messageBackground(messageRole, wasEdited, isAssistantError && !errorMessage);
const backgroundColor = messageBackground(messageRole, wasEdited, false /*isAssistantError && !errorMessage*/);
// avatar
const avatarEl: React.JSX.Element | null = React.useMemo(
() => showAvatar ? makeAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, messageSender, messageTyping) : null,
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping, showAvatar],
() => showAvatar ? makeMessageAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, !!messagePendingIncomplete, true) : null,
[messageAvatar, messageOriginLLM, messagePendingIncomplete, messagePurposeId, messageRole, showAvatar],
);
return (
<ListItem
role='chat-message'
onMouseUp={(ENABLE_SELECTION_TOOLBAR && !fromSystem && !isAssistantError) ? handleBlocksMouseUp : undefined}
onMouseUp={(ENABLE_BUBBLE && !fromSystem /*&& !isAssistantError*/) ? handleBlocksMouseUp : undefined}
sx={{
// style
backgroundColor: backgroundColor,
@@ -566,24 +461,43 @@ export function ChatMessage(props: {
{/* (Optional) underlayed top decorator */}
{props.topDecorator}
{/* Message Row: Avatar, Blocks (1 text -> blocksRenderer) */}
<Box sx={{
{/* Message Row: Aside, Fragment[][], Aside2 */}
<Box role={undefined /* aside | message | ops */} sx={{
display: 'flex',
flexDirection: !fromAssistant ? 'row-reverse' : 'row',
alignItems: 'flex-start',
alignItems: 'flex-start', // avatars at the top, and honor 'static' position
gap: { xs: 0, md: 1 },
}}>
{/* Avatar (Persona) */}
{showAvatar && (
<Box sx={personaSx}>
{/* [aside A] Editing: Apply */}
{isEditingText && (
<Box sx={messageAsideColumnSx}>
{/*<Typography level='body-xs'>&nbsp;</Typography>*/}
<Tooltip arrow disableInteractive title='Apply Edits'>
<IconButton size='sm' variant='solid' color='warning' onClick={handleEditsApply} sx={{ mt: 0.25 }}>
<CheckRoundedIcon />
</IconButton>
</Tooltip>
<Typography level='body-xs' sx={{ overflowWrap: 'anywhere', mt: 0.25 }}>
Done
</Typography>
</Box>
)}
{/* [aside B] Avatar (Persona) */}
{showAvatar && !isEditingText && (
<Box sx={messageAsideColumnSx}>
{/* Persona Avatar or Menu Button */}
<Box
onClick={handleOpsMenuToggle}
onClick={(event) => {
event.shiftKey && console.log(props.message);
handleOpsMenuToggle(event);
}}
onContextMenu={handleOpsMenuToggle}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onMouseEnter={props.isMobile ? undefined : () => setIsHovering(true)}
onMouseLeave={props.isMobile ? undefined : () => setIsHovering(false)}
sx={{ display: 'flex' }}
>
{(isHovering || opsMenuAnchor) ? (
@@ -595,12 +509,12 @@ export function ChatMessage(props: {
)}
</Box>
{/* Assistant model name */}
{/* Assistant (llm/function) name */}
{fromAssistant && (
<Tooltip arrow title={messageTyping ? null : (messageOriginLLM || 'unk-model')} variant='solid'>
<Tooltip arrow title={messagePendingIncomplete ? null : (messageOriginLLM || 'unk-model')} variant='solid'>
<Typography level='body-xs' sx={{
overflowWrap: 'anywhere',
...(messageTyping ? { animation: `${animationColorRainbow} 5s linear infinite` } : {}),
...(messagePendingIncomplete ? { animation: `${animationColorRainbow} 5s linear infinite` } : {}),
}}>
{prettyBaseModel(messageOriginLLM)}
</Typography>
@@ -611,45 +525,114 @@ export function ChatMessage(props: {
)}
{/* Edit / Blocks */}
{isEditing ? (
{/* (many-type) Fragment Classes */}
<Box ref={blocksRendererRef /* restricts the BUBBLE menu to the children of this */} sx={{
// style
flexGrow: 1, // capture all the space, for edit modes
minWidth: 0, // VERY important, otherwise very wide messages will overflow the container, causing scroll on the whole page
my: 'auto', // v-center content if there's any gap (e.g. single line of text)
<InlineTextarea
initialText={messageText} onEdit={handleTextEdited}
sx={editBlocksSx}
/>
// layout
display: 'flex',
flexDirection: 'column',
gap: 1.5, // we give a bit more space between the 'classes' of fragments (content, attachments, etc.)
}}>
) : (
{/* (optional) Message date */}
{(props.showBlocksDate === true && !!(messageUpdated || messageCreated)) && (
<Typography level='body-sm' sx={{ mx: 1.5, textAlign: fromAssistant ? 'left' : 'right' }}>
<TimeAgo date={messageUpdated || messageCreated} />
</Typography>
)}
{/* Image Attachment Fragments (just for a prettier display on top of the message) */}
{imageAttachments.length >= 1 && !isEditingText && (
<ImageAttachmentFragments
imageAttachments={imageAttachments}
contentScaling={contentScaling}
messageRole={messageRole}
isMobile={props.isMobile}
onFragmentDelete={handleFragmentDelete}
/>
)}
{/* Content Fragments (iterating all to preserve the index) */}
<ContentFragments
fragments={contentFragments}
<BlocksRenderer
ref={blocksRendererRef}
text={messageText}
fromRole={messageRole}
contentScaling={contentScaling}
errorMessage={errorMessage}
fitScreen={props.fitScreen}
isBottom={props.isBottom}
messageOriginLLM={messageOriginLLM}
messageRole={messageRole}
optiAllowSubBlocksMemo={!!messagePendingIncomplete}
renderTextAsMarkdown={renderMarkdown}
renderTextDiff={textDiffs || undefined}
showDate={props.showBlocksDate === true ? messageUpdated || messageCreated || undefined : undefined}
showTopWarning={(fromSystem && wasEdited) ? 'modified by user - auto-update disabled' : undefined}
showUnsafeHtml={props.showUnsafeHtml}
wasUserEdited={wasEdited}
onContextMenu={(props.onMessageEdit && ENABLE_SELECTION_RIGHT_CLICK_MENU) ? handleBlocksContextMenu : undefined}
onDoubleClick={(props.onMessageEdit && doubleClickToEdit) ? handleBlocksDoubleClick : undefined}
optiAllowMemo={messageTyping}
textEditsState={textContentEditState}
setEditedText={handleEditSetText}
onEditsApply={handleEditsApply}
onEditsCancel={handleEditsCancel}
onFragmentDelete={handleFragmentDelete}
onFragmentReplace={handleFragmentReplace}
onContextMenu={(props.onMessageFragmentReplace && ENABLE_CONTEXT_MENU) ? handleBlocksContextMenu : undefined}
onDoubleClick={(props.onMessageFragmentReplace && doubleClickToEdit) ? handleBlocksDoubleClick : undefined}
/>
{/* If editing and there's no content, have a button to create a new TextContentFragment */}
{isEditingText && !contentFragments.length && (
<Button variant='plain' color='neutral' onClick={handleFragmentNew} sx={{ justifyContent: 'flex-start' }}>
add text ...
</Button>
)}
{/* Document Fragments */}
{nonImageAttachments.length >= 1 && !isEditingText && (
<DocumentFragments
attachmentFragments={nonImageAttachments}
messageRole={messageRole}
contentScaling={contentScaling}
isMobile={props.isMobile}
renderTextAsMarkdown={renderMarkdown}
onFragmentDelete={handleFragmentDelete}
onFragmentReplace={handleFragmentReplace}
/>
)}
{/* Reply-To Bubble */}
{!!messageMetadata?.inReplyToText && (
<ReplyToBubble
inlineUserMessage
replyToText={messageMetadata.inReplyToText}
className='reply-to-bubble'
/>
)}
</Box>
{/* Editing: Cancel */}
{isEditingText && (
<Box sx={messageAsideColumnSx}>
{/*<Typography level='body-xs'>&nbsp;</Typography>*/}
<Tooltip arrow disableInteractive title='Discard Edits'>
<IconButton size='md' onClick={handleEditsCancel}>
<CloseRoundedIcon />
</IconButton>
</Tooltip>
<Typography level='body-xs' sx={{ overflowWrap: 'anywhere' }}>
Cancel
</Typography>
</Box>
)}
</Box>
{/* Reply-To Bubble */}
{!!messageMetadata?.inReplyToText && <ReplyToBubble inlineMessage replyToText={messageMetadata.inReplyToText} className='reply-to-bubble' />}
{/* Overlay copy icon */}
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
<Tooltip title={messageTyping ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditingText && (
<Tooltip title={messagePendingIncomplete ? null : (fromAssistant ? 'Copy message' : 'Copy input')} variant='solid'>
<IconButton
variant='outlined' onClick={handleOpsCopy}
sx={{
@@ -662,7 +645,7 @@ export function ChatMessage(props: {
)}
{/* Operations Menu (3 dots) */}
{/* Message Operations Menu (3 dots) */}
{!!opsMenuAnchor && (
<CloseableMenu
dense placement='bottom-end'
@@ -681,11 +664,10 @@ export function ChatMessage(props: {
{/* Edit / Copy */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{/* Edit */}
{!!props.onMessageEdit && (
<MenuItem variant='plain' disabled={messageTyping} onClick={handleOpsEdit} sx={{ flex: 1 }}>
<ListItemDecorator><EditRoundedIcon /></ListItemDecorator>
{isEditing ? 'Discard' : 'Edit'}
{/*{!isEditing && <span style={{ opacity: 0.5, marginLeft: '8px' }}>{doubleClickToEdit ? '(double-click)' : ''}</span>}*/}
{!!props.onMessageFragmentReplace && (
<MenuItem variant='plain' disabled={!!messagePendingIncomplete} onClick={handleOpsEditToggle} sx={{ flex: 1 }}>
<ListItemDecorator>{isEditingText ? <CloseRoundedIcon /> : <EditRoundedIcon />}</ListItemDecorator>
{isEditingText ? 'Discard' : 'Edit'}
</MenuItem>
)}
{/* Copy */}
@@ -696,10 +678,12 @@ export function ChatMessage(props: {
{/* Starred */}
{!!onMessageToggleUserFlag && (
<MenuItem onClick={handleOpsToggleStarred} sx={{ flexGrow: 0, px: 1 }}>
{isUserStarred
? <StarRoundedIcon color='primary' sx={{ fontSize: 'xl2' }} />
: <StarOutlineRoundedIcon sx={{ fontSize: 'xl2' }} />
}
<Tooltip disableInteractive title={!isUserStarred ? 'Star message - use @ to refer to it later' : 'Unstar'}>
{isUserStarred
? <StarRoundedIcon color='primary' sx={{ fontSize: 'xl2' }} />
: <StarOutlineRoundedIcon sx={{ fontSize: 'xl2' }} />
}
</Tooltip>
</MenuItem>
)}
</Box>
@@ -785,12 +769,12 @@ export function ChatMessage(props: {
)}
{/* Selection Toolbar */}
{ENABLE_SELECTION_TOOLBAR && !!selToolbarAnchor && (
<Popper placement='top-start' open anchorEl={selToolbarAnchor} slotProps={{
{/* Bubble */}
{ENABLE_BUBBLE && !!bubbleAnchor && (
<Popper placement='top-start' open anchorEl={bubbleAnchor} slotProps={{
root: { style: { zIndex: themeZIndexPageBar + 1 } },
}}>
<ClickAwayListener onClickAway={() => closeToolbar()}>
<ClickAwayListener onClickAway={() => closeBubble()}>
<ButtonGroup
variant='plain'
sx={{
@@ -833,11 +817,11 @@ export function ChatMessage(props: {
<AccountTreeOutlinedIcon sx={{ color: couldDiagram ? 'primary' : 'neutral.plainDisabledColor' }} />
</IconButton>
</Tooltip>}
{/*{!!props.onTextImagine && <Tooltip disableInteractive arrow placement='top' title='Auto-Draw'>*/}
{/* <IconButton onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>*/}
{/* {!props.isImagining ? <FormatPaintOutlinedIcon /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}*/}
{/* </IconButton>*/}
{/*</Tooltip>}*/}
{!!props.onTextImagine && <Tooltip disableInteractive arrow placement='top' title='Auto-Draw'>
<IconButton onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
{!props.isImagining ? <FormatPaintOutlinedIcon /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}
</IconButton>
</Tooltip>}
{!!props.onTextSpeak && <Tooltip disableInteractive arrow placement='top' title='Speak'>
<IconButton onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
{!props.isSpeaking ? <RecordVoiceOverOutlinedIcon /> : <CircularProgress sx={{ '--CircularProgress-size': '16px' }} />}
@@ -849,11 +833,11 @@ export function ChatMessage(props: {
)}
{/* Selection (Contextual) Menu */}
{!!selMenuAnchor && (
{/* Context (Right-click) Menu */}
{!!contextMenuAnchor && (
<CloseableMenu
dense placement='bottom-start'
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
open anchorEl={contextMenuAnchor} onClose={closeContextMenu}
sx={{ minWidth: 220 }}
>
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1, alignItems: 'center' }}>
@@ -4,10 +4,11 @@ import { Box, Button, Checkbox, IconButton, ListItem, Sheet, Typography } from '
import ClearIcon from '@mui/icons-material/Clear';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { DMessage } from '~/common/state/store-chats';
import { DMessage, messageFragmentsReduceText } from '~/common/stores/chat/chat.message';
import { TokenBadgeMemo } from '../composer/TokenBadge';
import { makeAvatar, messageBackground } from './ChatMessage';
import { isErrorChatMessage } from './explainServiceErrors';
import { makeMessageAvatar, messageBackground } from './messageUtils';
/**
@@ -44,10 +45,8 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
// derived state
const {
id: messageId,
text: messageText,
sender: messageSender,
avatar: messageAvatar,
typing: messageTyping,
pendingIncomplete: messagePendingIncomplete,
role: messageRole,
purposeId: messagePurposeId,
originLLM: messageOriginLLM,
@@ -55,15 +54,17 @@ export function CleanerMessage(props: { message: DMessage, selected: boolean, re
updated: messageUpdated,
} = props.message;
const messageText = messageFragmentsReduceText(props.message.fragments);
const fromAssistant = messageRole === 'assistant';
const isAssistantError = fromAssistant && (messageText.startsWith('[Issue] ') || messageText.startsWith('[OpenAI Issue]'));
const isAssistantError = fromAssistant && isErrorChatMessage(messageText);
const backgroundColor = messageBackground(messageRole, !!messageUpdated, isAssistantError);
const avatarEl: React.JSX.Element | null = React.useMemo(() =>
makeAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, messageSender, messageTyping, 'sm'),
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping],
makeMessageAvatar(messageAvatar, messageRole, messageOriginLLM, messagePurposeId, !!messagePendingIncomplete),
[messageAvatar, messageOriginLLM, messagePendingIncomplete, messagePurposeId, messageRole],
);
const handleCheckedChange = (event: React.ChangeEvent<HTMLInputElement>) =>
@@ -28,37 +28,39 @@ const bubbleComposerSx: SxProps = {
alignItems: 'start',
};
const inlineMessageSx: SxProps = {
export const inlineMessageBubbleSx: SxProps = {
...bubbleComposerSx,
// redefine
// border: 'none',
mt: 1,
// mt: 1,
borderColor: `${INLINE_COLOR}.outlinedColor`,
borderRadius: 'sm',
boxShadow: 'xs',
width: undefined,
padding: '0.375rem 0.25rem 0.375rem 0.5rem',
// self-layout (parent: 'block', as 'grid' was not working and the user would scroll the app on the x-axis on mobile)
// ml: 'auto',
float: 'inline-end',
mr: { xs: 7.75, md: 10.5 }, // personaSx.minWidth + gap (md: 1) + 1.5 (text margin)
// FORMERLY: self-layout (parent: 'block', as 'grid' was not working and the user would scroll the app on the x-axis on mobile)
// float: 'inline-end',
// mr: { xs: 7.75, md: 10.5 }, // personaSx.minWidth + gap (md: 1) + 1.5 (text margin)
// now: the parent is a 'grid' to v-layout fragment types
mx: '0.75rem', // 1.5, like margin of text blocks
};
export function ReplyToBubble(props: {
replyToText: string | null,
inlineMessage?: boolean
inlineUserMessage?: boolean
onClear?: () => void,
className?: string,
}) {
return (
<Box className={props.className} sx={!props.inlineMessage ? bubbleComposerSx : inlineMessageSx}>
<Box className={props.className} sx={!props.inlineUserMessage ? bubbleComposerSx : inlineMessageBubbleSx}>
<Tooltip disableInteractive arrow title='Referring to this assistant text' placement='top'>
<ReplyRoundedIcon sx={{
color: props.inlineMessage ? `${INLINE_COLOR}.outlinedColor` : 'primary.solidBg',
color: props.inlineUserMessage ? `${INLINE_COLOR}.outlinedColor` : 'primary.solidBg',
fontSize: 'xl',
mt: 0.125,
}} />
@@ -0,0 +1,61 @@
import * as React from 'react';
import { Link } from '~/common/components/Link';
export function isErrorChatMessage(text: string) {
return ['**[Service Issue] ', '[Issue] ', '[OpenAI Issue] '].some(prefix => text.startsWith(prefix));
}
export function explainServiceErrors(text: string, isAssistant: boolean, modelId?: string) {
const isAssistantError = isAssistant && isErrorChatMessage(text);
if (!isAssistantError)
return null;
switch (true) {
case text.includes('"insufficient_quota"'):
return <>
{/*The model appears to be occupied at the moment. Kindly try another model, try again after some time,*/}
{/*or give it another go by selecting <b>Run again</b> from the message menu.*/}
The OpenAI API key appears to have <b>insufficient quota</b>. Please
check <Link noLinkStyle href='https://platform.openai.com/usage' target='_blank'>your usage</Link> and
make sure the usage is under <Link noLinkStyle href='https://platform.openai.com/account/billing/limits' target='_blank'>the limits</Link>.
</>;
case text.includes('"invalid_api_key"'):
return <>
The OpenAI API key appears to be incorrect or to have expired.
Please <Link noLinkStyle href='https://platform.openai.com/api-keys' target='_blank'>check your
API key</Link> and update it in <b>Models</b>.
</>;
// [OpenAI] "Service Temporarily Unavailable (503)", {"code":503,"message":"Service Unavailable.","param":null,"type":"cf_service_unavailable"}
case text.includes('"cf_service_unavailable"'):
return <>
The OpenAI servers appear to be having trouble at the moment. Kindly follow
the <Link noLinkStyle href='https://status.openai.com/' target='_blank'>OpenAI Status</Link> page
for up to date information, and at your option try again.
</>;
case text.includes('"model_not_found"'):
return <>
The API key appears to be unauthorized for {modelId || 'this model'}. You can change to <b>GPT-3.5
Turbo</b> and simultaneously <Link noLinkStyle href='https://openai.com/waitlist/gpt-4-api' target='_blank'>request
access</Link> to the desired model.
</>;
case text.includes('"context_length_exceeded"'):
const pattern = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/;
const match = pattern.exec(text);
const usedText = match ? <b>{parseInt(match[2] || '0').toLocaleString()} tokens &gt; {parseInt(match[1] || '0').toLocaleString()}</b> : '';
return <>
This thread <b>surpasses the maximum size</b> allowed for {modelId || 'this model'}. {usedText}.
Please consider removing some earlier messages from the conversation, start a new conversation,
choose a model with larger context, or submit a shorter new message.
{!usedText && ` -- ${text}`}
</>;
default:
return undefined;
}
}
@@ -0,0 +1,120 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button } 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 TelegramIcon from '@mui/icons-material/Telegram';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import TextureIcon from '@mui/icons-material/Texture';
import type { DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
import { ellipsizeMiddle } from '~/common/util/textUtils';
function iconForFragment({ part }: DMessageAttachmentFragment): React.ComponentType<any> {
switch (part.pt) {
case 'doc':
switch (part.type) {
case 'text/plain':
return TextFieldsIcon;
case 'text/html':
return CodeIcon;
case 'text/markdown':
return CodeIcon;
case 'application/vnd.agi.ocr':
return part.meta?.srcOcrFrom === 'image' ? AbcIcon : PictureAsPdfIcon;
case 'application/vnd.agi.ego':
return TelegramIcon;
default:
return TextureIcon;
}
case 'image_ref':
return ImageOutlinedIcon;
case '_pt_sentinel':
return TextureIcon;
}
}
export function DocumentFragmentButton(props: {
fragment: DMessageAttachmentFragment,
contentScaling: ContentScaling,
isSelected: boolean,
toggleSelected: (fragmentId: DMessageFragmentId) => void,
}) {
// derived state
const { fragment, isSelected, toggleSelected } = props;
// only operate on doc fragments
if (fragment.part.pt !== 'doc')
throw new Error('Unexpected part type: ' + fragment.part.pt);
// handlers
const handleSelectFragment = React.useCallback(() => {
toggleSelected(fragment.fId);
}, [fragment.fId, toggleSelected]);
// memos
const buttonSx = React.useMemo((): SxProps => ({
// from ATTACHMENT_MIN_STYLE
// height: '100%',
minHeight: props.contentScaling === 'md' ? 40 : props.contentScaling === 'sm' ? 38 : 36,
minWidth: '64px',
maxWidth: '280px',
padding: 0,
// style
fontSize: themeScalingMap[props.contentScaling]?.fragmentButtonFontSize ?? undefined,
border: '1px solid',
borderRadius: 'sm',
boxShadow: 'xs',
...isSelected ? {
borderColor: 'neutral.solidBg',
} : {
borderColor: 'primary.outlinedBorder',
backgroundColor: 'background.surface',
},
// from LLMAttachmentItem
display: 'flex', flexDirection: 'row',
}), [isSelected, props.contentScaling]);
const buttonText = ellipsizeMiddle(fragment.title || 'Text', 28 /* totally arbitrary length */);
const Icon = iconForFragment(fragment);
return (
<Button
size={props.contentScaling === 'md' ? 'md' : 'sm'}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'neutral' : 'neutral'}
onClick={handleSelectFragment}
sx={buttonSx}
>
{!!Icon && (
<Box sx={{
height: '100%',
paddingX: '0.5rem',
borderRight: '1px solid',
borderRightColor: isSelected ? 'neutral.solidBg' : 'primary.outlinedBorder',
display: 'flex', alignItems: 'center',
}}>
<Icon />
</Box>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', paddingX: '0.5rem' }}>
<Box sx={{ whiteSpace: 'nowrap', fontWeight: 'md' }}>
{buttonText}
</Box>
{/*<Box sx={{ fontSize: 'xs', fontWeight: 'sm' }}>*/}
{/* {fragment.caption}*/}
{/*</Box>*/}
</Box>
</Button>
);
}
@@ -0,0 +1,162 @@
import * as React from 'react';
import { Box, Button } from '@mui/joy';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
import { createDMessageDataInlineText, createDocAttachmentFragment, DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
import { marshallWrapText } from '~/common/stores/chat/chat.tokens';
import { ContentPartTextEditor } from '../fragments-content/ContentPartTextEditor';
export function DocumentFragmentEditor(props: {
fragment: DMessageAttachmentFragment,
editedText?: string,
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
messageRole: DMessageRole,
contentScaling: ContentScaling,
isMobile?: boolean,
renderTextAsMarkdown: boolean,
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
onFragmentReplace: (fragmentId: DMessageFragmentId, newContent: DMessageAttachmentFragment) => void,
}) {
// derived state
const { editedText, fragment, onFragmentDelete, onFragmentReplace } = props;
const [isEditing, setIsEditing] = React.useState(false);
const [isDeleteArmed, setIsDeleteArmed] = React.useState(false);
const fragmentId = fragment.fId;
const fragmentTitle = fragment.title;
const fragmentCaption = fragment.caption;
const part = fragment.part;
if (part.pt !== 'doc')
throw new Error('Unexpected part type: ' + part.pt);
// delete
const handleToggleDeleteArmed = React.useCallback(() => {
// setIsEditing(false);
setIsDeleteArmed(on => !on);
}, []);
const handleFragmentDelete = React.useCallback(() => {
onFragmentDelete(fragmentId);
}, [fragmentId, onFragmentDelete]);
// edit
const handleToggleEdit = React.useCallback(() => {
setIsDeleteArmed(false);
setIsEditing(on => !on);
}, []);
const handleEditApply = React.useCallback(() => {
setIsDeleteArmed(false);
if (editedText === undefined)
return;
// only edit DOCs
if (fragment.part.pt !== 'doc') {
console.warn('handleEditApply: unexpected part type:', fragment.part.pt);
return;
}
if (editedText.length > 0) {
const newData = createDMessageDataInlineText(editedText, fragment.part.data.mimeType);
const newAttachment = createDocAttachmentFragment(fragment.title, fragment.caption, fragment.part.type, newData, fragment.part.ref, fragment.part.meta);
// reuse the same fragment ID, which makes the screen not flash (otherwise the whole editor would disappear as the ID does not exist anymore)
newAttachment.fId = fragmentId;
onFragmentReplace(fragmentId, newAttachment);
setIsEditing(false);
} else {
// if the user deleted all text, let's remove the part
handleFragmentDelete();
}
}, [editedText, fragment, fragmentId, handleFragmentDelete, onFragmentReplace]);
return (
<Box sx={{
backgroundColor: 'background.level2',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
borderRadius: 'sm',
boxShadow: 'inset 2px 0px 5px -4px var(--joy-palette-background-backdrop)',
p: 1,
mt: 0.5,
}}>
{isEditing ? (
// Document Editor
<ContentPartTextEditor
textPartText={part.data.text}
fragmentId={fragmentId}
contentScaling={props.contentScaling}
editedText={props.editedText}
setEditedText={props.setEditedText}
onEnterPressed={handleEditApply}
onEscapePressed={handleToggleEdit}
/>
) : (
// Document viewer, including collapse/expand
<AutoBlocksRenderer
text={marshallWrapText(part.data.text, /*fragment.title ||*/ part.meta?.srcFileName || part.ref, 'markdown-code')}
// text={selectedFragment.part.text}
fromRole={props.messageRole}
contentScaling={props.contentScaling}
fitScreen={props.isMobile}
specialCodePlain
renderTextAsMarkdown={props.renderTextAsMarkdown}
/>
)}
{/* Edit / Delete commands */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', mt: 1 }}>
<Box sx={{ display: 'flex', gap: 1 }}>
{isDeleteArmed ? (
<Button variant='solid' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<CloseRoundedIcon />}>
Cancel
</Button>
) : (
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleDeleteArmed} startDecorator={<DeleteOutlineIcon />}>
Delete
</Button>
)}
{isDeleteArmed && (
<Button variant='plain' color='danger' size='sm' onClick={handleFragmentDelete} startDecorator={<DeleteForeverIcon />}>
Delete
</Button>
)}
</Box>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
{isEditing ? (
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<CloseRoundedIcon />}>
Cancel
</Button>
) : (
<Button variant='plain' color='neutral' size='sm' onClick={handleToggleEdit} startDecorator={<EditRoundedIcon />}>
Edit
</Button>
)}
{isEditing && (
<Button variant='plain' color='success' onClick={handleEditApply} size='sm' startDecorator={<CheckRoundedIcon />}>
Save
</Button>
)}
</Box>
</Box>
</Box>
);
}
@@ -0,0 +1,95 @@
import * as React from 'react';
import { Box } from '@mui/joy';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageAttachmentFragment, DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
import type { ChatMessageTextPartEditState } from '../ChatMessage';
import { DocumentFragmentButton } from './DocumentFragmentButton';
import { DocumentFragmentEditor } from './DocumentFragmentEditor';
/**
* Displays a list of 'cards' which are buttons with a mutually exclusive active state.
* When one is active, there is a content part just right under (with the collapse mechanism in case it's a user role).
* If one is clicked the content part (use ContentPartText) is displayed.
*/
export function DocumentFragments(props: {
attachmentFragments: DMessageAttachmentFragment[],
messageRole: DMessageRole,
contentScaling: ContentScaling,
isMobile?: boolean,
renderTextAsMarkdown: boolean;
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
onFragmentReplace: (fragmentId: DMessageFragmentId, newFragment: DMessageAttachmentFragment) => void,
}) {
// state
const [activeFragmentId, setActiveFragmentId] = React.useState<DMessageFragmentId | null>(null);
const [editState, setEditState] = React.useState<ChatMessageTextPartEditState | null>(null);
// selection
const handleToggleSelectedId = React.useCallback((fragmentId: DMessageFragmentId) => setActiveFragmentId(prevId => prevId === fragmentId ? null : fragmentId), []);
const selectedFragment = props.attachmentFragments.find(fragment => fragment.fId === activeFragmentId);
// editing
const handleEditSetText = React.useCallback((fragmentId: DMessageFragmentId, value: string) => setEditState(prevState => ({ ...prevState, [fragmentId]: value })), []);
// [effect] clear edits on onmount
React.useEffect(() => {
return () => setEditState(null);
}, []);
return (
<Box aria-label={`${props.attachmentFragments.length} attachments`} sx={{
// layout
display: 'flex',
flexDirection: 'column',
}}>
{/* Horizontally scrollable Document buttons */}
<Box sx={{
pb: 0.5, // 4px: to show the button shadow
// layout
display: 'flex',
flexWrap: 'wrap',
gap: 1,
justifyContent: props.messageRole === 'assistant' ? 'flex-start' : 'flex-end',
}}>
{props.attachmentFragments.map((attachmentFragment) =>
<DocumentFragmentButton
key={attachmentFragment.fId}
fragment={attachmentFragment}
contentScaling={props.contentScaling}
isSelected={activeFragmentId === attachmentFragment.fId}
toggleSelected={handleToggleSelectedId}
/>,
)}
</Box>
{/* Document Viewer & Editor */}
{!!selectedFragment && (
<DocumentFragmentEditor
fragment={selectedFragment}
messageRole={props.messageRole}
editedText={editState?.[selectedFragment.fId]}
setEditedText={handleEditSetText}
contentScaling={props.contentScaling}
isMobile={props.isMobile}
renderTextAsMarkdown={props.renderTextAsMarkdown}
onFragmentDelete={props.onFragmentDelete}
onFragmentReplace={props.onFragmentReplace}
/>
)}
</Box>
);
}
@@ -0,0 +1,116 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box } from '@mui/joy';
import { RenderImageRefDBlob, showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
import { DMessageAttachmentFragment, DMessageFragmentId, isImageRefPart } from '~/common/stores/chat/chat.fragments';
// configuration
const CARD_MIN_SQR = 84;
const CARD_MAX_WIDTH = CARD_MIN_SQR * 3; // 3:1 max wide ratio (252px)
const CARD_MAX_HEIGHT = CARD_MIN_SQR * 2.25; // 1:2.25 max tall ratio (189px)
const layoutSx: SxProps = {
// style
my: 'auto',
flex: 0,
// layout
display: 'flex',
flexWrap: 'wrap',
// alignItems: 'center', // commented to keep them to the top
// justifyContent: 'flex-end', // commented as we do it dynamically
gap: { xs: 0.5, md: 1 },
};
const imageSheetPatchSx: SxProps = {
// undo the RenderImageURL default style
m: 0,
minWidth: CARD_MIN_SQR,
minHeight: CARD_MIN_SQR,
boxShadow: 'xs',
// border: 'none',
// style
// backgroundColor: 'background.popup',
borderRadius: 'sm',
overflow: 'hidden',
// style the <img> tag
'& picture > img': {
// override the style in RenderImageURL
maxWidth: CARD_MAX_WIDTH, // very important to keep the aspect ratio
maxHeight: CARD_MAX_HEIGHT, // very important to keep the aspect ratio
// width: '100%',
// height: '100%',
// objectFit: 'cover',
},
};
/**
* Shows image attachments in a flexbox that wraps the images (overflowing by rows)
* Also see `TextAttachmentFragments` for the text version, and 'ContentFragments'.
*/
export function ImageAttachmentFragments(props: {
imageAttachments: DMessageAttachmentFragment[],
contentScaling: ContentScaling,
messageRole: DMessageRole,
isMobile?: boolean,
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
}) {
const layoutSxMemo = React.useMemo((): SxProps => ({
...layoutSx,
justifyContent: props.messageRole === 'assistant' ? 'flex-start' : 'flex-end',
}), [props.messageRole]);
const cardStyleSxMemo = React.useMemo((): SxProps => ({
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
lineHeight: themeScalingMap[props.contentScaling]?.blockLineHeight ?? 1.75,
...imageSheetPatchSx,
}), [props.contentScaling]);
return (
<Box aria-label={`${props.imageAttachments.length} images`} sx={layoutSxMemo}>
{/* render each image attachment */}
{props.imageAttachments.map(attachmentFragment => {
// only operate on image_ref
if (!isImageRefPart(attachmentFragment.part))
throw new Error('Unexpected part type: ' + attachmentFragment.part.pt);
const { title, part: imageRefPart } = attachmentFragment;
const { dataRef, altText } = imageRefPart;
// only support rendering DBLob images as cards for now
if (dataRef.reftype === 'dblob') {
return (
<RenderImageRefDBlob
key={'att-img-' + attachmentFragment.fId}
dataRefDBlobAssetId={dataRef.dblobAssetId}
dataRefMimeType={dataRef.mimeType}
imageAltText={imageRefPart.altText || title}
imageWidth={imageRefPart.width}
imageHeight={imageRefPart.height}
onOpenInNewTab={() => showImageDataRefInNewTab(dataRef)}
onDeleteFragment={() => props.onFragmentDelete(attachmentFragment.fId)}
scaledImageSx={cardStyleSxMemo}
variant='attachment-card'
/>
);
}
throw new Error('Unexpected dataRef type: ' + dataRef.reftype);
})}
</Box>
);
}
@@ -0,0 +1,169 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box } from '@mui/joy';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
import { DMessageContentFragment, DMessageFragment, DMessageFragmentId, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
import type { ChatMessageTextPartEditState } from '../ChatMessage';
import { ContentPartImageRef } from './ContentPartImageRef';
import { ContentPartPlaceholder } from './ContentPartPlaceholder';
import { ContentPartTextAutoBlocks } from './ContentPartTextAutoBlocks';
import { ContentPartTextEditor } from './ContentPartTextEditor';
const editLayoutSx: SxProps = {
display: 'grid',
gap: 1.5, // see why we give more space on ChatMessage
// horizontal separator between messages (second part+ and before)
// '& > *:not(:first-child)': {
// borderTop: '1px solid',
// borderTopColor: 'background.level3',
// },
};
const startLayoutSx: SxProps = {
...editLayoutSx,
justifyContent: 'flex-start',
};
const endLayoutSx: SxProps = {
...editLayoutSx,
justifyContent: 'flex-end',
};
export function ContentFragments(props: {
fragments: DMessageFragment[]
contentScaling: ContentScaling,
fitScreen: boolean,
messageOriginLLM?: string,
messageRole: DMessageRole,
optiAllowSubBlocksMemo?: boolean,
renderTextAsMarkdown: boolean,
showTopWarning?: string,
showUnsafeHtml?: boolean,
textEditsState: ChatMessageTextPartEditState | null,
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
onEditsApply: () => void,
onEditsCancel: () => void,
onFragmentDelete: (fragmentId: DMessageFragmentId) => void,
onFragmentReplace: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
onContextMenu?: (event: React.MouseEvent) => void;
onDoubleClick?: (event: React.MouseEvent) => void;
}) {
const fromAssistant = props.messageRole === 'assistant';
const isEditingText = !!props.textEditsState;
const isMonoFragment = props.fragments.length < 2;
// if no fragments, don't box them
if (!props.fragments.length)
return null;
return <Box aria-label='message body' sx={isEditingText ? editLayoutSx : fromAssistant ? startLayoutSx : endLayoutSx}>
{props.fragments.map((fragment) => {
// only proceed with DMessageContentFragment
if (!isContentFragment(fragment))
return null;
// editing for text parts
if (props.textEditsState && (isTextPart(fragment.part) || fragment.part.pt === 'error')) {
return (
<ContentPartTextEditor
key={'edit-' + fragment.fId}
textPartText={isTextPart(fragment.part) ? fragment.part.text : fragment.part.error}
fragmentId={fragment.fId}
contentScaling={props.contentScaling}
editedText={props.textEditsState[fragment.fId]}
setEditedText={props.setEditedText}
onEnterPressed={props.onEditsApply}
onEscapePressed={props.onEditsCancel}
/>
);
}
switch (fragment.part.pt) {
case 'error':
return (
<ContentPartPlaceholder
key={fragment.fId}
placeholderText={fragment.part.error}
messageRole={props.messageRole}
contentScaling={props.contentScaling}
showAsDanger
showAsItalic
/>
);
case 'image_ref':
return (
<ContentPartImageRef
key={fragment.fId}
imageRefPart={fragment.part}
fragmentId={fragment.fId}
contentScaling={props.contentScaling}
onFragmentDelete={!isMonoFragment ? props.onFragmentDelete : undefined}
onFragmentReplace={props.onFragmentReplace}
/>
);
case 'ph':
return (
<ContentPartPlaceholder
key={fragment.fId}
placeholderText={fragment.part.pText}
messageRole={props.messageRole}
contentScaling={props.contentScaling}
showAsItalic
/>
);
case 'text':
return (
<ContentPartTextAutoBlocks
key={fragment.fId}
// ref={blocksRendererRef}
textPartText={fragment.part.text}
messageRole={props.messageRole}
messageOriginLLM={props.messageOriginLLM}
contentScaling={props.contentScaling}
fitScreen={props.fitScreen}
renderTextAsMarkdown={props.renderTextAsMarkdown}
// renderTextDiff={textDiffs || undefined}
showUnsafeHtml={props.showUnsafeHtml}
showTopWarning={props.showTopWarning}
optiAllowSubBlocksMemo={!!props.optiAllowSubBlocksMemo}
onContextMenu={props.onContextMenu}
onDoubleClick={props.onDoubleClick}
/>
);
case 'tool_call':
case 'tool_response':
case '_pt_sentinel':
default:
return (
<ContentPartPlaceholder
key={fragment.fId}
placeholderText={`Unknown Content fragment: ${fragment.part.pt}`}
messageRole={props.messageRole}
contentScaling={props.contentScaling}
showAsDanger
/>
);
}
}).filter(Boolean)}
</Box>;
}
@@ -0,0 +1,77 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box } from '@mui/joy';
import { BlocksContainer } from '~/modules/blocks/BlocksContainer';
import { RenderImageRefDBlob, showImageDataRefInNewTab } from '~/modules/blocks/image/RenderImageRefDBlob';
import { RenderImageURL } from '~/modules/blocks/image/RenderImageURL';
import type { DMessageContentFragment, DMessageFragmentId, DMessageImageRefPart } from '~/common/stores/chat/chat.fragments';
import { ContentScaling, themeScalingMap } from '~/common/app.theme';
export function ContentPartImageRef(props: {
imageRefPart: DMessageImageRefPart,
fragmentId: DMessageFragmentId,
contentScaling: ContentScaling,
onFragmentDelete?: (fragmentId: DMessageFragmentId) => void,
onFragmentReplace?: (fragmentId: DMessageFragmentId, newFragment: DMessageContentFragment) => void,
}) {
// derived state
const { fragmentId, imageRefPart, onFragmentDelete, onFragmentReplace } = props;
const { dataRef } = imageRefPart;
// event handlers
const handleDeleteFragment = React.useCallback(() => {
onFragmentDelete?.(fragmentId);
}, [fragmentId, onFragmentDelete]);
const handleReplaceFragment = React.useCallback((newImageFragment: DMessageContentFragment) => {
onFragmentReplace?.(fragmentId, newImageFragment);
}, [fragmentId, onFragmentReplace]);
const handleOpenInNewTab = React.useCallback(() => {
void showImageDataRefInNewTab(dataRef); // fire/forget
}, [dataRef]);
// memo the scaled image style
const scaledImageSx = React.useMemo((): SxProps => ({
// overflowX: 'auto', // <- this would make the right side margin scrollable
fontSize: themeScalingMap[props.contentScaling]?.blockFontSize ?? undefined,
lineHeight: themeScalingMap[props.contentScaling]?.blockLineHeight ?? 1.75,
marginBottom: themeScalingMap[props.contentScaling]?.blockImageGap ?? 1.5,
}), [props.contentScaling]);
return (
<BlocksContainer>
{dataRef.reftype === 'dblob' ? (
<RenderImageRefDBlob
dataRefDBlobAssetId={dataRef.dblobAssetId}
dataRefMimeType={dataRef.mimeType}
imageAltText={imageRefPart.altText}
imageWidth={imageRefPart.width}
imageHeight={imageRefPart.height}
onOpenInNewTab={handleOpenInNewTab}
onDeleteFragment={onFragmentDelete ? handleDeleteFragment : undefined}
onReplaceFragment={onFragmentReplace ? handleReplaceFragment : undefined}
scaledImageSx={scaledImageSx}
variant='content-part'
/>
) : dataRef.reftype === 'url' ? (
<RenderImageURL
imageURL={dataRef.url}
expandableText={imageRefPart.altText}
scaledImageSx={scaledImageSx}
variant='content-part'
/>
) : (
<Box>
ContentPartImageRef: unknown reftype
</Box>
)}
</BlocksContainer>
);
}
@@ -0,0 +1,37 @@
import * as React from 'react';
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
export function ContentPartPlaceholder(props: {
placeholderText: string,
messageRole: DMessageRole,
contentScaling: ContentScaling,
showAsDanger?: boolean,
showAsItalic?: boolean,
// showAsProgress?: boolean,
}) {
// const placeholder = (
return (
<AutoBlocksRenderer
text={props.placeholderText}
fromRole={props.messageRole}
contentScaling={props.contentScaling}
fitScreen={false}
showAsDanger={props.showAsDanger}
showAsItalic={props.showAsItalic}
renderTextAsMarkdown={false}
/>
);
//
// return props.showAsProgress ? (
// <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
// <CircularProgress color='neutral' size='sm' sx={{ ml: 1.5, '--CircularProgress-size': '16px', '--CircularProgress-trackThickness': '2px' }} /> {placeholder}
// </Box>
// ) : (
// placeholder
// );
}
@@ -0,0 +1,70 @@
import * as React from 'react';
import type { Diff as TextDiff } from '@sanity/diff-match-patch';
import { AutoBlocksRenderer } from '~/modules/blocks/AutoBlocksRenderer';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { InlineError } from '~/common/components/InlineError';
import { explainServiceErrors } from '../explainServiceErrors';
/**
* The OG part, comprised of text, which can be markdown, have code blocks, etc.
* Uses BlocksRenderer to render the markdown/code/html/text, etc.
*/
export function ContentPartTextAutoBlocks(props: {
textPartText: string,
messageRole: DMessageRole,
messageOriginLLM?: string,
contentScaling: ContentScaling,
fitScreen: boolean,
renderTextAsMarkdown: boolean,
renderTextDiff?: TextDiff[];
showUnsafeHtml?: boolean,
showTopWarning: string | undefined,
optiAllowSubBlocksMemo: boolean,
onContextMenu?: (event: React.MouseEvent) => void;
onDoubleClick?: (event: React.MouseEvent) => void;
}) {
// derived state
const messageText = props.textPartText;
const fromAssistant = props.messageRole === 'assistant';
const errorMessage = React.useMemo(
() => explainServiceErrors(messageText, fromAssistant, props.messageOriginLLM),
[fromAssistant, messageText, props.messageOriginLLM],
);
// if errored, render an Auto-Error message
if (errorMessage) {
return (
<GoodTooltip placement='top' arrow title={messageText}>
<div><InlineError error={`${errorMessage}. Hover this message for more details.`} /></div>
</GoodTooltip>
);
}
return (
<AutoBlocksRenderer
text={messageText || ''}
fromRole={props.messageRole}
contentScaling={props.contentScaling}
fitScreen={props.fitScreen}
showUnsafeHtml={props.showUnsafeHtml}
showTopWarning={props.showTopWarning}
renderTextAsMarkdown={props.renderTextAsMarkdown}
renderTextDiff={props.renderTextDiff}
optiAllowSubBlocksMemo={props.optiAllowSubBlocksMemo}
onContextMenu={props.onContextMenu}
onDoubleClick={props.onDoubleClick}
/>
);
}
@@ -0,0 +1,76 @@
import * as React from 'react';
import { BlocksTextarea } from '~/modules/blocks/BlocksContainer';
import type { ContentScaling } from '~/common/app.theme';
import type { DMessageFragmentId } from '~/common/stores/chat/chat.fragments';
/**
* Very similar to <InlineTextArea /> but with externally controlled state rather than internal.
* Made it for as the editing alternative for <ContentPartText />.
*/
export function ContentPartTextEditor(props: {
// current value
textPartText: string,
fragmentId: DMessageFragmentId,
// visual
contentScaling: ContentScaling,
// edited value
editedText?: string,
setEditedText: (fragmentId: DMessageFragmentId, value: string) => void,
// events
onEnterPressed: () => void,
onEscapePressed: () => void,
}) {
// external
// NOTE: we disabled `useUIPreferencesStore(state => state.enterIsNewline)` on 2024-06-19, as it's
// not a good pattern for this kind of editing and we have buttons to take care of Save/Cancel
const enterIsNewline = true;
// derived state
const { fragmentId, setEditedText, onEnterPressed, onEscapePressed } = props;
// handlers
const handleEditTextChanged = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
(e.target.value !== undefined) && setEditedText(fragmentId, e.target.value);
}, [fragmentId, setEditedText]);
const handleEditKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
const shiftOrAlt = e.shiftKey || e.altKey;
if (enterIsNewline ? shiftOrAlt : !shiftOrAlt) {
e.preventDefault();
onEnterPressed();
}
} else if (e.key === 'Escape') {
e.preventDefault();
onEscapePressed();
}
}, [enterIsNewline, onEnterPressed, onEscapePressed]);
return (
<BlocksTextarea
variant={/*props.invertedColors ? 'plain' :*/ 'soft'}
color={/*props.decolor ? undefined : props.invertedColors ? 'primary' :*/ 'warning'}
autoFocus
size={props.contentScaling !== 'md' ? 'sm' : undefined}
value={(props.editedText !== undefined)
? props.editedText /* self-text */
: props.textPartText /* DMessageTextPart text */
}
onChange={handleEditTextChanged}
onKeyDown={handleEditKeyDown}
// onBlur={props.disableAutoSaveOnBlur ? undefined : handleEditBlur}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'done',
},
}}
/>
);
}
@@ -0,0 +1,118 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box } from '@mui/joy';
import Face6Icon from '@mui/icons-material/Face6';
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import type { DMessageRole } from '~/common/stores/chat/chat.message';
import { animationColorRainbow } from '~/common/util/animUtils';
// Animations
const ANIM_BUSY_DOWNLOADING = 'https://i.giphy.com/26u6dIwIphLj8h10A.webp'; // hourglass: https://i.giphy.com/TFSxpAIYz5inJGuY8f.webp, small-lq: https://i.giphy.com/131tNuGktpXGhy.webp, floppy: https://i.giphy.com/RxR1KghIie2iI.webp
const ANIM_BUSY_PAINTING = 'https://i.giphy.com/media/5t9ujj9cMisyVjUZ0m/giphy.webp';
const ANIM_BUSY_THINKING = 'https://i.giphy.com/media/l44QzsOLXxcrigdgI/giphy.webp';
export const ANIM_BUSY_TYPING = 'https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp';
export const messageAsideColumnSx: SxProps = {
// make this stick to the top of the screen
position: 'sticky',
top: 0,
// flexBasis: 0, // this won't let the item grow
minWidth: { xs: 50, md: 64 },
maxWidth: 80,
textAlign: 'center',
// layout
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
};
export const avatarIconSx = {
width: 36,
height: 36,
} as const;
export function makeMessageAvatar(
messageAvatarUrl: string | null,
messageRole: DMessageRole | string,
messageOriginLLM: string | undefined,
messagePurposeId: SystemPurposeId | string | undefined,
messageIncomplete: boolean,
larger?: boolean,
): React.JSX.Element {
const nameByRole = messageRole === 'user' ? 'You' : messageRole === 'assistant' ? 'Assistant' : 'System';
if (typeof messageAvatarUrl === 'string' && messageAvatarUrl)
return <Avatar alt={nameByRole} src={messageAvatarUrl} />;
const mascotSx = larger ? { width: 48, height: 48 } : avatarIconSx;
switch (messageRole) {
case 'system':
return <SettingsSuggestIcon sx={avatarIconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
case 'user':
return <Face6Icon sx={avatarIconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
case 'assistant':
const isDownload = messageOriginLLM === 'web';
const isTextToImage = messageOriginLLM === 'DALL·E' || messageOriginLLM === 'Prodia';
const isReact = messageOriginLLM?.startsWith('react-');
// animation on incomplete messages
if (messageIncomplete)
return <Avatar
alt={nameByRole} variant='plain'
src={isDownload ? ANIM_BUSY_DOWNLOADING
: isTextToImage ? ANIM_BUSY_PAINTING
: isReact ? ANIM_BUSY_THINKING
: ANIM_BUSY_TYPING}
sx={{ ...mascotSx, borderRadius: 'sm' }}
/>;
// icon: text-to-image
if (isTextToImage)
return <FormatPaintOutlinedIcon sx={{
...avatarIconSx,
animation: `${animationColorRainbow} 1s linear 2.66`,
}} />;
// purpose symbol (if present)
const symbol = SystemPurposes[messagePurposeId as SystemPurposeId]?.symbol;
if (symbol)
return <Box sx={{
fontSize: '24px',
textAlign: 'center',
width: '100%',
minWidth: `${avatarIconSx.width}px`,
lineHeight: `${avatarIconSx.height}px`,
}}>
{symbol}
</Box>;
// default assistant avatar
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
}
return <Avatar alt={nameByRole} />;
}
export function messageBackground(messageRole: DMessageRole | string, wasEdited: boolean, isAssistantIssue: boolean): string {
switch (messageRole) {
case 'user':
return 'primary.plainHoverBg'; // was .background.level1
case 'assistant':
return isAssistantIssue ? 'danger.softBg' : 'background.surface';
case 'system':
return wasEdited ? 'warning.softHoverBg' : 'neutral.softBg';
default:
return '#ff0000';
}
}
@@ -2,9 +2,10 @@ import * as React from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
import { v4 as uuidv4 } from 'uuid';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { agiUuid } from '~/common/util/idUtils';
import { useChatStore } from '~/common/stores/chat/store-chats';
// change this to increase/decrease the number history steps per pane
@@ -54,7 +55,7 @@ interface AppChatPanesStore extends AppChatPanesState {
function createPane(conversationId: DConversationId | null = null): ChatPane {
return {
paneId: uuidv4(),
paneId: agiUuid('chat-pane'),
conversationId,
history: conversationId ? [conversationId] : [],
historyIndex: conversationId ? 0 : -1,
@@ -63,7 +64,7 @@ function createPane(conversationId: DConversationId | null = null): ChatPane {
function duplicatePane(pane: ChatPane): ChatPane {
return {
paneId: uuidv4(),
paneId: agiUuid('chat-pane'),
conversationId: pane.conversationId,
history: [...pane.history],
historyIndex: pane.historyIndex,
@@ -1,6 +1,5 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { v4 as uuidv4 } from 'uuid';
import type { SxProps } from '@mui/joy/styles/types';
import { Alert, Avatar, Box, Button, Card, CardContent, Checkbox, IconButton, Input, List, ListItem, ListItemButton, Textarea, Tooltip, Typography } from '@mui/joy';
@@ -16,10 +15,12 @@ import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { useChatLLM } from '~/modules/llms/store-llms';
import { DConversationId, DMessage, useChatStore } from '~/common/state/store-chats';
import { DConversationId } from '~/common/stores/chat/chat.conversation';
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
import { createDMessageTextContent } from '~/common/stores/chat/chat.message';
import { lineHeightTextareaMd } from '~/common/app.theme';
import { navigateToPersonas } from '~/common/app.routes';
import { useChatStore } from '~/common/stores/chat/store-chats';
import { useChipBoolean } from '~/common/components/useChipBoolean';
import { useUIPreferencesStore } from '~/common/state/store-ui';
@@ -158,7 +159,6 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
// Handlers
// Modify the handlePurposeChanged function to check for the YouTube Transcriber
const handlePurposeChanged = React.useCallback((purposeId: SystemPurposeId | null) => {
if (purposeId) {
if (purposeId === 'YouTubeTranscriber') {
@@ -179,25 +179,14 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
}, [systemPurposeId]);
// Implement handleAddMessage function
const handleAddMessage = (messageText: string) => {
const handleAppendTranscriptAsMessage = (messageText: string) => {
// Retrieve the appendMessage action from the useChatStore
const { appendMessage } = useChatStore.getState();
const conversationId = props.conversationId;
// Create a new message object
const newMessage: DMessage = {
id: uuidv4(),
text: messageText,
sender: 'Bot',
avatar: null,
typing: false,
role: 'assistant' as 'assistant',
tokenCount: 0,
created: Date.now(),
updated: null,
};
const newMessage = createDMessageTextContent('assistant', messageText); // [chat] append assistant:YouTube transcript
// Append the new message to the conversation
appendMessage(conversationId, newMessage);
@@ -467,7 +456,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
{/* [row -1] YouTube URL */}
{isYouTubeTranscriberActive && (
<YouTubeURLInput
onSubmit={(url) => handleAddMessage(url)}
onSubmit={(transcript) => handleAppendTranscriptAsMessage(transcript)}
isFetching={false}
sx={{
gridColumn: '1 / -1',
+108
View File
@@ -0,0 +1,108 @@
import { getChatLLMId } from '~/modules/llms/store-llms';
import { inlineUpdateHistoryForReplyTo } from '~/modules/aifn/replyto/replyTo';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import type { DMessage } from '~/common/stores/chat/chat.message';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { createTextContentFragment, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
import { getConversationSystemPurposeId } from '~/common/stores/chat/store-chats';
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
import type { ChatExecuteMode } from '../execute-mode/execute-mode.types';
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
import { textToDrawCommand } from '../commands/CommandsDraw';
import { _handleExecuteCommand, RET_NO_CMD } from './_handleExecuteCommand';
import { runAssistantUpdatingStateV1 } from './chat-stream-v1';
import { runImageGenerationUpdatingState } from './image-generate';
import { runPersonaOnConversationHead } from './chat-persona';
import { runReActUpdatingState } from './react-tangent';
export async function _handleExecute(chatExecuteMode: ChatExecuteMode, conversationId: DConversationId, executeCallerNameDebug: string) {
// Handle missing conversation
if (!conversationId)
return 'err-no-conversation';
const chatLLMId = getChatLLMId();
const cHandler = ConversationsManager.getHandler(conversationId);
const initialHistory = cHandler.historyViewHead(executeCallerNameDebug) as Readonly<DMessage[]>;
// Update the system message from the active persona to the history
// NOTE: this does NOT call setMessages anymore (optimization). make sure to:
// 1. all the callers need to pass a new array
// 2. all the exit points need to call setMessages
const _inplaceEditableHistory = [...initialHistory];
cHandler.inlineUpdatePurposeInHistory(_inplaceEditableHistory, chatLLMId || undefined);
// FIXME: shouldn't do this for all the code paths. The advantage for having it here (vs Composer output only) is re-executing history
// TODO: move this to the server side after transferring metadata?
inlineUpdateHistoryForReplyTo(_inplaceEditableHistory);
// Set the history - note that 'history' objects become invalid after this, and you'd have to
// re-read it from the store, such as with `cHandler.historyView()`
cHandler.historyReplace(_inplaceEditableHistory);
// Handle unconfigured
if (!chatLLMId || !chatExecuteMode)
return !chatLLMId ? 'err-no-chatllm' : 'err-no-chatmode';
// handle missing last user message (or fragment)
// note that we use the initial history, as the user message could have been displaced on the edited versions
const lastMessage = initialHistory.length >= 1 ? initialHistory.slice(-1)[0] : null;
const firstFragment = lastMessage?.fragments[0];
if (!lastMessage || !firstFragment)
return 'err-no-last-message';
// execute a command, if the last message has one
if (lastMessage.role === 'user') {
const cmdRC = await _handleExecuteCommand(lastMessage.id, firstFragment, cHandler, chatLLMId);
if (cmdRC !== RET_NO_CMD) return cmdRC;
}
// get the system purpose (note: we don't react to it, or it would invalidate half UI components..)
// TODO: change this massively
if (!getConversationSystemPurposeId(conversationId)) {
cHandler.messageAppendAssistantText('Issue: no Persona selected.', 'issue');
return 'err-no-persona';
}
// synchronous long-duration tasks, which update the state as they go
switch (chatExecuteMode) {
case 'generate-content':
return await runPersonaOnConversationHead(chatLLMId, conversationId);
case 'generate-text-v1':
return await runAssistantUpdatingStateV1(conversationId, cHandler.historyViewHead('generate-text-v1'), chatLLMId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
case 'beam-content':
cHandler.beamInvoke(cHandler.historyViewHead('beam-content'), [], null);
return true;
case 'append-user':
return true;
case 'generate-image':
// verify we were called with a single DMessageTextContent
if (!isContentFragment(firstFragment) || !isTextPart(firstFragment.part))
return false;
const imagePrompt = firstFragment.part.text;
cHandler.messageFragmentReplace(lastMessage.id, firstFragment.fId, createTextContentFragment(textToDrawCommand(imagePrompt)), true);
return await runImageGenerationUpdatingState(cHandler, imagePrompt);
case 'react-content':
// verify we were called with a single DMessageTextContent
if (!isContentFragment(firstFragment) || !isTextPart(firstFragment.part))
return false;
const reactPrompt = firstFragment.part.text;
cHandler.messageFragmentReplace(lastMessage.id, firstFragment.fId, createTextContentFragment(textToDrawCommand(reactPrompt)), true);
return await runReActUpdatingState(cHandler, reactPrompt, chatLLMId);
default:
console.log('Chat execute: issue running', chatExecuteMode, conversationId, lastMessage);
return false;
}
}
-151
View File
@@ -1,151 +0,0 @@
import { getChatLLMId } from '~/modules/llms/store-llms';
import { updateHistoryForReplyTo } from '~/modules/aifn/replyto/replyTo';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { createDMessage, DConversationId, DMessage, getConversationSystemPurposeId } from '~/common/state/store-chats';
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
import { extractChatCommand, findAllChatCommands } from '../commands/commands.registry';
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
import { runAssistantUpdatingState } from './chat-stream';
import { runBrowseGetPageUpdatingState } from './browse-load';
import { runImageGenerationUpdatingState } from './image-generate';
import { runReActUpdatingState } from './react-tangent';
import type { ChatModeId } from '../AppChat';
export async function _handleExecute(chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) {
// Handle missing conversation
if (!conversationId)
return 'err-no-conversation';
const chatLLMId = getChatLLMId();
// Update the system message from the active persona to the history
// NOTE: this does NOT call setMessages anymore (optimization). make sure to:
// 1. all the callers need to pass a new array
// 2. all the exit points need to call setMessages
const cHandler = ConversationsManager.getHandler(conversationId);
cHandler.inlineUpdatePurposeInHistory(history, chatLLMId || undefined);
// FIXME: shouldn't do this for all the code paths. The advantage for having it here (vs Composer output only) is re-executing history
// TODO: move this to the server side after transferring metadata?
updateHistoryForReplyTo(history);
// Handle unconfigured
if (!chatLLMId || !chatModeId) {
// set the history (e.g. the updated system prompt and the user prompt) at least, see #523
cHandler.messagesReplace(history);
return !chatLLMId ? 'err-no-chatllm' : 'err-no-chatmode';
}
// Valid /commands are intercepted here, and override chat modes, generally for mechanics or sidebars
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
if (lastMessage?.role === 'user') {
const chatCommand = extractChatCommand(lastMessage.text)[0];
if (chatCommand && chatCommand.type === 'cmd') {
switch (chatCommand.providerId) {
case 'ass-browse':
cHandler.messagesReplace(history); // show command
return await runBrowseGetPageUpdatingState(cHandler, chatCommand.params);
case 'ass-t2i':
cHandler.messagesReplace(history); // show command
return await runImageGenerationUpdatingState(cHandler, chatCommand.params);
case 'ass-react':
cHandler.messagesReplace(history); // show command
return await runReActUpdatingState(cHandler, chatCommand.params, chatLLMId);
case 'chat-alter':
// /clear
if (chatCommand.command === '/clear') {
if (chatCommand.params === 'all') {
cHandler.messagesReplace([]);
} else {
cHandler.messagesReplace(history);
cHandler.messageAppendAssistant('Issue: this command requires the \'all\' parameter to confirm the operation.', undefined, 'issue', false);
}
return true;
}
// /assistant, /system
Object.assign(lastMessage, {
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
sender: 'Bot',
text: chatCommand.params || '',
} satisfies Partial<DMessage>);
cHandler.messagesReplace(history);
return true;
case 'cmd-help':
const chatCommandsText = findAllChatCommands()
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
.join('\n');
cHandler.messagesReplace(history);
cHandler.messageAppendAssistant('Available Chat Commands:\n' + chatCommandsText, undefined, 'help', false);
return true;
case 'mode-beam':
if (chatCommand.isError) {
cHandler.messagesReplace(history);
return false;
}
// remove '/beam ', as we want to be a user chat message
Object.assign(lastMessage, { text: chatCommand.params || '' });
cHandler.messagesReplace(history);
ConversationsManager.getHandler(conversationId).beamInvoke(history, [], null);
return true;
default:
cHandler.messagesReplace([...history, createDMessage('assistant', 'This command is not supported.')]);
return false;
}
}
}
// get the system purpose (note: we don't react to it, or it would invalidate half UI components..)
if (!getConversationSystemPurposeId(conversationId)) {
cHandler.messagesReplace(history);
cHandler.messageAppendAssistant('Issue: no Persona selected.', undefined, 'issue', false);
return 'err-no-persona';
}
// synchronous long-duration tasks, which update the state as they go
switch (chatModeId) {
case 'generate-text':
cHandler.messagesReplace(history);
return await runAssistantUpdatingState(conversationId, history, chatLLMId, getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount());
case 'generate-text-beam':
cHandler.messagesReplace(history);
cHandler.beamInvoke(history, [], null);
return true;
case 'append-user':
cHandler.messagesReplace(history);
return true;
case 'generate-image':
if (!lastMessage?.text) break;
// also add a 'fake' user message with the '/draw' command
cHandler.messagesReplace(history.map(message => (message.id !== lastMessage.id) ? message : {
...message,
text: `/draw ${lastMessage.text}`,
}));
return await runImageGenerationUpdatingState(cHandler, lastMessage.text);
case 'generate-react':
if (!lastMessage?.text) break;
cHandler.messagesReplace(history);
return await runReActUpdatingState(cHandler, lastMessage.text, chatLLMId);
}
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
console.log('Chat execute: issue running', chatModeId, conversationId, lastMessage);
cHandler.messagesReplace(history);
return false;
}
@@ -0,0 +1,69 @@
import type { DLLMId } from '~/modules/llms/store-llms';
import type { DMessageId } from '~/common/stores/chat/chat.message';
import { ConversationHandler } from '~/common/chats/ConversationHandler';
import { createTextContentFragment, DMessageFragment, isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
import { extractChatCommand, helpPrettyChatCommands } from '../commands/commands.registry';
import { runBrowseGetPageUpdatingState } from './browse-load';
import { runImageGenerationUpdatingState } from './image-generate';
import { runReActUpdatingState } from './react-tangent';
export const RET_NO_CMD = 'no-cmd';
export async function _handleExecuteCommand(lastMessageId: DMessageId, lastMessageFirstFragment: DMessageFragment, cHandler: ConversationHandler, chatLLMId: DLLMId) {
// commands must have a first Content DMessageTextPart
if (!isContentFragment(lastMessageFirstFragment) || !isTextPart(lastMessageFirstFragment.part))
return RET_NO_CMD;
// check if we have a command
const chatCommand = extractChatCommand(lastMessageFirstFragment.part.text)[0];
if (chatCommand?.type !== 'cmd')
return RET_NO_CMD;
// Valid /commands are intercepted here, and override chat modes, generally for mechanics or sidebars
switch (chatCommand.providerId) {
case 'cmd-ass-browse':
return await runBrowseGetPageUpdatingState(cHandler, chatCommand.params);
case 'cmd-ass-t2i':
return await runImageGenerationUpdatingState(cHandler, chatCommand.params);
case 'cmd-chat-alter':
// clear command
if (chatCommand.command === '/clear') {
if (chatCommand.params === 'all')
cHandler.historyClear();
else
cHandler.messageAppendAssistantText('Issue: this command requires the \'all\' parameter to confirm the operation.', 'issue');
return true;
}
// assistant/system command: change role and remove the /command
cHandler.messageEdit(lastMessageId, { role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user' }, false, false);
cHandler.messageFragmentReplace(lastMessageId, lastMessageFirstFragment.fId, createTextContentFragment(chatCommand.params || ''), true);
return true;
case 'cmd-help':
cHandler.messageAppendAssistantText(`Available Chat Commands:\n${helpPrettyChatCommands()}`, 'help');
return true;
case 'cmd-mode-beam':
if (chatCommand.isErrorNoArgs || !chatCommand.params)
return false;
// remove '/beam ', as we want to be a user chat message
cHandler.messageFragmentReplace(lastMessageId, lastMessageFirstFragment.fId, createTextContentFragment(chatCommand.params), true);
cHandler.beamInvoke(cHandler.historyViewHead('cmd-mode-beam'), [], null);
return true;
case 'cmd-mode-react':
return await runReActUpdatingState(cHandler, chatCommand.params, chatLLMId);
default:
cHandler.messageAppendAssistantText('This command is not supported', 'help');
return false;
}
}
+14 -5
View File
@@ -1,26 +1,35 @@
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
import { createErrorContentFragment, createTextContentFragment } from '~/common/stores/chat/chat.fragments';
export const runBrowseGetPageUpdatingState = async (cHandler: ConversationHandler, url?: string) => {
if (!url) {
cHandler.messageAppendAssistant('Issue: no URL provided.', undefined, 'issue', false);
cHandler.messageAppendAssistantText('Issue: no URL provided.', 'issue');
return false;
}
// noinspection HttpUrlsUsage
const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', '');
const assistantMessageId = cHandler.messageAppendAssistant(`Loading page at ${shortUrl}...`, undefined, 'web', true);
const { assistantMessageId, placeholderFragmentId } = cHandler.messageAppendAssistantPlaceholder(
`Loading page at ${shortUrl}...`,
{ originLLM: 'web' },
);
try {
const page = await callBrowseFetchPage(url);
const pageContent = page.content.markdown || page.content.text || page.content.html || 'Issue: page load did not produce an answer: no text found';
cHandler.messageEdit(assistantMessageId, { text: pageContent, typing: false }, true);
const pageContent = page.content.markdown || page.content.text || page.content.html || 'Issue: Browsing did not produce a page content.';
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createTextContentFragment(pageContent), true);
return true;
} catch (error: any) {
console.error(error);
cHandler.messageEdit(assistantMessageId, { text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').', typing: false }, true);
const pageError = 'Issue: Browsing did not produce a page.\n(error: ' + (error?.message || error?.toString() || 'unknown') + ').';
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createErrorContentFragment(pageError), true);
return false;
}
};
+184
View File
@@ -0,0 +1,184 @@
import type { DLLMId } from '~/modules/llms/store-llms';
import type { VChatContextRef, VChatMessageIn, VChatStreamContextName } from '~/modules/llms/llm.client';
import { aixStreamingChatGenerate, StreamingClientUpdate } from '~/modules/aix/client/aix.client';
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
import { PersonaChatMessageSpeak } from './persona/PersonaChatMessageSpeak';
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { DMessage, messageFragmentsReplaceLastContentText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message';
import { getUXLabsHighPerformance } from '~/common/state/store-ux-labs';
import { isContentFragment, isTextPart } from '~/common/stores/chat/chat.fragments';
import { getChatAutoAI } from '../store-app-chat';
import { getInstantAppChatPanesCount } from '../components/panes/usePanesManager';
/**
* The main "chat" function.
*/
export async function runPersonaOnConversationHead(
assistantLlmId: DLLMId,
conversationId: DConversationId,
): Promise<boolean> {
const cHandler = ConversationsManager.getHandler(conversationId);
const history = cHandler.historyViewHead('runPersonaOnConversationHead') as Readonly<DMessage[]>;
const parallelViewCount = getUXLabsHighPerformance() ? 0 : getInstantAppChatPanesCount();
// ai follow-up operations (fire/forget)
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
// assistant placeholder
const { assistantMessageId } = cHandler.messageAppendAssistantPlaceholder(
'...',
{ originLLM: assistantLlmId, purposeId: history[0].purposeId },
);
// AutoSpeak
const autoSpeaker = autoSpeak !== 'off' ? new PersonaChatMessageSpeak(autoSpeak) : null;
// when an abort controller is set, the UI switches to the "stop" mode
const abortController = new AbortController();
cHandler.setAbortController(abortController);
// stream the assistant's messages directly to the state store
let instructions: VChatMessageIn[];
try {
instructions = history.map((m): VChatMessageIn => ({ role: m.role, content: messageSingleTextOrThrow(m) /* BIG FIXME */ }));
} catch (error) {
console.error('runAssistantUpdatingState: error:', error, history);
throw error;
}
const messageStatus = await llmGenerateContentStream(
assistantLlmId,
instructions,
'conversation',
conversationId,
parallelViewCount,
abortController.signal,
(accumulatedMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => {
if (abortController.signal.aborted) return;
// typing sound
// if (messageComplete)
// AudioGenerator.basicAstralChimes({ volume: 0.4 }, 0, 2, 250);
cHandler.messageEdit(assistantMessageId, accumulatedMessage, messageComplete, false);
if (autoSpeaker && accumulatedMessage.fragments?.length && isContentFragment(accumulatedMessage.fragments[0]) && isTextPart(accumulatedMessage.fragments[0].part)) {
if (messageComplete)
autoSpeaker.finalizeText(accumulatedMessage.fragments[0].part.text);
else
autoSpeaker.handleTextSoFar(accumulatedMessage.fragments[0].part.text);
}
},
);
// check if aborted
const hasBeenAborted = abortController.signal.aborted;
// clear to send, again
// FIXME: race condition? (for sure!)
cHandler.setAbortController(null);
if (autoTitleChat) {
// fire/forget, this will only set the title if it's not already set
void autoConversationTitle(conversationId, false);
}
if (!hasBeenAborted && (autoSuggestDiagrams || autoSuggestHTMLUI || autoSuggestQuestions))
autoSuggestions(null, conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions);
return messageStatus.outcome === 'success';
}
type StreamMessageOutcome = 'success' | 'aborted' | 'errored';
type StreamMessageStatus = { outcome: StreamMessageOutcome, errorMessage?: string };
type StreamMessageUpdate = Pick<DMessage, 'fragments' | 'originLLM' | 'pendingIncomplete'>;
export async function llmGenerateContentStream(
llmId: DLLMId,
messagesHistory: VChatMessageIn[],
contextName: VChatStreamContextName,
contextRef: VChatContextRef,
parallelViewCount: number, // 0: disable, 1: default throttle (12Hz), 2+ reduce frequency with the square root
abortSignal: AbortSignal,
onMessageUpdated: (incrementalMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => void,
): Promise<StreamMessageStatus> {
const returnStatus: StreamMessageStatus = { outcome: 'success', errorMessage: undefined };
const throttler = new ThrottleFunctionCall(parallelViewCount);
// TODO: should clean this up once we have multi-fragment streaming/recombination
const incrementalAnswer: StreamMessageUpdate = {
fragments: [],
};
try {
await aixStreamingChatGenerate(llmId, messagesHistory, contextName, contextRef, null, null, abortSignal,
(update: StreamingClientUpdate, done: boolean) => {
// grow the incremental message
if (update.textSoFar) incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, update.textSoFar);
if (update.originLLM) incrementalAnswer.originLLM = update.originLLM;
if (update.typing !== undefined)
incrementalAnswer.pendingIncomplete = update.typing ? true : undefined;
// throttle the update
throttler.handleUpdate(() => {
onMessageUpdated(incrementalAnswer, false);
});
},
);
} catch (error: any) {
if (error?.name !== 'AbortError') {
console.error('Fetch request error:', error);
const errorText = ` [Issue: ${error.message || (typeof error === 'string' ? error : 'Chat stopped.')}]`;
incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, errorText, true);
returnStatus.outcome = 'errored';
returnStatus.errorMessage = error.message;
} else
returnStatus.outcome = 'aborted';
}
// Ensure the last content is flushed out, and mark as complete
throttler.finalize(() => {
onMessageUpdated({ ...incrementalAnswer, pendingIncomplete: undefined }, true);
});
return returnStatus;
}
export class ThrottleFunctionCall {
private readonly throttleDelay: number;
private lastCallTime: number = 0;
constructor(throttleUnits: number) {
// 12 messages per second works well for 60Hz displays (single chat, and 24 in 4 chats, see the square root below)
const baseDelayMs = 1000 / 12;
this.throttleDelay = throttleUnits === 0 ? 0
: throttleUnits > 1 ? Math.round(baseDelayMs * Math.sqrt(throttleUnits))
: baseDelayMs;
}
handleUpdate(fn: () => void): void {
const now = Date.now();
if (this.throttleDelay === 0 || this.lastCallTime === 0 || now - this.lastCallTime >= this.throttleDelay) {
fn();
this.lastCallTime = now;
}
}
finalize(fn: () => void): void {
fn(); // Always execute the final update
}
}
@@ -1,73 +1,90 @@
import type { DLLMId } from '~/modules/llms/store-llms';
import type { StreamingClientUpdate } from '~/modules/llms/vendors/unifiedStreamingClient';
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { autoConversationTitle } from '~/modules/aifn/autotitle/autoTitle';
import { llmStreamingChatGenerate, VChatContextRef, VChatMessageIn, VChatStreamContextName } from '~/modules/llms/llm.client';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
import type { DMessage } from '~/common/state/store-chats';
import { ConversationsManager } from '~/common/chats/ConversationsManager';
import { DMessage, messageFragmentsReduceText, messageFragmentsReplaceLastContentText, messageSingleTextOrThrow } from '~/common/stores/chat/chat.message';
import { ChatAutoSpeakType, getChatAutoAI } from '../store-app-chat';
export const STREAM_TEXT_INDICATOR = '...';
/**
* 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, parallelViewCount: number) {
export async function runAssistantUpdatingStateV1(
conversationId: string,
history: Readonly<DMessage[]>,
assistantLlmId: DLLMId,
parallelViewCount: number,
) {
const cHandler = ConversationsManager.getHandler(conversationId);
// ai follow-up operations (fire/forget)
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
const { autoSpeak, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
// create a blank and 'typing' message for the assistant
const assistantMessageId = cHandler.messageAppendAssistant(STREAM_TEXT_INDICATOR, history[0].purposeId, assistantLlmId, true);
// assistant placeholder
const { assistantMessageId } = cHandler.messageAppendAssistantPlaceholder(
'...',
{ originLLM: assistantLlmId, purposeId: history[0].purposeId },
);
// when an abort controller is set, the UI switches to the "stop" mode
const abortController = new AbortController();
cHandler.setAbortController(abortController);
// stream the assistant's messages
const messageStatus = await streamAssistantMessage(
// stream the assistant's messages directly to the state store
const overwriteMessageParts = (incrementalMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => {
cHandler.messageEdit(assistantMessageId, incrementalMessage, messageComplete, false);
};
let instructions: VChatMessageIn[];
try {
instructions = history.map((m): VChatMessageIn => ({ role: m.role, content: messageSingleTextOrThrow(m) /* BIG FIXME */ }));
} catch (error) {
console.error('runAssistantUpdatingState: error:', error, history);
throw error;
}
const messageStatus = await streamAssistantMessageV1(
assistantLlmId,
history.map((m): VChatMessageIn => ({ role: m.role, content: m.text })),
instructions,
'conversation',
conversationId,
parallelViewCount,
autoSpeak,
(update) => cHandler.messageEdit(assistantMessageId, update, false),
overwriteMessageParts,
abortController.signal,
);
// clear to send, again
// FIXME: race condition?
// FIXME: race condition? (for sure!)
cHandler.setAbortController(null);
if (autoTitleChat) {
// fire/forget, this will only set the title if it's not already set
void conversationAutoTitle(conversationId, false);
void autoConversationTitle(conversationId, false);
}
if (autoSuggestDiagrams || autoSuggestQuestions)
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
if (autoSuggestDiagrams || autoSuggestHTMLUI || autoSuggestQuestions)
autoSuggestions(null, conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestHTMLUI, autoSuggestQuestions);
return messageStatus.outcome === 'success';
}
type StreamMessageOutcome = 'success' | 'aborted' | 'errored';
type StreamMessageStatus = { outcome: StreamMessageOutcome, errorMessage?: string };
type StreamMessageUpdate = Pick<DMessage, 'fragments' | 'originLLM' | 'pendingIncomplete'>;
export async function streamAssistantMessage(
export async function streamAssistantMessageV1(
llmId: DLLMId,
messagesHistory: VChatMessageIn[],
contextName: VChatStreamContextName,
contextRef: VChatContextRef,
throttleUnits: number, // 0: disable, 1: default throttle (12Hz), 2+ reduce the message frequency with the square root
autoSpeak: ChatAutoSpeakType,
editMessage: (update: Partial<DMessage>) => void,
onMessageUpdated: (incrementalMessage: Partial<StreamMessageUpdate>, messageComplete: boolean) => void,
abortSignal: AbortSignal,
): Promise<StreamMessageStatus> {
@@ -85,24 +102,28 @@ export async function streamAssistantMessage(
if (throttleUnits > 1)
throttleDelay = Math.round(throttleDelay * Math.sqrt(throttleUnits));
function throttledEditMessage(updatedMessage: Partial<DMessage>) {
function throttledEditMessage(updatedMessage: Partial<StreamMessageUpdate>) {
const now = Date.now();
if (throttleUnits === 0 || now - lastCallTime >= throttleDelay) {
editMessage(updatedMessage);
onMessageUpdated(updatedMessage, false);
lastCallTime = now;
}
}
const incrementalAnswer: Partial<DMessage> = { text: '' };
// TODO: should clean this up once we have multi-fragment streaming/recombination
const incrementalAnswer: StreamMessageUpdate = {
fragments: [],
};
try {
await llmStreamingChatGenerate(llmId, messagesHistory, contextName, contextRef, null, null, abortSignal, (update: StreamingClientUpdate) => {
const textSoFar = update.textSoFar;
// grow the incremental message
if (textSoFar) incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, textSoFar);
if (update.originLLM) incrementalAnswer.originLLM = update.originLLM;
if (textSoFar) incrementalAnswer.text = textSoFar;
if (update.typing !== undefined) incrementalAnswer.typing = update.typing;
if (update.typing !== undefined)
incrementalAnswer.pendingIncomplete = update.typing ? true : undefined;
// Update the data store, with optional max-frequency throttling (e.g. OpenAI is downsamped 50 -> 12Hz)
// This can be toggled from the settings
@@ -125,21 +146,22 @@ export async function streamAssistantMessage(
if (error?.name !== 'AbortError') {
console.error('Fetch request error:', error);
const errorText = ` [Issue: ${error.message || (typeof error === 'string' ? error : 'Chat stopped.')}]`;
incrementalAnswer.text = (incrementalAnswer.text || '') + errorText;
incrementalAnswer.fragments = messageFragmentsReplaceLastContentText(incrementalAnswer.fragments, errorText, true);
returnStatus.outcome = 'errored';
returnStatus.errorMessage = error.message;
} else
returnStatus.outcome = 'aborted';
}
// Optimized:
// 1 - stop the typing animation
// 2 - ensure the last content is flushed out
editMessage({ ...incrementalAnswer, typing: false });
// Ensure the last content is flushed out, and mark as complete
onMessageUpdated({ ...incrementalAnswer, pendingIncomplete: undefined }, true);
// 📢 TTS: all
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && incrementalAnswer.text && !spokenLine && !abortSignal.aborted)
void speakText(incrementalAnswer.text);
if ((autoSpeak === 'all' || autoSpeak === 'firstLine') && !spokenLine && !abortSignal.aborted) {
const incrementalText = messageFragmentsReduceText(incrementalAnswer.fragments);
if (incrementalText.length > 0)
void speakText(incrementalText);
}
return returnStatus;
}
+52 -11
View File
@@ -1,7 +1,11 @@
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageOrThrow } from '~/modules/t2i/t2i.client';
import type { DBlobAssetId } from '~/modules/dblobs/dblobs.types';
import { gcDBImageAssets } from '~/modules/dblobs/dblobs.images';
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageContentFragments } from '~/modules/t2i/t2i.client';
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { createErrorContentFragment, isContentOrAttachmentFragment, isImageRefPart } from '~/common/stores/chat/chat.fragments';
import { useChatStore } from '~/common/stores/chat/store-chats';
/**
@@ -9,7 +13,7 @@ import type { TextToImageProvider } from '~/common/components/useCapabilities';
*/
export async function runImageGenerationUpdatingState(cHandler: ConversationHandler, imageText?: string) {
if (!imageText) {
cHandler.messageAppendAssistant('Issue: no image description provided.', undefined, 'issue', false);
cHandler.messageAppendAssistantText('Issue: no image description provided.', 'issue');
return false;
}
@@ -18,7 +22,7 @@ export async function runImageGenerationUpdatingState(cHandler: ConversationHand
try {
t2iProvider = getActiveTextToImageProviderOrThrow();
} catch (error: any) {
cHandler.messageAppendAssistant(`[Issue] Sorry, I can't generate images right now. ${error?.message || error?.toString() || 'Unknown error'}.`, undefined, 'issue', false);
cHandler.messageAppendAssistantText(`[Issue] Sorry, I can't generate images right now. ${error?.message || error?.toString() || 'Unknown error'}.`, 'issue');
return 'err-t2i-unconfigured';
}
@@ -28,18 +32,55 @@ export async function runImageGenerationUpdatingState(cHandler: ConversationHand
if (repeat > 1)
imageText = imageText.replace(/x(\d+)$|\[(\d+)]$/, '').trim(); // Remove the "xN" or "[N]" part from the imageText
const assistantMessageId = cHandler.messageAppendAssistant(
`Give me ${t2iProvider.vendor === 'openai' ? 'a dozen' : 'a few'} seconds while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'}...`,
undefined, t2iProvider.painter, true,
const { assistantMessageId, placeholderFragmentId } = cHandler.messageAppendAssistantPlaceholder(
`Give me ${t2iProvider.vendor === 'openai' ? 'a minute' : 'a few seconds'} while I draw ${imageText?.length > 20 ? 'that' : '"' + imageText + '"'} with ${t2iProvider.painter}...`,
{ originLLM: t2iProvider.painter },
);
try {
const imageUrls = await t2iGenerateImageOrThrow(t2iProvider, imageText, repeat);
cHandler.messageEdit(assistantMessageId, { text: imageUrls.join('\n'), typing: false }, true);
const imageContentFragments = await t2iGenerateImageContentFragments(t2iProvider, imageText, repeat, 'global', 'app-chat');
// add the image content fragments to the message
for (const imageContentFragment of imageContentFragments)
cHandler.messageFragmentAppend(assistantMessageId, imageContentFragment, false, false);
cHandler.messageFragmentDelete(assistantMessageId, placeholderFragmentId, true, true);
return true;
} catch (error: any) {
const errorMessage = error?.message || error?.toString() || 'Unknown error';
cHandler.messageEdit(assistantMessageId, { text: `[Issue] Sorry, I couldn't create an image for you. ${errorMessage}`, typing: false }, false);
const drawError = `Issue: Sorry, I couldn't create an image for you.\n${error?.message || error?.toString() || 'Unknown error'}`;
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createErrorContentFragment(drawError), true);
return false;
}
}
}
/**
* Garbage collect unreferenced dblobs in global chats
*/
export async function gcChatImageAssets() {
// find all the dblob references in all chats
const chatsAssetIDs: Set<DBlobAssetId> = new Set();
const chatStore = useChatStore.getState();
for (const chat of chatStore.conversations) {
for (const message of chat.messages) {
for (const fragment of message.fragments) {
if (!isContentOrAttachmentFragment(fragment) || !isImageRefPart(fragment.part))
continue;
if (fragment.part.dataRef.reftype !== 'dblob')
continue;
chatsAssetIDs.add(fragment.part.dataRef.dblobAssetId);
}
}
}
// sanity check: if no blobs are referenced, do nothing; in case we have a state bug and we don't wipe the db
if (!chatsAssetIDs.size)
return;
// perform the GC (set to array)
await gcDBImageAssets('global', 'app-chat', Array.from(chatsAssetIDs));
}
@@ -0,0 +1,46 @@
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
export type AutoSpeakType = 'off' | 'firstLine' | 'all';
export class PersonaChatMessageSpeak {
private spokenLine: boolean = false;
constructor(private autoSpeakType: AutoSpeakType) {
}
handleTextSoFar(textSoFar: string): void {
if (this.spokenLine || this.autoSpeakType === 'off') return;
// 📢 TTS: first-line
if (this.autoSpeakType === 'firstLine') {
const cutPoint = this.findLastCutPoint(textSoFar);
if (cutPoint > 100 && cutPoint < 400) {
this.spokenLine = true;
const firstParagraph = textSoFar.substring(0, cutPoint);
this.speak(firstParagraph);
}
}
}
finalizeText(fullText: string): void {
if (!this.spokenLine && this.autoSpeakType !== 'off' && fullText.length > 0) {
this.speak(fullText);
}
}
private findLastCutPoint(text: string): number {
let cutPoint = text.lastIndexOf('\n');
if (cutPoint < 0)
cutPoint = text.lastIndexOf('. ');
return cutPoint;
}
private speak(text: string) {
console.log('📢 TTS:', text);
// fire/forget: we don't want to stall this loop
void speakText(text);
}
}
+16 -8
View File
@@ -3,9 +3,9 @@ import { DLLMId } from '~/modules/llms/store-llms';
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
import type { ConversationHandler } from '~/common/chats/ConversationHandler';
import { createErrorContentFragment, createTextContentFragment } from '~/common/stores/chat/chat.fragments';
import { STREAM_TEXT_INDICATOR } from './chat-stream';
// configuration
const EPHEMERAL_DELETION_DELAY = 5 * 1000;
@@ -14,13 +14,16 @@ const EPHEMERAL_DELETION_DELAY = 5 * 1000;
*/
export async function runReActUpdatingState(cHandler: ConversationHandler, question: string | undefined, assistantLlmId: DLLMId) {
if (!question) {
cHandler.messageAppendAssistant('Issue: no question provided.', undefined, 'issue', false);
cHandler.messageAppendAssistantText('Issue: no question provided.', 'issue');
return false;
}
// create a blank and 'typing' message for the assistant - to be filled when we're done
// create an assistant placeholder message - to be filled when we're done
const assistantModelLabel = 'react-' + assistantLlmId; //.slice(4, 7); // HACK: this is used to change the Avatar animation
const assistantMessageId = cHandler.messageAppendAssistant(STREAM_TEXT_INDICATOR, undefined, assistantModelLabel, true);
const { assistantMessageId, placeholderFragmentId } = cHandler.messageAppendAssistantPlaceholder(
'...',
{ originLLM: assistantModelLabel },
);
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
// create an ephemeral space
@@ -39,14 +42,19 @@ export async function runReActUpdatingState(cHandler: ConversationHandler, quest
const agent = new Agent();
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
cHandler.messageEdit(assistantMessageId, { text: reactResult, typing: false }, false);
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createTextContentFragment(reactResult), true);
setTimeout(() => eHandler.delete(), EPHEMERAL_DELETION_DELAY);
return true;
} catch (error: any) {
console.error(error);
console.error('ReAct error', error);
logToEphemeral(ephemeralText + `\nIssue: ${error || 'unknown'}`);
cHandler.messageEdit(assistantMessageId, { text: 'Issue: ReAct did not produce an answer.', typing: false }, false);
const reactError = `Issue: ReAct couldn't answer your question. ${error?.message || error?.toString() || 'Unknown error'}`;
cHandler.messageFragmentReplace(assistantMessageId, placeholderFragmentId, createErrorContentFragment(reactError), true);
return false;
}
}
@@ -0,0 +1,71 @@
import * as React from 'react';
import { Box, MenuItem, Radio, Typography } from '@mui/joy';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { KeyStroke, platformAwareKeystrokes } from '~/common/components/KeyStroke';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import type { ChatExecuteMode } from './execute-mode.types';
import { ExecuteModeItems } from './execute-mode.items';
export function ExecuteModeMenu(props: {
isMobile: boolean,
hasCapabilityT2I: boolean,
anchorEl: HTMLAnchorElement | null,
onClose: () => void,
chatExecuteMode: ChatExecuteMode,
onSetChatExecuteMode: (chatExecuteMode: ChatExecuteMode) => void,
}) {
// external state
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
return (
<CloseableMenu
placement='top-end'
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/* Items */}
{Object.entries(ExecuteModeItems)
.filter(([_key, data]) => !data.hideOnDesktop || props.isMobile)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatExecuteMode(key as ChatExecuteMode)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio color={data.highlight ? 'success' : undefined} checked={key === props.chatExecuteMode} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.hasCapabilityT2I) ? 'Unconfigured' : ''}</Typography>
</Box>
{(key === props.chatExecuteMode || !!data.shortcut) && (
<KeyStroke combo={platformAwareKeystrokes(
newLineShortcut(
(key === props.chatExecuteMode) ? 'ENTER'
: data.shortcut ? data.shortcut
: 'ENTER',
enterIsNewline,
),
)} />
)}
</Box>
</MenuItem>,
)}
</CloseableMenu>
);
}
function newLineShortcut(shortcut: string, enterIsNewLine: boolean) {
if (shortcut === 'ENTER')
return enterIsNewLine ? 'Shift + Enter' : 'Enter';
return shortcut;
}
@@ -0,0 +1,68 @@
import * as React from 'react';
import type { ColorPaletteProp } from '@mui/joy/styles/types';
import type { ChatExecuteMode } from './execute-mode.types';
interface ModeDescription {
// menu data
label: string;
description: string | React.JSX.Element;
canAttach?: boolean;
highlight?: boolean;
shortcut?: string;
hideOnDesktop?: boolean;
requiresTTI?: boolean;
// button data
sendColor: ColorPaletteProp;
sendText: string;
}
export const ExecuteModeItems: { [key in ChatExecuteMode]: ModeDescription } = {
'generate-content': {
label: 'Chat',
description: 'Persona replies',
canAttach: true,
sendColor: 'primary',
sendText: 'Chat · DEV',
},
'generate-text-v1': {
label: 'Chat (Stable)',
description: 'Model replies (stable)',
canAttach: true,
sendColor: 'primary',
sendText: 'Chat · Stable',
},
'beam-content': {
label: 'Beam', // Best of, Auto-Prime, Top Pick, Select Best
description: 'Combine multiple models', // Smarter: combine...
shortcut: 'Ctrl + Enter',
canAttach: true,
hideOnDesktop: true,
sendColor: 'primary',
sendText: 'Beam',
},
'append-user': {
label: 'Write',
description: 'Append a message',
shortcut: 'Alt + Enter',
canAttach: true,
sendColor: 'primary',
sendText: 'Write',
},
'generate-image': {
label: 'Draw',
description: 'AI Image Generation',
requiresTTI: true,
sendColor: 'warning',
sendText: 'Draw',
},
'react-content': {
label: 'Reason + Act', // · α
description: 'Answer questions in multiple steps',
sendColor: 'success',
sendText: 'ReAct',
},
};
@@ -0,0 +1,12 @@
/**
* Mode: how to treat the input from the Composer
* Was: ChatModeId
*/
export type ChatExecuteMode =
| 'append-user'
| 'beam-content'
| 'generate-content'
| 'generate-image'
| 'generate-text-v1'
| 'react-content'
;
@@ -0,0 +1,52 @@
import * as React from 'react';
import type { ChatExecuteMode } from './execute-mode.types';
import { ExecuteModeMenu } from './ExecuteModeMenu';
import { ExecuteModeItems } from './execute-mode.items';
export function chatExecuteModeCanAttach(chatExecuteMode: ChatExecuteMode) {
return !!ExecuteModeItems[chatExecuteMode]?.canAttach;
}
export function useChatExecuteMode(capabilityHasT2I: boolean, isMobile: boolean) {
// state
const [chatExecuteMode, setChatExecuteMode] = React.useState<ChatExecuteMode>('generate-content');
const [chatExecuteModeMenuAnchor, setChatExecuteModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
const handleMenuHide = React.useCallback(() => setChatExecuteModeMenuAnchor(null), []);
const handleMenuShow = React.useCallback((event: React.MouseEvent<HTMLAnchorElement>) => {
setChatExecuteModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
}, []);
const handleChangeMode = React.useCallback((mode: ChatExecuteMode) => {
handleMenuHide();
setChatExecuteMode(mode);
}, [handleMenuHide]);
const chatExecuteMenuComponent = React.useMemo(() => !!chatExecuteModeMenuAnchor && (
<ExecuteModeMenu
isMobile={isMobile}
hasCapabilityT2I={capabilityHasT2I}
anchorEl={chatExecuteModeMenuAnchor}
onClose={handleMenuHide}
chatExecuteMode={chatExecuteMode}
onSetChatExecuteMode={handleChangeMode}
/>
), [capabilityHasT2I, chatExecuteMode, chatExecuteModeMenuAnchor, handleMenuHide, handleChangeMode, isMobile]);
return {
chatExecuteMode,
chatExecuteMenuComponent,
chatExecuteModeSendColor: ExecuteModeItems[chatExecuteMode]?.sendColor || 'primary',
chatExecuteModeSendLabel: ExecuteModeItems[chatExecuteMode]?.sendText || 'Send',
chatExecuteMenuShown: !!chatExecuteModeMenuAnchor,
showChatExecuteMenu: handleMenuShow,
};
}
+25 -6
View File
@@ -1,5 +1,4 @@
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { persist } from 'zustand/middleware';
import { useShallow } from 'zustand/react/shallow';
@@ -19,6 +18,9 @@ interface AppChatStore {
autoSuggestDiagrams: boolean,
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => void;
autoSuggestHTMLUI: boolean;
setAutoSuggestHTMLUI: (autoSuggestHTMLUI: boolean) => void;
autoSuggestQuestions: boolean,
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void;
@@ -30,6 +32,9 @@ interface AppChatStore {
filterHasStars: boolean;
setFilterHasStars: (filterHasStars: boolean) => void;
filterHasImageAssets: boolean;
setFilterHasImageAssets: (filterHasImageAssets: boolean) => void;
micTimeoutMs: number;
setMicTimeoutMs: (micTimeoutMs: number) => void;
@@ -57,6 +62,9 @@ const useAppChatStore = create<AppChatStore>()(persist(
autoSuggestDiagrams: false,
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => _set({ autoSuggestDiagrams }),
autoSuggestHTMLUI: false,
setAutoSuggestHTMLUI: (autoSuggestHTMLUI: boolean) => _set({ autoSuggestHTMLUI }),
autoSuggestQuestions: false,
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => _set({ autoSuggestQuestions }),
@@ -66,6 +74,9 @@ const useAppChatStore = create<AppChatStore>()(persist(
filterHasStars: false,
setFilterHasStars: (filterHasStars: boolean) => _set({ filterHasStars }),
filterHasImageAssets: false,
setFilterHasImageAssets: (filterHasImageAssets: boolean) => _set({ filterHasImageAssets }),
micTimeoutMs: 2000,
setMicTimeoutMs: (micTimeoutMs: number) => _set({ micTimeoutMs }),
@@ -102,49 +113,57 @@ const useAppChatStore = create<AppChatStore>()(persist(
));
export const useChatAutoAI = () => useAppChatStore(state => ({
export const useChatAutoAI = () => useAppChatStore(useShallow(state => ({
autoSpeak: state.autoSpeak,
autoSuggestDiagrams: state.autoSuggestDiagrams,
autoSuggestHTMLUI: state.autoSuggestHTMLUI,
autoSuggestQuestions: state.autoSuggestQuestions,
autoTitleChat: state.autoTitleChat,
setAutoSpeak: state.setAutoSpeak,
setAutoSuggestDiagrams: state.setAutoSuggestDiagrams,
setAutoSuggestHTMLUI: state.setAutoSuggestHTMLUI,
setAutoSuggestQuestions: state.setAutoSuggestQuestions,
setAutoTitleChat: state.setAutoTitleChat,
}), shallow);
})));
export const getChatAutoAI = (): {
autoSpeak: ChatAutoSpeakType,
autoSuggestDiagrams: boolean,
autoSuggestHTMLUI: boolean,
autoSuggestQuestions: boolean,
autoTitleChat: boolean,
} => useAppChatStore.getState();
export const useChatAutoSuggestHTMLUI = (): boolean =>
useAppChatStore(state => state.autoSuggestHTMLUI);
export const useChatMicTimeoutMsValue = (): number =>
useAppChatStore(state => state.micTimeoutMs);
export const useChatMicTimeoutMs = (): [number, (micTimeoutMs: number) => void] =>
useAppChatStore(state => [state.micTimeoutMs, state.setMicTimeoutMs], shallow);
useAppChatStore(useShallow(state => [state.micTimeoutMs, state.setMicTimeoutMs]));
export const useChatDrawerFilters = () => {
const values = useAppChatStore(useShallow(state => ({
filterHasStars: state.filterHasStars,
filterHasImageAssets: state.filterHasImageAssets,
showPersonaIcons: state.showPersonaIcons,
showRelativeSize: state.showRelativeSize,
})));
return {
...values,
toggleFilterHasStars: () => useAppChatStore.getState().setFilterHasStars(!values.filterHasStars),
toggleFilterHasImageAssets: () => useAppChatStore.getState().setFilterHasImageAssets(!values.filterHasImageAssets),
toggleShowPersonaIcons: () => useAppChatStore.getState().setShowPersonaIcons(!values.showPersonaIcons),
toggleShowRelativeSize: () => useAppChatStore.getState().setShowRelativeSize(!values.showRelativeSize),
};
};
export const useChatShowTextDiff = (): [boolean, (showDiff: boolean) => void] =>
useAppChatStore(state => [state.showTextDiff, state.setShowTextDiff], shallow);
useAppChatStore(useShallow(state => [state.showTextDiff, state.setShowTextDiff]));
export const getChatShowSystemMessages = (): boolean =>
useAppChatStore.getState().showSystemMessages;
export const useChatShowSystemMessages = (): [boolean, (showSystemMessages: boolean) => void] =>
useAppChatStore(state => [state.showSystemMessages, state.setShowSystemMessages], shallow);
useAppChatStore(useShallow(state => [state.showSystemMessages, state.setShowSystemMessages]));
+43 -48
View File
@@ -1,73 +1,68 @@
import * as React from 'react';
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useRouterQuery } from '~/common/app.routes';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useProcessingQueue } from '~/common/logic/ProcessingQueue';
import { DrawHeading } from './components/DrawHeading';
import { DrawUnconfigured } from './components/DrawUnconfigured';
import { TextToImage } from './TextToImage';
import { DrawCreate } from './DrawCreate';
import { DrawGallery } from './DrawGallery';
import { drawCreateQueue } from './queue-draw-create';
import { useDrawSectionDropdown } from './useDrawSectionDropdown';
export interface AppDrawIntent {
backTo: 'app-chat';
}
// export interface AppDrawIntent {
// backTo: 'app-chat';
// }
export function AppDraw() {
// state
const [showHeading, setShowHeading] = React.useState<boolean>(true);
const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
const [section, setSection] = React.useState<number>(0);
const [showHeader, setShowHeader] = React.useState(true);
// const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
// external state
const isMobile = useIsMobile();
const query = useRouterQuery<Partial<AppDrawIntent>>();
const { queueState, queueAddItem, queueCancelAll } = useProcessingQueue(drawCreateQueue);
const { activeProviderId, mayWork, providers, setActiveProviderId } = useCapabilityTextToImage();
// const query = useRouterQuery<Partial<AppDrawIntent>>();
// [effect] set intent from the query parameters
React.useEffect(() => {
if (query.backTo) {
setDrawIntent({
backTo: query.backTo || 'app-chat',
});
}
}, [query]);
// React.useEffect(() => {
// if (query.backTo) {
// setDrawIntent({
// backTo: query.backTo || 'app-chat',
// });
// }
// }, [query]);
// const hasIntent = !!drawIntent && !!drawIntent.backTo;
// usePluggableOptimaLayout(null, null, null, 'aa');
// pluggable layout
const { drawSection, drawSectionDropdown } = useDrawSectionDropdown(queueState.items.length, queueCancelAll);
usePluggableOptimaLayout(null, drawSectionDropdown, null, 'aa');
return <>
switch (drawSection) {
case 'create':
return (
<DrawCreate
queue={drawCreateQueue}
isMobile={isMobile}
showHeader={showHeader}
onHideHeader={() => setShowHeader(false)}
mayWork={mayWork}
providers={providers}
activeProviderId={activeProviderId}
setActiveProviderId={setActiveProviderId}
/>
);
{/* The container is a 100dvh, flex column with App bg (see `pageCoreSx`) */}
case 'browse':
return <DrawGallery domain='draw' />;
{showHeading && <DrawHeading
section={section}
setSection={setSection}
showSections
onRemoveHeading={() => setShowHeading(false)}
sx={{
px: { xs: 1, md: 2 },
py: { xs: 1, md: 6 },
}}
/>}
{!mayWork && <DrawUnconfigured />}
{/*{mayWork && <Gallery />}*/}
{mayWork && (
<TextToImage
isMobile={isMobile}
providers={providers}
activeProviderId={activeProviderId}
setActiveProviderId={setActiveProviderId}
/>
)}
</>;
case 'media':
return <DrawGallery domain='app' />;
}
}
+257
View File
@@ -0,0 +1,257 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box } from '@mui/joy';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
import { ScrollToBottomButton } from '~/common/scroll-to-bottom/ScrollToBottomButton';
import { DesignerPrompt, PromptComposer } from './create/PromptComposer';
import { DrawCreateQueue } from './queue-draw-create';
import { DrawSectionHeading } from './create/DrawSectionHeading';
import { ProviderConfigure } from './create/ProviderConfigure';
import { ZeroDrawConfig } from './create/ZeroDrawConfig';
import { ZeroGenerations } from './create/ZeroGenerations';
import { useProcessingQueue } from '~/common/logic/ProcessingQueue';
const imagineWorkspaceSx: SxProps = {
flexGrow: 1,
overflowY: 'auto',
// style
backgroundColor: 'background.level3',
boxShadow: 'inset 0 0 4px 0px rgba(0, 0, 0, 0.2)',
// layout
display: 'flex',
flexDirection: 'column',
};
const imagineScrollContainerSx: SxProps = {
flex: 1,
overflowY: 'auto',
position: 'relative',
minHeight: 128,
};
/*async function queryActiveGenerateImageVector(singlePrompt: string, vectorSize: number = 1) {
const imageContentFragments = await t2iGenerateImageContentFragments(null, singlePrompt, vectorSize, 'global', 'app-draw');
for (const imageContentFragment of imageContentFragments) {
console.log('TODO: notImplemented: imagePartDataRef: CRUD and View of blobs as ImageBlocks', imageContentFragment.part);
}
// TODO continue...
return [];
}*/
/*
function TempPromptImageGen(props: { prompt: DesignerPrompt, sx?: SxProps }) {
// NOTE: we shall consider a multidimensional shape-based design
// derived state
const { prompt: dp } = props;
// external state
const { data: imageBlocks, error, isPending } = useQuery<ImageBlock[], Error>({
enabled: !!dp.prompt,
queryKey: ['draw-dpid', dp.uuid],
queryFn: () => queryActiveGenerateImageVector(dp.prompt, dp._repeatCount),
refetchOnReconnect: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
staleTime: Infinity,
});
return <>
{error && <InlineError error={error} />}
{Array.from({ length: dp._repeatCount }).map((_, index) => {
const imgUid = `gen-img-${index}`;
const imageBlock = imageBlocks?.[index] || null;
return imageBlock
// ? <RenderImage key={imgUid} imageBlock={imageBlock} noTooltip />
? <Box sx={{
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
mx: 'auto', my: 'auto', // mt: (index > 0 || !props.isFirst) ? 1.5 : 0,
boxShadow: 'lg',
backgroundColor: 'neutral.solidBg',
'& picture': { display: 'flex' },
'& img': { maxWidth: '100%', maxHeight: '100%' },
}}>
<picture><img src={imageBlock.url} alt={imageBlock.alt} /></picture>
</Box>
: <Card key={imgUid} sx={{ mb: 'auto' }}>
<Skeleton animation='wave' variant='rectangular' sx={{ minWidth: 128, width: '100%', aspectRatio: 1 }} />
</Card>;
})}
</>;
}
*/
export function DrawCreate(props: {
queue: DrawCreateQueue,
isMobile: boolean,
showHeader: boolean,
onHideHeader: () => void,
mayWork: boolean,
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void,
}) {
// state
const [prompts, setPrompts] = React.useState<DesignerPrompt[]>([]);
// external state
const { queueState } = useProcessingQueue(props.queue);
console.log('DrawCreate', { queueState });
// handlers
const handleStopDrawing = React.useCallback(() => {
setPrompts([]);
}, []);
const { queue } = props;
const handlePromptEnqueue = React.useCallback((designerPrompts: DesignerPrompt[]) => {
for (const designerPrompt of designerPrompts) {
void queue.enqueueItem(designerPrompt); // fire/forget
}
}, [queue]);
return <>
{/* The container is a '100dvh flex column' with App background (see `pageCoreSx`) */}
{/* Embossed Imagine Workspace */}
<Box sx={imagineWorkspaceSx}>
{/* This box is here to let ScrollToBottomButton anchor to this (relative) insted of the scroll-dependent ScrollToBottom */}
<Box sx={imagineScrollContainerSx}>
{/* [overlay] Welcoming header - Closeable */}
{props.showHeader && (
<DrawSectionHeading
isBeta
title='Imagine'
subTitle={props.mayWork ? 'Model, Prompts, Go!' : 'No AI providers configured :('}
chipText='Multi-model, AI Text-to-Image'
highlight={props.mayWork}
onRemoveHeading={props.onHideHeader}
sx={{
position: 'absolute',
left: 0, top: 0, right: 0,
zIndex: 1,
m: { xs: 1, md: 2 },
boxShadow: 'md',
}}
/>
)}
<ScrollToBottom
bootToBottom
stickToBottomInitial
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
p: { xs: 1, md: 2 },
}}
>
{/* Gallery/Placeholders Grid */}
<Box sx={{
// my: 'auto',
mt: 'auto',
mx: 'auto',
border: '1px solid purple',
minHeight: '300px',
// layout
display: 'grid',
gridTemplateColumns: props.isMobile
? 'repeat(auto-fit, minmax(320px, 1fr))'
: 'repeat(auto-fit, minmax(max(min(100%, 400px), 100%/5), 1fr))',
gap: { xs: 2, md: 2 },
}}>
{/* {prompts.map((prompt, _index) => {*/}
{/* return (*/}
{/* <TempPromptImageGen*/}
{/* key={prompt.dpId}*/}
{/* prompt={prompt}*/}
{/* sx={{*/}
{/* border: DEBUG_LAYOUT ? '1px solid green' : undefined,*/}
{/* }}*/}
{/* />*/}
{/* );*/}
<Box sx={{background:'red'}}>a</Box>
<Box>a</Box>
<Box>a</Box>
<Box>a</Box>
<Box>a</Box>
<Box>a</Box>
</Box>
{/* Fallback */}
<ZeroGenerations />
{/* End with this Unconfigured message */}
{!props.mayWork && <ZeroDrawConfig />}
{/* Visibility and actions are handled via Context */}
<ScrollToBottomButton />
</ScrollToBottom>
</Box>
{/* Prompt Composer - inside the workspace for root-scrollability */}
<PromptComposer
isMobile={props.isMobile}
queueLength={prompts.length}
onDrawingStop={handleStopDrawing}
onPromptEnqueue={handlePromptEnqueue}
sx={{
flex: 0,
backgroundColor: 'background.level2',
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
}}
/>
</Box>
{/* AI Service Provider Options */}
<ProviderConfigure
providers={props.providers}
activeProviderId={props.activeProviderId}
setActiveProviderId={props.setActiveProviderId}
sx={{
backgroundColor: 'background.level1',
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
}}
/>
</>;
}
+78
View File
@@ -0,0 +1,78 @@
import * as React from 'react';
import { Box, Table } from '@mui/joy';
import { DBlobAssetType, DBlobImageAsset } from '~/modules/dblobs/dblobs.types';
import { useDBAssetsByScopeAndType } from '~/modules/dblobs/dblobs.hooks';
import { ZeroGallery } from './gallery/ZeroGallery';
export function DrawGallery(props: { domain: 'draw' | 'app' }) {
const [items] = useDBAssetsByScopeAndType<DBlobImageAsset>(
DBlobAssetType.IMAGE,
'global',
props.domain === 'draw' ? 'app-draw' : 'app-chat',
);
const boxStyles = {
flexGrow: 1,
overflowY: 'auto',
p: { xs: 2, md: 6 },
};
const cellStyles = {
overflowWrap: 'anywhere',
whiteSpace: 'break-spaces',
};
return (
<Box sx={boxStyles}>
<Table borderAxis='both' size='sm' stripe='odd' variant='plain'>
<thead>
<tr>
<th>Image</th>
<th>Origin</th>
<th>Metadata</th>
</tr>
</thead>
<tbody>
{(items || []).map(({ id, label, cache, data, origin, metadata, createdAt, updatedAt }) => (
<tr key={id}>
<td>
<Box sx={cellStyles}>
<picture style={{ display: 'flex', maxWidth: 256, maxHeight: 256 }}>
<img
src={cache.thumb256?.base64 ? `data:${cache.thumb256?.mimeType};base64,${cache.thumb256?.base64}` : `data:${data.mimeType};base64,${data.base64}`}
alt={label}
style={{
boxShadow: '0 0 4px 1px rgba(0, 0, 0, 0.1)',
maxWidth: '100%',
maxHeight: '100%',
opacity: cache.thumb256?.base64 ? 1 : 0.5,
}}
/>
</picture>
{label}
</Box>
</td>
<td>
<Box sx={cellStyles}>{JSON.stringify(origin, null, 2)}</Box>
</td>
<td>
<Box sx={cellStyles}>
{JSON.stringify(metadata, null, 2)}
<br />
{createdAt ? new Date(createdAt).toLocaleString() : 'no creation'}
<br />
{updatedAt && updatedAt !== createdAt ? new Date(updatedAt).toLocaleString() : null}
</Box>
</td>
</tr>
))}
</tbody>
</Table>
{(!items || items.length === 0) && <ZeroGallery domain={props.domain} />}
</Box>
);
}
-10
View File
@@ -1,10 +0,0 @@
import { AppPlaceholder } from '../AppPlaceholder';
import * as React from 'react';
export function Gallery() {
return (
<AppPlaceholder text='Drawing App is under development. v1.16.' />
);
}
-173
View File
@@ -1,173 +0,0 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Card, Skeleton } from '@mui/joy';
import type { ImageBlock } from '~/modules/blocks/blocks';
import { getActiveTextToImageProviderOrThrow, t2iGenerateImageOrThrow } from '~/modules/t2i/t2i.client';
import { heuristicMarkdownImageReferenceBlocks } from '~/modules/blocks/RenderImage';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { InlineError } from '~/common/components/InlineError';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { DesignerPrompt, PromptDesigner } from './components/PromptDesigner';
import { ProviderConfigure } from './components/ProviderConfigure';
const STILL_LAYOUTING = false;
/**
* @returns up-to `vectorSize` image URLs
*/
async function queryActiveGenerateImageVector(singlePrompt: string, vectorSize: number = 1) {
const t2iProvider = getActiveTextToImageProviderOrThrow();
const mdStringsVector = await t2iGenerateImageOrThrow(t2iProvider, singlePrompt, vectorSize);
if (!mdStringsVector?.length)
throw new Error('No image generated');
const block = heuristicMarkdownImageReferenceBlocks(mdStringsVector.join('\n'));
if (!block?.length)
throw new Error('No URLs in the generated images');
return block;
}
function TempPromptImageGen(props: { prompt: DesignerPrompt, sx?: SxProps }) {
// NOTE: we shall consider a multidimensional shape-based design
// derived state
const { prompt: dp } = props;
// external state
const { data: imageBlocks, error, isLoading } = useQuery<ImageBlock[], Error>({
enabled: !!dp.prompt,
queryKey: ['draw-uuid', dp.uuid],
queryFn: () => queryActiveGenerateImageVector(dp.prompt, dp._repeatCount),
refetchOnReconnect: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
staleTime: Infinity,
});
return <>
{error && <InlineError error={error} />}
{Array.from({ length: dp._repeatCount }).map((_, index) => {
const imgUid = `gen-img-${index}`;
const imageBlock = imageBlocks?.[index] || null;
return imageBlock
// ? <RenderImage key={imgUid} imageBlock={imageBlock} noTooltip />
? <Box sx={{
display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', position: 'relative',
mx: 'auto', my: 'auto', // mt: (index > 0 || !props.isFirst) ? 1.5 : 0,
boxShadow: 'lg',
backgroundColor: 'neutral.solidBg',
'& picture': { display: 'flex' },
'& img': { maxWidth: '100%', maxHeight: '100%' },
}}>
<picture><img src={imageBlock.url} alt={imageBlock.alt} /></picture>
</Box>
: <Card key={imgUid} sx={{ mb: 'auto' }}>
<Skeleton animation='wave' variant='rectangular' sx={{ minWidth: 128, width: '100%', aspectRatio: 1 }} />
</Card>;
})}
</>;
};
export function TextToImage(props: {
isMobile: boolean,
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void
}) {
// state
const [prompts, setPrompts] = React.useState<DesignerPrompt[]>([]);
const handleStopDrawing = React.useCallback(() => {
setPrompts([]);
}, []);
const handlePromptEnqueue = React.useCallback((prompts: DesignerPrompt[]) => {
setPrompts((prevPrompts) => [...prompts, ...prevPrompts]);
}, []);
return <>
<ProviderConfigure
providers={props.providers}
activeProviderId={props.activeProviderId}
setActiveProviderId={props.setActiveProviderId}
sx={{
p: { xs: 1, md: 2 },
}}
/>
{/* TMP Body */}
<Box sx={{
flexGrow: 1,
overflowY: 'auto',
// style
backgroundColor: 'background.level2',
border: STILL_LAYOUTING ? '1px solid blue' : undefined,
p: { xs: 1, md: 2 },
}}>
<Box sx={{
// my: 'auto',
// display: 'flex', flexDirection: 'column', alignItems: 'center',
border: STILL_LAYOUTING ? '1px solid purple' : undefined,
minHeight: '300px',
// layout
display: 'grid',
gridTemplateColumns: props.isMobile
? 'repeat(auto-fit, minmax(320px, 1fr))'
: 'repeat(auto-fit, minmax(max(min(100%, 400px), 100%/5), 1fr))',
gap: { xs: 2, md: 2 },
}}>
{prompts.map((prompt, index) => {
return (
<TempPromptImageGen
key={prompt.uuid}
prompt={prompt}
sx={{
border: STILL_LAYOUTING ? '1px solid green' : undefined,
}}
/>
);
})}
</Box>
</Box>
<PromptDesigner
isMobile={props.isMobile}
queueLength={prompts.length}
onDrawingStop={handleStopDrawing}
onPromptEnqueue={handlePromptEnqueue}
sx={{
backgroundColor: themeBgAppChatComposer,
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
}}
/>
</>;
}
-95
View File
@@ -1,95 +0,0 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, ButtonGroup, Chip, Divider, IconButton, Typography } from '@mui/joy';
import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
import { animationShadowRingLimey } from '~/common/util/animUtils';
export function DrawHeading(props: {
section: number,
setSection: (section: number) => void,
showSections?: boolean,
onRemoveHeading?: () => void,
sx?: SxProps,
}) {
return (
<Box onClick={props.onRemoveHeading} sx={{
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 3,
...props.sx,
}}>
{/* Flashy Button */}
<IconButton
variant='soft' color='success'
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
pointerEvents: 'none',
backgroundColor: 'background.popup',
animation: `${animationShadowRingLimey} 5s infinite`,
}}>
<FormatPaintTwoToneIcon />
</IconButton>
{/* Messaging */}
<Box>
<Typography level='title-lg'>
Draw with AI
</Typography>
<Typography level='title-sm' sx={{ mt: 1 }}>
Turn your ideas into images
</Typography>
<Chip variant='outlined' size='sm' sx={{ px: 1, py: 0.5, mt: 0.25, ml: -1, textWrap: 'wrap' }}>
Multi-models, AI assisted
</Chip>
</Box>
{/* Section Selector*/}
{props.showSections && (
<Divider sx={{ flex: 1 }}>
<ButtonGroup
// color='primary'
size='sm'
orientation='horizontal'
sx={{
mx: 'auto',
backgroundColor: 'background.surface',
boxShadow: 'sm',
'& > button': {
minWidth: 104,
},
}}
>
<Button
variant={props.section === 0 ? 'solid' : 'plain'}
onClick={() => props.setSection(0)}
>
Generate
</Button>
<Button
disabled
variant={props.section === 1 ? 'solid' : 'plain'}
onClick={() => props.setSection(1)}
>
Refine
</Button>
{/*<Button*/}
{/* disabled*/}
{/* variant={props.section === 2 ? 'solid' : 'plain'}*/}
{/* onClick={() => props.setSection(1)}*/}
{/*>*/}
{/* Gallery*/}
{/*</Button>*/}
</ButtonGroup>
</Divider>
)}
</Box>
);
}
-356
View File
@@ -1,356 +0,0 @@
import * as React from 'react';
import { v4 as uuidv4 } from 'uuid';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, ButtonGroup, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import FormatPaintTwoToneIcon from '@mui/icons-material/FormatPaintTwoTone';
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import MoreTimeIcon from '@mui/icons-material/MoreTime';
import RemoveIcon from '@mui/icons-material/Remove';
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
import { animationEnterBelow } from '~/common/util/animUtils';
import { lineHeightTextareaMd } from '~/common/app.theme';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ButtonPromptFromIdea } from './ButtonPromptFromIdea';
import { ButtonPromptFromX } from './ButtonPromptFromX';
import { useDrawIdeas } from '../state/useDrawIdeas';
const promptButtonClass = 'PromptDesigner-button';
export interface DesignerPrompt {
uuid: string,
prompt: string,
_repeatCount: number,
// tags: string[],
// effects: string[],
// style: string[],
// detail: string[],
// restyle: string[],
// [key: string]: string[],
}
export function PromptDesigner(props: {
isMobile: boolean,
queueLength: number,
onDrawingStop: () => void,
onPromptEnqueue: (prompt: DesignerPrompt[]) => void,
sx?: SxProps,
}) {
// state
const [nextPrompt, setNextPrompt] = React.useState<string>('');
const [tempCount, setTempCount] = React.useState<number>(1);
const [tempRepeat, setTempRepeat] = React.useState<number>(1);
// external state
const { currentIdea, nextRandomIdea } = useDrawIdeas();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
// derived state
const userHasText = !!nextPrompt;
const nonEmptyPrompt = nextPrompt || currentIdea.prompt;
const queueLength = props.queueLength;
const qBusy = queueLength > 0;
// Drawing
const { onDrawingStop, onPromptEnqueue } = props;
const handleDrawStop = React.useCallback(() => {
onDrawingStop();
}, [onDrawingStop]);
const handlePromptEnqueue = React.useCallback(() => {
setNextPrompt('');
onPromptEnqueue([{
uuid: uuidv4(),
prompt: nonEmptyPrompt,
_repeatCount: tempRepeat,
}]);
}, [nonEmptyPrompt, onPromptEnqueue, tempRepeat]);
// Typing
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNextPrompt(e.target.value);
// setUserHasChanged(true);
}, []);
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Check for the primary Draw key
if (e.key !== 'Enter')
return;
// Shift: toggles the 'enter is newline'
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (userHasText)
handlePromptEnqueue();
return e.preventDefault();
}
}, [enterIsNewline, handlePromptEnqueue, userHasText]);
// Ideas
const handleIdeaUse = React.useCallback(() => {
setNextPrompt(currentIdea.prompt);
}, [currentIdea.prompt]);
// PromptFx
const textEnrichComponents = React.useMemo(() => {
const handleClickMissing = (_event: React.MouseEvent) => {
alert('Not implemented yet');
};
return (
// PromptFx Buttons
<Box sx={{
flex: 1,
margin: 1,
// layout
display: 'flex', flexFlow: 'row wrap', alignItems: 'center', gap: 1,
// Buttons (tagged by class)
[`& .${promptButtonClass}`]: {
'--Button-gap': '1.2rem',
transition: 'background-color 0.2s, color 0.2s',
minWidth: 100,
},
}}>
{/* Change / Use idea */}
{/*{props.isMobile && (*/}
{/* <ButtonGroup variant='soft' color='neutral' sx={{ borderRadius: 'sm' }}>*/}
{/* <Button className={promptButtonClass} disabled={userHasText} onClick={handleIdeaNext}>*/}
{/* Idea*/}
{/* </Button>*/}
{/* <Tooltip disableInteractive title='Use Idea'>*/}
{/* <IconButton onClick={handleIdeaUse}>*/}
{/* <ArrowDownwardIcon />*/}
{/* </IconButton>*/}
{/* </Tooltip>*/}
{/* </ButtonGroup>*/}
{/*)}*/}
{/* PromptFx */}
<Button
variant='soft' color='success'
disabled={!userHasText}
className={promptButtonClass}
endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}
onClick={handleClickMissing}
sx={{ borderRadius: 'sm' }}
>
Enhance
</Button>
{/*<Button*/}
{/* variant='soft' color='success'*/}
{/* disabled={!userHasText}*/}
{/* className={promptButtonClass}*/}
{/* endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}*/}
{/* onClick={handleClickMissing}*/}
{/* sx={{ borderRadius: 'sm' }}*/}
{/*>*/}
{/* Restyle*/}
{/*</Button>*/}
<ButtonGroup sx={{ ml: 'auto' }}>
{tempCount > 1 && <IconButton onClick={() => setTempCount(count => count - 1)}>
<RemoveIcon />
</IconButton>}
{tempCount > 1 && <>
<IconButton>
<KeyboardArrowLeftIcon />
</IconButton>
<Button
sx={{
px: 0,
minWidth: '3rem',
pointerEvents: 'none',
}}>
<Typography level='body-xs' color='danger' sx={{ fontWeight: 'lg' }}>
{tempCount > 1 ? `1 / ${tempCount}` : '1'}
</Typography>
</Button>
<IconButton>
<KeyboardArrowRightIcon />
</IconButton>
</>}
<IconButton onClick={() => setTempCount(count => count + 1)}>
<AddIcon />
</IconButton>
</ButtonGroup>
{/* Char counter */}
{/*<Typography level='body-sm' sx={{ ml: 'auto', mr: 1 }}>*/}
{/* {!!nonEmptyPrompt?.length && nonEmptyPrompt.length.toLocaleString()}*/}
{/*</Typography>*/}
</Box>
);
}, [tempCount, userHasText]);
return (
<Box aria-label='Drawing Prompt' component='section' sx={props.sx}>
<Grid container spacing={{ xs: 1, md: 2 }}>
{/* Prompt (Text) Box */}
<Grid xs={12} md={9}><Box sx={{ display: 'flex', gap: { xs: 1, md: 2 } }}>
{props.isMobile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<ArrowForwardRoundedIcon />
</MenuButton>
<Menu placement='top'>
{/* Add From History? */}
{/*<MenuItem>*/}
{/* <ButtonPromptFromPlaceholder name='History' disabled />*/}
{/*</MenuItem>*/}
<MenuItem>
<ButtonPromptFromIdea disabled={userHasText} onIdeaNext={nextRandomIdea} onIdeaUse={handleIdeaUse} />
</MenuItem>
<MenuItem>
<ButtonPromptFromX name='Image' disabled />
</MenuItem>
{/*<MenuItem>*/}
{/* <ButtonPromptFromPlaceholder name='Chat' disabled />*/}
{/*</MenuItem>*/}
</Menu>
</Dropdown>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ButtonPromptFromIdea disabled={userHasText} onIdeaNext={nextRandomIdea} onIdeaUse={handleIdeaUse} />
<ButtonPromptFromX name='Image' disabled />
{/*<ButtonPromptFromPlaceholder name='Chats' disabled />*/}
</Box>
)}
<Textarea
variant='outlined'
// size='sm'
autoFocus
minRows={props.isMobile ? 5 : 3}
maxRows={props.isMobile ? 6 : 8}
placeholder={currentIdea.prompt}
value={nextPrompt}
onChange={handleTextareaTextChange}
onKeyDown={handleTextareaKeyDown}
startDecorator={textEnrichComponents}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'send',
// ref: props.designerTextAreaRef,
},
}}
sx={{
flexGrow: 1,
boxShadow: 'lg',
'&:focus-within': { backgroundColor: 'background.popup' },
lineHeight: lineHeightTextareaMd,
}}
/>
</Box></Grid>
{/* [Desktop: Right, Mobile: Bottom] Buttons */}
<Grid xs={12} md={3} spacing={1}>
<Box sx={{ display: 'grid', gap: 1 }}>
{/* / Stop */}
{!qBusy ? (
<Button
key='draw-queue'
variant='solid' color='primary'
endDecorator={<FormatPaintTwoToneIcon />}
onClick={handlePromptEnqueue}
sx={{
animation: `${animationEnterBelow} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-primary-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Draw {tempCount > 1 ? `(${tempCount})` : ''}
</Button>
) : <>
{/* Stop + */}
<Button
key='draw-terminate'
variant='soft' color='warning'
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
onClick={handleDrawStop}
sx={{
// animation: `${animationEnterBelow} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-warning-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Stop / CLEAR (wip)
</Button>
{/* + Enqueue */}
<Button
key='draw-queuemore'
variant='soft'
color='primary'
endDecorator={<MoreTimeIcon sx={{ fontSize: 18 }} />}
onClick={handlePromptEnqueue}
sx={{
animation: `${animationEnterBelow} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-primary-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Enqueue
</Button>
</>}
{/* Repeat */}
<Box sx={{ flex: 1, display: 'flex', '& > *': { flex: 1 } }}>
{[1, 2, 3, 4].map((n) => (
<Button
key={n}
variant={tempRepeat === n ? 'soft' : 'plain'} color='neutral'
onClick={() => setTempRepeat(n)}
sx={{ fontWeight: tempRepeat === n ? 'xl' : 400 /* reset, from 600 */ }}
>
{`x${n}`}
</Button>
))}
</Box>
</Box>
</Grid>
</Grid> {/* Prompt Designer */}
{/* Modals... */}
{/* ... */}
</Box>
);
}
@@ -28,24 +28,26 @@ export function ButtonPromptFromIdea(props: {
return props.isMobile ? null : (
<ButtonGroup
variant='soft' color='neutral'
variant='outlined' color='neutral'
disabled={props.disabled}
sx={{
// '--ButtonGroup-separatorSize': 0,
minWidth: 160,
}}
>
<Button
fullWidth onClick={handleIdeaNext}
startDecorator={<LightbulbOutlinedIcon />}
sx={{
// '--Button-gap': 'auto',
// minWidth: 100,
justifyContent: 'flex-start',
transition: 'background-color 0.2s, color 0.2s',
}}>
Idea
</Button>
<Tooltip disableInteractive title='New Idea'>
<Button
fullWidth onClick={handleIdeaNext}
startDecorator={<LightbulbOutlinedIcon />}
sx={{
// '--Button-gap': 'auto',
// minWidth: 100,
justifyContent: 'flex-start',
transition: 'background-color 0.2s, color 0.2s',
}}>
Idea
</Button>
</Tooltip>
<Tooltip disableInteractive title='Use Idea'>
<IconButton size='sm' onClick={onIdeaUse}>
<ArrowForwardRoundedIcon />

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