Compare commits

..

634 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 7d6d7e619b Anthropic: hardcode date 2024-06-20 12:42:10 -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 34caa16e39 1.16.3: release 2024-06-20 12:27:42 -07:00
Enrico Ros 976426dbd3 Anthropic: support Claude 3.5 Sonnet 2024-06-20 12:27:26 -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 b4d8e39d56 Gemini: acknowledge the new capability to createCachedContent. Fixes #565 2024-06-10 23:56:02 -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 11c41e7381 Function call: increase debug verbosity 2024-06-07 14:18:01 -07:00
Enrico Ros 358d8a54ff Increase llms alignment before function calling. 2024-06-07 14:11:36 -07:00
Enrico Ros 3c8fedce68 Highlight issues with chatGenerateWithFunctions 2024-06-07 12:38:21 -07:00
Enrico Ros 5066336c75 Merge branch 'refs/heads/main-stable' 2024-06-07 12:16:49 -07:00
Enrico Ros 1744b5b9d0 Throw if function calling on a model that doesn't support it 2024-06-07 12:15:25 -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 0c15476dd2 1.16.2: release 2024-06-06 22:10:27 -07:00
Enrico Ros 94ef76c67e Gemini: update
(cherry picked from commit 3050b546ac)
2024-06-06 21:42:47 -07:00
Enrico Ros bd5bf6f94f Gemini: update
(cherry picked from commit 1429726ba6)
2024-06-06 21:42:47 -07:00
Enrico Ros 1fbf454c3c Add Codestral - Fixes #558
(cherry picked from commit 4075581acd)
2024-06-06 21:42:47 -07:00
Enrico Ros 07b62fe5c1 Streaming uplink: index sources for unification. 2024-06-06 21:42:47 -07:00
Enrico Ros 7fbf6ee2e8 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
(cherry picked from commit baad3ae1c3)
2024-06-06 21:41:04 -07:00
Enrico Ros ba66fc30c5 Fix TimeoutError issue
(cherry picked from commit 7c099cab94)
2024-06-06 21:41:04 -07:00
Enrico Ros 45b7ed3220 Mistral: update pricing
(cherry picked from commit 05aa4b547f)
2024-06-06 21:41:04 -07:00
Enrico Ros 20f1c4c0ae Mistral: update
#518

(cherry picked from commit 6afb61d25d)
2024-06-06 21:41:04 -07:00
Enrico Ros 97b6fc5e2b Already Set
(cherry picked from commit a7ce5c1ca6)
2024-06-06 21:41:04 -07:00
Enrico Ros 44d8c30187 Start opened
(cherry picked from commit 952bd2bd93)
2024-06-06 21:41:04 -07:00
Enrico Ros e3957bf08b Page download: improve
(cherry picked from commit f9d33d4888)
2024-06-06 21:41:03 -07:00
Enrico Ros acfe0aba21 Beam: bits
(cherry picked from commit 81d99f19d4)
2024-06-06 21:41:03 -07:00
Enrico Ros 6247b5411b Beam: recall importing rays
(cherry picked from commit 454a4257da)
2024-06-06 21:41:03 -07:00
Enrico Ros 5cc0b0a011 Beam: fix reactive bug
(cherry picked from commit e513b42786)
2024-06-06 21:41:03 -07:00
Enrico Ros 1fed2fb18c Beam: if auto-start, give the chance to change merge model
(cherry picked from commit b607e3c034)
2024-06-06 21:41:03 -07:00
Enrico Ros 8a0e7a4e3d Tiktoken: in the future, show tokens
(cherry picked from commit d5c3f5012b)
2024-06-06 21:41:03 -07:00
Enrico Ros 29a784c6c6 Update TikToken for perfect token computation on 'o' models.
(cherry picked from commit 21d045be59)
2024-06-06 21:41:03 -07:00
Enrico Ros 409a3ee194 DChat: remove IDB migration
(cherry picked from commit 44ab0483b6)
2024-06-06 21:41:03 -07:00
Enrico Ros 54caa3e01a Gemini: improve support (incl. interfaces, cost, visibility)
(cherry picked from commit 9eb0cc0b62)
2024-06-06 21:41:03 -07:00
Enrico Ros e1a723a39f (bits)
(cherry picked from commit 2db74867f5)
2024-06-06 21:41:03 -07:00
Enrico Ros 463ea35d7c Default to the full context window
(cherry picked from commit fd30baafb8)
2024-06-06 21:41:03 -07:00
Enrico Ros f751c91c68 Browse: improve markdown transform
(cherry picked from commit 3623eef47f)
2024-06-06 21:41:03 -07:00
Enrico Ros ad24c8771a Browse: full support for markdown transform
(cherry picked from commit 7b07bb7884)
2024-06-06 21:41:03 -07:00
Enrico Ros 6f82e2c3ed Browse: markdown transform as default
(cherry picked from commit 7946cd6614)
2024-06-06 21:41:03 -07:00
Enrico Ros f4b39071f0 Browse: support transform (skel)
(cherry picked from commit 51b6e30986)
2024-06-06 21:41:03 -07:00
Enrico Ros 621c968f3f Hold Shift to delete without confirmation: fixes #537
(cherry picked from commit 002df7b0f9)
2024-06-06 21:41:03 -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
Enrico Ros 564cf0fed0 1.16.1: default:hidden on the first Turbo 2024-05-13 12:04:31 -07:00
Enrico Ros dee9492d4c 1.16.0: update 2024-05-13 12:02:02 -07:00
Enrico Ros 6ae026f7c5 OpenAI: un-star Turbo 2024-05-13 11:49:10 -07:00
Enrico Ros 6bcbe286f3 OpenAI: add support for 'o' models 2024-05-13 11:47:55 -07:00
Enrico Ros 6f35f72607 Beam: auto-merge 2024-05-12 23:39:06 -07:00
Enrico Ros 3a7aa75538 Soft-wrap as a global preference. Fixes #517 2024-05-10 04:05:50 -07:00
Enrico Ros e4e7ac260a pdfjs: image generation (just in case) 2024-05-10 02:46:28 -07:00
Enrico Ros b8aaa4bb42 pdfjs: better parsing (for humans) 2024-05-10 02:19:45 -07:00
Enrico Ros 7793e2694b pdfjs: roll 2024-05-10 02:19:32 -07:00
Enrico Ros 83f2c72f29 Roll packages 2024-05-10 01:38:56 -07:00
Enrico Ros 1caeaee7f0 1.16.0: update News 2024-05-09 01:00:53 -07:00
Enrico Ros f354134234 Update README 2024-05-09 00:56:50 -07:00
Enrico Ros 66219d30e0 ReplyTo: fix bubble 2024-05-09 00:48:24 -07:00
Enrico Ros b9e3942ed8 ChatMessage: fix broken overflow 2024-05-09 00:18:29 -07:00
Enrico Ros 2354cdc1d1 ReplyTo: render in ChatMessage 2024-05-09 00:18:21 -07:00
Enrico Ros d929438df9 ReplyTo: extract 2024-05-09 00:09:17 -07:00
Enrico Ros 1acaed1de7 ReplyTo: Move Bubble 2024-05-09 00:03:22 -07:00
Enrico Ros 16195f8a55 ReplyTo: works 100 for OpenAI, ok for Anthropic, exposes Chat sequencing issues for a couple 2024-05-09 00:00:37 -07:00
Enrico Ros d7fc8c178f 1.16.0: enable cost by default 2024-05-08 15:39:03 -07:00
Enrico Ros 2894e16706 Merge branch 'release-1.16.0' 2024-05-08 15:11:10 -07:00
Enrico Ros c2340f3432 1.16.0: README 2024-05-08 15:03:32 -07:00
Enrico Ros 3b7b3106db Misc 2024-05-08 14:37:31 -07:00
Enrico Ros cff92819f9 1.16.0: News 2024-05-08 14:13:01 -07:00
Enrico Ros 2f981d852b Show message costs (option) 2024-05-08 13:11:21 -07:00
Enrico Ros 8eef74d776 1.16.0: version 2024-05-08 11:53:56 -07:00
Enrico Ros 60e46204dc Update default contextWindow to 8192
And override as per https://github.com/enricoros/big-AGI/pull/518#issuecomment-2090736347
2024-05-07 04:44:38 -07:00
Enrico Ros 6a5d783435 Show Costs on Hover. #480, #341 2024-05-07 04:33:39 -07:00
Enrico Ros 0223e076c4 LLM Options: improve 2024-05-07 03:54:28 -07:00
Enrico Ros ce80c78319 1.16.0: disable Reply-To (note: full in a different branch) 2024-05-07 02:55:14 -07:00
Enrico Ros cc0085ae61 Group vendors (disabled) 2024-05-07 02:46:41 -07:00
Enrico Ros f28e243b9d Chat: perfect execution error reporting, Fixes #523 2024-05-07 02:19:54 -07:00
Enrico Ros 2e4532593f Toggle JSON mode, Fixes #515 2024-05-07 00:58:02 -07:00
Enrico Ros 1f10905a03 Fix model temperaturs 2024-05-07 00:47:11 -07:00
Enrico Ros 88762db484 Anthropic: more precise usage link to show the token usage. Fixes #524 2024-05-06 23:48:41 -07:00
Enrico Ros 3b5ab0ac70 Beam: fix relaxed parsing. Fixes #528 2024-05-06 23:45:34 -07:00
Enrico Ros 8903c9296b OpenRouter: update parser 2024-05-06 22:56:09 -07:00
Enrico Ros 97858a3c94 docs/installation: mention optionality 2024-05-06 22:26:40 -07:00
Enrico Ros 0ec3e83518 Merge pull request #521 from dandv/patch-1
Docs: fix command to run local build
2024-05-06 22:25:51 -07:00
Enrico Ros 8c007b5bf7 Merge pull request #522 from dandv/patch-2
E: grammar in OpenAISourceSetup.tsx
2024-05-06 22:21:46 -07:00
Enrico Ros 768236b0e2 Merge pull request #525 from PrivTEC/patch-1
Correct typo in config-feature-browse.md
2024-05-06 22:20:18 -07:00
Enrico Ros 495d78b885 Perplexity: update models, with the ne online models 2024-05-06 21:20:02 -07:00
Enrico Ros 34b1e515fe Figure out unused model vendors 2024-05-06 21:04:02 -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
PrivTEC 79edbd3fa5 Correct typo in config-feature-browse.md
Corrected the typo from "proyy" to "proxy" in the file `config-feature-browse.md`. This change addresses a small, but significant error in the configuration documentation.
2024-05-06 03:51:04 +02:00
Dan Dascalescu f50d9994e2 E: grammar in OpenAISourceSetup.tsx 2024-05-04 22:22:34 +03:00
Dan Dascalescu 1603d3085f Docs: fix command to run local build 2024-05-04 22:16:12 +03:00
Enrico Ros ccf7036f33 Longer timeouts 2024-05-02 00:43:10 -07:00
Enrico Ros a0a1a5e3c1 Update the proxy desc 2024-05-02 00:09:17 -07:00
Enrico Ros fbf9120859 Default to llama3 2024-05-01 23:59:09 -07:00
Enrico Ros 8a770beec3 Update Ollama models 2024-05-01 23:05:30 -07:00
Enrico Ros 6b31669765 Fix diagrams in Dark mode. Fixes #520 2024-05-01 22:54:53 -07:00
Enrico Ros 26d72fc2d8 DMesage: add metadata 2024-04-25 22:17:36 -07:00
Enrico Ros 5eb56d0994 Move Diff'er. 2024-04-25 22:16:14 -07:00
Enrico Ros dbc4a922d5 Message Toolbar: good looking too. 2024-04-25 22:15:20 -07:00
Enrico Ros 141f423842 Diagrams: auto-switch 2024-04-25 22:15:00 -07:00
Enrico Ros 667f2433ab Diagrams: enter 2024-04-25 22:14:59 -07:00
Enrico Ros fd930ef548 Message Toolbar: fix disappearance 2024-04-25 22:14:49 -07:00
Enrico Ros 7eadfb1a63 E: PageDrawerHeader style 2024-04-25 22:11:28 -07:00
Enrico Ros 67cb07ac92 E: Style 2024-04-25 21:53:10 -07:00
Enrico Ros 96d28c43fc Manifest: update 2024-04-25 18:38:56 -07:00
Enrico Ros e57e3f5f0a Code: soft wrap. Closes #517 2024-04-25 11:41:34 -07:00
Enrico Ros 7b99bd71da Update overlay buttons 2024-04-25 11:36:58 -07:00
Enrico Ros 861a037321 Tweaks 2024-04-24 18:51:40 -07:00
Enrico Ros 84cbe6c434 RenderCode: title looks 2024-04-24 18:33:45 -07:00
Enrico Ros 2cbb811523 RenderCode: fix titles 2024-04-24 12:32:17 -07:00
Enrico Ros 8ef4faa10f Llms: update 'latest' 2024-04-24 12:25:34 -07:00
Enrico Ros f6a1c9bf52 Diagrams: fix centering 2024-04-24 03:42:50 -07:00
Enrico Ros 5d9f6fb4f5 Code blocks: undo the removal of ? 2024-04-24 03:31:00 -07:00
Enrico Ros 66840a8ecd Diagrams: center Mermaid and PlantUML diagrams 2024-04-24 03:30:28 -07:00
Enrico Ros a8ee6b255a Diagrams: improve hotfixes for Haiku and 3.5 2024-04-24 03:30:16 -07:00
Enrico Ros bd73d1c533 Diagrams: improve prompts 2024-04-24 03:30:05 -07:00
Enrico Ros e33c0ebc42 Fix code block separation in case of nested blocks. 2024-04-24 02:42:43 -07:00
Enrico Ros 57e4a35fee AppChat: extract chat executor (1st step) 2024-04-24 01:59:49 -07:00
Enrico Ros d490b57410 Diagrams: improve instructions 2024-04-24 01:59:08 -07:00
Enrico Ros 0416602e5f Diagrams: improve dialog 2024-04-24 01:59:01 -07:00
Enrico Ros ddc27b2eb9 BlockCode: improve looks 2024-04-24 01:36:32 -07:00
Enrico Ros 374deb147b Composer: improve ReplyTo integration 2024-04-24 00:03:30 -07:00
Enrico Ros d2eabd1ad0 Composer: correctness of activation 2024-04-24 00:02:42 -07:00
Enrico Ros efbc625cc3 Composer: onAction callback 2024-04-23 23:52:09 -07:00
Enrico Ros 91ae0b8cb0 Codeblocks: broader inclusion of filenames 2024-04-23 23:46:20 -07:00
Enrico Ros ddc5741b00 Attachments: getCollapsedAttachments 2024-04-23 23:18:39 -07:00
Enrico Ros 4729aca6b0 ReplyTo: improve bubble 2024-04-23 22:56:05 -07:00
Enrico Ros bb4fc3a70c Anthropic: relax key validation on custom deployments. Closes #511 2024-04-23 20:32:08 -07:00
Enrico Ros 5d8084b650 Llms: streaming: cleanups 2024-04-23 05:07:55 -07:00
Enrico Ros f316b892f5 Revert "Llms: fix Streaming timeouts (2)"
This reverts commit cbda1d7cd0.
2024-04-23 03:15:07 -07:00
Enrico Ros cbda1d7cd0 Llms: fix Streaming timeouts (2) 2024-04-23 02:07:20 -07:00
Enrico Ros 2f8e879976 Llms: fix Streaming timeouts 2024-04-23 01:45:27 -07:00
Enrico Ros cc0ac5ae3c React: fix llm naming 2024-04-22 23:59:30 -07:00
Enrico Ros 0185d24fb3 Beam: improve Merge disablement 2024-04-22 23:59:08 -07:00
Enrico Ros 97dbdc9c31 Beam: improve inlining (not ready yet) 2024-04-22 23:58:26 -07:00
Enrico Ros a07c66c9a3 Beam: lay down some inlining code 2024-04-22 21:49:14 -07:00
Enrico Ros 308bd25bc0 Beam: improve Tutorial 2024-04-22 21:48:00 -07:00
Enrico Ros 70066a03b6 Explainer Carousel: improvements 2024-04-22 21:44:17 -07:00
Enrico Ros a7f3872af3 Beam: update bar icons 2024-04-22 16:38:26 -07:00
Enrico Ros 22e10e675a RMB on Chat Avatar brings up the menu 2024-04-22 16:31:30 -07:00
Enrico Ros 89679e946d Beam: remove optionality (/beam, chat mode, composer button & shortcut, message beam from) 2024-04-22 16:12:09 -07:00
Enrico Ros 1d1bb9d3df Beam: explain a possible missing user message 2024-04-22 15:58:39 -07:00
Enrico Ros 8faf2b2595 Beam: move scroll button to the Gather pane 2024-04-22 15:58:18 -07:00
Enrico Ros e47ad9700e Anthropic: workaround for history[0] being assistant 2024-04-22 15:40:48 -07:00
Enrico Ros 372b19a057 Formulas: fix rendering for OpenAI-style inline '\(' and block '\[' latex. Fixes #508 2024-04-22 04:39:12 -07:00
Enrico Ros cbe156a868 Merge branch 'refs/heads/main-stable' 2024-04-22 02:57:08 -07:00
Enrico Ros 181a3881e2 Groq: update models
(cherry picked from commit 3eef03b303)
2024-04-22 02:56:47 -07:00
Enrico Ros 3eef03b303 Groq: update models 2024-04-22 02:52:19 -07:00
Enrico Ros ad56e3165c Beam: fix pixel-bound loading of presets 2024-04-22 02:27:07 -07:00
Enrico Ros b1a96b6e75 Beam: clear heuristics for llm selection 2024-04-22 02:26:48 -07:00
Enrico Ros 56419b1b4e Beam: persist the last configuration 2024-04-22 02:19:17 -07:00
Enrico Ros 372f14a9c5 Beam: auto-configure from Elo 2024-04-22 01:01:43 -07:00
Enrico Ros e1ec56a120 Beam: remove fallbackLlmId 2024-04-22 01:01:33 -07:00
Enrico Ros 5bb11249d6 Beam: remove reactive (view-based) ray conf 2024-04-22 01:01:17 -07:00
Enrico Ros 9fbcca1ff2 Llms: avoid name clash 2024-04-22 00:54:41 -07:00
Enrico Ros 323f2b2c3e Llms: cleaner 2024-04-22 00:52:56 -07:00
Enrico Ros b971d38dd5 Llms: heuristic to auto-pick the best diverse LLMs 2024-04-22 00:49:06 -07:00
Enrico Ros 278f479a3a Beam: rename terminate 2024-04-22 00:48:36 -07:00
Enrico Ros 03aea5678d Llms: misc 2024-04-22 00:17:49 -07:00
Enrico Ros b62b8ee7e6 Beam: App: fix state 2024-04-22 00:12:49 -07:00
Enrico Ros 63f55551e5 Beam: gather show all prompts 2024-04-21 23:30:41 -07:00
Enrico Ros b185fbc57d Beam: fallback llm Id 2024-04-21 23:24:52 -07:00
Enrico Ros ceb9d58e72 Beam: fix import rays 2024-04-21 23:10:47 -07:00
Enrico Ros a0bb515a4f Beam: minor bits 2024-04-21 22:28:36 -07:00
Enrico Ros 2cfac2f18b Beam: combine two menus into one 2024-04-21 22:05:08 -07:00
Enrico Ros d412f538b2 Make it more explicit we're only not rolling this one. 2024-04-21 21:30:26 -07:00
Enrico Ros 94f90ad861 Roll packages, but hold Next back. 2024-04-21 21:22:47 -07:00
Enrico Ros 4a402e7937 Roll pdfjs 2024-04-21 21:19:30 -07:00
Enrico Ros c226d6c391 Lock Next to 14.1, as 14.2 introduces the async/await messages when running/building, and we don't know what it means yet.
"The generated code contains 'async/await' because this module is using "topLevelAwait"."

See: https://github.com/vercel/next.js/issues/64792
2024-04-21 21:17:24 -07:00
Enrico Ros 67410e6c59 Revert "Roll packages." - Next v14.2.2 shows some async/await messages.
See https://github.com/vercel/next.js/issues/64792

This reverts commit 419c361147.
2024-04-21 21:12:32 -07:00
Enrico Ros 419c361147 Roll packages. 2024-04-21 20:39:56 -07:00
Enrico Ros 3769a53ffa Merge pull request #507 from mludvig/arm-build-1
Build multi-arch docker image for x64-64 and ARM64
2024-04-15 22:04:07 -07:00
Michael Ludvig ec4aaa3bfb Cleanup 2024-04-16 16:51:57 +12:00
Michael Ludvig be52680fcd Put back hashes and comments 2024-04-16 16:20:48 +12:00
Michael Ludvig 9d41ab9339 Merge branch 'enricoros:main' into arm-build-1 2024-04-16 12:36:23 +12:00
Michael Ludvig f126fc3087 Cleanup 2024-04-16 11:52:58 +12:00
Michael Ludvig 764377037c Disabled arm 32 again (not supported by Prisma) 2024-04-16 11:22:15 +12:00
Michael Ludvig 8e09eaab45 Add sha tag 2024-04-16 11:10:32 +12:00
Michael Ludvig 6523da186c Update versions, add arm32 2024-04-16 10:29:18 +12:00
Michael Ludvig 6471fd8b6f Enable action 2024-04-16 10:01:41 +12:00
Michael Ludvig 247a74881a Added buildx support 2024-04-15 11:34:42 +12:00
Enrico Ros 3ef09f0a5f Models: upgrade data structure to v2 - auto-pick 2024-04-12 05:50:46 -07:00
Enrico Ros b924d331f9 Models: upgrade data structure to v2 2024-04-12 05:36:18 -07:00
Enrico Ros 14041b6012 Beam: simplify a bit 2024-04-12 03:44:54 -07:00
Enrico Ros 2c6cc5ecec Cleanup models update logic 2024-04-12 02:44:14 -07:00
Enrico Ros ac022b1df0 Models: adding prices and benchmarks for a few models 2024-04-12 02:09:14 -07:00
Enrico Ros 0a2081de08 Better Beam Hint 2024-04-12 01:06:25 -07:00
Enrico Ros 64a8e554c7 Designer update 2024-04-12 00:46:58 -07:00
Enrico Ros 082d29fd2f Improve style 2024-04-12 00:45:00 -07:00
Enrico Ros ba5cf9d002 Composer: show the bubble 2024-04-12 00:22:55 -07:00
Enrico Ros 57a55318df Stabilize 2024-04-12 00:07:40 -07:00
Enrico Ros e70f4f7a59 ChatMessageList: this side is probably done 2024-04-11 21:10:56 -07:00
Enrico Ros 1d217fad67 Warning 2024-04-11 21:10:39 -07:00
Enrico Ros e95d46f085 ConversationHandler: prepare chat overlays 2024-04-11 21:08:04 -07:00
Enrico Ros f4577878e1 ChatMessage: Reply on 2024-04-11 20:36:32 -07:00
Enrico Ros 1bd1e5c8e3 ChatMessage: Toolbar complete 2024-04-11 20:19:30 -07:00
Enrico Ros c975dee965 ChatMessageList: remove menu items if t2i off 2024-04-11 19:22:03 -07:00
Enrico Ros 9d690f4219 ChatMessage: fix double-closure 2024-04-11 18:22:12 -07:00
Enrico Ros 29ddb3f58d ChatMessage: improve menu 2024-04-11 18:12:44 -07:00
Enrico Ros 8626bc0b1c BlocksRenderer: selection color 2024-04-11 18:12:37 -07:00
Enrico Ros c362cf6596 Propagate information on whether this can be spoken 2024-04-11 17:52:50 -07:00
Enrico Ros 97264fc5ff ChatMessage: toolbar framework 2024-04-11 17:04:44 -07:00
Enrico Ros 494c4409c1 BlocksRenderer: more v-padding for an improved mouse-up behavior 2024-04-11 16:40:47 -07:00
Enrico Ros d46e366c81 Blocks Renderer: use refs 2024-04-11 13:16:13 -07:00
Enrico Ros 6afe33ee9c decolor 2024-04-11 10:13:54 -07:00
Enrico Ros 903c9e1cc3 Improve options 2024-04-11 10:12:03 -07:00
Enrico Ros 3ef43fc3f5 Merge branch 'joriskalz-chat-with-youtube' 2024-04-11 09:58:56 -07:00
Enrico Ros b1c3be05dd Integrate YouTube transcriber (hidden by default) 2024-04-11 09:58:45 -07:00
Enrico Ros efee23b4a7 Update shadows 2024-04-11 09:49:13 -07:00
Enrico Ros 06b67a7586 Merge branch 'chat-with-youtube' of https://github.com/joriskalz/big-AGI-dev into joriskalz-chat-with-youtube 2024-04-11 09:33:56 -07:00
Joris Kalz 889a2dbf9d Remvoved unwanted new line. 2024-04-11 11:45:03 +01:00
Joris Kalz 2f80fcc888 Removed comments 2024-04-11 11:43:54 +01:00
Joris Kalz f7ee479c1d Removed comments 2024-04-11 11:36:27 +01:00
Joris Kalz 94fa0981fe Update YouTube Transcriber voiceId in data.ts 2024-04-11 11:33:55 +01:00
Joris Kalz 4c74afe438 Update YouTube Transcriber system message in data.ts 2024-04-11 11:33:42 +01:00
Joris Kalz f76cea22de Fix YouTube Transcriber activation bug in PersonaSelector component 2024-04-10 22:18:35 +01:00
Joris Kalz 3d49110808 Implement handleAddMessage function in PersonaSelector component 2024-04-10 22:14:15 +01:00
Joris Kalz 88a4579f7a Refactor PersonaSelector component to handle YouTube Transcriber tile click 2024-04-10 22:00:29 +01:00
Joris Kalz 241bde0333 Update YouTubeURLInput component to handle YouTube video transcripts 2024-04-10 21:48:20 +01:00
Joris Kalz 73c7867cd6 Add YouTube Transcriber persona and handle YouTube Transcriber tile click 2024-04-10 11:53:48 +01:00
Enrico Ros b35254f7ad Qol 2024-04-10 03:14:15 -07:00
Enrico Ros 213e78c956 Beam: save the merge model, and shrink rays when loading a smaller preset 2024-04-10 03:01:18 -07:00
Enrico Ros 7bf552c491 1.15.1 2024-04-10 01:09:25 -07:00
Enrico Ros 3bf9923f86 Update README 2024-04-10 01:03:06 -07:00
Enrico Ros a6a8a28f59 Update models pricing 2024-04-10 00:33:16 -07:00
Enrico Ros 56a8e452bf OpenAI: 2024-04-09 models 2024-04-10 00:17:08 -07:00
Enrico Ros 6bec0bf70d Models: precise id matching 2024-04-09 23:15:17 -07:00
Enrico Ros 5dc9c8f90e Gemini: support Pro 1.5 2024-04-09 22:41:05 -07:00
Enrico Ros e3290e12b1 Fix mic layout 2024-04-09 22:37:47 -07:00
Enrico Ros 9f37ce9e42 Warn if clipboard access is prevented 2024-04-09 19:49:52 -07:00
Enrico Ros 8904c0c811 E: Consistent file names, shortcuts. 2024-04-09 19:40:56 -07:00
Enrico Ros b0d021b7f2 E: Ctrl+O opens chat file 2024-04-09 17:41:53 -07:00
Enrico Ros 0175f3b8a1 E: Manifest File Handlers 2024-04-09 17:04:48 -07:00
Enrico Ros 0fa9d5bf62 E: Save conversations. Closes #466 2024-04-09 16:44:40 -07:00
Enrico Ros 4919e38e3e OpenAI-derivatives: Remove UI validation - never helped. Fixes #446 2024-04-09 16:28:09 -07:00
Enrico Ros 2e99533f96 Fit on mobile 2024-04-09 16:21:05 -07:00
Enrico Ros f095645d89 Merge pull request #498 from dogmatic69/trailing-whitespace
chore: remove trailing whitespace
2024-04-09 01:48:19 -07:00
Enrico Ros 757c83142e Merge pull request #497 from dogmatic69/env-docs
chore: link to env docs
2024-04-09 01:47:50 -07:00
Carl Sutton 36d274ca9f chore: remove trailing whitespace 2024-04-09 10:13:35 +02:00
Carl Sutton ec11b61f67 chore: link to env docs 2024-04-09 10:09:37 +02:00
Enrico Ros 7765271d63 PPLX: fix alternation 2024-04-09 00:55:04 -07:00
Enrico Ros 7c2464bba7 PPLX: fix models 2024-04-09 00:32:27 -07:00
Enrico Ros 17e010f93c Anthropic: fix empty messages 2024-04-09 00:19:48 -07:00
Enrico Ros 452d630a2a Tryfix for the Autocomplete 2024-04-08 23:39:06 -07:00
Enrico Ros f317a3e38f Test client-side fetch (no cors) 2024-04-08 22:15:15 -07:00
Enrico Ros f56195058e Fix ssr issue 2024-04-08 22:13:46 -07:00
Enrico Ros 2e93dbb10c Improve Error reporting 2024-04-08 21:01:57 -07:00
Enrico Ros f862456d73 Decrease errors 2024-04-08 18:52:11 -07:00
Enrico Ros d99b0b2137 Reduce errors 2024-04-08 18:43:01 -07:00
Enrico Ros 1d390f9aa7 3,000 2024-04-07 16:18:45 -07:00
Enrico Ros 514beb7940 Merge pull request #492 from enricoros/main
Update BeamFusionGrid.tsx
2024-04-06 16:48:46 -07:00
Enrico Ros c7bdfce734 Update BeamFusionGrid.tsx 2024-04-06 16:47:36 -07:00
Enrico Ros e5fe4b06ad Show warning for non-US merges 2024-04-06 15:58:38 -07:00
Enrico Ros 89b7c265d3 show URL attachments as well 2024-04-06 15:41:35 -07:00
Enrico Ros 698c31943e Centralize Lang 2024-04-06 14:27:21 -07:00
Enrico Ros b70060d46e Beam: understand tutorial usage 2024-04-06 14:27:10 -07:00
Enrico Ros 6ddc5ef53e Roll packages 2024-04-06 13:57:57 -07:00
Enrico Ros 212023c7e4 Merge pull request #484
Update README.md (Added Midori AI subsystem to the readme)
2024-04-05 18:30:32 -07:00
Enrico Ros b687f23c95 Anthropic: server status. #485 2024-04-05 14:07:13 -07:00
Luna Midori 7a05d01554 Update README.md 2024-04-03 15:17:43 -07:00
384 changed files with 24050 additions and 8383 deletions
+9 -1
View File
@@ -32,6 +32,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
@@ -49,13 +55,15 @@ jobs:
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
- name: Build and push Docker image
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
file: Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
build-args: NEXT_PUBLIC_GA4_MEASUREMENT_ID=${{ secrets.GA4_MEASUREMENT_ID }}
+59 -82
View File
@@ -15,9 +15,39 @@ 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)
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [documentation](docs/README.md)
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2) 👉 [installation](docs/installation.md) 👉 [documentation](docs/README.md)
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: bigger better features (incl. Beam-2) are being cooked outside of `main`.
[//]: # (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)
### What's New in 1.16.1...1.16.3 · Jun 20, 2024 (patch releases)
- 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
- 1.16.2: Added the latest Mistral model
- 1.16.2: Tokenizer support for gpt-4o
- 1.16.2: Updates to Beam
- 1.16.1: Support for the new OpenAI GPT-4o 2024-05-13 model
### What's New in 1.16.0 · May 9, 2024 · Crystal Clear
- [Beam](https://big-agi.com/blog/beam-multi-model-ai-reasoning) core and UX improvements based on user feedback
- Chat cost estimation 💰 (enable it in Labs / hover the token counter)
- Save/load chat files with Ctrl+S / Ctrl+O on desktop
- Major enhancements to the Auto-Diagrams tool
- YouTube Transcriber Persona for chatting with video content, [#500](https://github.com/enricoros/big-AGI/pull/500)
- Improved formula rendering (LaTeX), and dark-mode diagrams, [#508](https://github.com/enricoros/big-AGI/issues/508), [#520](https://github.com/enricoros/big-AGI/issues/520)
- Models update: **Anthropic**, **Groq**, **Ollama**, **OpenAI**, **OpenRouter**, **Perplexity**
- Code soft-wrap, chat text selection toolbar, 3x faster on Apple silicon, and more [#517](https://github.com/enricoros/big-AGI/issues/517), [507](https://github.com/enricoros/big-AGI/pull/507)
#### 3,000 Commits Milestone · April 7, 2024
![big-AGI Milestone](https://github.com/enricoros/big-AGI/assets/32999/47fddbb1-9bd6-4b58-ace4-781dfcb80923)
- 🥇 Today we <b>celebrate commit 3000</b> in just over one year, and going stronger 🚀
- 📢️ Thanks everyone for your support and words of love for Big-AGI, we are committed to creating the best AI experiences for everyone.
### What's New in 1.15.0 · April 1, 2024 · Beam
@@ -26,9 +56,11 @@ big-AGI is an open book; see the **[ready-to-ship and future ideas](https://gith
- Message **Starring ⭐**: star important messages within chats, to attach them later. [#476](https://github.com/enricoros/big-AGI/issues/476)
- Enhanced the default Persona
- Fixes to Gemini models and SVGs, improvements to UI and icons
- 1.15.1: Support for Gemini Pro 1.5 and OpenAI Turbo models
- Beast release, over 430 commits, 10,000+ lines changed: [release notes](https://github.com/enricoros/big-AGI/releases/tag/v1.15.0), and changes [v1.14.1...v1.15.0](https://github.com/enricoros/big-AGI/compare/v1.14.1...v1.15.0)
### What's New in 1.14.1 · March 7, 2024 · Modelmorphic
<details>
<summary>What's New in 1.14.1 · March 7, 2024 · Modelmorphic</summary>
- **Anthropic** [Claude-3](https://www.anthropic.com/news/claude-3-family) model family support. [#443](https://github.com/enricoros/big-AGI/issues/443)
- New **[Perplexity](https://www.perplexity.ai/)** and **[Groq](https://groq.com/)** integration (thanks @Penagwin). [#407](https://github.com/enricoros/big-AGI/issues/407), [#427](https://github.com/enricoros/big-AGI/issues/427)
@@ -38,7 +70,10 @@ big-AGI is an open book; see the **[ready-to-ship and future ideas](https://gith
- Enhanced UX with auto-sizing charts, refined search and folder functionalities, perfected scaling
- And with more UI improvements, documentation, bug fixes (20 tickets), and developer enhancements
### What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind
</details>
<details>
<summary>What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind</summary>
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
@@ -50,6 +85,8 @@ https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385
- Better looking chats with improved spacing, fonts, and menus
- More: new video player, [LM Studio tutorial](https://github.com/enricoros/big-AGI/blob/main/docs/config-local-lmstudio.md) (thanks @aj47), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/deploy-database.md) (thanks @ranfysvalle02), and speedups
</details>
<details>
<summary>What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline</summary>
@@ -106,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/) |
@@ -141,6 +178,22 @@ Add extra functionality with these integrations:
<br/>
## 🚀 Installation
To get started with big-AGI, follow our comprehensive [Installation Guide](docs/installation.md).
The guide covers various installation options, whether you're spinning it up on
your local computer, deploying on Vercel, on Cloudflare, or rolling it out
through Docker.
Whether you're a developer, system integrator, or enterprise user, you'll find step-by-step instructions
to set up big-AGI quickly and easily.
[![Installation Guide](https://img.shields.io/badge/Installation%20Guide-blue?style=for-the-badge&logo=read-the-docs&logoColor=white)](docs/installation.md)
Or bring your API keys and jump straight into our free instance on [big-AGI.com](https://big-agi.com).
<br/>
# 🌟 Get Involved!
[//]: # ([![Official Discord]&#40;https://img.shields.io/discord/1098796266906980422?label=discord&logo=discord&logoColor=%23fff&style=for-the-badge&#41;]&#40;https://discord.gg/MkH4qj2Jp9&#41;)
@@ -150,86 +203,10 @@ Add extra functionality with these integrations:
- [ ]**Give us a star** on GitHub 👆
- [ ] 🚀 **Do you like code**? You'll love this gem of a project! [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
- [ ] 💡 Got a feature suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
- [ ]Deploy your [fork](docs/customizations.md) for your friends and family, or [customize it for work](docs/customizations.md)
- [ ] Check out some of the big-AGI [**community projects**](docs/customizations.md)
| Project | Features | GitHub |
|---------|----------------------------------------------------|-------------------------------------------------------------------------------------|
| CoolAGI | Code Interpreter, Vision, Mind maps, and much more | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
- [ ][Deploy](docs/installation.md) your [fork](docs/customizations.md) for your friends and family, or [customize it for work](docs/customizations.md)
<br/>
# 🧩 Develop
[//]: # (![TypeScript]&#40;https://img.shields.io/badge/TypeScript-007ACC?style=&logo=typescript&logoColor=white&#41;)
[//]: # (![React]&#40;https://img.shields.io/badge/React-61DAFB?style=&logo=react&logoColor=black&#41;)
[//]: # (![Next.js]&#40;https://img.shields.io/badge/Next.js-000000?style=&logo=vercel&logoColor=white&#41;)
To download and run this Typescript/React/Next.js project locally, the only prerequisite is Node.js with the `npm` package manager.
Clone this repo, install the dependencies (all local), and run the development server (which auto-watches the
files for changes):
```bash
git clone https://github.com/enricoros/big-agi.git
cd big-agi
npm install
npm run dev
# You will see something like:
#
# ▲ Next.js 14.1.0
# - Local: http://localhost:3000
# ✓ Ready in 2.6s
```
The development app will be running on `http://localhost:3000`. Development builds have the advantage of not requiring
a build step, but can be slower than production builds. Also, development builds won't have timeout on edge functions.
## 🛠️ Deploy from source
The _production_ build of the application is optimized for performance and is performed by the `npm run build` command,
after installing the required dependencies.
```bash
# .. repeat the steps above up to `npm install`, then:
npm run build
next start --port 3000
```
The app will be running on the specified port, e.g. `http://localhost:3000`.
Want to deploy with username/password? See the [Authentication](docs/deploy-authentication.md) guide.
## 🐳 Deploy with Docker
For more detailed information on deploying with Docker, please refer to the [docker deployment documentation](docs/deploy-docker.md).
Build and run:
```bash
docker build -t big-agi .
docker run -d -p 3000:3000 big-agi
```
Or run the official container:
- manually: `docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi`
- or, with docker-compose: `docker-compose up` or see [the documentation](docs/deploy-docker.md) for a composer file with integrated browsing
## ☁️ Deploy on Cloudflare Pages
Please refer to the [Cloudflare deployment documentation](docs/deploy-cloudflare.md).
## 🚀 Deploy on Vercel
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&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)
[//]: # ([![GitHub stars]&#40;https://img.shields.io/github/stars/enricoros/big-agi&#41;]&#40;https://github.com/enricoros/big-agi/stargazers&#41;)
[//]: # ([![GitHub forks]&#40;https://img.shields.io/github/forks/enricoros/big-agi&#41;]&#40;https://github.com/enricoros/big-agi/network&#41;)
+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 };
+8 -14
View File
@@ -37,22 +37,16 @@ System integrators, administrators, whitelabelers: instead of using the public b
Step-by-step deployment and system configuration instructions.
- **Deploy Your Own**
- straightforward: **Local development**, **Vercel 1-Click**
- **[Cloudflare Deployment](deploy-cloudflare.md)**
- **[Docker Deployment](deploy-docker.md)**: Containers for Local or Cloud deployments
- **[Installation](installation.md)**: Set up your own instance of big-AGI and related products
- build from source or use pre-built
- locally, in the public cloud, or on your own servers
- **Deployment Server Features**
- **[Database Setup](deploy-database.md)**: Optional, only required to enable "Chat Link Sharing"
- **[Environment Variables](environment-variables.md)**: 📌 Set server-side API keys and special features in your deployments
- **[HTTP Basic Authentication](deploy-authentication.md)**: Optional, Secure your big-AGI instance with a username and password
## Customization & Derivative UIs
👏 Customize big-AGI to fit your needs.
- **[Customizing big-AGI](customizations.md)**: how to alter source code and server-side configuration
- **Advanced Customizations**:
- **[Source code alterations guide](customizations.md)**: source code primer and alterations guidelines
- **[Basic Authentication](deploy-authentication.md)**: Optional, adds a username and password wall
- **[Database Setup](deploy-database.md)**: Optional, enables "Chat Link Sharing"
- **[Environment Variables](environment-variables.md)**: 📌 Pre-configures models and services
## Support and Community
+30 -2
View File
@@ -5,11 +5,39 @@ by release.
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
### 1.16.0 - Mar 2024
### 1.17.0 - Jun 2024
- milestone: [1.16.0](https://github.com/enricoros/big-agi/milestone/16)
- 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.3 · Jun 20, 2024 (patch releases)
- 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
- 1.16.2: Added the latest Mistral model
- 1.16.2: Tokenizer support for gpt-4o
- 1.16.2: Updates to Beam
- 1.16.1: Support for the new OpenAI GPT-4o 2024-05-13 model
### What's New in 1.16.0 · May 9, 2024 · Crystal Clear
- [Beam](https://big-agi.com/blog/beam-multi-model-ai-reasoning) core and UX improvements based on user feedback
- Chat cost estimation 💰 (enable it in Labs / hover the token counter)
- Save/load chat files with Ctrl+S / Ctrl+O on desktop
- Major enhancements to the Auto-Diagrams tool
- YouTube Transcriber Persona for chatting with video content, [#500](https://github.com/enricoros/big-AGI/pull/500)
- Improved formula rendering (LaTeX), and dark-mode diagrams, [#508](https://github.com/enricoros/big-AGI/issues/508), [#520](https://github.com/enricoros/big-AGI/issues/520)
- Models update: **Anthropic**, **Groq**, **Ollama**, **OpenAI**, **OpenRouter**, **Perplexity**
- Code soft-wrap, chat text selection toolbar, 3x faster on Apple silicon, and more [#517](https://github.com/enricoros/big-AGI/issues/517), [507](https://github.com/enricoros/big-AGI/pull/507)
- Developers: update the LLMs data structures
### What's New in 1.15.1 · April 10, 2024 (minor release, models support)
- Support for the newly released Gemini Pro 1.5 models
- Support for the new OpenAI 2024-04-09 Turbo models
- Resilience fixes after the large success of 1.15.0
### What's New in 1.15.0 · April 1, 2024 · Beam
- ⚠️ [**Beam**: the multi-model AI chat](https://big-agi.com/blog/beam-multi-model-ai-reasoning). find better answers, faster - a game-changer for brainstorming, decision-making, and creativity. [#443](https://github.com/enricoros/big-AGI/issues/443)
+3
View File
@@ -20,6 +20,9 @@ If you have an `API Endpoint` and `API Key`, you can configure big-AGI as follow
The deployed models are now available in the application. If you don't have a configured
Azure OpenAI service instance, continue with the next section.
In addition to using the UI, configuration can also be done using
[environment variables](environment-variables.md).
## Setting Up Azure
### Step 1: Azure Account & Subscription
+2 -2
View File
@@ -68,7 +68,7 @@ The chat agent won't be able to access the web sites if the browserless containe
- MAX_CONCURRENT_SESSIONS=10
```
You can then add the proyy lines to your `.env` file.
You can then add the proxy lines to your `.env` file.
```
https_proxy=http://PROXY-IP:PROXY-PORT
@@ -115,4 +115,4 @@ If you encounter any issues or have questions about configuring the browse funct
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
Last updated on Feb 27, 2024 ([edit on GitHub](https://github.com/enricoros/big-AGI/edit/main/docs/config-feature-browse.md))
Last updated on Feb 27, 2024 ([edit on GitHub](https://github.com/enricoros/big-AGI/edit/main/docs/config-feature-browse.md))
+3
View File
@@ -37,6 +37,9 @@ Check the URL and modify if different.
2. Enter the API URL: `http://localhost:1234` (modify if different)
3. Refresh by clicking on the `Models` button to load models from LM Studio
In addition to using the UI, configuration can also be done using
[environment variables](environment-variables.md).
## Troubleshooting
- **Missing @mui/material**: Execute `npm install @mui/material` or `yarn add @mui/material`
+3
View File
@@ -36,6 +36,9 @@ Follow the guide at: https://localai.io/basics/getting_started/
- Load the models (click on `Models 🔄`)
- Select the model and chat
In addition to using the UI, configuration can also be done using
[environment variables](environment-variables.md).
### Integration: Models Gallery
If the running LocalAI instance is configured with a [Model Gallery](https://localai.io/models/):
+16 -9
View File
@@ -13,7 +13,7 @@ _Last updated Dec 16, 2023_
1. **Ensure Ollama API Server is Running**: Follow the official instructions to get Ollama up and running on your machine
- For detailed instructions on setting up the Ollama API server, please refer to the
[Ollama download page](https://ollama.ai/download) and [instructions for linux](https://github.com/jmorganca/ollama/blob/main/docs/linux.md).
[Ollama download page](https://ollama.ai/download) and [instructions for linux](https://github.com/jmorganca/ollama/blob/main/docs/linux.md).
2. **Add Ollama as a Model Source**: In `big-AGI`, navigate to the **Models** section, select **Add a model source**, and choose **Ollama**
3. **Enter Ollama Host URL**: Provide the Ollama Host URL where the API server is accessible (e.g., `http://localhost:11434`)
4. **Refresh Model List**: Once connected, refresh the list of available models to include the Ollama models
@@ -22,6 +22,9 @@ _Last updated Dec 16, 2023_
you'll have to press the 'Pull' button again, until a green message appears.
5. **Chat with Ollama models**: select an Ollama model and begin chatting with AI personas
In addition to using the UI, configuration can also be done using
[environment variables](environment-variables.md).
**Visual Configuration Guide**:
* After adding the `Ollama` model vendor, entering the IP address of an Ollama server, and refreshing models:<br/>
@@ -37,7 +40,7 @@ _Last updated Dec 16, 2023_
### ⚠️ Network Troubleshooting
If you get errors about the server having trouble connecting with Ollama, please see
If you get errors about the server having trouble connecting with Ollama, please see
[this message](https://github.com/enricoros/big-AGI/issues/276#issuecomment-1858591483) on Issue #276.
And in brief, make sure the Ollama endpoint is accessible from the servers where you run big-AGI (which could
@@ -69,15 +72,19 @@ Then, edit the nginx configuration file `/etc/nginx/sites-enabled/default` and a
```nginx
location /ollama/ {
proxy_pass http://localhost:11434;
proxy_pass http://127.0.0.1:11434/;
# Disable buffering for the streaming responses (SSE)
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
# Disable buffering for the streaming responses
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
# Longer timeouts
proxy_read_timeout 3600;
proxy_connect_timeout 3600;
proxy_send_timeout 3600;
}
```
+4 -4
View File
@@ -25,15 +25,15 @@ This guide assumes that **big-AGI** is already installed on your system. Note th
- Stop the Web UI as we need to modify the startup flags to enable the OpenAI API
2. Enable the **openai extension**
- Edit `CMD_FLAGS.txt`
- Make sure that `--listen --api` is present and uncommented
- Make sure that `--listen --api` is present and uncommented
3. Restart text-generation-webui
- Double-click on "start"
- You should see something like:
- You should see something like:
```
2023-12-07 21:51:21 INFO:Loading the extension "openai"...
2023-12-07 21:51:21 INFO:OpenAI-compatible API URL:
http://0.0.0.0:5000
http://0.0.0.0:5000
...
INFO: Uvicorn running on http://0.0.0.0:5000 (Press CTRL+C to quit)
Running on local URL: http://0.0.0.0:7860
+3
View File
@@ -22,6 +22,9 @@ This document details the process of integrating OpenRouter with big-AGI.
![feature-openrouter-configure.png](pixels/feature-openrouter-configure.png)
4. OpenAI GPT4-32k and other models will now be accessible and selectable in the application.
In addition to using the UI, configuration can also be done using
[environment variables](environment-variables.md).
### Pricing
OpenRouter independently manages its service and pricing and is not affiliated with big-AGI.
+4 -4
View File
@@ -66,7 +66,7 @@ Test your application thoroughly using local development (refer to README.md for
We introduced the `/info/debug` page that provides a detailed overview of the application's environment, including the API keys, environment variables, and other configuration settings.
<br/>
<br/>
## Community Projects - Share Your Project
@@ -74,12 +74,12 @@ After deployment, share your project with the community. We will link to your pr
| Project | Features | GitHub |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|
| 🚀 CoolAGI: Where AI meets Imagination<br/>![CoolAGI Logo](https://github.com/nextgen-user/freegpt4plus/assets/150797204/9b0e1232-4791-4d61-b949-16f9eb284c22) | Code Interpreter, Vision, Mind maps, Web Searches, Advanced Data Analytics, Large Data Handling and more! | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
| 🚀 CoolAGI: Where AI meets Imagination<br/>![CoolAGI Logo](https://github.com/nextgen-user/freegpt4plus/assets/150797204/9b0e1232-4791-4d61-b949-16f9eb284c22) | Code Interpreter, Vision, Mind maps, Web Searches, Advanced Data Analytics, Large Data Handling and more! | [nextgen-user/CoolAGI](https://github.com/nextgen-user/CoolAGI) |
| HL-GPT | Fully remodeled UI | [harlanlewis/nextjs-chatgpt-app](https://github.com/harlanlewis/nextjs-chatgpt-app) |
For public projects, update your README.md with your modifications and submit a pull request to add your project to our list, aiding in its discovery.
<br/>
<br/>
## Best Practices
+1 -1
View File
@@ -53,7 +53,7 @@ As of Feb 27, 2024, this feature is in development.
## Configurations
| Scope | Default | Description / Instructions |
| Scope | Default | Description / Instructions |
|-----------------------------------------------------------------------------------------|------------------|-------------------------------------------------------------------------------------------------------------------------|
| Your source builds of big-AGI | None | **Vercel**: enable Vercel Analytics from the dashboard. · **Google Analytics**: set environment variable at build time. |
| Your docker builds of big-AGI | None | **Vercel**: n/a. · **Google Analytics**: set environment variable at `docker build` time. |
+1 -1
View File
@@ -9,7 +9,7 @@ Docker ensures faster development cycles, easier collaboration, and seamless env
```bash
git clone https://github.com/enricoros/big-agi.git
cd big-agi
```
```
2. **Build the Docker Image**: Build a local docker image from the provided Dockerfile:
```bash
docker build -t big-agi .
+2 -2
View File
@@ -91,7 +91,7 @@ requiring the user to enter an API key
| `ANTHROPIC_API_HOST` | Changes the backend host for the Anthropic vendor, to enable platforms such as [config-aws-bedrock.md](config-aws-bedrock.md) | Optional |
| `GEMINI_API_KEY` | The API key for Google AI's Gemini | Optional |
| `GROQ_API_KEY` | The API key for Groq Cloud | Optional |
| `LOCALAI_API_HOST` | Sets the URL of the LocalAI server, or defaults to http://127.0.0.1:8080 | Optional |
| `LOCALAI_API_HOST` | Sets the URL of the LocalAI server, or defaults to http://127.0.0.1:8080 | Optional |
| `LOCALAI_API_KEY` | The (Optional) API key for LocalAI | Optional |
| `MISTRAL_API_KEY` | The API key for Mistral | Optional |
| `OLLAMA_API_HOST` | Changes the backend host for the Ollama vendor. See [config-local-ollama.md](config-local-ollama) | |
@@ -128,7 +128,7 @@ Enable the app to Talk, Draw, and Google things up.
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
| **Browse** | |
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing (pade downloadeing), etc. |
| **Backend** | |
| **Backend** | |
| `BACKEND_ANALYTICS` | Semicolon-separated list of analytics flags (see backend.analytics.ts). Flags: `domain` logs the responding domain. |
| `HTTP_BASIC_AUTH_USERNAME` | See the [Authentication](deploy-authentication.md) guide. Username for HTTP Basic Authentication. |
| `HTTP_BASIC_AUTH_PASSWORD` | Password for HTTP Basic Authentication. |
+119
View File
@@ -0,0 +1,119 @@
# Installation Guide
Welcome to the big-AGI Installation Guide - Whether you're a developer
eager to explore, a system integrator, or an enterprise looking for a
white-label solution, this comprehensive guide ensures a smooth setup
process for your own instance of big-AGI and related products.
**Try big-AGI** - You don't need to install anything if you want to play with big-AGI
and have your API keys to various model services. You can access our free instance on [big-AGI.com](https://big-agi.com).
The free instance runs the latest `main-stable` branch from this repository.
## 🧩 Build-your-own
If you want to change the code, have a deeper configuration,
add your own models, or run your own instance, follow the steps below.
### Local Development
**Prerequisites:**
- Node.js and npm installed on your machine.
**Steps:**
1. Clone the big-AGI repository:
```bash
git clone https://github.com/enricoros/big-AGI.git
cd big-AGI
```
2. Install dependencies:
```bash
npm install
```
3. Run the development server:
```bash
npm run dev
```
Your big-AGI instance is now running at `http://localhost:3000`.
### Local Production build
The production build is optimized for performance and follows
the same steps 1 and 2 as for [local development](#local-development).
3. Build the production version:
```bash
# .. repeat the steps above up to `npm install`, then:
npm run build
```
4. Start the production server (`npx` may be optional):
```bash
npx next start --port 3000
```
Your big-AGI production instance is on `http://localhost:3000`.
### Advanced Customization
Want to pre-enable models, customize the interface, or deploy with username/password or alter code to your needs?
Check out the [Customizations Guide](README.md) for detailed instructions.
## ☁️ Cloud Deployment Options
To deploy big-AGI on a public server, you have several options. Choose the one that best fits your needs.
### Deploy on Vercel
Install big-AGI on Vercel with just a few clicks.
Create your GitHub fork, create a Vercel project over that fork, and deploy it. Or press the button below for convenience.
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI&env=OPENAI_API_KEY&envDescription=Backend%20API%20keys%2C%20optional%20and%20may%20be%20overridden%20by%20the%20UI.&envLink=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-AGI%2Fblob%2Fmain%2Fdocs%2Fenvironment-variables.md&project-name=big-AGI)
### Deploy on Cloudflare
Deploy on Cloudflare's global network by installing big-AGI on
Cloudflare Pages. Check out the [Cloudflare Installation Guide](deploy-cloudflare.md)
for step-by-step instructions.
### Docker Deployments
Containerize your big-AGI installation using Docker for portability and scalability.
Our [Docker Deployment Guide](deploy-docker.md) will walk you through the process,
or follow the steps below for a quick start.
1. (optional) Build the Docker image - if you do not want to use the [pre-built Docker images](https://github.com/enricoros/big-AGI/pkgs/container/big-agi):
```bash
docker build -t big-agi .
```
2. Run the Docker container with either:
```bash
# 2A. if you built the image yourself:
docker run -d -p 3000:3000 big-agi
# 2B. or use the pre-built image:
docker run -d -p 3000:3000 ghcr.io/enricoros/big-agi
# 2C. or use docker-compose:
docker-compose up
```
Access your big-AGI instance at `http://localhost:3000`.
### Midori AI Subsystem for Docker Deployment
Follow the instructions found on [Midori AI Subsystem Site](https://io.midori-ai.xyz/subsystem/manager/)
for your host OS. After completing the setup process, install the Big-AGI docker backend to the Midori AI Subsystem.
## Enterprise-Grade Installation
For businesses seeking a fully-managed, scalable solution, consider our managed installations.
Enjoy all the features of big-AGI without the hassle of infrastructure management. [hello@big-agi.com](mailto:hello@big-agi.com) to learn more.
## Support
Join our vibrant community of developers, researchers, and AI enthusiasts. Share your projects, get help, and collaborate with others.
- [Discord Community](https://discord.gg/MkH4qj2Jp9)
- [Twitter](https://twitter.com/yourusername)
For any questions or inquiries, please don't hesitate to [reach out to our team](mailto:hello@big-agi.com).
+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;
},
+4992 -876
View File
File diff suppressed because it is too large Load Diff
+73 -41
View File
@@ -1,18 +1,23 @@
{
"name": "big-agi",
"version": "1.15.0",
"version": "1.16.0",
"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"
@@ -21,67 +26,94 @@
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.14",
"@mui/joy": "^5.0.0-beta.32",
"@next/bundle-analyzer": "^14.1.4",
"@next/third-parties": "^14.1.4",
"@prisma/client": "^5.11.0",
"@emotion/styled": "^11.11.5",
"@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.9.2",
"@tanstack/react-query": "~4.36.1",
"@trpc/client": "10.44.1",
"@trpc/next": "10.44.1",
"@trpc/react-query": "10.44.1",
"@trpc/server": "10.44.1",
"@vercel/analytics": "^1.2.2",
"@vercel/speed-insights": "^1.0.10",
"@t3-oss/env-nextjs": "^0.10.1",
"@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.0.379",
"pdfjs-dist": "4.4.168",
"plantuml-encoder": "^1.4.0",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-csv": "^2.2.2",
"react-dom": "^18.2.0",
"react-dom": "^18.3.1",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.1",
"react-player": "^2.15.1",
"react-resizable-panels": "^2.0.13",
"react-player": "^2.16.0",
"react-resizable-panels": "^2.0.20",
"react-timeago": "^7.2.0",
"rehype-katex": "^7.0.0",
"remark-gfm": "^4.0.0",
"sharp": "^0.33.2",
"remark-math": "^6.0.0",
"sharp": "^0.33.4",
"superjson": "^2.2.1",
"tesseract.js": "^5.0.5",
"tiktoken": "^1.0.13",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zustand": "^4.5.2"
"tesseract.js": "^5.1.0",
"tiktoken": "^1.0.15",
"turndown": "^7.2.0",
"zod": "^3.23.8",
"zustand": "^4.5.4"
},
"devDependencies": {
"@cloudflare/puppeteer": "0.0.5",
"@types/node": "^20.11.30",
"@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.3",
"@types/react": "^18.2.67",
"@types/prismjs": "^1.26.4",
"@types/react": "^18.3.3",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-csv": "^1.1.10",
"@types/react-dom": "^18.2.22",
"@types/react-dom": "^18.3.0",
"@types/react-katex": "^3.0.4",
"@types/react-timeago": "^4.1.7",
"@types/uuid": "^9.0.8",
"@types/turndown": "^5.0.4",
"concurrently": "^8.2.2",
"electron": "^31.1.0",
"electron-builder": "^24.13.3",
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.4",
"prettier": "^3.2.5",
"prisma": "^5.11.0",
"typescript": "^5.4.3"
"eslint-config-next": "^14.2.4",
"prettier": "^3.3.2",
"prisma": "^5.16.1",
"typescript": "^5.5.3"
},
"engines": {
"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';
+6 -4
View File
@@ -17,7 +17,7 @@ import { Brand } from '~/common/app.config';
import { ROUTE_APP_CHAT, ROUTE_INDEX } from '~/common/app.routes';
// apps access
import { incrementalNewsVersion } from '../../src/apps/news/news.version';
import { incrementalNewsVersion, useAppNewsStateStore } from '../../src/apps/news/news.version';
// capabilities access
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs, useCapabilityTextToImage } from '~/common/components/useCapabilities';
@@ -25,13 +25,14 @@ 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';
// utils access
import { clientHostName, isChromeDesktop, isFirefox, isIPhoneUser, isMacUser, isPwa, isVercelFromFrontend } from '~/common/util/pwaUtils';
import { getGA4MeasurementId } from '~/common/components/GoogleAnalytics';
import { prettyTimestampForFilenames } from '~/common/util/timeUtils';
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
@@ -80,7 +81,8 @@ function AppDebug() {
const chatsCount = useChatStore.getState().conversations?.length;
const uxLabsExperiments = Object.entries(useUXLabsStore.getState()).filter(([_k, v]) => v === true).map(([k, _]) => k).join(', ');
const { folders, enableFolders } = useFolderStore.getState();
const { lastSeenNewsVersion, usageCount } = useAppStateStore.getState();
const { lastSeenNewsVersion } = useAppNewsStateStore.getState();
const { usageCount } = useAppStateStore.getState();
// derived state
@@ -127,7 +129,7 @@ function AppDebug() {
const handleDownload = async () => {
fileSave(
new Blob([JSON.stringify({ client: cClient, agi: cProduct, backend: cBackend }, null, 2)], { type: 'application/json' }),
{ fileName: `big-agi-debug-${new Date().toISOString().replace(/:/g, '-')}.json`, extensions: ['.json'] },
{ fileName: `big-agi_debug_${prettyTimestampForFilenames()}.json`, extensions: ['.json'] },
)
.then(() => setSaved(true))
.catch(e => console.error('Error saving debug.json', e));
+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.' />}
+6 -3
View File
@@ -77,9 +77,12 @@ function AppShareTarget() {
setIsDownloading(true);
callBrowseFetchPage(intentURL)
.then(page => {
if (page.stopReason !== 'error')
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + page.content + '\n```\n');
else
if (page.stopReason !== 'error') {
let pageContent = page.content.markdown || page.content.text || page.content.html || '';
if (pageContent)
pageContent = '\n\n```' + intentURL + '\n' + pageContent + '\n```\n';
queueComposerTextAndLaunchApp(pageContent);
} else
setErrorMessage('Could not read any data' + page.error ? ': ' + page.error : '');
})
.catch(error => setErrorMessage(error?.message || error || 'Unknown error'))
+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 />);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

+28 -3
View File
@@ -3,9 +3,16 @@
"short_name": "big-AGI",
"theme_color": "#32383E",
"background_color": "#9FA6AD",
"description": "Personal AGI App",
"description": "Your Generative AI Suite",
"categories": [
"productivity",
"AI",
"tool",
"utilities"
],
"display": "standalone",
"start_url": "/",
"start_url": "/?source=pwa",
"scope": "/",
"icons": [
{
"src": "/icons/icon-192x192.png",
@@ -24,6 +31,17 @@
"type": "image/png"
}
],
"file_handlers": [
{
"action": "/link/share_target",
"accept": {
"application/big-agi": [
".agi",
".agi.json"
]
}
}
],
"share_target": {
"action": "/link/share_target",
"method": "GET",
@@ -33,5 +51,12 @@
"text": "text",
"url": "url"
}
}
},
"shortcuts": [
{
"name": "Call",
"url": "/call",
"description": "Call a Persona"
}
]
}
File diff suppressed because one or more lines are too long
+18 -12
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;
}
@@ -30,8 +31,16 @@ export function AppBeam() {
// state
const [showDebug, setShowDebug] = React.useState(false);
const conversation = React.useRef<DConversation>(initTestConversation());
const beamStoreApi = React.useRef(initTestBeamStore(conversation.current.messages)).current;
const [conversation, setConversation] = React.useState<DConversation>(() => initTestConversation());
const [beamStoreApi] = React.useState(() => createBeamVanillaStore());
// reinit the beam store if the conversation changes
React.useEffect(() => {
initTestBeamStore(conversation.messages, beamStoreApi);
}, [beamStoreApi, conversation]);
// external state
const isMobile = useIsMobile();
@@ -44,7 +53,7 @@ export function AppBeam() {
const handleClose = React.useCallback(() => {
beamStoreApi.getState().terminate();
beamStoreApi.getState().terminateKeepingSettings();
}, [beamStoreApi]);
@@ -56,10 +65,7 @@ export function AppBeam() {
</Button>
{/* 'open' */}
<Button size='sm' variant='plain' color='neutral' onClick={() => {
conversation.current = initTestConversation();
initTestBeamStore(conversation.current.messages, beamStoreApi);
}}>
<Button size='sm' variant='plain' color='neutral' onClick={() => setConversation(initTestConversation())}>
.open
</Button>
@@ -67,7 +73,7 @@ export function AppBeam() {
<Button size='sm' variant='plain' color='neutral' onClick={handleClose}>
.close
</Button>
</>, [beamStoreApi, handleClose, showDebug]), null, 'AppBeam');
</>, [handleClose, showDebug]), null, 'AppBeam');
return (
+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
+30 -19
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { useShallow } from 'zustand/react/shallow';
import { Box, Card, ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
@@ -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';
@@ -99,7 +101,7 @@ export function Telephone(props: {
// external state
const { chatLLMId, chatLLMDropdown } = useChatLLMDropdown();
const { chatTitle, reMessages } = useChatStore(state => {
const { chatTitle, reMessages } = useChatStore(useShallow(state => {
const conversation = props.callIntent.conversationId
? state.conversations.find(conversation => conversation.id === props.callIntent.conversationId) ?? null
: null;
@@ -107,7 +109,7 @@ export function Telephone(props: {
chatTitle: conversation ? conversationTitle(conversation) : null,
reMessages: conversation ? conversation.messages : null,
};
}, shallow);
}));
const persona = SystemPurposes[props.callIntent.personaId as SystemPurposeId] ?? undefined;
const personaCallStarters = persona?.call?.starters ?? undefined;
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
@@ -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
@@ -225,7 +236,7 @@ export function Telephone(props: {
let finalText = '';
let error: any | null = null;
setPersonaTextInterim('💭...');
llmStreamingChatGenerate(chatLLMId, callPrompt, null, null, responseAbortController.current.signal, ({ textSoFar }) => {
llmStreamingChatGenerate(chatLLMId, callPrompt, 'call', callMessages[0].id, null, null, responseAbortController.current.signal, ({ textSoFar }) => {
const text = textSoFar?.trim();
if (text) {
finalText = text;
@@ -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}
+174 -240
View File
@@ -1,11 +1,14 @@
import * as React from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import type { SxProps } from '@mui/joy/styles/types';
import { useTheme } from '@mui/joy';
import { DEV_MODE_SETTINGS } from '../settings-modal/UxLabsSettings';
import { DiagramConfig, DiagramsModal } from '~/modules/aifn/digrams/DiagramsModal';
import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal';
import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
import { downloadConversation, openAndLoadConversations } from '~/modules/trade/trade.client';
import { getChatLLMId, useChatLLM } from '~/modules/llms/store-llms';
import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
@@ -14,59 +17,61 @@ 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, getConversation, getConversationSystemPurposeId, useConversation } from '~/common/state/store-chats';
import { getUXLabsHighPerformance, useUXLabsStore } from '~/common/state/store-ux-labs';
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';
import { useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
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 { getInstantAppChatPanesCount, usePanesManager } from './components/panes/usePanesManager';
import { usePanesManager } from './components/panes/usePanesManager';
import { DEV_MODE_SETTINGS } from '../settings-modal/UxLabsSettings';
import { extractChatCommand, findAllChatCommands } from './commands/commands.registry';
import { runAssistantUpdatingState } from './editors/chat-stream';
import { runBrowseGetPageUpdatingState } from './editors/browse-load';
import { runImageGenerationUpdatingState } from './editors/image-generate';
import { runReActUpdatingState } from './editors/react-tangent';
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;
}
const composerOpenSx: SxProps = {
zIndex: 21, // just to allocate a surface, and potentially have a shadow
backgroundColor: themeBgAppChatComposer,
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
};
const composerClosedSx: SxProps = {
display: 'none',
};
export function AppChat() {
// state
@@ -90,7 +95,7 @@ export function AppChat() {
const showAltTitleBar = useUXLabsStore(state => DEV_MODE_SETTINGS && state.labsChatBarAlt === 'title');
const { openLlmOptions } = useOptimaLayout();
const { openLlmOptions, openModelsSetup, openPreferencesTab } = useOptimaLayout();
const { chatLLM } = useChatLLM();
@@ -107,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
@@ -151,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;
@@ -186,160 +195,64 @@ export function AppChat() {
// Execution
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
const chatLLMId = getChatLLMId();
if (!chatModeId || !conversationId || !chatLLMId) return;
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')
openPreferencesTab(PreferencesTab.Draw);
else if (outcome === 'err-no-persona')
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]);
// 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);
const handleComposerAction = React.useCallback((conversationId: DConversationId, chatExecuteMode: ChatExecuteMode, fragments: (DMessageContentFragment | DMessageAttachmentFragment)[], metadata?: DMessageMetadata): boolean => {
// 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);
// [multicast] send the message to all the panes
const uniqueConversationIds = willMulticast
? Array.from(new Set([conversationId, ...paneUniqueConversationIds]))
: [conversationId];
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')
return cHandler.messagesReplace([]);
cHandler.messagesReplace(history);
cHandler.messageAppendAssistant('Issue: this command requires the \'all\' parameter to confirm the operation.', undefined, 'issue', false);
return;
}
// /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>);
return cHandler.messagesReplace(history);
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;
case 'mode-beam':
if (chatCommand.isError)
return cHandler.messagesReplace(history);
// remove '/beam ', as we want to be a user chat message
Object.assign(lastMessage, { text: chatCommand.params || '' });
cHandler.messagesReplace(history);
return ConversationsManager.getHandler(conversationId).beamInvoke(history, [], null);
default:
return cHandler.messagesReplace([...history, createDMessage('assistant', 'This command is not supported.')]);
}
}
}
// 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;
}
// 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);
return cHandler.beamInvoke(history, [], null);
case 'append-user':
return cHandler.messagesReplace(history);
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);
}, []);
const handleComposerAction = React.useCallback((chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): 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,
},
});
// validate conversation existence
const uniqueConverations = uniqueConversationIds.map(cId => getConversation(cId)).filter(Boolean) as DConversation[];
if (!uniqueConverations.length)
return false;
}
const userText = multiPartMessage[0].text;
// multicast: send the message to all the panes
const uniqueIds = new Set([conversationId]);
if (willMulticast)
chatPanes.forEach(pane => pane.conversationId && uniqueIds.add(pane.conversationId));
// we loop to handle both the normal and multicast modes
let enqueued = false;
for (const _cId of uniqueIds) {
const _conversation = getConversation(_cId);
if (_conversation) {
// start execution fire/forget
void _handleExecute(chatModeId, _cId, [..._conversation.messages, createDMessage('user', userText)]);
enqueued = true;
}
}
return enqueued;
}, [chatPanes, willMulticast, _handleExecute]);
for (const conversation of uniqueConverations) {
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
await _handleExecute('generate-text', conversationId, history);
}, [_handleExecute]);
// 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(chatExecuteMode /* various */, conversation.id, 'chat-composer-action'); // append user message, then '*-*'
}
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];
return await _handleExecute('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
}
}, [_handleExecute, focusedPaneConversationId]);
}, [focusedPaneConversationId, handleExecuteAndOutcome]);
const handleMessageBeamLastInFocusedPane = React.useCallback(async () => {
// Ctrl + Shift + B
@@ -355,16 +268,15 @@ export function AppChat() {
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string): Promise<void> => {
const handleImagineFromText = React.useCallback(async (conversationId: DConversationId, messageText: string) => {
const conversation = getConversation(conversationId);
if (!conversation)
return;
const imaginedPrompt = await imaginePromptFromText(messageText) || 'An error sign.';
return await _handleExecute('generate-image', conversationId, [
...conversation.messages,
createDMessage('user', imaginedPrompt),
]);
}, [_handleExecute]);
const imaginedPrompt = await imaginePromptFromText(messageText, conversationId) || 'An error sign.';
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> => {
await speakText(text);
@@ -398,6 +310,32 @@ export function AppChat() {
setTradeConfig({ dir: 'export', conversationId, exportAll });
}, []);
const handleFileOpenConversation = React.useCallback(() => {
openAndLoadConversations(true)
.then((outcome) => {
// activate the last (most recent) imported conversation
if (outcome?.activateConversationId) {
showNextTitleChange.current = true;
handleOpenConversationInFocusedPane(outcome.activateConversationId);
}
})
.catch(() => {
addSnackbar({ key: 'chat-import-fail', message: 'Could not open the file.', type: 'issue' });
});
}, [handleOpenConversationInFocusedPane]);
const handleFileSaveConversation = React.useCallback((conversationId: DConversationId | null) => {
const conversation = getConversation(conversationId);
conversation && downloadConversation(conversation, 'json')
.then(() => {
addSnackbar({ key: 'chat-save-as-ok', message: 'File saved.', type: 'success' });
})
.catch((err: any) => {
if (err?.name !== 'AbortError')
addSnackbar({ key: 'chat-save-as-fail', message: `Could not save the file. ${err?.message || ''}`, type: 'issue' });
});
}, []);
const handleConversationBranch = React.useCallback((srcConversationId: DConversationId, messageId: string | null): DConversationId | null => {
// clone data
const branchedConversationId = branchConversation(srcConversationId, messageId);
@@ -420,7 +358,7 @@ export function AppChat() {
const handleConfirmedClearConversation = React.useCallback(() => {
if (clearConversationId) {
ConversationsManager.getHandler(clearConversationId).messagesReplace([]);
ConversationsManager.getHandler(clearConversationId).historyClear();
setClearConversationId(null);
}
}, [clearConversationId]);
@@ -438,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(() => {
@@ -453,11 +394,13 @@ 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],
['n', true, false, true, handleConversationNewInFocusedPane],
['o', true, false, false, handleFileOpenConversation],
['s', true, false, false, () => handleFileSaveConversation(focusedPaneConversationId)],
['b', true, false, true, () => isFocusedChatEmpty || (focusedPaneConversationId && handleConversationBranch(focusedPaneConversationId, null))],
['x', true, false, true, () => isFocusedChatEmpty || (focusedPaneConversationId && handleConversationClear(focusedPaneConversationId))],
['d', true, false, true, () => focusedPaneConversationId && handleDeleteConversations([focusedPaneConversationId], false)],
@@ -467,7 +410,7 @@ export function AppChat() {
['o', true, true, false, handleOpenChatLlmOptions],
['+', true, true, false, useUIPreferencesStore.getState().increaseContentScaling],
['-', true, true, false, useUIPreferencesStore.getState().decreaseContentScaling],
], [focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationNewInFocusedPane, handleDeleteConversations, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]);
], [focusedPaneConversationId, handleConversationBranch, handleConversationClear, handleConversationNewInFocusedPane, handleFileOpenConversation, handleFileSaveConversation, handleDeleteConversations, handleMessageBeamLastInFocusedPane, handleMessageRegenerateLastInFocusedPane, handleNavigateHistoryInFocusedPane, handleOpenChatLlmOptions, isFocusedChatEmpty]);
useGlobalShortcuts(shortcuts);
@@ -488,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}
@@ -498,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(() =>
@@ -530,9 +473,9 @@ export function AppChat() {
{chatPanes.map((pane, idx) => {
const _paneIsFocused = idx === focusedPaneIndex;
const _paneConversationId = pane.conversationId;
const _paneChatHandler = chatHandlers[idx] ?? null;
const _paneChatBeamStore = beamsStores[idx] ?? null;
const _paneChatBeamIsOpen = !!beamsOpens?.[idx];
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}`;
const _sepId = `sep-pane-${idx}`;
@@ -580,47 +523,46 @@ export function AppChat() {
<ScrollToBottom
bootToBottom
stickToBottomInitial
sx={_paneChatBeamIsOpen ? { display: 'none' } : undefined}
sx={{ display: 'flex', flexDirection: 'column' }}
>
<ChatMessageList
conversationId={_paneConversationId}
conversationHandler={_paneChatHandler}
capabilityHasT2I={capabilityHasT2I}
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
fitScreen={isMobile || isMultiPane}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
sx={{
minHeight: '100%', // ensures filling of the blank space on newer chats
}}
/>
{!_paneBeamIsOpen && (
<ChatMessageList
conversationId={_paneConversationId}
conversationHandler={_paneChatHandler}
capabilityHasT2I={capabilityHasT2I}
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
fitScreen={isMobile || isMultiPane}
isMobile={isMobile}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
onTextDiagram={handleTextDiagram}
onTextImagine={handleImagineFromText}
onTextSpeak={handleTextSpeak}
sx={{
flexGrow: 1,
}}
/>
)}
{/*<Ephemerals*/}
{/* conversationId={_paneConversationId}*/}
{/* sx={{*/}
{/* // TODO: Fixme post panels?*/}
{/* // flexGrow: 0.1,*/}
{/* flexShrink: 0.5,*/}
{/* overflowY: 'auto',*/}
{/* minHeight: 64,*/}
{/* }}*/}
{/*/>*/}
{_paneBeamIsOpen && (
<ChatBeamWrapper
beamStore={_paneBeamStore}
isMobile={isMobile}
inlineSx={{
flexGrow: 1,
// minHeight: 'calc(100vh - 69px - var(--AGI-Nav-width))',
}}
/>
)}
{/* Visibility and actions are handled via Context */}
<ScrollToBottomButton />
</ScrollToBottom>
{(_paneChatBeamIsOpen && !!_paneChatBeamStore) && (
<ChatBeamWrapper beamStore={_paneChatBeamStore} isMobile={isMobile} />
)}
</Panel>
{/* Panel Separators & Resizers */}
@@ -639,22 +581,14 @@ 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 ? {
display: 'none',
} : {
zIndex: 21, // just to allocate a surface, and potentially have a shadow
backgroundColor: themeBgAppChatComposer,
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
}}
sx={beamOpenStoreInFocusedPane ? composerClosedSx : composerOpenSx}
/>
{/* Diagrams */}
+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',
+3 -4
View File
@@ -1,17 +1,16 @@
import { ChatBeamIcon } from '~/common/components/icons/ChatBeamIcon';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsBeam: ICommandsProvider = {
id: 'mode-beam',
id: 'cmd-mode-beam',
rank: 9,
getCommands: () => useUXLabsStore.getState().labsBeam ? [{
getCommands: () => [{
primary: '/beam',
arguments: ['prompt'],
description: 'Combine the smarts of models',
Icon: ChatBeamIcon,
}] : [],
}],
};
+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,
}];
}
+14 -7
View File
@@ -1,16 +1,25 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Modal, ModalClose } from '@mui/joy';
import { BeamStoreApi, useBeamStore } from '~/modules/beam/store-beam.hooks';
import { BeamView } from '~/modules/beam/BeamView';
import { themeZIndexBeamView } from '~/common/app.theme';
import { ScrollToBottom } from '~/common/scroll-to-bottom/ScrollToBottom';
/*const overlaySx: SxProps = {
position: 'absolute',
inset: 0,
zIndex: themeZIndexBeamView, // stay on top of Message > Chips (:1), and Overlays (:2) - note: Desktop Drawer (:26)
}*/
export function ChatBeamWrapper(props: {
beamStore: BeamStoreApi,
isMobile: boolean,
inlineSx?: SxProps,
}) {
// state
@@ -36,16 +45,14 @@ export function ChatBeamWrapper(props: {
position: 'absolute',
inset: 0,
}}>
{beamView}
<ScrollToBottom disableAutoStick>
{beamView}
</ScrollToBottom>
<ModalClose sx={{ color: 'white', backgroundColor: 'background.surface', boxShadow: 'xs', mr: 2 }} />
</Box>
</Modal>
) : (
<Box sx={{
position: 'absolute',
inset: 0,
zIndex: themeZIndexBeamView, // stay on top of Message > Chips (:1), and Overlays (:2) - note: Desktop Drawer (:26)
}}>
<Box sx={props.inlineSx}>
{beamView}
</Box>
);
+86 -67
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,33 +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 handleTextDiagram = React.useCallback(async (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: DMessageId, text: string) => {
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
}, [conversationId, onTextDiagram]);
@@ -169,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
@@ -224,13 +235,16 @@ export function ChatMessageList(props: {
);
return (
<List sx={{
p: 0, ...(props.sx || {}),
// this makes sure that the the window is scrolled to the bottom (column-reverse)
display: 'flex',
flexDirection: 'column',
<List role='chat-messages-list' sx={{
p: 0,
...(props.sx || {}),
// fix for the double-border on the last message (one by the composer, one to the bottom of the message)
// marginBottom: '-1px',
// layout
display: 'flex',
flexDirection: 'column',
}}>
{optionalTranslationWarning}
@@ -247,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 ? (
@@ -264,21 +278,26 @@ 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}
onTextDiagram={handleTextDiagram}
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
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,102 +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';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
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 labsBeam = useUXLabsStore(state => state.labsBeam);
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]) => key !== 'generate-text-beam' || labsBeam)
.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>
);
}
+295 -239
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { useShallow } from 'zustand/react/shallow';
import { fileOpen, FileWithHandle } from 'browser-fs-access';
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Tooltip, Typography } from '@mui/joy';
@@ -15,29 +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, 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';
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';
@@ -46,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 { getTextBlockText, 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';
@@ -62,7 +68,7 @@ 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';
import { useComposerStartupText } from './store-composer';
@@ -94,31 +100,34 @@ 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: (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart) => 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();
const { labsAttachScreenCapture, labsBeam, labsCameraDesktop } = useUXLabsStore(state => ({
const { labsAttachScreenCapture, labsCameraDesktop, labsShowCost } = useUXLabsStore(useShallow(state => ({
labsAttachScreenCapture: state.labsAttachScreenCapture,
labsBeam: state.labsBeam,
labsCameraDesktop: state.labsCameraDesktop,
}), shallow);
labsShowCost: state.labsShowCost,
})));
const timeToShowTips = useAppStateStore(state => state.usageCount > 2);
const { novel: explainShiftEnter, touch: touchShiftEnter } = useUICounter('composer-shift-enter');
const { novel: explainAltEnter, touch: touchAltEnter } = useUICounter('composer-alt-enter');
@@ -126,43 +135,65 @@ export function Composer(props: {
const [startupText, setStartupText] = useComposerStartupText();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(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,
};
}, shallow);
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 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: (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;
const tokenPriceIn = props.chatLLM?.pricing?.chatIn;
const tokenPriceOut = props.chatLLM?.pricing?.chatOut;
// Effect: load initial text if queued up (e.g. by /link/share_targe)
@@ -174,81 +205,96 @@ export function Composer(props: {
}, [setComposeText, setStartupText, startupText]);
// Overlay actions
const handleReplyToClear = React.useCallback(() => {
conversationOverlayStore?.getState().setReplyToText(null);
}, [conversationOverlayStore]);
React.useEffect(() => {
if (replyToGenerateText)
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 attachments
const multiPartMessage = llmAttachments.getAttachmentsOutputs(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));
// send the message
const enqueued = onAction(_chatModeId, conversationId, multiPartMessage);
if (enqueued) {
clearAttachments();
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, llmAttachments, onAction, setComposeText]);
}, [attachmentsTakeAllFragments, handleClear, onAction, replyToGenerateText, targetConversationId]);
const handleSendClicked = React.useCallback(() => {
handleSendAction(chatModeId, composeText);
}, [chatModeId, composeText, handleSendAction]);
const handleSendTextBeamClicked = React.useCallback(() => {
labsBeam && handleSendAction('generate-text-beam', composeText);
}, [composeText, handleSendAction, labsBeam]);
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;
@@ -265,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;
@@ -304,15 +353,15 @@ export function Composer(props: {
// Alt (Windows) or Option (Mac) + Enter: append the message instead of sending it
if (e.altKey) {
touchAltEnter();
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 (labsBeam && ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey))) {
touchCtrlEnter();
handleSendAction('generate-text-beam', composeText);
if ((isMacUser && e.metaKey && !e.ctrlKey) || (!isMacUser && e.ctrlKey && !e.metaKey)) {
if (await handleSendAction('beam-content', composeText)) // 'ctrl+enter' -> beam
touchCtrlEnter();
return e.preventDefault();
}
@@ -321,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, labsBeam, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
}, [actileInterceptKeydown, assistantAbortible, chatExecuteMode, composeText, enterIsNewline, handleSendAction, touchAltEnter, touchCtrlEnter, touchShiftEnter]);
// Focus mode
@@ -352,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;
@@ -395,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')
@@ -406,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 });
@@ -423,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 attachmentOutputs = llmAttachments.getAttachmentOutputs(currentText, attachmentId);
const inlinedText = getTextBlockText(attachmentOutputs) || '';
removeAttachment(attachmentId);
return inlinedText;
});
}, [llmAttachments, removeAttachment, setComposeText]);
const handleAttachmentsInlineText = React.useCallback(() => {
setComposeText(currentText => {
const attachmentsOutputs = llmAttachments.getAttachmentsOutputs(currentText);
const inlinedText = getTextBlockText(attachmentsOutputs) || '';
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
@@ -470,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]);
@@ -489,31 +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 showChatExtras = isText;
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 />
@@ -525,15 +564,16 @@ export function Composer(props: {
isDraw ? 'Describe an idea or a drawing...'
: isReAct ? 'Multi-step reasoning question...'
: isTextBeam ? 'Beam: combine the smarts of models...'
: props.isDeveloperMode ? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
: props.capabilityHasT2I ? 'Chat · /beam · /draw · drop files...'
: 'Chat · /react · drop files...';
: showChatReplyTo ? 'Chat about this'
: props.isDeveloperMode ? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
: props.capabilityHasT2I ? 'Chat · /beam · /draw · drop files...'
: 'Chat · /react · drop files...';
if (isDesktop && timeToShowTips) {
if (explainShiftEnter)
textPlaceholder += !enterIsNewline ? '\n\n💡 Shift + Enter to add a new line' : '\n\n💡 Shift + Enter to send';
else if (explainAltEnter)
textPlaceholder += platformAwareKeystrokes('\n\n💡 Tip: Alt + Enter to just append the message');
else if (labsBeam && explainCtrlEnter)
else if (explainCtrlEnter)
textPlaceholder += platformAwareKeystrokes('\n\n💡 Tip: Ctrl + Enter to beam');
}
@@ -541,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*/}
@@ -597,28 +642,30 @@ 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'
color={isDraw ? 'warning' : isReAct ? 'success' : undefined}
autoFocus
minRows={isMobile ? 4 : 5}
minRows={isMobile ? 4 : showChatReplyTo ? 4 : 5}
maxRows={isMobile ? 8 : 10}
placeholder={textPlaceholder}
value={composeText}
@@ -629,6 +676,7 @@ export function Composer(props: {
onPasteCapture={handleAttachCtrlV}
// onFocusCapture={handleFocusModeOn}
// onBlurCapture={handleFocusModeOff}
endDecorator={showChatReplyTo && <ReplyToBubble replyToText={replyToGenerateText} onClear={handleReplyToClear} className='reply-to-bubble' />}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'send',
@@ -636,21 +684,21 @@ 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={{
backgroundColor: 'background.level1',
'&:focus-within': { backgroundColor: 'background.popup' },
'&:focus-within': { backgroundColor: 'background.popup', '.reply-to-bubble': { backgroundColor: 'background.popup' } },
lineHeight: lineHeightTextareaMd,
}} />
{tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
<TokenProgressbarMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} />
{!showChatReplyTo && tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
<TokenProgressbarMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} />
)}
{!!tokenLimit && (
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} showExcess absoluteBottomRight />
{!showChatReplyTo && tokenLimit > 0 && (
<TokenBadgeMemo direct={tokensComposer} history={tokensHistory} responseMax={tokensReponseMax} limit={tokenLimit} tokenPriceIn={tokenPriceIn} tokenPriceOut={tokenPriceOut} showCost={labsShowCost} enableHover={!isMobile} showExcess absoluteBottomRight />
)}
</Box>
@@ -678,20 +726,32 @@ export function Composer(props: {
{/* overlay: Mic */}
{micIsRunning && (
<Card
color='primary' variant='soft' invertedColors
color='primary' variant='soft'
sx={{
display: 'flex',
position: 'absolute', bottom: 0, left: 0, right: 0, top: 0,
// alignItems: 'center', justifyContent: 'center',
border: '1px solid',
borderColor: 'primary.solidBg',
borderRadius: 'sm',
zIndex: zIndexComposerOverlayMic,
px: 1.5, py: 1,
pl: 1.5,
pr: { xs: 1.5, md: 5 },
py: 0.625,
overflow: 'auto',
}}>
<Typography sx={{
color: 'primary.softColor',
lineHeight: lineHeightTextareaMd,
'& .interim': {
textDecoration: 'underline',
textDecorationThickness: '0.25em',
textDecorationColor: 'rgba(var(--joy-palette-primary-mainChannel) / 0.1)',
textDecorationSkipInk: 'none',
textUnderlineOffset: '0.25em',
},
}}>
<Typography>
{speechInterimResult.transcript}{' '}
<span style={{ opacity: 0.8 }}>{speechInterimResult.interimTranscript}</span>
<span className={speechInterimResult.interimTranscript !== 'Listening...' ? 'interim' : undefined}>{speechInterimResult.interimTranscript}</span>
</Typography>
</Card>
)}
@@ -715,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}>
@@ -736,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 } }} />
@@ -744,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` }}
@@ -776,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>}
@@ -791,17 +853,18 @@ 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>
</ButtonGroup>
{/* [desktop] secondary-top buttons */}
{labsBeam && isDesktop && showChatExtras && !assistantAbortible && (
{isDesktop && showChatExtras && !assistantAbortible && (
<ButtonBeamMemo
disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
disabled={noConversation || noLLM || !llmAttachmentDrafts.canAttachAllFragments}
hasContent={!!composeText}
onClick={handleSendTextBeamClicked}
/>
)}
@@ -811,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} />}
@@ -827,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>
+125 -45
View File
@@ -3,41 +3,81 @@ import * as React from 'react';
import { Badge, Box, ColorPaletteProp, Tooltip } from '@mui/joy';
function alignRight(value: number, columnSize: number = 7) {
function alignRight(value: number, columnSize: number = 8) {
const str = value.toLocaleString();
return str.padStart(columnSize);
}
function formatCost(cost: number) {
return cost < 1
? (cost * 100).toFixed(cost < 0.010 ? 2 : 1) + ' ¢'
: '$ ' + cost.toFixed(2);
}
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, historyTokens?: number, responseMaxTokens?: number): {
color: ColorPaletteProp, message: string, remainingTokens: number
export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, historyTokens?: number, responseMaxTokens?: number, tokenPriceIn?: number, tokenPriceOut?: number): {
color: ColorPaletteProp,
message: string,
remainingTokens: number,
costMax?: number,
costMin?: number,
} {
const usedTokens = directTokens + (historyTokens || 0) + (responseMaxTokens || 0);
const remainingTokens = tokenLimit - usedTokens;
const usedInputTokens = directTokens + (historyTokens || 0);
const usedMaxTokens = usedInputTokens + (responseMaxTokens || 0);
const remainingTokens = tokenLimit - usedMaxTokens;
const gteLimit = (remainingTokens <= 0 && tokenLimit > 0);
// message
let message: string = gteLimit ? '⚠️ ' : '';
// costs
let costMax: number | undefined = undefined;
let costMin: number | undefined = undefined;
// no limit: show used tokens only
if (!tokenLimit) {
message += `Requested: ${usedTokens.toLocaleString()} tokens`;
message += `Requested: ${usedMaxTokens.toLocaleString()} tokens`;
}
// has full information (d + i < l)
else if (historyTokens || responseMaxTokens) {
message +=
`${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens >= 0 ? 'available' : 'excess'} message tokens\n\n` +
`${Math.abs(remainingTokens).toLocaleString()} ${remainingTokens >= 0 ? 'available' : 'excess'} message tokens\n\n` +
` = Model max tokens: ${alignRight(tokenLimit)}\n` +
` - This message: ${alignRight(directTokens)}\n` +
` - History: ${alignRight(historyTokens || 0)}\n` +
` - Max response: ${alignRight(responseMaxTokens || 0)}`;
// add the price, if available
if (tokenPriceIn || tokenPriceOut) {
costMin = tokenPriceIn ? usedInputTokens * tokenPriceIn / 1E6 : undefined;
const costOutMax = (tokenPriceOut && responseMaxTokens) ? responseMaxTokens * tokenPriceOut / 1E6 : undefined;
if (costMin || costOutMax) {
message += `\n\n\n▶ Chat Turn Cost (max, approximate)\n`;
if (costMin) message += '\n' +
` Input tokens: ${alignRight(usedInputTokens)}\n` +
` Input Price $/M: ${tokenPriceIn!.toFixed(2).padStart(8)}\n` +
` Input cost: ${('$' + costMin!.toFixed(4)).padStart(8)}\n`;
if (costOutMax) message += '\n' +
` Max output tokens: ${alignRight(responseMaxTokens!)}\n` +
` Output Price $/M: ${tokenPriceOut!.toFixed(2).padStart(8)}\n` +
` Max output cost: ${('$' + costOutMax!.toFixed(4)).padStart(8)}\n`;
if (costMin) message += '\n' +
` > Min turn cost: ${formatCost(costMin).padStart(8)}`;
costMax = (costMin && costOutMax) ? costMin + costOutMax : undefined;
if (costMax) message += '\n' +
` < Max turn cost: ${formatCost(costMax).padStart(8)}`;
}
}
}
// Cleaner mode: d + ? < R (total is the remaining in this case)
else {
message +=
`${(tokenLimit + usedTokens).toLocaleString()} available tokens after deleting this\n\n` +
`${(tokenLimit + usedMaxTokens).toLocaleString()} available tokens after deleting this\n\n` +
` = Currently free: ${alignRight(tokenLimit)}\n` +
` + This message: ${alignRight(usedTokens)}`;
` + This message: ${alignRight(usedMaxTokens)}`;
}
const color: ColorPaletteProp =
@@ -47,23 +87,21 @@ export function tokensPrettyMath(tokenLimit: number | 0, directTokens: number, h
? 'warning'
: 'primary';
return { color, message, remainingTokens };
return { color, message, remainingTokens, costMax, costMin };
}
export const TokenTooltip = (props: { message: string | null, color: ColorPaletteProp, placement?: 'top' | 'top-end', children: React.JSX.Element }) =>
export const TokenTooltip = (props: { message: string | null, color: ColorPaletteProp, placement?: 'top' | 'top-end', children: React.ReactElement }) =>
<Tooltip
placement={props.placement}
variant={props.color !== 'primary' ? 'solid' : 'soft'} color={props.color}
title={props.message
? <Box sx={{ p: 2, whiteSpace: 'pre' }}>
{props.message}
</Box>
: null
}
title={props.message ? <Box sx={{ p: 2, whiteSpace: 'pre' }}>{props.message}</Box> : null}
sx={{
fontFamily: 'code',
boxShadow: 'xl',
// fontSize: '0.8125rem',
border: '1px solid',
borderColor: `${props.color}.outlinedColor`,
boxShadow: 'md',
}}
>
{props.children}
@@ -76,38 +114,80 @@ export const TokenTooltip = (props: { message: string | null, color: ColorPalett
export const TokenBadgeMemo = React.memo(TokenBadge);
function TokenBadge(props: {
direct: number, history?: number, responseMax?: number, limit: number,
showExcess?: boolean, absoluteBottomRight?: boolean, inline?: boolean,
direct: number,
history?: number,
responseMax?: number,
limit: number,
tokenPriceIn?: number,
tokenPriceOut?: number,
enableHover?: boolean,
showCost?: boolean
showExcess?: boolean,
absoluteBottomRight?: boolean,
inline?: boolean,
}) {
const { message, color, remainingTokens } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
// state
const [isHovering, setIsHovering] = React.useState(false);
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
const value = (props.showExcess && (props.limit && remainingTokens <= 0))
? Math.abs(remainingTokens)
: props.direct;
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 = (!props.enableHover || isHovering)
? '< ' + formatCost(costMax)
: '> ' + formatCost(costMin);
} else {
// show the direct tokens, unless we exceed the limit and 'showExcess' is enabled
const value = (props.showExcess && (props.limit && remainingTokens <= 0))
? Math.abs(remainingTokens)
: props.direct;
badgeValue = value.toLocaleString();
}
const shallHide = !props.direct && remainingTokens >= 0 && !showAltCosts;
if (shallHide) return null;
return (
<Badge
variant='solid' color={color} max={100000}
invisible={!props.direct && remainingTokens >= 0}
badgeContent={
<TokenTooltip color={color} message={message}>
<span>{value.toLocaleString()}</span>
</TokenTooltip>
}
sx={{
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: 8 }),
cursor: 'help',
}}
slotProps={{
badge: {
sx: {
fontFamily: 'code',
...((props.absoluteBottomRight || props.inline) && { position: 'static', transform: 'none' }),
<TokenTooltip color={color} message={message} placement='top-end'>
<Badge
variant='soft' color={color} max={1000000}
// invisible={shallHide}
onMouseEnter={props.enableHover ? handleHoverEnter : undefined}
onMouseLeave={props.enableHover ? handleHoverLeave : undefined}
badgeContent={badgeValue}
slotProps={{
root: {
sx: {
...((props.absoluteBottomRight) && { position: 'absolute', bottom: 8, right: 8 }),
cursor: 'help',
},
},
},
}}
/>
badge: {
sx: {
// the badge (not the tooltip)
// boxShadow: 'sm',
fontFamily: 'code',
fontSize: 'xs',
...((props.absoluteBottomRight || props.inline) && { position: 'static', transform: 'none' }),
},
},
}}
/>
</TokenTooltip>
);
}
@@ -12,7 +12,15 @@ import { tokensPrettyMath, TokenTooltip } from './TokenBadge';
*/
export const TokenProgressbarMemo = React.memo(TokenProgressbar);
function TokenProgressbar(props: { direct: number, history: number, responseMax: number, limit: number }) {
function TokenProgressbar(props: {
direct: number,
history: number,
responseMax: number,
limit: number,
tokenPriceIn?: number,
tokenPriceOut?: number,
}) {
// external state
const theme = useTheme();
@@ -40,7 +48,7 @@ function TokenProgressbar(props: { direct: number, history: number, responseMax:
const overflowColor = theme.palette.danger.softColor;
// tooltip message/color
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax);
const { message, color } = tokensPrettyMath(props.limit, props.direct, props.history, props.responseMax, props.tokenPriceIn, props.tokenPriceOut);
// sizes
const containerHeight = 8;
@@ -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,186 +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() : '(base64 image)'} bytes`).join(' · ')}
</Typography>
{!!tokenCountApprox && <Typography level='body-xs'>
🡒 {tokenCountApprox.toLocaleString()} tokens
</Typography>}
</Box>
</MenuItem>
)}
{DEBUG_ATTACHMENTS && !!aInput && <ListDivider />}
{/* Destructive Operations */}
{/*<MenuItem onClick={handleCopyOutputToClipboard} disabled={!isOutputTextInlineable}>*/}
{/* <ListItemDecorator><ContentCopyIcon /></ListItemDecorator>*/}
{/* Copy*/}
{/*</MenuItem>*/}
{/*<MenuItem onClick={handleSummarizeText} disabled={!isOutputTextInlineable}>*/}
{/* <ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>*/}
{/* Shrink*/}
{/*</MenuItem>*/}
<MenuItem onClick={handleInlineText} disabled={!isOutputTextInlineable}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Inline text
</MenuItem>
<MenuItem onClick={handleRemove}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Remove
</MenuItem>
</CloseableMenu>
);
}
@@ -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
</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,372 +0,0 @@
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { createBase36Uid } from '~/common/util/textUtils';
import { htmlTableToMarkdown } from '~/common/util/htmlTableToMarkdown';
import { pdfToText } from '~/common/util/pdfUtils';
import type { Attachment, AttachmentConverter, AttachmentId, AttachmentInput, AttachmentSource } from './store-attachments';
import type { ComposerOutputMultiPart } from '../composer.types';
// extensions to treat as plain text
const PLAIN_TEXT_EXTENSIONS: string[] = ['.ts', '.tsx'];
// 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);
if (page.content) {
edit({
input: {
mimeType: 'text/plain',
data: page.content,
dataSize: page.content.length,
},
});
} else
edit({ inputError: 'No content found at this link' });
} catch (error: any) {
edit({ inputError: `Issue downloading page: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
}
break;
// Attach file as input
case 'file':
edit({ label: source.refPath, ref: source.refPath });
// fix missing/wrong mimetypes
let mimeType = source.fileWithHandle.type;
if (!mimeType) {
// see note on 'attachAppendDataTransfer'; this is a fallback for drag/drop missing Mimes sometimes
console.warn('Assuming the attachment is text/plain. From:', source.origin, ', name:', source.refPath);
mimeType = 'text/plain';
} else {
// possibly fix wrongly assigned mimetypes (from the extension alone)
if (!mimeType.startsWith('text/') && PLAIN_TEXT_EXTENSIONS.some(ext => source.refPath.endsWith(ext)))
mimeType = 'text/plain';
}
// UX: just a hint of a loading state
await new Promise(resolve => setTimeout(resolve, 100));
try {
const fileArrayBuffer = await source.fileWithHandle.arrayBuffer();
edit({
input: {
mimeType,
data: fileArrayBuffer,
dataSize: fileArrayBuffer.byteLength,
},
});
} catch (error: any) {
edit({ inputError: `Issue loading file: ${error?.message || (typeof error === 'string' ? error : JSON.stringify(error))}` });
}
break;
case 'text':
if (source.textHtml && source.textPlain) {
edit({
label: 'Rich Text',
ref: '',
input: {
mimeType: 'text/plain',
data: source.textPlain,
dataSize: source.textPlain!.length,
altMimeType: 'text/html',
altData: source.textHtml,
},
});
} else {
const text = source.textHtml || source.textPlain || '';
edit({
label: 'Text',
ref: '',
input: {
mimeType: 'text/plain',
data: text,
dataSize: text.length,
},
});
}
break;
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 converter, got:', typeof input.data);
break;
}
// duplicate the ArrayBuffer to avoid mutation
const pdfData = new Uint8Array(input.data.slice(0));
const pdfText = await pdfToText(pdfData);
outputs.push({
type: 'text-block',
text: pdfText,
title: ref,
collapsible: true,
});
break;
case 'pdf-images':
// TODO: extract all pages as individual images
break;
case 'image':
// TODO: continue here
/*outputs.push({
type: 'image-part',
base64Url: `data:notImplemented.yet:)`,
collapsible: false,
});*/
break;
case 'image-ocr':
if (!(input.data instanceof ArrayBuffer)) {
console.log('Expected ArrayBuffer for Image OCR converter, got:', typeof input.data);
break;
}
try {
const { recognize } = await import('tesseract.js');
const buffer = Buffer.from(input.data);
const result = await recognize(buffer, undefined, {
errorHandler: e => console.error(e),
logger: (message) => {
if (message.status === 'recognizing text')
console.log('OCR progress:', message.progress);
},
});
outputs.push({
type: 'text-block',
text: result.data.text,
title: ref,
collapsible: true,
});
} catch (error) {
console.error(error);
}
break;
case '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[];
getAttachmentOutputs: (initialTextBlockText: string | null, attachmentId: AttachmentId) => ComposerOutputMultiPart;
getAttachmentsOutputs: (initialTextBlockText: string | null) => ComposerOutputMultiPart;
isOutputAttacheable: boolean;
isOutputTextInlineable: boolean;
tokenCountApprox: number;
}
export interface LLMAttachment {
attachment: Attachment;
attachmentOutputs: ComposerOutputMultiPart;
isUnconvertible: boolean;
isOutputMissing: boolean;
isOutputAttachable: boolean;
isOutputTextInlineable: boolean;
tokenCountApprox: number | null;
}
export function useLLMAttachments(attachments: Attachment[], chatLLMId: DLLMId | null): LLMAttachments {
return React.useMemo(() => {
// HACK: in the future, switch to LLM capabilities (LLM_IF_OAI_Chat, LLM_IF_OAI_Vision, etc.)
const supportsImages = !!chatLLMId?.endsWith('-vision-preview');
const supportedOutputPartTypes: ComposerOutputPartType[] = supportsImages ? ['text-block', 'image-part'] : ['text-block'];
const llmAttachments = attachments.map(attachment => toLLMAttachment(attachment, supportedOutputPartTypes, chatLLMId));
const getAttachmentOutputs = (initialTextBlockText: string | null, attachmentId: AttachmentId): ComposerOutputMultiPart => {
// get outputs of a specific attachment
const outputs = attachments.find(a => a.id === attachmentId)?.outputs || [];
return attachmentCollapseOutputs(initialTextBlockText, outputs);
};
const getAttachmentsOutputs = (initialTextBlockText: string | null): ComposerOutputMultiPart => {
// accumulate all outputs of all attachments
const allOutputs = llmAttachments.reduce((acc, a) => acc.concat(a.attachment.outputs), [] as ComposerOutputMultiPart);
return attachmentCollapseOutputs(initialTextBlockText, allOutputs);
};
return {
attachments: llmAttachments,
getAttachmentOutputs,
getAttachmentsOutputs,
isOutputAttacheable: llmAttachments.every(a => a.isOutputAttachable),
isOutputTextInlineable: llmAttachments.every(a => a.isOutputTextInlineable),
tokenCountApprox: llmAttachments.reduce((acc, a) => acc + (a.tokenCountApprox || 0), 0),
};
}, [attachments, chatLLMId]);
}
export function getTextBlockText(outputs: ComposerOutputMultiPart): string | null {
const textOutputs = outputs.filter(part => part.type === 'text-block');
return (textOutputs.length === 1 && textOutputs[0].type === 'text-block') ? textOutputs[0].text : null;
}
function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: ComposerOutputPartType[], llmForTokenCount: DLLMId | null): LLMAttachment {
const { converters, outputs } = attachment;
const isUnconvertible = converters.length === 0;
const isOutputMissing = outputs.length === 0;
const isOutputAttachable = areAllOutputsSupported(outputs, supportedOutputPartTypes);
const isOutputTextInlineable = areAllOutputsSupported(outputs, supportedOutputPartTypes.filter(pt => pt === 'text-block'));
const attachmentOutputs = attachmentCollapseOutputs(null, outputs);
const tokenCountApprox = llmForTokenCount
? attachmentOutputs.reduce((acc, output) => {
if (output.type === 'text-block')
return acc + (countModelTokens(output.text, llmForTokenCount, 'attachments tokens count') ?? 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;
}
@@ -11,10 +11,14 @@ import { animationEnterBelow } from '~/common/util/animUtils';
const desktopLegend =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Combine the answers from multiple models<br />
{/*{platformAwareKeystrokes('Ctrl + Enter')}*/}
<KeyStroke combo='Ctrl + Enter' sx={{ mt: 0.5, mb: 0.25 }} />
</Box>;
const desktopLegendNoContent =
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Enter the text to Beam, then press this
</Box>;
const mobileSx: SxProps = {
mr: { xs: 1, md: 2 },
};
@@ -31,13 +35,13 @@ const desktopSx: SxProps = {
export const ButtonBeamMemo = React.memo(ButtonBeam);
function ButtonBeam(props: { isMobile?: boolean, disabled?: boolean, onClick: () => void }) {
function ButtonBeam(props: { isMobile?: boolean, disabled?: boolean, hasContent?: boolean, onClick: () => void }) {
return props.isMobile ? (
<IconButton variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} sx={mobileSx}>
<ChatBeamIcon />
</IconButton>
) : (
<Tooltip disableInteractive variant='solid' arrow placement='right' title={desktopLegend}>
<Tooltip disableInteractive variant='solid' arrow placement='right' title={props.hasContent ? desktopLegend : desktopLegendNoContent}>
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<ChatBeamIcon />} sx={desktopSx}>
Beam
</Button>
@@ -1,15 +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,
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';
@@ -31,7 +31,7 @@ export function ChatBarAltBeam(props: {
requiresConfirmation: store.isScattering || store.isGatheringAny || store.raysReady > 0,
// actions
setIsMaximized: store.setIsMaximized,
terminateBeam: store.terminate,
terminateBeam: store.terminateKeepingSettings,
})));
@@ -59,20 +59,11 @@ 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 (
<Box sx={{ display: 'flex', gap: { xs: 1, md: 3 }, alignItems: 'center' }}>
{/* [desktop] maximize button, or a disabled spacer */}
{props.isMobile ? null : (
<GoodTooltip title='Maximize'>
<IconButton size='sm' onClick={handleMaximizeBeam}>
<FullscreenRoundedIcon />
</IconButton>
</GoodTooltip>
)}
<Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'center' }}>
{/* Title & Status */}
<Typography level='title-md'>
@@ -89,11 +80,24 @@ export function ChatBarAltBeam(props: {
</Typography>
{/* Right Close Icon */}
<GoodTooltip usePlain title={<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>Close Beam Mode <KeyStroke combo='Esc' /></Box>}>
<IconButton aria-label='Close' size='sm' onClick={handleCloseBeam}>
<CloseRoundedIcon />
</IconButton>
</GoodTooltip>
<Box sx={{ display: 'flex' }}>
{/* [desktop] maximize button, or a disabled spacer */}
{!props.isMobile && (
<GoodTooltip usePlain title={<Box sx={{ p: 1 }}>Maximize</Box>}>
<IconButton size='sm' onClick={handleMaximizeBeam}>
<FullscreenRoundedIcon />
</IconButton>
</GoodTooltip>
)}
<GoodTooltip usePlain title={<Box sx={{ p: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>Back to Chat <KeyStroke combo='Esc' /></Box>}>
<IconButton aria-label='Close' size='sm' onClick={handleCloseBeam}>
<CloseRoundedIcon />
</IconButton>
</GoodTooltip>
</Box>
{/* Confirmation Modal */}
@@ -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 <>
@@ -277,7 +286,6 @@ function ChatDrawer(props: {
<Button
// variant='outlined'
variant={disableNewButton ? undefined : 'soft'}
color='primary'
disabled={disableNewButton}
onClick={handleButtonNew}
sx={{
@@ -285,16 +293,12 @@ function ChatDrawer(props: {
justifyContent: 'flex-start',
padding: '0px 0.75rem',
// text size
fontSize: 'sm',
fontWeight: 'lg',
// style
// backgroundColor: 'background.popup',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
borderRadius: 'sm',
'--ListItemDecorator-size': 'calc(2.5rem - 1px)', // compensate for the border
// backgroundColor: 'background.popup',
// boxShadow: (disableNewButton || props.isMobile) ? 'none' : 'xs',
// transition: 'box-shadow 0.2s',
}}
@@ -315,7 +319,7 @@ function ChatDrawer(props: {
bottomBarBasis={filteredChatsBarBasis}
onConversationActivate={handleConversationActivate}
onConversationBranch={onConversationBranch}
onConversationDelete={handleConversationDeleteNoConfirmation}
onConversationDeleteNoConfirmation={handleConversationDeleteNoConfirmation}
onConversationExport={onConversationsExportDialog}
onConversationFolderChange={handleConversationFolderChange}
/>
@@ -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
@@ -42,7 +43,7 @@ export const ChatDrawerItemMemo = React.memo(ChatDrawerItem, (prev, next) =>
prev.bottomBarBasis === next.bottomBarBasis &&
prev.onConversationActivate === next.onConversationActivate &&
prev.onConversationBranch === next.onConversationBranch &&
prev.onConversationDelete === next.onConversationDelete &&
prev.onConversationDeleteNoConfirmation === next.onConversationDeleteNoConfirmation &&
prev.onConversationExport === next.onConversationExport &&
prev.onConversationFolderChange === next.onConversationFolderChange,
);
@@ -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;
}
@@ -76,7 +79,7 @@ function ChatDrawerItem(props: {
bottomBarBasis: number,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationDelete: (conversationId: DConversationId) => void,
onConversationDeleteNoConfirmation: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId, exportAll: boolean) => void,
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
}) {
@@ -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,14 +164,23 @@ function ChatDrawerItem(props: {
const handleTitleEditAuto = React.useCallback(async () => {
setIsAutoEditingTitle(true);
await conversationAutoTitle(conversationId, true);
await autoConversationTitle(conversationId, true);
setIsAutoEditingTitle(false);
}, [conversationId]);
// Delete
const handleDeleteButtonShow = React.useCallback(() => setDeleteArmed(true), []);
const { onConversationDeleteNoConfirmation } = props;
const handleDeleteButtonShow = React.useCallback((event: React.MouseEvent) => {
// special case: if 'Shift' is pressed, delete immediately
if (event.shiftKey) { // immediately delete:conversation
event.stopPropagation();
onConversationDeleteNoConfirmation(conversationId);
return;
}
setDeleteArmed(true);
}, [conversationId, onConversationDeleteNoConfirmation]);
const handleDeleteButtonHide = React.useCallback(() => setDeleteArmed(false), []);
@@ -163,12 +188,12 @@ function ChatDrawerItem(props: {
if (deleteArmed) {
setDeleteArmed(false);
event.stopPropagation();
props.onConversationDelete(conversationId);
onConversationDeleteNoConfirmation(conversationId);
}
}, [conversationId, deleteArmed, 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;
@@ -176,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',
@@ -202,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
@@ -227,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 && (
@@ -264,6 +297,7 @@ function ChatDrawerItem(props: {
}),
// style
fontSize: 'inherit',
backgroundColor: isActive ? 'neutral.solidActiveBg' : 'neutral.softBg',
borderRadius: 'md',
mx: '0.25rem',
@@ -316,7 +350,7 @@ function ChatDrawerItem(props: {
</FadeInButton>
</Tooltip>
<Tooltip disableInteractive title='Branch'>
<Tooltip disableInteractive title='Duplicate (Branch)'>
<FadeInButton size='sm' onClick={handleConversationBranch}>
<ForkRightIcon />
</FadeInButton>
@@ -36,8 +36,9 @@ export function FolderListItem(props: {
// Menu
const handleMenuOpen = (event: React.MouseEvent<HTMLAnchorElement>) => {
setMenuAnchorEl(event.currentTarget);
const handleMenuToggle = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault(); // added for the Right mouse click (to prevent the menu)
setMenuAnchorEl(anchor => anchor ? null : event.currentTarget);
setDeleteArmed(false); // Reset delete armed state
};
@@ -188,9 +189,11 @@ export function FolderListItem(props: {
{/* Icon to show the Popup menu */}
<IconButton
size='sm'
variant='outlined'
className='menu-icon'
onClick={handleMenuOpen}
onClick={handleMenuToggle}
onContextMenu={handleMenuToggle}
sx={{
visibility: 'hidden',
my: '-0.25rem', /* absorb the button padding */
@@ -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: {
File diff suppressed because it is too large Load Diff
@@ -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>) =>
@@ -0,0 +1,87 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, IconButton, Tooltip, Typography } from '@mui/joy';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ReplyRoundedIcon from '@mui/icons-material/ReplyRounded';
// configuration
const INLINE_COLOR = 'primary';
const bubbleComposerSx: SxProps = {
// contained
width: '100%',
zIndex: 2, // stays on top of the 'tokens' bubble in the composer
// style
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
borderRadius: 'sm',
boxShadow: 'xs',
padding: '0.5rem 0.25rem 0.5rem 0.5rem',
// layout
display: 'flex',
alignItems: 'start',
};
export const inlineMessageBubbleSx: SxProps = {
...bubbleComposerSx,
// redefine
// border: 'none',
// mt: 1,
borderColor: `${INLINE_COLOR}.outlinedColor`,
borderRadius: 'sm',
boxShadow: 'xs',
width: undefined,
padding: '0.375rem 0.25rem 0.375rem 0.5rem',
// 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,
inlineUserMessage?: boolean
onClear?: () => void,
className?: string,
}) {
return (
<Box className={props.className} sx={!props.inlineUserMessage ? bubbleComposerSx : inlineMessageBubbleSx}>
<Tooltip disableInteractive arrow title='Referring to this assistant text' placement='top'>
<ReplyRoundedIcon sx={{
color: props.inlineUserMessage ? `${INLINE_COLOR}.outlinedColor` : 'primary.solidBg',
fontSize: 'xl',
mt: 0.125,
}} />
</Tooltip>
<Typography level='body-sm' sx={{
flex: 1,
ml: 1,
mr: 0.5,
overflow: 'auto',
maxHeight: '5.75rem',
lineHeight: 'xl',
color: /*props.inlineMessage ? 'text.tertiary' :*/ 'text.secondary',
whiteSpace: 'break-spaces', // 'balance'
}}>
{props.replyToText}
</Typography>
{!!props.onClear && (
<IconButton size='sm' onClick={props.onClear} sx={{ my: -0.5, background: 'none' }}>
<CloseRoundedIcon />
</IconButton>
)}
</Box>
);
}
@@ -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,
@@ -10,17 +10,21 @@ import EditNoteIcon from '@mui/icons-material/EditNote';
import SearchIcon from '@mui/icons-material/Search';
import TelegramIcon from '@mui/icons-material/Telegram';
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../data';
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { useChatLLM } from '~/modules/llms/store-llms';
import { DConversationId, 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';
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../data';
import { YouTubeURLInput } from './YouTubeURLInput';
import { usePurposeStore } from './store-purposes';
@@ -116,6 +120,8 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
const [searchQuery, setSearchQuery] = React.useState('');
const [filteredIDs, setFilteredIDs] = React.useState<SystemPurposeId[] | null>(null);
const [editMode, setEditMode] = React.useState(false);
const [isYouTubeTranscriberActive, setIsYouTubeTranscriberActive] = React.useState(false);
// external state
const showFinder = useUIPreferencesStore(state => state.showPersonaFinder);
@@ -154,10 +160,39 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
// Handlers
const handlePurposeChanged = React.useCallback((purposeId: SystemPurposeId | null) => {
if (purposeId && setSystemPurposeId)
setSystemPurposeId(props.conversationId, purposeId);
if (purposeId) {
if (purposeId === 'YouTubeTranscriber') {
// If the YouTube Transcriber tile is clicked, set the state accordingly
setIsYouTubeTranscriberActive(true);
} else {
setIsYouTubeTranscriberActive(false);
}
if (setSystemPurposeId) {
setSystemPurposeId(props.conversationId, purposeId);
}
}
}, [props.conversationId, setSystemPurposeId]);
React.useEffect(() => {
const isTranscriberActive = systemPurposeId === 'YouTubeTranscriber';
setIsYouTubeTranscriberActive(isTranscriberActive);
}, [systemPurposeId]);
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 = createDMessageTextContent('assistant', messageText); // [chat] append assistant:YouTube transcript
// Append the new message to the conversation
appendMessage(conversationId, newMessage);
};
const handleCustomSystemMessageChange = React.useCallback((v: React.ChangeEvent<HTMLTextAreaElement>): void => {
// TODO: persist this change? Right now it's reset every time.
// maybe we shall have a "save" button just save on a state to persist between sessions
@@ -418,6 +453,17 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
/>
)}
{/* [row -1] YouTube URL */}
{isYouTubeTranscriberActive && (
<YouTubeURLInput
onSubmit={(transcript) => handleAppendTranscriptAsMessage(transcript)}
isFetching={false}
sx={{
gridColumn: '1 / -1',
}}
/>
)}
</Box>
</Box>
@@ -0,0 +1,74 @@
import * as React from 'react';
import { Box, Button, Input } from '@mui/joy';
import YouTubeIcon from '@mui/icons-material/YouTube';
import type { SxProps } from '@mui/joy/styles/types';
import { useYouTubeTranscript, YTVideoTranscript } from '~/modules/youtube/useYouTubeTranscript';
interface YouTubeURLInputProps {
onSubmit: (transcript: string) => void;
isFetching: boolean;
sx?: SxProps;
}
export const YouTubeURLInput: React.FC<YouTubeURLInputProps> = ({ onSubmit, isFetching, sx }) => {
const [url, setUrl] = React.useState('');
const [submitFlag, setSubmitFlag] = React.useState(false);
// Function to extract video ID from URL
function extractVideoID(videoURL: string): string | null {
const regExp = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^#&?]*).*/;
const match = videoURL.match(regExp);
return (match && match[1]?.length == 11) ? match[1] : null;
}
const videoID = extractVideoID(url);
// Callback function to handle new transcript
const handleNewTranscript = (newTranscript: YTVideoTranscript) => {
onSubmit(newTranscript.transcript); // Pass the transcript text to the onSubmit handler
setSubmitFlag(false); // Reset submit flag after handling
};
const { transcript, isFetching: isTranscriptFetching, isError, error } = useYouTubeTranscript(videoID && submitFlag ? videoID : null, handleNewTranscript);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUrl(event.target.value);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); // Prevent form from causing a page reload
setSubmitFlag(true); // Set flag to indicate a submit action
};
return (
<Box sx={{ mb: 1, ...sx }}>
<form onSubmit={handleSubmit}>
<Input
required
type='url'
fullWidth
disabled={isFetching || isTranscriptFetching}
variant='outlined'
placeholder='Enter YouTube Video URL'
value={url}
onChange={handleChange}
startDecorator={<YouTubeIcon sx={{ color: '#f00' }} />}
sx={{ mb: 1.5, backgroundColor: 'background.popup' }}
/>
<Button
type='submit'
variant='solid'
disabled={isFetching || isTranscriptFetching || !url}
loading={isFetching || isTranscriptFetching}
sx={{ minWidth: 140 }}
>
Get Transcript
</Button>
{isError && <div>Error fetching transcript. Please try again.</div>}
</form>
</Box>
);
};
@@ -18,7 +18,7 @@ export const usePurposeStore = create<PurposeStore>()(
(set) => ({
// default state
hiddenPurposeIDs: ['Developer', 'Designer'],
hiddenPurposeIDs: ['Developer', 'Designer', 'YouTubeTranscriber'],
toggleHiddenPurposeId: (purposeId: string) => {
set(state => {
@@ -37,14 +37,19 @@ export const usePurposeStore = create<PurposeStore>()(
/* versioning:
* 1: hide 'Developer' as 'DeveloperPreview' is best
* 2: add a hidden 'YouTubeTranscriber' purpose
*/
version: 1,
version: 2,
migrate: (state: any, fromVersion: number): PurposeStore => {
// 0 -> 1: rename 'enterToSend' to 'enterIsNewline' (flip the meaning)
if (state && fromVersion === 0)
if (!state.hiddenPurposeIDs.includes('Developer'))
state.hiddenPurposeIDs.push('Developer');
// 1 -> 2: add a hidden 'YouTubeTranscriber' purpose
if (state && fromVersion === 1)
if (!state.hiddenPurposeIDs.includes('YouTubeTranscriber'))
state.hiddenPurposeIDs.push('YouTubeTranscriber');
return state;
},
}),
+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;
}
}

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