Compare commits

...

162 Commits

Author SHA1 Message Date
Enrico Ros 3d39a35c03 Release fix: decrease visual clutter 2024-02-07 23:52:24 -08:00
Enrico Ros 5ca9475bb6 1.13.0: Update README 2024-02-07 23:50:07 -08:00
Enrico Ros f12386c614 Merge branch 'release-1.13.0' 2024-02-07 23:47:29 -08:00
Enrico Ros 485dd0d91f 1.13.0: README and Changelog 2024-02-07 23:46:51 -08:00
Enrico Ros fc137176bd 1.13.0: Rename 2024-02-07 23:22:25 -08:00
Enrico Ros b34fe2f9f6 1.13.0: Disable Draw & Workspace for release 2024-02-07 23:16:07 -08:00
Enrico Ros 3b7916c536 1.13.0: Fix date 2024-02-07 23:13:22 -08:00
Enrico Ros d11a2b59ee Move release Covers 2024-02-07 23:13:15 -08:00
Enrico Ros 63d1ec4c30 Fix Cover Image sizing to absorb the border 2024-02-07 23:01:10 -08:00
Enrico Ros 4ed49be67e 1.13.0: Cover Image 2024-02-07 23:00:54 -08:00
Enrico Ros 3a0749c5b2 1.13.0: News 2024-02-07 22:16:10 -08:00
Enrico Ros 63470adc0f Explicitly call out the code line height 2024-02-07 21:35:50 -08:00
Enrico Ros 0bbfad4b41 1.13.0: Version 2024-02-07 20:58:07 -08:00
Enrico Ros f9cb97ca49 For later 2024-02-07 20:57:49 -08:00
Enrico Ros b63636cf2f Style 2024-02-07 20:51:44 -08:00
Enrico Ros 54b388c9ae Reorder Developer2 2024-02-07 18:14:25 -08:00
Enrico Ros d233f0946f Zen mode: do not show chat list underbars 2024-02-07 18:13:27 -08:00
Enrico Ros 671ac36946 PMix: notes 2024-02-07 18:11:58 -08:00
Enrico Ros e6ba217302 PMix: improve local time 2024-02-07 18:02:20 -08:00
Enrico Ros b9a18a5442 Dev2: add icon 2024-02-07 17:53:39 -08:00
Enrico Ros f8d0f25f72 On mobile, auto-fit mermaid and PlantUML by default. 2024-02-07 17:45:46 -08:00
Enrico Ros 2213c61760 Reuse 2024-02-07 17:36:43 -08:00
Enrico Ros e7edffa237 Add a Dev2 Example/Preview 2024-02-07 17:33:47 -08:00
Enrico Ros fd83aca7a4 Bare bones prompt mixer 2024-02-07 17:33:30 -08:00
Enrico Ros bdc2d07747 PersonaSelector: show prompt 2024-02-07 17:03:36 -08:00
Enrico Ros 1953f7d31a Can Scale (up/dn) SVG, Mermaid and PlantUMLs 2024-02-07 09:57:53 -08:00
Enrico Ros 054ed80bbe GitHub Markdown style: scaleable spacing. #399 2024-02-07 09:08:58 -08:00
Enrico Ros 13b64e65c3 Dynamic Text Size switching. Fixes #399 2024-02-07 09:07:19 -08:00
Enrico Ros ee9ee72505 Fix a few styling issues on the blocks 2024-02-07 07:59:19 -08:00
Enrico Ros 1b631a91b3 Improve Markdown rendering spacing. Blocks break the top/bottom margins. 2024-02-07 07:52:17 -08:00
Enrico Ros 118d2cb2ad Nits. 2024-02-07 07:26:03 -08:00
Enrico Ros b6acfa9d49 LM Studio Config: add @techfren's video 2024-02-07 06:59:46 -08:00
Enrico Ros 4798ba3fd0 Dynamic Video Player 2024-02-07 06:59:00 -08:00
Enrico Ros 14608f97da Roll packages - hold back Joy which depends on yet another version of MUI 2024-02-07 06:29:48 -08:00
Enrico Ros 901d590159 Update config-lmstudio.md 2024-02-07 06:10:50 -08:00
Enrico Ros 28e71d4ac7 LMStudio: make the doc and link the Video by @techfren 2024-02-07 05:36:15 -08:00
Enrico Ros 7f958c9e66 Multi-Chats: super-power to create new 2024-02-07 04:25:09 -08:00
Enrico Ros 910f0c5556 New Chats: improve appearance 2024-02-07 04:25:08 -08:00
Enrico Ros 427ef8c108 MultiChat: show where windows are open, nicely 2024-02-07 04:08:23 -08:00
Enrico Ros 2efdfca7e5 MultiChat: improve color, to better relate to the drawer 2024-02-07 04:00:56 -08:00
Enrico Ros bc113b08f7 Do a better job at signaling which window is where 2024-02-07 03:59:39 -08:00
Enrico Ros 262a6d2560 Bring branch with split 2024-02-07 03:31:15 -08:00
Enrico Ros f9224aa25d Split-open: say it's already open 2024-02-07 03:29:11 -08:00
Enrico Ros 6d0f7949f8 Persona Selector: show newly missing 2024-02-07 03:29:10 -08:00
Enrico Ros 1a679bcf90 Use the Memo RenderMarkdown 2024-02-07 03:06:52 -08:00
Enrico Ros 5de34fe3af Split Screen: Duplicate into new (but disable this while testing it) 2024-02-07 03:02:13 -08:00
Enrico Ros 420b4565dd Add a command (/clear all) to reset chats. 2024-02-07 02:23:47 -08:00
Enrico Ros 27eb9adb16 Memo code and markdown rendering for the current message. Shall help vigorously. #402. It's a tradeoff with mem tho. 2024-02-07 02:05:10 -08:00
Enrico Ros c4277b9ef0 Optimization on the message being typed - recycles references to speed up React. Fixes #402 2024-02-07 01:53:28 -08:00
Enrico Ros ec39c58474 Message Render: cleanup diffing pipeline 2024-02-07 01:31:07 -08:00
Enrico Ros 3ce2e86a66 Reminders for #401 2024-02-07 00:56:59 -08:00
Enrico Ros d62757d94a Blocks Renderers: extraction, cleanups, more maintainable and optimized 2024-02-07 00:15:58 -08:00
Enrico Ros 7ba315c796 Font Size: UI Setting 2024-02-06 21:34:36 -08:00
Enrico Ros 75e909e0e7 Font Size: add persisted variable 2024-02-06 21:33:41 -08:00
Enrico Ros 285c6a3fac Update Labels width 2024-02-06 21:26:55 -08:00
Enrico Ros 9bcdbf8db6 PersonaSelector: support for imageUri 2024-02-06 20:17:18 -08:00
Enrico Ros ae9d85d2cd Fix accessibility 2024-02-06 20:09:17 -08:00
Enrico Ros ad3191fcaf Optimize with negligible loss of functionality 2024-02-06 19:39:32 -08:00
Enrico Ros d6c98bd304 Models: auto symlink labeling 2024-02-06 17:35:09 -08:00
Enrico Ros 52c1be20d9 Update knowledge cutoff function 2024-02-06 17:27:03 -08:00
Enrico Ros 69fb879439 Update default models 2024-02-06 17:26:23 -08:00
Enrico Ros 135153464a Fix build. 2024-02-06 17:11:23 -08:00
Enrico Ros 87e556d6c4 PersonaSelector: collapse examples on Custom 2024-02-06 16:54:08 -08:00
Enrico Ros 46866ac061 PersonaSelector: fix h-scroll 2024-02-06 16:51:38 -08:00
Enrico Ros 9f222caadf Increase resiliency, and relax deletion/creation of new chats. 2024-02-06 16:42:49 -08:00
Enrico Ros f82ac7a476 PersonaSelector: improve highlight 2024-02-06 15:58:42 -08:00
Enrico Ros 4fa5d875e9 PersonaSelector: animated collapse 2024-02-06 15:47:33 -08:00
Enrico Ros e2b1c6aff0 PersonaSelector: toggleable examples 2024-02-06 15:40:05 -08:00
Enrico Ros 16b25fcc1f PersonaSelector: recycle tile 2024-02-06 15:13:00 -08:00
Enrico Ros 17cd765d00 PersonaSelector: style 2024-02-06 14:53:57 -08:00
Enrico Ros 1ea8b42e5f PersonaSelector: smaller tiles 2024-02-06 14:53:57 -08:00
Enrico Ros 6b5a207522 Merge pull request #397 from oblivio/main
Update config-database.md
2024-02-06 06:53:55 -08:00
Enrico Ros 85d5fef3fb Further improve the Persona selector 2024-02-06 06:12:46 -08:00
Enrico Ros e9a77abd83 Nit 2024-02-06 05:28:21 -08:00
Enrico Ros 9d2857d41e Persona Selector: improve layouts 2024-02-06 05:24:59 -08:00
Enrico Ros 62e71307d0 Explain Shift+Enter 2024-02-06 03:23:47 -08:00
Enrico Ros f517f12b7e Composer: improve layout (but keep the grid that stacks on mobile, for now) 2024-02-06 03:05:06 -08:00
Enrico Ros 510b1d178d MultiChat: button on mobile 2024-02-06 02:05:11 -08:00
Enrico Ros 890e8afd47 Fix for issue reported by @frigjord 2024-02-06 01:03:36 -08:00
Enrico Ros c25ce6db9d Multiple panes splits 2024-02-06 00:25:00 -08:00
Enrico Ros ec789de1d1 Improve the 'Clear folder' and no-folders appearance 2024-02-05 22:56:27 -08:00
Enrico Ros e96ac16d85 Branch: assign to the same folder 2024-02-05 22:51:53 -08:00
Enrico Ros 9d6fe97b11 Assign to folder 2024-02-05 22:51:36 -08:00
Enrico Ros 8e90552fec PageBarDropdowns: extensive improvements 2024-02-05 22:42:45 -08:00
Fabian Valle 71c8d5527e Update config-database.md
Include specific changes required when using MongoDB Atlas. The LinkStorage model needs to change, as well as the db in the Prisma configuration.
2024-02-05 22:49:00 -05:00
Enrico Ros 9fef95303a News: fix CLS 2024-02-05 18:28:38 -08:00
Enrico Ros 8458da826e Merge branch 'main-stable' 2024-02-05 18:15:16 -08:00
Enrico Ros df59f5eb6b News: improve layout, move roadmap as the second item 2024-02-05 18:15:10 -08:00
Enrico Ros 7c0ec8677f News: improve layout, move roadmap as the second item 2024-02-05 18:10:32 -08:00
Enrico Ros 2e23026690 Support for Cover images for releases
(cherry picked from commit 7bc110820e)
2024-02-05 18:10:29 -08:00
Enrico Ros 7bc110820e Support for Cover images for releases 2024-02-05 17:08:16 -08:00
Enrico Ros d3cddd5b60 Merge pull request #393 from oblivio/main
MongoDB Atlas Support
2024-02-05 14:17:32 -08:00
Fabian Valle 24cff721dc update database docs 2024-02-05 10:40:45 -05:00
Fabian Valle 054df44e05 update database docs 2024-02-05 10:39:56 -05:00
Fabian Valle 2dc3af3761 update database docs 2024-02-05 10:38:50 -05:00
Fabian Valle 3d9bf70c85 update database docs 2024-02-05 10:34:44 -05:00
Fabian Valle 30f4f6e7b8 update database docs 2024-02-05 10:33:25 -05:00
Enrico Ros c5c71859f9 Merge branch 'main-stable' 2024-02-05 01:39:05 -08:00
Enrico Ros b1a12d88a1 Delay the Models dialog to the idle cycles (for CLS) 2024-02-05 01:38:53 -08:00
Enrico Ros 78d06e79a5 Merge branch 'main-stable' 2024-02-05 00:07:27 -08:00
Enrico Ros 7580f1526f Optimize Persona Selector (includes fixing CLS). 2024-02-05 00:07:15 -08:00
Fabian Valle 198e76c291 update documentation to explain how to setup MongoDB by modifying the schema.prisma file 2024-02-04 21:10:58 -05:00
Fabian Valle f47bb1484c modify prisma back to original for backwards compatibility 2024-02-04 21:10:23 -05:00
Enrico Ros 91f5136e29 Clarify News button 2024-02-04 15:08:47 -08:00
Enrico Ros da3be58eec Move Files in Chats 2024-02-04 14:20:59 -08:00
Fabian Valle 94432b496b Update env.mjs
+MDB_URI
2024-02-04 10:57:06 -05:00
Fabian Valle eab2550b88 Update backend.router.ts
+MDB_URI
2024-02-04 10:55:56 -05:00
Fabian Valle 179a496737 Update schema.prisma
+MDB_URI
2024-02-04 10:54:47 -05:00
Fabian Valle 8f62c2ab78 Update environment-variables.md
+MDB_URI
2024-02-04 10:53:45 -05:00
Enrico Ros 9eaee22e3b Optimize rendering of PageBarDropdowns 2024-02-04 04:18:20 -08:00
Enrico Ros 2bdfe8399d Optimize rendering of DrawerItems - the Memo is working now 2024-02-04 03:15:00 -08:00
Enrico Ros 001570464c Show Split chats in the Drawer. Fixes #389 2024-02-04 01:58:27 -08:00
Enrico Ros 90e77010bb Merge branch 'aj47-main' 2024-02-03 21:55:56 -08:00
Enrico Ros 6b73294186 Style the button 2024-02-03 21:55:23 -08:00
Enrico Ros 101237aa75 Merge branch 'main' of https://github.com/aj47/big-AGI into aj47-main 2024-02-03 21:05:31 -08:00
Arash Joobandi 8d3377aeb3 misssing commit 2024-02-04 15:51:21 +11:00
Arash Joobandi 3ad350b10b implement react-csv download 2024-02-04 15:49:47 +11:00
Arash Joobandi ce00480d99 add download csv button 2024-02-04 15:31:41 +11:00
Enrico Ros 2e7f2b6004 Rename 'broadcast' to 'multicast' in code, and much improve the Panes and Multicase modes - #388 2024-02-03 19:59:27 -08:00
Enrico Ros aad0eae1b2 Split Chats: Broadcast mode. Fixes #388 2024-02-03 18:40:41 -08:00
Enrico Ros be3e64b1aa Improve Chat Page Menu 2024-02-03 15:46:47 -08:00
Enrico Ros c089ea7499 Chat Split: land. Controls in Page Menu. Fixes #208 2024-02-03 15:31:59 -08:00
Enrico Ros 190010b3e3 Uniform PageMenu (vs. ChatMessage Menu) looks 2024-02-03 14:53:15 -08:00
Enrico Ros 4dcdc175ee Style: slightly smaller radios 2024-02-03 14:46:17 -08:00
Enrico Ros 35fe54c713 Let's just do the opposite, shall we 2024-02-03 14:45:51 -08:00
Enrico Ros fd22d55835 Split view: layout panes vertically on mobile 2024-02-03 14:32:23 -08:00
Enrico Ros c978d78bd4 Improve Menus fit on mobile 2024-02-03 14:22:36 -08:00
Enrico Ros fb488596b8 Fix build 2024-02-03 04:55:18 -08:00
Enrico Ros 9edfa48e23 Split panes: perfect radius 2024-02-03 04:44:07 -08:00
Enrico Ros 25360c5fba Fix drag to resize chat panes: close on drag, 'gray-out' effect, perfect duplicate. 2024-02-03 04:08:33 -08:00
Enrico Ros e8ed346f20 Move Vendor icons 2024-02-03 03:17:10 -08:00
Enrico Ros 507a35a826 Panes: correctly remove when un-splitting 2024-02-03 02:08:29 -08:00
Enrico Ros e604cf97ae Rename closeOpsMenu 2024-02-03 02:08:29 -08:00
Enrico Ros 510753ae1c UXLabs: improve settings 2024-02-03 01:21:10 -08:00
Enrico Ros 828dfb56a2 Screenshots: attach window/screen captures (in 'labs' mode for now). Fixes #387 2024-02-03 01:13:06 -08:00
Enrico Ros 843a8dcd69 HTML5Video ops: use async/await 2024-02-03 00:33:52 -08:00
Enrico Ros 53255d5524 Extract HTML5 Video Frame rendering utils 2024-02-03 00:19:13 -08:00
Enrico Ros 0f8a5149b5 Readme: remove outdated screenshots 2024-02-02 16:30:52 -08:00
Enrico Ros 442d7e5fb5 Readme: update picture 2024-02-02 16:26:05 -08:00
Enrico Ros 11011d5367 OpenAI: improve model sorting, and update the 3.5-Turbo symlink and 3.5 0125 model description. Fixes #380 2024-02-02 16:15:33 -08:00
Enrico Ros b80afca458 Improve Export/Import looks and behavior - Fixes #375 2024-02-02 16:01:30 -08:00
Enrico Ros a93d9aab08 Roll packages 2024-02-02 15:10:11 -08:00
Enrico Ros 721d31d98d Uniform Menu appearances (smaller icons, dense by default). Fixes #382 2024-02-02 15:06:45 -08:00
Enrico Ros 8d83cff966 Share ZIndex 2024-02-02 15:06:44 -08:00
Enrico Ros 7643ee7749 ChatMessage: reorder operation menu items 2024-02-02 15:06:44 -08:00
Enrico Ros 78b0d5eb96 Draw: multiplier (mock) 2024-02-01 02:56:45 -08:00
Enrico Ros 517252240a Draw: roll placeholder 2024-02-01 02:45:53 -08:00
Enrico Ros 173635cfd1 Draw: show count 2024-02-01 02:45:09 -08:00
Enrico Ros 051a05435e Draw: Vector indicator 2024-02-01 02:44:15 -08:00
Enrico Ros cb367596d1 Draw: improve layout 2024-02-01 02:15:42 -08:00
Enrico Ros 37de238f92 Workspace: add placeholder 2024-02-01 01:09:25 -08:00
Enrico Ros b977c0e31c Call: recolor PTT 2024-02-01 01:06:56 -08:00
Enrico Ros f58c4ec8d7 Call: use <ScrollToBottom/> 2024-02-01 01:04:50 -08:00
Enrico Ros 48b0815363 Hamburger: animate click 2024-02-01 00:22:28 -08:00
Enrico Ros 4f15c9f749 Roll packages 2024-02-01 00:04:38 -08:00
Enrico Ros 7dd5175063 Merge branch 'main-stable' 2024-01-30 14:48:52 -08:00
Enrico Ros cb9c6739cb Avoid 404s on this asset 2024-01-30 14:48:40 -08:00
Enrico Ros e541430891 Roll packages 2024-01-30 02:53:59 -08:00
Enrico Ros 60057716ae LLMOptions: fix corners 2024-01-29 18:03:17 -08:00
Enrico Ros f684442cc0 Update text description 2024-01-29 17:27:02 -08:00
Enrico Ros d4246d305e Draw: Commit to v1.13 2024-01-29 17:21:40 -08:00
Enrico Ros d13fafb2da Azure: improve model naming for deployments named exactly after OpenAI models. 2024-01-29 17:10:35 -08:00
141 changed files with 4073 additions and 2096 deletions
+14 -12
View File
@@ -21,7 +21,19 @@ shows the current developments and future ideas.
- Got a suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
- Want to contribute? [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
### What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
- **Side-by-Side Split Windows**: multitask with parallel conversations. [#208](https://github.com/enricoros/big-AGI/issues/208)
- **Multi-Chat Mode**: message everyone, all at once. [#388](https://github.com/enricoros/big-AGI/issues/388)
- **Export tables as CSV** - big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
- **Adjustable Text Size**: enjoy denser chats. [#399](https://github.com/enricoros/big-AGI/issues/399)
- Dev2 Persona Technology Preview
- 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-lmstudio.md), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/config-database.md) (thanks @ranfysvalle02), and speedups
### What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
@@ -45,21 +57,11 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
- Enable adding up to five custom OpenAI-compatible endpoints
- Developer enhancements: new 'Actiles' framework
### What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI
- **New UI**: for both desktop and mobile, sets the stage for future scale. [#201](https://github.com/enricoros/big-AGI/issues/201)
- **Conversation Folders**: enhanced conversation organization. [#321](https://github.com/enricoros/big-AGI/issues/321)
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
- Resizable panes in split-screen conversations.
- Large performance optimizations
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
For full details and former releases, check out the [changelog](docs/changelog.md).
## ✨ Key Features 👊
![Ask away, paste a ton, copy the gems](docs/pixels/big-AGI-compo1.png)
[More](docs/pixels/big-AGI-compo2b.png), [screenshots](docs/pixels).
![big-AGI screenshot](docs/pixels/big-AGI-compo-20240201_small.png)
- **AI Personas**: Tailor your AI interactions with customizable personas
- **Sleek UI/UX**: A smooth, intuitive, and mobile-responsive interface
+12
View File
@@ -10,6 +10,18 @@ by release.
- milestone: [1.13.0](https://github.com/enricoros/big-agi/milestone/13)
- work in progress: [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), [help here](https://github.com/users/enricoros/projects/4/views/4)
## What's New in 1.13.0 · Feb 8, 2024 · Multi + Mind
https://github.com/enricoros/big-AGI/assets/32999/01732528-730e-41dc-adc7-511385686b13
- **Side-by-Side Split Windows**: multitask with parallel conversations. [#208](https://github.com/enricoros/big-AGI/issues/208)
- **Multi-Chat Mode**: message everyone, all at once. [#388](https://github.com/enricoros/big-AGI/issues/388)
- **Export tables as CSV** - big thanks to @aj47. [#392](https://github.com/enricoros/big-AGI/pull/392)
- **Adjustable Text Size**: enjoy denser chats. [#399](https://github.com/enricoros/big-AGI/issues/399)
- Dev2 Persona Technology Preview
- 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-lmstudio.md), [MongoDB support](https://github.com/enricoros/big-AGI/blob/main/docs/config-database.md) (thanks @ranfysvalle02), and speedups
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
+65
View File
@@ -0,0 +1,65 @@
**Connecting Your Database for Enhanced Features:**
This guide outlines the database options and setup steps for enabling features like Chat Link Sharing in your application.
### Choose Your Database:
**1. Serverless Postgres (default):**
- Available on Vercel, Neon, and other platforms.
- Less feature-rich but a suitable option depending on your needs.
- **Connection String:** Replace placeholders with your Postgres credentials.
- `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15`
**2. MongoDB Atlas (alternative):**
- **Highly Recommended:** More than a database, it's a data platform. MongoDB Atlas is a robust cloud-based platform that offers scalability, security, and a suite of developer tools. No need for a separate vector database, you can query your vector embeddings right within your operational database!
- **Additional Features:** MongoDB Atlas is packed with unique features designed to streamline the development process such as: Atlas App Services, Atlas search (with vector search), Atlas charts, Data Federation, and more.
- **Connection String:** Replace placeholders with your Atlas credentials.
- `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority`
### Environment Variables:
#### Postgres:
| Variable | |
|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `POSTGRES_PRISMA_URL` | `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15` |
| `POSTGRES_URL_NON_POOLING` (optional) | URL for the Postgres database without pooling (specific use cases) |
#### MongoDB:
| Variable | |
|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `MDB_URI` | `mongodb://USER:PASS@CLUSTER-NAME.mongodb.net/DATABASE-NAME?retryWrites=true&w=majority` |
### MongoDB Atlas + Prisma
When using MongoDB Atlas, you'll need to make the below changes to the file `prisma.schema`
```
...
datasource db {
provider = "mongodb"
url = env("MDB_URI")
}
//
// Storage of Linked Data
//
model LinkStorage {
id String @id @default(uuid()) @map("_id")
// ...rest of file
```
### Initial Setup Steps:
1. **Run `npx prisma db:push`:** Create or update the database schema (run once after connecting).
### Additional Resources:
- Prisma documentation: [https://www.prisma.io/docs/](https://www.prisma.io/docs/)
- MongoDB Atlas: [https://www.mongodb.com/atlas/database](https://www.mongodb.com/atlas/database)
- Atlas App Services: [https://www.mongodb.com/docs/atlas/app-services/](https://www.mongodb.com/docs/atlas/app-services/)
- Atlas vector search: [https://www.mongodb.com/products/platform/atlas-vector-search/](https://www.mongodb.com/products/platform/atlas-vector-search)
- Atlas Data Federation: [https://www.mongodb.com/products/platform/atlas-data-federation](https://www.mongodb.com/products/platform/atlas-data-federation)
+51
View File
@@ -0,0 +1,51 @@
# Integrating LM Studio with big-AGI
Quickly set up LM Studio with big-AGI to run local and open LLMs on your computer for enhanced privacy and control over AI interactions.
## Video Tutorial
For a visual step-by-step guide, watch our [YouTube tutorial](https://www.youtube.com/watch?v=MqXzxVokMDk).
[![Running big-AGI locally with LM Studio YouTube Tutorial](http://img.youtube.com/vi/MqXzxVokMDk/0.jpg)](http://www.youtube.com/watch?v=MqXzxVokMDk "Running big-AGI locally with LM Studio")
## Quick Setup Guide
### Installing big-AGI
Clone and set up big-AGI:
```bash
git clone https://github.com/enricoros/big-agi.git && cd big-agi
npm install # Or: yarn install
npm run dev # Or: yarn dev
# If missing dependencies:
npm install @mui/material # Or: yarn add @mui/material
```
### Configuring LM Studio
Ensure LM Studio is running (default: [http://localhost:1234](http://localhost:1234)).
Check the URL and modify if different.
1. Download local models in LM Studio
2. Start the LM Studio server
3. Optionally. Check the logs
### Integration in big-AGI
1. In big-AGI, navigate to **Models** > **Add** > **LM Studio**
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
## Troubleshooting
- **Missing @mui/material**: Execute `npm install @mui/material` or `yarn add @mui/material`
- **Connection Issues**: Check LM Studio's URL and ensure it's operational
## Further Assistance
Advanced configurations and more:
- big-AGI Community: [Discord](https://discord.gg/MkH4qj2Jp9)
- LM Studio: [LM Studio home page](https://lmstudio.ai/)
+6 -10
View File
@@ -12,10 +12,13 @@ Environment variables can be set by creating a `.env` file in the root directory
The following is an example `.env` for copy-paste convenience:
```bash
# Database
# Database (Postgres)
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
# Database (MongoDB)
MDB_URI=
# LLMs
OPENAI_API_KEY=
OPENAI_API_HOST=
@@ -57,16 +60,9 @@ HTTP_BASIC_AUTH_PASSWORD=
### Database
To enable features such as Chat Link Shring, you need to connect the backend to a database. We require
serverless Postgres, which is available on Vercel, Neon and more.
For Database configuration see [config-database.md](config-database.md).
Also make sure that you run `npx prisma db:push` to create the initial schema on the database for the
first time (or update it on a later stage).
| Variable | Description |
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `POSTGRES_PRISMA_URL` | The URL of the Postgres database used by Prisma - example: `postgres://USER:PASS@SOMEHOST.postgres.vercel-storage.com/SOMEDB?pgbouncer=true&connect_timeout=15` |
| `POSTGRES_URL_NON_POOLING` | The URL of the Postgres database without pooling |
To enable features such as Chat Link Sharing, you need to connect the backend to a database. We currently support Postgres and MongoDB.
### LLMs
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

+410 -304
View File
File diff suppressed because it is too large Load Diff
+15 -12
View File
@@ -1,6 +1,6 @@
{
"name": "big-agi",
"version": "1.12.0",
"version": "1.13.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -17,10 +17,10 @@
"@emotion/react": "^11.11.3",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.6",
"@mui/joy": "^5.0.0-beta.24",
"@mui/icons-material": "^5.15.8",
"@mui/joy": "5.0.0-beta.25",
"@next/bundle-analyzer": "^14.1.0",
"@prisma/client": "^5.8.1",
"@prisma/client": "^5.9.1",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.8.0",
"@tanstack/react-query": "~4.36.1",
@@ -28,8 +28,8 @@
"@trpc/next": "10.44.1",
"@trpc/react-query": "10.44.1",
"@trpc/server": "10.44.1",
"@vercel/analytics": "^1.1.2",
"@vercel/speed-insights": "^1.0.8",
"@vercel/analytics": "^1.1.3",
"@vercel/speed-insights": "^1.0.9",
"browser-fs-access": "^0.35.0",
"eventsource-parser": "^1.1.1",
"idb-keyval": "^6.2.1",
@@ -40,35 +40,38 @@
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-csv": "^2.2.2",
"react-dom": "^18.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^1.0.9",
"react-player": "^2.14.1",
"react-resizable-panels": "^2.0.3",
"react-timeago": "^7.2.0",
"remark-gfm": "^4.0.0",
"superjson": "^2.2.1",
"tesseract.js": "^5.0.4",
"tiktoken": "^1.0.11",
"tiktoken": "^1.0.13",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zustand": "^4.5.0"
},
"devDependencies": {
"@cloudflare/puppeteer": "^0.0.5",
"@types/node": "^20.11.7",
"@types/node": "^20.11.16",
"@types/nprogress": "^0.2.3",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.48",
"@types/react": "^18.2.55",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-csv": "^1.1.10",
"@types/react-dom": "^18.2.18",
"@types/react-katex": "^3.0.4",
"@types/react-timeago": "^4.1.7",
"@types/uuid": "^9.0.8",
"eslint": "^8.56.0",
"eslint-config-next": "^14.1.0",
"prettier": "^3.2.4",
"prisma": "^5.8.1",
"prettier": "^3.2.5",
"prisma": "^5.9.1",
"typescript": "^5.3.3"
},
"engines": {
+1 -1
View File
@@ -24,7 +24,7 @@ export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
<link rel='shortcut icon' href='/favicon.ico' />
<link rel='icon' type='image/png' sizes='32x32' href='/icons/favicon-32x32.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/icons/favicon-16x16.png' />
<link rel='apple-touch-icon' sizes='180x180' href='/icons/apple-touch-icon.png' />
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
<link rel='manifest' href='/manifest.json' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-status-bar-style' content='black' />
+12
View File
@@ -0,0 +1,12 @@
import * as React from 'react';
import { Box } from '@mui/joy';
// import { AppWorkspace } from '../src/apps/personas/AppWorkspace';
import { withLayout } from '~/common/layout/withLayout';
export default function PersonasPage() {
return withLayout({ type: 'optima' }, <Box />);
}

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+68 -40
View File
@@ -9,7 +9,9 @@ import MicIcon from '@mui/icons-material/Mic';
import MicNoneIcon from '@mui/icons-material/MicNone';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import { useChatLLMDropdown } from '../chat/components/applayout/useLLMDropdown';
import { ScrollToBottom } from '../chat/components/scroll-to-bottom/ScrollToBottom';
import { ScrollToBottomButton } from '../chat/components/scroll-to-bottom/ScrollToBottomButton';
import { useChatLLMDropdown } from '../chat/components/useLLMDropdown';
import { EXPERIMENTAL_speakTextStream } from '~/modules/elevenlabs/elevenlabs.client';
import { SystemPurposeId, SystemPurposes } from '../../data';
@@ -313,52 +315,74 @@ export function Telephone(props: {
{/* Live Transcript, w/ streaming messages, audio indication, etc. */}
{(isConnected || isEnded) && (
<Card variant='soft' sx={{
<Card variant='outlined' sx={{
flexGrow: 1,
maxHeight: '24%',
minHeight: '15%',
maxHeight: '28%',
minHeight: '20%',
width: '100%',
// style
backgroundColor: 'background.surface',
// backgroundColor: 'background.surface',
borderRadius: 'lg',
boxShadow: 'sm',
// boxShadow: 'sm',
// children
display: 'flex', flexDirection: 'column-reverse',
overflow: 'auto',
padding: 0, // move this to the ScrollToBottom component
}}>
{/* Messages in reverse order, for auto-scroll from the bottom */}
<Box sx={{ display: 'flex', flexDirection: 'column-reverse', gap: 1 }}>
<ScrollToBottom
// bootToBottom
stickToBottom
sx={{
// allows the content to be scrolled (all browsers)
overflowY: 'auto',
// actually make sure this scrolls & fills
height: '100%',
{/* Listening... */}
{isRecording && (
<CallMessage
text={<>{speechInterim?.transcript ? speechInterim.transcript + ' ' : ''}<i>{speechInterim?.interimTranscript}</i></>}
variant={isRecordingSpeech ? 'solid' : 'outlined'}
role='user'
/>
)}
// content
display: 'grid',
padding: 1,
}}
>
{/* Persona streaming text... */}
{!!personaTextInterim && (
<CallMessage
text={personaTextInterim}
variant='solid' color='neutral'
role='assistant'
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Messages (last 6 messages, in reverse order) */}
{callMessages.slice(-6).reverse().map((message) =>
<CallMessage
key={message.id}
text={message.text}
variant={message.role === 'assistant' ? 'solid' : 'soft'} color='neutral'
role={message.role} />,
)}
</Box>
{/* Call Messages [] */}
{callMessages.map((message) =>
<CallMessage
key={message.id}
text={message.text}
variant={message.role === 'assistant' ? 'solid' : 'soft'}
color={message.role === 'assistant' ? 'neutral' : 'primary'}
role={message.role} />,
)}
{/* Persona streaming text... */}
{!!personaTextInterim && (
<CallMessage
text={personaTextInterim}
variant='outlined'
color='neutral'
role='assistant'
/>
)}
{/* Listening... */}
{isRecording && (
<CallMessage
text={<>{speechInterim?.transcript.trim() || null}{speechInterim?.interimTranscript.trim() ? <i> {speechInterim.interimTranscript}</i> : null}</>}
variant={(isRecordingSpeech || !!speechInterim?.transcript) ? 'soft' : 'outlined'}
color='primary'
role='user'
/>
)}
</Box>
{/* Visibility and actions are handled via Context */}
<ScrollToBottomButton />
</ScrollToBottom>
</Card>
)}
@@ -371,11 +395,15 @@ export function Telephone(props: {
{/* [Calling] Hang / PTT (mute not enabled yet) */}
{isConnected && <CallButton Icon={CallEndIcon} text='Hang up' color='danger' variant='soft' onClick={handleCallStop} />}
{isConnected && (pushToTalk
? <CallButton Icon={MicIcon} onClick={toggleRecording}
text={isRecordingSpeech ? 'Listening...' : isRecording ? 'Listening' : 'Push To Talk'}
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'} sx={!isRecording ? { backgroundColor: 'background.surface' } : undefined} />
: null
{isConnected && (pushToTalk ? (
<CallButton
Icon={MicIcon} onClick={toggleRecording}
text={isRecordingSpeech ? 'Listening...' : isRecording ? 'Listening' : 'Push To Talk'}
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'}
color='primary'
sx={!isRecording ? { backgroundColor: 'background.surface' } : undefined}
/>
) : null
// <CallButton disabled={true} Icon={MicOffIcon} onClick={() => setMicMuted(muted => !muted)}
// text={micMuted ? 'Muted' : 'Mute'}
// color={micMuted ? 'warning' : undefined} variant={micMuted ? 'solid' : 'outlined'} />
+1 -1
View File
@@ -19,9 +19,9 @@ export function CallMessage(props: {
alignSelf: props.role === 'user' ? 'end' : 'start',
whiteSpace: 'break-spaces',
borderRadius: 'lg',
mt: 'auto',
// boxShadow: 'md',
py: 1,
px: 1.5,
...(props.sx || {}),
}}
>
+129 -97
View File
@@ -1,6 +1,5 @@
import * as React from 'react';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import { Panel, PanelGroup } from 'react-resizable-panels';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { useTheme } from '@mui/joy';
@@ -15,18 +14,18 @@ import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { Brand } from '~/common/app.config';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
import { GoodPanelResizeHandler } from '~/common/components/panes/GoodPanelResizeHandler';
import { PanelResizeInset } from '~/common/components/panes/GoodPanelResizeHandler';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/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 { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
import { ChatDrawerMemo } from './components/applayout/ChatDrawer';
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
import { ChatDrawerMemo } from './components/ChatDrawer';
import { ChatDropdowns } from './components/ChatDropdowns';
import { ChatPageMenuItems } from './components/ChatPageMenuItems';
import { ChatMessageList } from './components/ChatMessageList';
import { Composer } from './components/composer/Composer';
import { Ephemerals } from './components/Ephemerals';
@@ -55,19 +54,22 @@ const SPECIAL_ID_WIPE_ALL: DConversationId = 'wipe-chats';
export function AppChat() {
// state
const [isComposerMulticast, setIsComposerMulticast] = React.useState(false);
const [isMessageSelectionMode, setIsMessageSelectionMode] = React.useState(false);
const [diagramConfig, setDiagramConfig] = React.useState<DiagramConfig | null>(null);
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(null);
const [clearConversationId, setClearConversationId] = React.useState<DConversationId | null>(null);
const [deleteConversationId, setDeleteConversationId] = React.useState<DConversationId | null>(null);
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
const showNextTitle = React.useRef(false);
const showNextTitleChange = React.useRef(false);
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const [_activeFolderId, setActiveFolderId] = React.useState<string | null>(null);
// external state
const theme = useTheme();
const isMobile = useIsMobile();
const { openLlmOptions } = useOptimaLayout();
const { chatLLM } = useChatLLM();
@@ -78,8 +80,7 @@ export function AppChat() {
navigateHistoryInFocusedPane,
openConversationInFocusedPane,
openConversationInSplitPane,
paneIndex,
duplicatePane,
focusedPaneIndex,
removePane,
setFocusedPane,
} = usePanesManager();
@@ -87,6 +88,7 @@ export function AppChat() {
const {
title: focusedChatTitle,
chatIdx: focusedChatNumber,
isNoChat: isNoChat,
isChatEmpty: isFocusedChatEmpty,
areChatsEmpty,
newConversationId,
@@ -112,8 +114,10 @@ export function AppChat() {
// Window actions
const panesConversationIDs = chatPanes.length > 0 ? chatPanes.map((pane) => pane.conversationId) : [null];
const isSplitPane = chatPanes.length > 1;
const isMultiPane = chatPanes.length >= 2;
const isMultiAddable = chatPanes.length < 4;
const isMultiConversationId = isMultiPane && new Set(chatPanes.map((pane) => pane.conversationId)).size >= 2;
const willMulticast = isComposerMulticast && isMultiConversationId;
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
conversationId && openConversationInFocusedPane(conversationId);
@@ -123,21 +127,14 @@ export function AppChat() {
conversationId && openConversationInSplitPane(conversationId);
}, [openConversationInSplitPane]);
const toggleSplitPane = React.useCallback(() => {
if (isSplitPane)
removePane(paneIndex ?? chatPanes.length - 1);
else
duplicatePane(paneIndex ?? chatPanes.length - 1);
}, [chatPanes.length, duplicatePane, isSplitPane, paneIndex, removePane]);
const handleNavigateHistory = React.useCallback((direction: 'back' | 'forward') => {
if (navigateHistoryInFocusedPane(direction))
showNextTitle.current = true;
showNextTitleChange.current = true;
}, [navigateHistoryInFocusedPane]);
React.useEffect(() => {
if (showNextTitle.current) {
showNextTitle.current = false;
if (showNextTitleChange.current) {
showNextTitleChange.current = false;
const title = (focusedChatNumber >= 0 ? `#${focusedChatNumber + 1} · ` : '') + (focusedChatTitle || 'New Chat');
const id = addSnackbar({ key: 'focused-title', message: title, type: 'title' });
return () => removeSnackbar(id);
@@ -169,6 +166,13 @@ export function AppChat() {
return await runReActUpdatingState(conversationId, chatCommand.params!, chatLLMId);
case 'chat-alter':
if (chatCommand.command === '/clear') {
if (chatCommand.params === 'all')
return setMessages(conversationId, []);
const helpMessage = createDMessage('assistant', 'This command requires the \'all\' parameter to confirm the operation.');
helpMessage.originLLM = Brand.Title.Base;
return setMessages(conversationId, [...history, helpMessage]);
}
Object.assign(lastMessage, {
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
sender: 'Bot',
@@ -234,17 +238,25 @@ export function AppChat() {
}
const userText = multiPartMessage[0].text;
// find conversation
const conversation = getConversation(conversationId);
if (!conversation)
return false;
// multicast: send the message to all the panes
const uniqueIds = new Set([conversationId]);
if (willMulticast)
chatPanes.forEach(pane => pane.conversationId && uniqueIds.add(pane.conversationId));
// start execution (async)
void _handleExecute(chatModeId, conversationId, [
...conversation.messages,
createDMessage('user', userText),
]);
return true;
// 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;
};
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
@@ -281,10 +293,10 @@ export function AppChat() {
// Chat actions
const handleConversationNew = React.useCallback(() => {
const handleConversationNew = React.useCallback((forceNoRecycle?: boolean) => {
// activate an existing new conversation if present, or create another
const conversationId = newConversationId
const conversationId = (newConversationId && !forceNoRecycle)
? newConversationId
: prependNewConversation(focusedSystemPurposeId ?? undefined);
setFocusedConversationId(conversationId);
@@ -300,29 +312,29 @@ export function AppChat() {
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
const handleConversationExport = React.useCallback((conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId }), []);
const handleConversationExport = React.useCallback((conversationId: DConversationId | null, exportAll: boolean) => {
setTradeConfig({ dir: 'export', conversationId, exportAll });
}, []);
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
showNextTitle.current = true;
const branchedConversationId = branchConversation(conversationId, messageId);
addSnackbar({
key: 'branch-conversation',
message: 'Branch started.',
type: 'success',
overrides: {
autoHideDuration: 3000,
startDecorator: <ForkRightIcon />,
},
});
const branchInAltPanel = useUXLabsStore.getState().labsSplitBranching;
if (branchInAltPanel)
const handleConversationBranch = React.useCallback((srcConversationId: DConversationId, messageId: string | null): DConversationId | null => {
// clone data
const branchedConversationId = branchConversation(srcConversationId, messageId);
// if a folder is active, add the new conversation to the folder
if (activeFolderId && branchedConversationId)
useFolderStore.getState().addConversationToFolder(activeFolderId, branchedConversationId);
// replace/open a new pane with this
showNextTitleChange.current = true;
if (isMultiAddable)
openSplitConversationId(branchedConversationId);
else
setFocusedConversationId(branchedConversationId);
return branchedConversationId;
}, [branchConversation, openSplitConversationId, setFocusedConversationId]);
const handleConversationFlatten = (conversationId: DConversationId) => setFlattenConversationId(conversationId);
return branchedConversationId;
}, [activeFolderId, branchConversation, isMultiAddable, openSplitConversationId, setFocusedConversationId]);
const handleConversationFlatten = React.useCallback((conversationId: DConversationId) => setFlattenConversationId(conversationId), []);
const handleConfirmedClearConversation = React.useCallback(() => {
if (clearConversationId) {
@@ -333,27 +345,24 @@ export function AppChat() {
const handleConversationClear = React.useCallback((conversationId: DConversationId) => setClearConversationId(conversationId), []);
const handleConfirmedDeleteConversation = () => {
if (deleteConversationId) {
let nextConversationId: DConversationId | null;
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, activeFolderId);
else
nextConversationId = deleteConversation(deleteConversationId);
setFocusedConversationId(nextConversationId);
setDeleteConversationId(null);
}
};
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
const handleConversationDelete = React.useCallback(
(conversationId: DConversationId, bypassConfirmation: boolean) => {
if (bypassConfirmation) setFocusedConversationId(deleteConversation(conversationId));
else setDeleteConversationId(conversationId);
},
[deleteConversation, setFocusedConversationId],
);
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
// show dialog if not bypassed
if (!bypassConfirmation)
return setDeleteConversationId(conversationId);
const nextConversationId = conversationId === SPECIAL_ID_WIPE_ALL
? wipeAllConversations(activeFolderId /* restricted to this folder (or null for all) */, /*focusedSystemPurposeId ??*/ undefined)
: deleteConversation(conversationId, /*focusedSystemPurposeId ??*/ undefined);
setFocusedConversationId(nextConversationId);
setDeleteConversationId(null);
}, [activeFolderId, deleteConversation, setFocusedConversationId, wipeAllConversations]);
const handleConfirmedDeleteConversation = React.useCallback(() => {
deleteConversationId && handleConversationDelete(deleteConversationId, true);
}, [deleteConversationId, handleConversationDelete]);
// Shortcuts
@@ -380,17 +389,16 @@ export function AppChat() {
const centerItems = React.useMemo(() =>
<ChatDropdowns
conversationId={focusedConversationId}
isSplitPanes={isSplitPane}
onToggleSplitPanes={toggleSplitPane}
/>,
[focusedConversationId, isSplitPane, toggleSplitPane],
[focusedConversationId],
);
const drawerContent = React.useMemo(() =>
<ChatDrawerMemo
activeConversationId={focusedConversationId}
activeFolderId={activeFolderId}
disableNewButton={isFocusedChatEmpty}
chatPanesConversationIds={chatPanes.map(pane => pane.conversationId).filter(Boolean) as DConversationId[]}
disableNewButton={isFocusedChatEmpty && !isNoChat}
onConversationActivate={setFocusedConversationId}
onConversationDelete={handleConversationDelete}
onConversationExportDialog={handleConversationExport}
@@ -399,21 +407,23 @@ export function AppChat() {
onConversationsDeleteAll={handleConversationsDeleteAll}
setActiveFolderId={setActiveFolderId}
/>,
[activeFolderId, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, setFocusedConversationId],
[activeFolderId, chatPanes, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, isNoChat, setFocusedConversationId],
);
const menuItems = React.useMemo(() =>
<ChatMenuItems
<ChatPageMenuItems
isMobile={isMobile}
conversationId={focusedConversationId}
disableItems={!focusedConversationId || isFocusedChatEmpty}
hasConversations={!areChatsEmpty}
isConversationEmpty={isFocusedChatEmpty}
isMessageSelectionMode={isMessageSelectionMode}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationClear={handleConversationClear}
onConversationFlatten={handleConversationFlatten}
// onConversationNew={handleConversationNew}
setIsMessageSelectionMode={setIsMessageSelectionMode}
/>,
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, isFocusedChatEmpty, isMessageSelectionMode],
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationFlatten, /*handleConversationNew,*/ isFocusedChatEmpty, isMessageSelectionMode, isMobile],
);
usePluggableOptimaLayout(drawerContent, centerItems, menuItems, 'AppChat');
@@ -421,32 +431,45 @@ export function AppChat() {
return <>
<PanelGroup
direction='horizontal'
direction={isMobile ? 'vertical' : 'horizontal'}
id='app-chat-panels'
>
{panesConversationIDs.map((_conversationId, idx, panels) =>
<React.Fragment key={`chat-pane-${idx}-${panels.length}-${_conversationId}`}>
{chatPanes.map((pane, idx) => {
const _paneConversationId = pane.conversationId;
const _panesCount = chatPanes.length;
const _keyAndId = `chat-pane-${idx}-${_paneConversationId}`;
const _sepId = `sep-pane-${idx}-${_paneConversationId}`;
return <React.Fragment key={_keyAndId}>
<Panel
id={'chat-pane-' + _conversationId}
id={_keyAndId}
order={idx}
collapsible
defaultSize={panels.length > 0 ? Math.round(100 / panels.length) : undefined}
collapsible={chatPanes.length === 2}
defaultSize={(_panesCount === 3 && idx === 1) ? 34 : Math.round(100 / _panesCount)}
minSize={20}
onClick={(event) => {
const setFocus = chatPanes.length < 2 || !event.altKey;
setFocusedPane(setFocus ? idx : -1);
}}
onCollapse={() => setTimeout(() => removePane(idx), 50)}
onCollapse={() => {
// NOTE: despite the delay to try to let the draggin settle, there seems to be an issue with the Pane locking the screen
// setTimeout(() => removePane(idx), 50);
// more than 2 will result in an assertion from the framework
if (chatPanes.length === 2) removePane(idx);
}}
style={{
// for anchoring the scroll button in place
position: 'relative',
// border only for active pane (if two or more panes)
...(panesConversationIDs.length < 2
? {}
: (_conversationId === focusedConversationId)
? { border: `2px solid ${theme.palette.primary.solidBg}` }
: { border: `2px solid ${theme.palette.background.level1}` }),
...(isMultiPane ? {
borderRadius: '0.375rem',
border: `2px solid ${idx === focusedPaneIndex
? ((willMulticast || !isMultiConversationId) ? theme.palette.primary.solidBg : theme.palette.primary.solidBg)
: ((willMulticast || !isMultiConversationId) ? theme.palette.warning.softActiveBg : theme.palette.background.level1)}`,
filter: (!willMulticast && idx !== focusedPaneIndex)
? (!isMultiConversationId ? 'grayscale(66.67%)' /* clone of the same */ : 'grayscale(66.67%)')
: undefined,
} : {}),
}}
>
@@ -462,10 +485,11 @@ export function AppChat() {
>
<ChatMessageList
conversationId={_conversationId}
conversationId={_paneConversationId}
capabilityHasT2I={capabilityHasT2I}
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
isMessageSelectionMode={isMessageSelectionMode}
isMobile={isMobile}
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationExecuteHistory={handleConversationExecuteHistory}
@@ -478,7 +502,7 @@ export function AppChat() {
/>
<Ephemerals
conversationId={_conversationId}
conversationId={_paneConversationId}
sx={{
// TODO: Fixme post panels?
// flexGrow: 0.1,
@@ -494,20 +518,28 @@ export function AppChat() {
</Panel>
{/* Panel Separators & Resizers */}
{idx < panels.length - 1 && <GoodPanelResizeHandler />}
{idx < _panesCount - 1 && (
<PanelResizeHandle id={_sepId}>
<PanelResizeInset />
</PanelResizeHandle>
)}
</React.Fragment>)}
</React.Fragment>;
})}
</PanelGroup>
<Composer
isMobile={isMobile}
chatLLM={chatLLM}
composerTextAreaRef={composerTextAreaRef}
conversationId={focusedConversationId}
capabilityHasT2I={capabilityHasT2I}
isMulticast={!isMultiConversationId ? null : isComposerMulticast}
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
onAction={handleComposerAction}
onTextImagine={handleTextImagine}
setIsMulticast={setIsComposerMulticast}
sx={{
zIndex: 21, // position: 'sticky', bottom: 0,
backgroundColor: themeBgAppChatComposer,
+8 -1
View File
@@ -1,8 +1,10 @@
import ClearIcon from '@mui/icons-material/Clear';
import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsAlter: ICommandsProvider = {
id: 'chat-alter',
rank: 20,
rank: 25,
getCommands: () => [{
primary: '/assistant',
@@ -14,6 +16,11 @@ export const CommandsAlter: ICommandsProvider = {
alternatives: ['/s'],
arguments: ['text'],
description: 'Injects system message',
}, {
primary: '/clear',
arguments: ['all'],
description: 'Clears the chat (removes all messages)',
Icon: ClearIcon,
}],
};
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ICommandsProvider } from './ICommandsProvider';
export const CommandsBrowse: ICommandsProvider = {
id: 'ass-browse',
rank: 25,
rank: 20,
getCommands: () => [{
primary: '/browse',
@@ -3,9 +3,10 @@ import { shallow } from 'zustand/shallow';
import { Box, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import ClearIcon from '@mui/icons-material/Clear';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import FileUploadOutlinedIcon from '@mui/icons-material/FileUploadOutlined';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
@@ -16,12 +17,13 @@ import { DFolder, useFolderStore } from '~/common/state/store-folders';
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
import { PageDrawerList, PageDrawerTallItemSx } from '~/common/layout/optima/components/PageDrawerList';
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
import { ChatFolderList } from './folder/ChatFolderList';
import { ClearFolderText } from './folder/useFolderDropdown';
import { ChatFolderList } from './folders/ChatFolderList';
import { ClearFolderText } from './folders/useFolderDropdown';
// this is here to make shallow comparisons work on the next hook
@@ -46,11 +48,24 @@ export const useFolders = (activeFolderId: string | null) => useFolderStore(({ e
}, shallow);
/*
* Returns a string with the pane indices where the conversation is also open, or false if it's not
*/
function findOpenInViewNumbers(chatPanesConversationIds: DConversationId[], ourId: DConversationId): string | false {
if (chatPanesConversationIds.length <= 1) return false;
return chatPanesConversationIds.reduce((acc: string[], id, idx) => {
if (id === ourId)
acc.push((idx + 1).toString());
return acc;
}, []).join(', ') || false;
}
/*
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
* to avoid unnecessary re-renders on each new character typed by the assistant
*/
export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null, chatPanesConversationIds: DConversationId[]): ChatNavigationItemData[] =>
useChatStore(({ conversations }) => {
const activeConversations = activeFolder
@@ -60,6 +75,7 @@ export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFold
return activeConversations.map((_c): ChatNavigationItemData => ({
conversationId: _c.id,
isActive: _c.id === activeConversationId,
isAlsoOpen: findOpenInViewNumbers(chatPanesConversationIds, _c.id),
isEmpty: !_c.messages.length && !_c.userTitle,
title: conversationTitle(_c),
folder: !allFolders.length
@@ -83,12 +99,13 @@ export const ChatDrawerMemo = React.memo(ChatDrawer);
function ChatDrawer(props: {
activeConversationId: DConversationId | null,
activeFolderId: string | null,
chatPanesConversationIds: DConversationId[],
disableNewButton: boolean,
onConversationActivate: (conversationId: DConversationId) => void,
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
onConversationExportDialog: (conversationId: DConversationId | null) => void,
onConversationExportDialog: (conversationId: DConversationId | null, exportAll: boolean) => void,
onConversationImportDialog: () => void,
onConversationNew: () => void,
onConversationNew: (forceNoRecycle: boolean) => void,
onConversationsDeleteAll: () => void,
setActiveFolderId: (folderId: string | null) => void,
}) {
@@ -102,20 +119,20 @@ function ChatDrawer(props: {
// external state
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId);
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId, props.chatPanesConversationIds);
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
// derived state
const selectConversationsCount = chatNavItems.length;
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
const singleChat = selectConversationsCount === 1;
const softMaxReached = selectConversationsCount >= 10;
const softMaxReached = selectConversationsCount >= 40 && showSymbols;
const isMultiPane = props.chatPanesConversationIds.length >= 2;
const handleButtonNew = React.useCallback(() => {
onConversationNew();
onConversationNew(isMultiPane);
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationNew]);
}, [closeDrawerOnMobile, isMultiPane, onConversationNew]);
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
@@ -126,8 +143,8 @@ function ChatDrawer(props: {
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
!singleChat && conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete, singleChat]);
conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete]);
// Folder change request
@@ -245,7 +262,7 @@ function ChatDrawer(props: {
/>
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
<ListItemButton disabled={props.disableNewButton} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
<ListItemButton disabled={props.disableNewButton && !isMultiPane} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Box sx={{
// style
@@ -284,7 +301,6 @@ function ChatDrawer(props: {
<ChatDrawerItemMemo
key={'nav-' + item.conversationId}
item={item}
isLonely={singleChat}
showSymbols={showSymbols}
bottomBarBasis={(softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
onConversationActivate={handleConversationActivate}
@@ -299,15 +315,15 @@ function ChatDrawer(props: {
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ListItemButton onClick={props.onConversationImportDialog} sx={{ flex: 1 }}>
<ListItemDecorator>
<FileUploadIcon />
<FileUploadOutlinedIcon />
</ListItemDecorator>
Import
{/*<OpenAIIcon sx={{ ml: 'auto' }} />*/}
</ListItemButton>
<ListItemButton disabled={!nonEmptyChats} onClick={() => props.onConversationExportDialog(props.activeConversationId)} sx={{ flex: 1 }}>
<ListItemButton disabled={!nonEmptyChats} onClick={() => props.onConversationExportDialog(props.activeConversationId, true)} sx={{ flex: 1 }}>
<ListItemDecorator>
<FileDownloadIcon />
<FileDownloadOutlinedIcon />
</ListItemDecorator>
Export
</ListItemButton>
@@ -326,9 +342,10 @@ function ChatDrawer(props: {
{/* [Menu] Chat Item Folder Change */}
{!!folderChangeRequest?.anchorEl && (
<CloseableMenu
bigIcons
open anchorEl={folderChangeRequest.anchorEl} onClose={handleConversationFolderCancel}
placement='bottom-start'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
zIndex={themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
@@ -355,6 +372,9 @@ function ChatDrawer(props: {
{!!folderChangeRequest.currentFolder && (
<ListItem onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, null)}>
<ListItemButton>
<ListItemDecorator>
<ClearIcon />
</ListItemDecorator>
{ClearFolderText}
</ListItemButton>
</ListItem>
@@ -1,6 +1,6 @@
import * as React from 'react';
import { Avatar, Box, Divider, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
import { Avatar, Box, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import CloseIcon from '@mui/icons-material/Close';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
@@ -10,13 +10,14 @@ import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import type { DFolder } from '~/common/state/store-folders';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { isDeepEqual } from '~/common/util/jsUtils';
// set to true to display the conversation IDs
@@ -24,17 +25,27 @@ import { InlineTextarea } from '~/common/components/InlineTextarea';
export const FadeInButton = styled(IconButton)({
opacity: 0.5,
opacity: 0.667,
transition: 'opacity 0.2s',
'&:hover': { opacity: 1 },
});
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem);
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem, (prev, next) =>
// usign a custom function because `ChatNavigationItemData` is a complex object and memo won't work
isDeepEqual(prev.item, next.item) &&
prev.showSymbols === next.showSymbols &&
prev.bottomBarBasis === next.bottomBarBasis &&
prev.onConversationActivate === next.onConversationActivate &&
prev.onConversationDelete === next.onConversationDelete &&
prev.onConversationExport === next.onConversationExport &&
prev.onConversationFolderChange === next.onConversationFolderChange,
);
export interface ChatNavigationItemData {
conversationId: DConversationId;
isActive: boolean;
isAlsoOpen: string | false;
isEmpty: boolean;
title: string;
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
@@ -51,13 +62,13 @@ export interface FolderChangeRequest {
}
function ChatDrawerItem(props: {
// NOTE: always update the Memo comparison if you add or remove props
item: ChatNavigationItemData,
isLonely: boolean,
showSymbols: boolean,
bottomBarBasis: number,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationDelete: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId, exportAll: boolean) => void,
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
}) {
@@ -67,7 +78,7 @@ function ChatDrawerItem(props: {
// derived state
const { onConversationExport, onConversationFolderChange } = props;
const { conversationId, isActive, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const { conversationId, isActive, isAlsoOpen, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const isNew = messageCount === 0;
@@ -88,7 +99,7 @@ function ChatDrawerItem(props: {
const handleConversationExport = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
conversationId && onConversationExport(conversationId);
conversationId && onConversationExport(conversationId, false);
}, [conversationId, onConversationExport]);
@@ -158,8 +169,9 @@ function ChatDrawerItem(props: {
}}
/>
) : (
<Typography>
{isNew ? '' : textSymbol}
<Typography sx={isNew ? { opacity: 0.4, filter: 'grayscale(0.75)' } : undefined}>
{/*{isNew ? '' : textSymbol}*/}
{textSymbol}
</Typography>
)}
</ListItemDecorator>}
@@ -209,20 +221,28 @@ function ChatDrawerItem(props: {
}} />
), [progress]);
return (isActive || isAlsoOpen) ? (
return isActive ? (
// Active Conversation
// Active or Also Open
<Sheet
variant={isActive ? 'solid' : 'plain'}
variant={isActive ? 'solid' : 'outlined'}
invertedColors={isActive}
onClick={!isActive ? handleConversationActivate : undefined}
sx={{
// common
// position: 'relative', // for the progress bar (now disabled)
'--ListItem-minHeight': '2.75rem',
position: 'relative', // for the progress bar
// '--variant-borderWidth': '0.125rem',
border: 'none', // there's a default border of 1px and invisible.. hmm
// differences between primary and secondary variants
...(isActive ? {
border: 'none', // there's a default border of 1px and invisible.. hmm
} : {
// '--variant-borderWidth': '0.125rem',
cursor: 'pointer',
}),
// style
backgroundColor: isActive ? 'neutral.solidActiveBg' : 'neutral.softBg',
borderRadius: 'md',
mx: '0.25rem',
'&:hover > button': {
@@ -235,82 +255,87 @@ function ChatDrawerItem(props: {
{/* Title row */}
<Box sx={{ display: 'flex', gap: 'var(--ListItem-gap)', minHeight: '2.25rem', alignItems: 'center' }}>
{titleRowComponent}
</Box>
{/* buttons row */}
<Box sx={{ display: 'flex', gap: 1, minHeight: '2.25rem', alignItems: 'center' }}>
{isActive && (
<Box sx={{ display: 'flex', gap: 1, minHeight: '2.25rem', alignItems: 'center' }}>
<ListItemDecorator />
<ListItemDecorator />
{/* Current Folder color, and change initiator */}
{(folder !== undefined) && <>
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
{folder ? (
<IconButton size='sm' onClick={handleFolderChangeBegin}>
<FolderIcon style={{ color: folder.color || 'inherit' }} />
</IconButton>
) : (
<FadeInButton size='sm' onClick={handleFolderChangeBegin}>
<FolderOutlinedIcon />
</FadeInButton>
)}
</Tooltip>
{/* Current Folder color, and change initiator */}
{(folder !== undefined) && <>
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
{folder ? (
<IconButton size='sm' onClick={handleFolderChangeBegin}>
<FolderIcon style={{ color: folder.color || 'inherit' }} />
</IconButton>
) : (
<FadeInButton size='sm' onClick={handleFolderChangeBegin}>
<FolderOutlinedIcon />
</FadeInButton>
)}
</Tooltip>
{/*<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />*/}
</>}
<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />
</>}
<Tooltip disableInteractive title='Rename'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditBegin}>
<EditIcon />
</FadeInButton>
</Tooltip>
{!isNew && <>
<Tooltip disableInteractive title='Auto-Title'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
<AutoFixHighIcon />
<Tooltip disableInteractive title='Rename'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditBegin}>
<EditIcon />
</FadeInButton>
</Tooltip>
<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />
<Tooltip disableInteractive title='Export'>
<FadeInButton size='sm' onClick={handleConversationExport}>
<FileDownloadOutlinedIcon />
</FadeInButton>
</Tooltip>
</>}
{/* --> */}
<Box sx={{ flex: 1 }} />
{/* Delete [armed, arming] buttons */}
{!props.isLonely && !searchFrequency && <>
{deleteArmed && (
<Tooltip disableInteractive title='Confirm Deletion'>
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1 }}>
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
{!isNew && <>
<Tooltip disableInteractive title='Auto-Title'>
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
<AutoFixHighIcon />
</FadeInButton>
</Tooltip>
)}
<Tooltip disableInteractive title={deleteArmed ? 'Cancel Delete' : 'Delete'}>
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
{deleteArmed ? <CloseIcon /> : <DeleteOutlineIcon />}
</FadeInButton>
</Tooltip>
</>}
{/*<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />*/}
</Box>
<Tooltip disableInteractive title='Export Chat'>
<FadeInButton size='sm' onClick={handleConversationExport}>
<FileDownloadOutlinedIcon />
</FadeInButton>
</Tooltip>
</>}
{/* --> */}
<Box sx={{ flex: 1 }} />
{/* Delete [armed, arming] buttons */}
{!searchFrequency && <>
{deleteArmed && (
<Tooltip disableInteractive title='Confirm Deletion'>
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1 }}>
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
</FadeInButton>
</Tooltip>
)}
<Tooltip disableInteractive title={deleteArmed ? 'Cancel Delete' : 'Delete'}>
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
{deleteArmed ? <CloseIcon /> : <DeleteOutlineIcon />}
</FadeInButton>
</Tooltip>
</>}
</Box>
)}
{/* View places row */}
{isAlsoOpen && (
<Typography level='body-xs' sx={{ mx: 'auto' }}>
<em>In view {isAlsoOpen}</em>
</Typography>
)}
</ListItem>
{/* Optional progress bar, underlay */}
{progressBarFixedComponent}
{/* NOTE: disabled on 20240204: quite distracting on the active chat sheet */}
{/*{progressBarFixedComponent}*/}
</Sheet>
@@ -1,20 +1,14 @@
import * as React from 'react';
import { IconButton } from '@mui/joy';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import type { DConversationId } from '~/common/state/store-chats';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { useChatLLMDropdown } from './useLLMDropdown';
import { usePersonaIdDropdown } from './usePersonaDropdown';
import { useFolderDropdown } from './folder/useFolderDropdown';
import { useFolderDropdown } from './folders/useFolderDropdown';
export function ChatDropdowns(props: {
conversationId: DConversationId | null
isSplitPanes: boolean
onToggleSplitPanes: () => void
}) {
// state
@@ -22,9 +16,6 @@ export function ChatDropdowns(props: {
const { personaDropdown } = usePersonaIdDropdown(props.conversationId);
const { folderDropdown } = useFolderDropdown(props.conversationId);
// external state
const labsSplitBranching = useUXLabsStore(state => state.labsSplitBranching);
return <>
{/* Persona selector */}
@@ -36,16 +27,5 @@ export function ChatDropdowns(props: {
{/* Folder selector */}
{folderDropdown}
{/* Split Panes button */}
{labsSplitBranching && <IconButton
variant={props.isSplitPanes ? 'solid' : undefined}
onClick={props.onToggleSplitPanes}
// sx={{
// ml: 'auto',
// }}
>
<VerticalSplitIcon />
</IconButton>}
</>;
}
+11 -7
View File
@@ -26,12 +26,14 @@ export function ChatMessageList(props: {
conversationId: DConversationId | null,
capabilityHasT2I: boolean,
chatLLMContextTokens: number | null,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
isMessageSelectionMode: boolean,
isMobile: boolean,
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
onTextSpeak: (selectedText: string) => Promise<void>,
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
sx?: SxProps,
}) {
@@ -148,17 +150,17 @@ export function ChatMessageList(props: {
});
// text-diff functionality, find the messages to diff with
// text-diff functionality: only diff the last message and when it's complete (not typing), and they're similar in size
const { diffMessage, diffText } = React.useMemo(() => {
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 { diffMessage: msgB, diffText: textA };
return { diffTargetMessage: msgB, diffPrevText: textA };
}
return { diffMessage: undefined, diffText: undefined };
return { diffTargetMessage: undefined, diffPrevText: undefined };
}, [conversationMessages]);
@@ -219,9 +221,11 @@ export function ChatMessageList(props: {
<ChatMessageMemo
key={'msg-' + message.id}
message={message}
diffPreviousText={message === diffMessage ? diffText : undefined}
diffPreviousText={message === diffTargetMessage ? diffPrevText : undefined}
isBottom={idx === count - 1}
isImagining={isImagining} isSpeaking={isSpeaking}
isImagining={isImagining}
isMobile={props.isMobile}
isSpeaking={isSpeaking}
onConversationBranch={handleConversationBranch}
onConversationRestartFrom={handleConversationRestartFrom}
onConversationTruncate={handleConversationTruncate}
@@ -0,0 +1,153 @@
import * as React from 'react';
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem, Switch, Tooltip } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlineBlankOutlined';
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
import ClearIcon from '@mui/icons-material/Clear';
import CompressIcon from '@mui/icons-material/Compress';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import HorizontalSplitIcon from '@mui/icons-material/HorizontalSplit';
import HorizontalSplitOutlinedIcon from '@mui/icons-material/HorizontalSplitOutlined';
import SettingsSuggestOutlinedIcon from '@mui/icons-material/SettingsSuggestOutlined';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import VerticalSplitOutlinedIcon from '@mui/icons-material/VerticalSplitOutlined';
import type { DConversationId } from '~/common/state/store-chats';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useChatShowSystemMessages } from '../store-app-chat';
import { usePaneDuplicateOrClose } from './panes/usePanesManager';
export function ChatPageMenuItems(props: {
isMobile: boolean,
conversationId: DConversationId | null,
disableItems: boolean,
hasConversations: boolean,
isMessageSelectionMode: boolean,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationClear: (conversationId: DConversationId) => void,
onConversationFlatten: (conversationId: DConversationId) => void,
// onConversationNew: (forceNoRecycle: boolean) => void,
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
}) {
// external state
const { closePageMenu } = useOptimaDrawers();
const { canAddPane, isMultiPane, duplicateFocusedPane, removeOtherPanes } = usePaneDuplicateOrClose();
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
const handleIncreaseMultiPane = React.useCallback((event?: React.MouseEvent) => {
event?.stopPropagation();
// create a new pane with the current conversation
duplicateFocusedPane();
// load a brand new conversation inside
// FIXME: still testing this
// props.onConversationNew(true);
}, [duplicateFocusedPane]);
const handleToggleMultiPane = React.useCallback((_event: React.MouseEvent) => {
if (isMultiPane)
removeOtherPanes();
else
handleIncreaseMultiPane(undefined);
}, [handleIncreaseMultiPane, isMultiPane, removeOtherPanes]);
const closeMenu = (event: React.MouseEvent) => {
event.stopPropagation();
closePageMenu();
};
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationClear(props.conversationId);
};
const handleConversationBranch = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationBranch(props.conversationId, null);
};
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationFlatten(props.conversationId);
};
const handleToggleMessageSelectionMode = (event: React.MouseEvent) => {
closeMenu(event);
props.setIsMessageSelectionMode(!props.isMessageSelectionMode);
};
const handleToggleSystemMessages = () => setShowSystemMessages(!showSystemMessages);
return <>
{/* System Message(s) */}
<MenuItem onClick={handleToggleSystemMessages}>
<ListItemDecorator><SettingsSuggestOutlinedIcon /></ListItemDecorator>
System messages
<Switch checked={showSystemMessages} onChange={handleToggleSystemMessages} sx={{ ml: 'auto' }} />
</MenuItem>
{/* Un /Split */}
<MenuItem onClick={handleToggleMultiPane}>
<ListItemDecorator>{props.isMobile
? (isMultiPane ? <HorizontalSplitIcon /> : <HorizontalSplitOutlinedIcon />)
: (isMultiPane ? <VerticalSplitIcon /> : <VerticalSplitOutlinedIcon />)
}</ListItemDecorator>
{/* Unsplit / Split text*/}
{isMultiPane ? 'Unsplit' : props.isMobile ? 'Split Down' : 'Split Right'}
{/* '+' */}
{isMultiPane && (
<Tooltip title='Add Another Split'>
<IconButton
size='sm'
variant='outlined'
disabled={!canAddPane}
onClick={handleIncreaseMultiPane}
sx={{ ml: 'auto', /*mr: '2px',*/ my: '-0.25rem' /* absorb the menuItem padding */ }}
>
<AddIcon />
</IconButton>
</Tooltip>
)}
</MenuItem>
<MenuItem disabled={props.disableItems} onClick={handleConversationBranch}>
<ListItemDecorator><ForkRightIcon /></ListItemDecorator>
Branch
</MenuItem>
<ListDivider />
<MenuItem disabled={props.disableItems} onClick={handleToggleMessageSelectionMode}>
<ListItemDecorator>{props.isMessageSelectionMode ? <CheckBoxOutlinedIcon /> : <CheckBoxOutlineBlankOutlinedIcon />}</ListItemDecorator>
<span style={props.isMessageSelectionMode ? { fontWeight: 800 } : {}}>
Cleanup ...
</span>
</MenuItem>
<MenuItem disabled={props.disableItems} onClick={handleConversationFlatten}>
<ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>
Compress ...
</MenuItem>
<ListDivider />
<MenuItem disabled={props.disableItems} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Reset Chat
{!props.disableItems && <KeyStroke combo='Ctrl + Alt + X' />}
</Box>
</MenuItem>
</>;
}
@@ -1,109 +0,0 @@
import * as React from 'react';
import { Box, ListDivider, ListItemDecorator, MenuItem, Switch } from '@mui/joy';
import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlineBlankOutlined';
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
import ClearIcon from '@mui/icons-material/Clear';
import CompressIcon from '@mui/icons-material/Compress';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
import type { DConversationId } from '~/common/state/store-chats';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useChatShowSystemMessages } from '../../store-app-chat';
export function ChatMenuItems(props: {
conversationId: DConversationId | null,
hasConversations: boolean,
isConversationEmpty: boolean,
isMessageSelectionMode: boolean,
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationClear: (conversationId: DConversationId) => void,
onConversationFlatten: (conversationId: DConversationId) => void,
}) {
// external state
const { closePageMenu } = useOptimaDrawers();
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
// derived state
const disabled = !props.conversationId || props.isConversationEmpty;
const closeMenu = (event: React.MouseEvent) => {
event.stopPropagation();
closePageMenu();
};
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationClear(props.conversationId);
};
const handleConversationBranch = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationBranch(props.conversationId, null);
};
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationFlatten(props.conversationId);
};
const handleToggleMessageSelectionMode = (event: React.MouseEvent) => {
closeMenu(event);
props.setIsMessageSelectionMode(!props.isMessageSelectionMode);
};
const handleToggleSystemMessages = () => setShowSystemMessages(!showSystemMessages);
return <>
{/*<ListItem>*/}
{/* <Typography level='body-sm'>*/}
{/* Conversation*/}
{/* </Typography>*/}
{/*</ListItem>*/}
<MenuItem onClick={handleToggleSystemMessages}>
<ListItemDecorator><SettingsSuggestIcon /></ListItemDecorator>
System message
<Switch checked={showSystemMessages} onChange={handleToggleSystemMessages} sx={{ ml: 'auto' }} />
</MenuItem>
<ListDivider inset='startContent' />
<MenuItem disabled={disabled} onClick={handleConversationBranch}>
<ListItemDecorator><ForkRightIcon /></ListItemDecorator>
Branch
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationFlatten}>
<ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>
Flatten
</MenuItem>
<ListDivider inset='startContent' />
<MenuItem disabled={disabled} onClick={handleToggleMessageSelectionMode}>
<ListItemDecorator>{props.isMessageSelectionMode ? <CheckBoxOutlinedIcon /> : <CheckBoxOutlineBlankOutlinedIcon />}</ListItemDecorator>
<span style={props.isMessageSelectionMode ? { fontWeight: 800 } : {}}>
Cleanup ...
</span>
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Reset Chat
{!disabled && <KeyStroke combo='Ctrl + Alt + X' />}
</Box>
</MenuItem>
</>;
}
@@ -1,115 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, ListItemButton, ListItemDecorator } from '@mui/joy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import SettingsIcon from '@mui/icons-material/Settings';
import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
import { PageBarDropdown, DropdownItems } from '~/common/layout/optima/components/PageBarDropdown';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
function AppBarLLMDropdown(props: {
llms: DLLM[],
chatLlmId: DLLMId | null,
setChatLlmId: (llmId: DLLMId | null) => void,
placeholder?: string,
}) {
// external state
const { openLlmOptions, openModelsSetup } = useOptimaLayout();
// build model menu items, filtering-out hidden models, and add Source separators
const llmItems: DropdownItems = {};
let prevSourceId: DModelSourceId | null = null;
for (const llm of props.llms) {
// filter-out hidden models
if (!(!llm.hidden || llm.id === props.chatLlmId))
continue;
// add separators when changing sources
if (!prevSourceId || llm.sId !== prevSourceId) {
if (prevSourceId)
llmItems[`sep-${llm.id}`] = {
type: 'separator',
title: llm.sId,
};
prevSourceId = llm.sId;
}
// add the model item
llmItems[llm.id] = {
title: llm.label,
// icon: llm.id.startsWith('some vendor') ? <VendorIcon /> : undefined,
};
}
const handleChatLLMChange = (_event: any, value: DLLMId | null) => value && props.setChatLlmId(value);
const handleOpenLLMOptions = () => props.chatLlmId && openLlmOptions(props.chatLlmId);
return (
<PageBarDropdown
items={llmItems}
value={props.chatLlmId} onChange={handleChatLLMChange}
placeholder={props.placeholder || 'Models …'}
appendOption={<>
{props.chatLlmId && (
<ListItemButton key='menu-opt' onClick={handleOpenLLMOptions}>
<ListItemDecorator><SettingsIcon color='success' /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Options
<KeyStroke combo='Ctrl + Shift + O' />
</Box>
</ListItemButton>
)}
<ListItemButton key='menu-llms' onClick={openModelsSetup}>
<ListItemDecorator><BuildCircleIcon color='success' /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Models
<KeyStroke combo='Ctrl + Shift + M' />
</Box>
</ListItemButton>
</>}
/>
);
}
export function useChatLLMDropdown() {
// external state
const { llms, chatLLMId, setChatLLMId } = useModelsStore(state => ({
llms: state.llms,
chatLLMId: state.chatLLMId,
setChatLLMId: state.setChatLLMId,
}), shallow);
const chatLLMDropdown = React.useMemo(
() => <AppBarLLMDropdown llms={llms} chatLlmId={chatLLMId} setChatLlmId={setChatLLMId} />,
[llms, chatLLMId, setChatLLMId],
);
return { chatLLMId, chatLLMDropdown };
}
/*export function useTempLLMDropdown(props: { initialLlmId: DLLMId | null }) {
// local state
const [llmId, setLlmId] = React.useState<DLLMId | null>(props.initialLlmId);
// external state
const llms = useModelsStore(state => state.llms, shallow);
const chatLLMDropdown = React.useMemo(
() => <AppBarLLMDropdown llms={llms} llmId={llmId} setLlmId={setLlmId} />,
[llms, llmId, setLlmId],
);
return { llmId, chatLLMDropdown };
}*/
@@ -1,60 +0,0 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
import { useUIPreferencesStore } from '~/common/state/store-ui';
function AppBarPersonaDropdown(props: {
systemPurposeId: SystemPurposeId | null,
setSystemPurposeId: (systemPurposeId: SystemPurposeId | null) => void,
}) {
// external state
const { zenMode } = useUIPreferencesStore(state => ({
zenMode: state.zenMode,
}), shallow);
const handleSystemPurposeChange = (_event: any, value: SystemPurposeId | null) => props.setSystemPurposeId(value);
// options
// let appendOption: React.JSX.Element | undefined = undefined;
return (
<PageBarDropdown
items={SystemPurposes} showSymbols={zenMode !== 'cleaner'}
value={props.systemPurposeId} onChange={handleSystemPurposeChange}
// appendOption={appendOption}
/>
);
}
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
// external state
const { systemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
return {
systemPurposeId: conversation?.systemPurposeId ?? null,
};
}, shallow);
const personaDropdown = React.useMemo(() => systemPurposeId
? <AppBarPersonaDropdown
systemPurposeId={systemPurposeId}
setSystemPurposeId={(systemPurposeId) => {
if (conversationId && systemPurposeId)
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
}}
/> : null,
[conversationId, systemPurposeId],
);
return { personaDropdown };
}
@@ -7,61 +7,21 @@ import InfoIcon from '@mui/icons-material/Info';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { InlineError } from '~/common/components/InlineError';
import { downloadVideoFrameAsPNG, renderVideoFrameAsPNGFile } from '~/common/util/videoUtils';
import { useCameraCapture } from '~/common/components/useCameraCapture';
function prettyFileName(renderedFrame: HTMLCanvasElement) {
const prettyDate = new Date().toISOString().replace(/[:-]/g, '').replace('T', '-').replace('Z', '');
const prettyResolution = `${renderedFrame.width}x${renderedFrame.height}`;
return `camera-${prettyDate}-${prettyResolution}.png`;
}
function renderVideoFrameToCanvas(videoElement: HTMLVideoElement): HTMLCanvasElement {
// paint the video on a canvas, to save it
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth || 640;
canvas.height = videoElement.videoHeight || 480;
const ctx = canvas.getContext('2d');
ctx?.drawImage(videoElement, 0, 0);
return canvas;
}
function renderVideoFrameToFile(videoElement: HTMLVideoElement, callback: (file: File) => void) {
// video to canvas
const renderedFrame = renderVideoFrameToCanvas(videoElement);
// canvas to blob to file to callback
renderedFrame.toBlob((blob) => {
if (blob) {
const file = new File([blob], prettyFileName(renderedFrame), { type: blob.type });
callback(file);
}
}, 'image/png');
}
function downloadVideoFrameAsPNG(videoElement: HTMLVideoElement) {
// video to canvas to png
const renderedFrame = renderVideoFrameToCanvas(videoElement);
const imageDataURL = renderedFrame.toDataURL('image/png');
// auto-download
const link = document.createElement('a');
link.download = prettyFileName(renderedFrame);
link.href = imageDataURL;
link.click();
}
export function CameraCaptureModal(props: {
onCloseModal: () => void,
onAttachImage: (file: File) => void
// onOCR: (ocrText: string) => void }
}) {
// state
// const [ocrProgress/*, setOCRProgress*/] = React.useState<number | null>(null);
const [showInfo, setShowInfo] = React.useState(false);
// camera operations
// state
const [showInfo, setShowInfo] = React.useState(false);
// const [ocrProgress/*, setOCRProgress*/] = React.useState<number | null>(null);
// external state
const {
videoRef,
cameras, cameraIdx, setCameraIdx,
@@ -70,10 +30,14 @@ export function CameraCaptureModal(props: {
} = useCameraCapture();
const stopAndClose = () => {
// derived state
const { onCloseModal, onAttachImage } = props;
const stopAndClose = React.useCallback(() => {
resetVideo();
props.onCloseModal();
};
onCloseModal();
}, [onCloseModal, resetVideo]);
/*const handleVideoOCRClicked = async () => {
if (!videoRef.current) return;
@@ -94,18 +58,21 @@ export function CameraCaptureModal(props: {
props.onOCR(result.data.text);
};*/
const handleVideoSnapClicked = () => {
const handleVideoSnapClicked = React.useCallback(async () => {
if (!videoRef.current) return;
renderVideoFrameToFile(videoRef.current, (file) => {
props.onAttachImage(file);
try {
const file = await renderVideoFrameAsPNGFile(videoRef.current, 'camera');
onAttachImage(file);
stopAndClose();
});
};
} catch (error) {
console.error('Error capturing video frame:', error);
}
}, [onAttachImage, stopAndClose, videoRef]);
const handleVideoDownloadClicked = () => {
const handleVideoDownloadClicked = React.useCallback(() => {
if (!videoRef.current) return;
downloadVideoFrameAsPNG(videoRef.current);
};
downloadVideoFrameAsPNG(videoRef.current, 'camera');
}, [videoRef]);
return (
@@ -53,32 +53,35 @@ export function ChatModeMenu(props: {
// external state
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
return <CloseableMenu
placement='top-end' sx={{ minWidth: 320 }}
open anchorEl={props.anchorEl} onClose={props.onClose}
>
return (
<CloseableMenu
placement='top-end'
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/*<MenuItem color='neutral' selected>*/}
{/* Conversation Mode*/}
{/*</MenuItem>*/}
{/**/}
{/*<ListDivider />*/}
{/* ChatMode items */}
{Object.entries(ChatModeItems)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio checked={key === props.chatModeId} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
{/* ChatMode items */}
{Object.entries(ChatModeItems)
.map(([key, data]) =>
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Radio checked={key === props.chatModeId} />
<Box sx={{ flexGrow: 1 }}>
<Typography>{data.label}</Typography>
<Typography level='body-xs'>{data.description}{(data.requiresTTI && !props.capabilityHasTTI) ? 'Unconfigured' : ''}</Typography>
</Box>
{(key === props.chatModeId || !!data.shortcut) && (
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
)}
</Box>
{(key === props.chatModeId || !!data.shortcut) && (
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
)}
</Box>
</MenuItem>)}
</MenuItem>)}
</CloseableMenu>;
</CloseableMenu>
);
}
+68 -44
View File
@@ -3,7 +3,7 @@ import { shallow } from 'zustand/shallow';
import { fileOpen, FileWithHandle } from 'browser-fs-access';
import { keyframes } from '@emotion/react';
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Tooltip, Typography } from '@mui/joy';
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import AttachFileIcon from '@mui/icons-material/AttachFile';
@@ -31,10 +31,10 @@ import { launchAppCall } from '~/common/app.routes';
import { lineHeightTextarea } from '~/common/app.theme';
import { playSoundUrl } from '~/common/util/audioUtils';
import { supportsClipboardRead } from '~/common/util/clipboardUtils';
import { supportsScreenCapture } from '~/common/util/screenCaptureUtils';
import { useDebouncer } from '~/common/components/useDebouncer';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUICounter, useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import type { ActileItem, ActileProvider } from './actile/ActileProvider';
@@ -50,9 +50,11 @@ import type { ComposerOutputMultiPart } from './composer.types';
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
import { ButtonAttachScreenCaptureMemo } from './buttons/ButtonAttachScreenCapture';
import { ButtonCall } from './buttons/ButtonCall';
import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
import { ButtonMicMemo } from './buttons/ButtonMic';
import { ButtonMultiChat } from './buttons/ButtonMultiChat';
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
import { ChatModeMenu } from './ChatModeMenu';
import { TokenBadgeMemo } from './TokenBadge';
@@ -76,13 +78,16 @@ export const animationStopEnter = keyframes`
* A React component for composing messages, with attachments and different modes.
*/
export function Composer(props: {
isMobile?: boolean;
chatLLM: DLLM | null;
composerTextAreaRef: React.RefObject<HTMLTextAreaElement>;
conversationId: DConversationId | null;
capabilityHasT2I: boolean;
isMulticast: boolean | null;
isDeveloperMode: boolean;
onAction: (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart) => boolean;
onTextImagine: (conversationId: DConversationId, text: string) => void;
setIsMulticast: (on: boolean) => void;
sx?: SxProps;
}) {
@@ -94,11 +99,12 @@ export function Composer(props: {
const [chatModeMenuAnchor, setChatModeMenuAnchor] = React.useState<HTMLAnchorElement | null>(null);
// external state
const isMobile = useIsMobile();
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
const { labsCameraDesktop } = useUXLabsStore(state => ({
const { labsAttachScreenCapture, labsCameraDesktop } = useUXLabsStore(state => ({
labsAttachScreenCapture: state.labsAttachScreenCapture,
labsCameraDesktop: state.labsCameraDesktop,
}), shallow);
const { novel: explainShiftEnter, touch: touchShiftEnter } = useUICounter('composer-shift-enter');
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
const [startupText, setStartupText] = useComposerStartupText();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
@@ -116,9 +122,11 @@ export function Composer(props: {
const { attachAppendClipboardItems, attachAppendDataTransfer, attachAppendFile, attachments: _attachments, clearAttachments, removeAttachment } =
useAttachments(browsingInComposer && !composeText.startsWith('/'));
// derived state
const isDesktop = !isMobile;
const isMobile = !!props.isMobile;
const isDesktop = !props.isMobile;
const chatLLMId = props.chatLLM?.id || null;
// attachments derived state
@@ -256,6 +264,8 @@ export function Composer(props: {
}
// Shift: toggles the 'enter is newline'
if (e.shiftKey)
touchShiftEnter();
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (!assistantAbortible)
handleSendAction(chatModeId, composeText);
@@ -263,7 +273,7 @@ export function Composer(props: {
}
}
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction, touchShiftEnter]);
// Focus mode
@@ -341,6 +351,10 @@ export function Composer(props: {
void attachAppendFile('camera', file);
}, [attachAppendFile]);
const handleAttachScreenCapture = React.useCallback((file: File) => {
void attachAppendFile('screencapture', file);
}, [attachAppendFile]);
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
const handleAttachFilePicker = React.useCallback(async () => {
@@ -429,32 +443,36 @@ export function Composer(props: {
? 'warning'
: isReAct ? 'success' : isDraw ? 'warning' : 'primary';
const textPlaceholder: string =
let textPlaceholder: string =
isDraw
? 'Describe an idea or a drawing...'
: isReAct
? 'Multi-step reasoning question...'
: props.isDeveloperMode
? 'Chat with me · drop source files · attach code...'
? 'Chat with me' + (isDesktop ? ' · drop source' : '') + ' · attach code...'
: props.capabilityHasT2I
? 'Chat · /react · /draw · drop files...'
: 'Chat · /react · drop files...';
if (isDesktop && explainShiftEnter)
textPlaceholder += !enterIsNewline ? '\nShift+Enter to add a new line' : '\nShift+Enter to send';
return (
<Box aria-label='User Message' component='section' sx={props.sx}>
<Grid container spacing={{ xs: 1, md: 2 }}>
{/* Button column and composer Text (mobile: top, desktop: left and center) */}
<Grid xs={12} md={9}><Stack direction='row' spacing={{ xs: 1, md: 2 }}>
<Grid xs={12} md={9}><Box sx={{ display: 'flex', gap: { xs: 1, md: 2 }, alignItems: 'flex-start' }}>
{/* Vertical (insert) buttons */}
{isMobile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* Start buttons column */}
<Box sx={{
flexGrow: 0,
display: 'grid', gap: 1,
}}>
{isMobile ? <>
{/* [mobile] Mic button */}
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
{/* [mobile] [+] button */}
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddCircleOutlineIcon />
@@ -477,9 +495,10 @@ export function Composer(props: {
</Menu>
</Dropdown>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* [Mobile] MultiChat button */}
{props.isMulticast !== null && <ButtonMultiChat isMobile multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
</> : <>
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
{/* Attach*/}
@@ -491,20 +510,23 @@ export function Composer(props: {
{/* Responsive Paste button */}
{supportsClipboardRead && <ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />}
{/* Responsive Screen Capture button */}
{labsAttachScreenCapture && supportsScreenCapture && <ButtonAttachScreenCaptureMemo onAttachScreenCapture={handleAttachScreenCapture} />}
{/* Responsive Camera OCR button */}
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
</Box>
)}
</>}
</Box>
{/* Vertically stacked [ Edit box + Overlays + Mic | Attachments ] */}
{/* [ Textarea + Overlays + Mic | Attachments ] */}
<Box sx={{
flexGrow: 1,
display: 'flex', flexDirection: 'column', gap: 1,
minWidth: 200, // enable X-scrolling (resetting any possible minWidth due to the attachments)
flexGrow: 1,
display: 'grid', gap: 1,
}}>
{/* Edit box + Overlays + Mic buttons */}
{/* Textarea + Mic buttons + Mic/Drag overlay */}
<Box sx={{ position: 'relative' }}>
{/* Edit box with inner Token Progress bar */}
@@ -628,13 +650,13 @@ export function Composer(props: {
</Box>
</Stack></Grid>
</Box></Grid>
{/* Send pane (mobile: bottom, desktop: right) */}
<Grid xs={12} md={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, height: '100%' }}>
{/* Send/Stop (and mobile corner buttons) */}
{/* This row is here only for the [mobile] bottom-start corner item */}
<Box sx={{ display: 'flex' }}>
{/* [mobile] bottom-corner secondary button */}
@@ -701,9 +723,11 @@ export function Composer(props: {
</Box>
{/* [desktop] Multicast switch (under the Chat button) */}
{isDesktop && props.isMulticast !== null && <ButtonMultiChat multiChat={props.isMulticast} onSetMultiChat={props.setIsMulticast} />}
{/* [desktop] secondary buttons (aligned to bottom for now, and mutually exclusive) */}
{isDesktop && <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1, justifyContent: 'flex-end' }}>
{isDesktop && <Box sx={{ mt: 'auto', display: 'grid', gap: 1 }}>
{/* [desktop] Call secondary button */}
{isChat && <ButtonCall disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
@@ -716,23 +740,23 @@ export function Composer(props: {
</Box>
</Grid>
{/* Mode selector */}
{!!chatModeMenuAnchor && (
<ChatModeMenu
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
capabilityHasTTI={props.capabilityHasT2I}
/>
)}
{/* Camera */}
{cameraCaptureComponent}
{/* Actile */}
{actileComponent}
</Grid>
{/* Mode selector */}
{!!chatModeMenuAnchor && (
<ChatModeMenu
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
capabilityHasTTI={props.capabilityHasT2I}
/>
)}
{/* Camera */}
{cameraCaptureComponent}
{/* Actile */}
{actileComponent}
</Box>
);
}
@@ -21,7 +21,11 @@ export function ActilePopup(props: {
const hasAnyIcon = props.items.some(item => !!item.Icon);
return (
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding noBottomPadding sx={{ minWidth: 320 }}>
<CloseableMenu
noTopPadding noBottomPadding
open anchorEl={props.anchorEl} onClose={props.onClose}
sx={{ minWidth: 320 }}
>
{!!props.title && (
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
@@ -50,7 +54,7 @@ export function ActilePopup(props: {
color={isActive ? 'primary' : undefined}
onClick={() => props.onItemClick(item)}
>
<ListItemButton>
<ListItemButton color='primary'>
{hasAnyIcon && (
<ListItemDecorator>
{item.Icon ? <item.Icon /> : null}
@@ -96,9 +96,9 @@ export function AttachmentMenu(props: {
return (
<CloseableMenu
dense placement='top' sx={{ minWidth: 200 }}
dense placement='top'
open anchorEl={props.menuAnchor} onClose={props.onClose}
noTopPadding noBottomPadding
sx={{ minWidth: 200 }}
>
{/* Move Arrows */}
@@ -141,9 +141,8 @@ export function Attachments(props: {
{/* Overall Menu */}
{!!overallMenuAnchor && (
<CloseableMenu
placement='top-start'
dense placement='top-start'
open anchorEl={overallMenuAnchor} onClose={handleOverallMenuHide}
noTopPadding noBottomPadding
>
<MenuItem onClick={handleAttachmentsInlineText} disabled={!isOutputTextInlineable}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
@@ -2,13 +2,13 @@ import { create } from 'zustand';
import type { FileWithHandle } from 'browser-fs-access';
import type { ComposerOutputMultiPart } from '../composer.types';
import { attachmentPerformConversion, attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync } from './pipeline';
import { attachmentCreate, attachmentDefineConverters, attachmentLoadInputAsync, attachmentPerformConversion } from './pipeline';
// Attachment Types
export type AttachmentSourceOriginDTO = 'drop' | 'paste';
export type AttachmentSourceOriginFile = 'camera' | 'file-open' | 'clipboard-read' | AttachmentSourceOriginDTO;
export type AttachmentSourceOriginFile = 'camera' | 'screencapture' | 'file-open' | 'clipboard-read' | AttachmentSourceOriginDTO;
export type AttachmentSource = {
media: 'url';
@@ -0,0 +1,62 @@
import * as React from 'react';
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
import ScreenshotMonitorIcon from '@mui/icons-material/ScreenshotMonitor';
import { takeScreenCapture } from '~/common/util/screenCaptureUtils';
export const ButtonAttachScreenCaptureMemo = React.memo(ButtonAttachScreenCapture);
function ButtonAttachScreenCapture(props: { isMobile?: boolean, onAttachScreenCapture: (file: File) => void }) {
// state
const [capturing, setCapturing] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// derived state
const { onAttachScreenCapture } = props;
const handleTakeScreenCapture = React.useCallback(async () => {
setError(null);
setCapturing(true);
try {
const file = await takeScreenCapture();
file && onAttachScreenCapture(file);
} catch (error: any) {
const message = error instanceof Error ? error.message : String(error);
setError(`Screen capture issue: ${message}`);
}
setCapturing(false);
}, [onAttachScreenCapture]);
return props.isMobile ? (
<IconButton onClick={handleTakeScreenCapture}>
<ScreenshotMonitorIcon />
</IconButton>
) : (
<Tooltip
arrow disableInteractive variant='solid' placement='top-start'
title={
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
<b>Attach screen capture</b><br />
{error || 'Attach the image of a window, a browser tab, or a screen'}
</Box>
}
>
<Button
fullWidth
variant={capturing ? 'solid' : 'plain'}
color={!!error ? 'danger' : 'neutral'}
onClick={handleTakeScreenCapture}
loading={capturing}
startDecorator={<ScreenshotMonitorIcon />}
sx={{ justifyContent: 'flex-start' }}
>
Screen
</Button>
</Tooltip>
);
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import { Box, FormControl, FormLabel, IconButton, Switch } from '@mui/joy';
import { ChatMulticastOnIcon } from '~/common/components/icons/ChatMulticastOnIcon';
import { ChatMulticastOffIcon } from '~/common/components/icons/ChatMulticastOffIcon';
export function ButtonMultiChat(props: { isMobile?: boolean, multiChat: boolean, onSetMultiChat: (multiChat: boolean) => void }) {
const { multiChat } = props;
return props.isMobile ? (
<IconButton
variant={multiChat ? 'solid' : 'outlined'}
color={multiChat ? 'warning' : undefined}
onClick={() => props.onSetMultiChat(!multiChat)}
>
{multiChat ? <ChatMulticastOnIcon /> : <ChatMulticastOffIcon />}
</IconButton>
) : (
<FormControl orientation='horizontal' sx={{ minHeight: '2.25rem', justifyContent: 'space-between' }}>
<FormLabel sx={{ gap: 1, flexFlow: 'row nowrap' }}>
<Box sx={{ display: { xs: 'none', lg: 'inline-block' } }}>
{multiChat ? <ChatMulticastOnIcon sx={{ color: 'warning.solidBg' }} /> : <ChatMulticastOffIcon />}
</Box>
{multiChat ? 'Multichat · On' : 'Multichat'}
</FormLabel>
<Switch color={multiChat ? 'primary' : undefined} checked={multiChat} onChange={(e) => props.onSetMultiChat(e.target.checked)} />
</FormControl>
);
}
@@ -12,6 +12,7 @@ import MoreVertIcon from '@mui/icons-material/MoreVert';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DFolder, FOLDERS_COLOR_PALETTE, useFolderStore } from '~/common/state/store-folders';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { themeZIndexOverMobileDrawer } from '~/common/app.theme';
export function FolderListItem(props: {
@@ -199,9 +200,9 @@ export function FolderListItem(props: {
{!!menuAnchorEl && (
<CloseableMenu
dense placement='top'
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
placement='top'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
zIndex={themeZIndexOverMobileDrawer /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
@@ -254,10 +255,10 @@ export function FolderListItem(props: {
id='folder-color'
sx={{
mb: 1.5,
fontWeight: 'xl',
textTransform: 'uppercase',
fontSize: 'xs',
fontWeight: 'xl',
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
Color
@@ -1,13 +1,14 @@
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 { DropdownItems, PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useFolderStore } from '~/common/state/store-folders';
export const ClearFolderText = 'Clear Folder';
export const ClearFolderText = 'No Folder';
const SPECIAL_ID_CLEAR_FOLDER = '_REMOVE_';
@@ -18,7 +19,10 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
// Prepare items for the dropdown
const folderItems: DropdownItems = React.useMemo(() => {
const folderItems: DropdownItems | null = React.useMemo(() => {
if (!folders.length)
return null;
// add one item per folder
const items = folders.reduce((items, folder) => {
items[folder.id] = {
@@ -31,6 +35,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
// add one item representing no folder
items[SPECIAL_ID_CLEAR_FOLDER] = {
title: ClearFolderText,
icon: <ClearIcon />,
};
return items;
@@ -38,7 +43,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
// Handle dropdown folder change
const handleFolderChange = React.useCallback((_event: any, folderId: string | null) => {
const handleFolderChange = React.useCallback((folderId: string | null) => {
if (conversationId && folderId) {
// Remove conversation from all folders
folders.forEach(folder => {
@@ -59,15 +64,15 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
const folderDropdown = React.useMemo(() => {
// don't show the dropdown if folders are not enabled
if (!enableFolders)
if (!enableFolders || !folderItems)
return null;
return (
<PageBarDropdown
<PageBarDropdownMemo
items={folderItems}
value={currentFolderId}
onChange={handleFolderChange}
placeholder='Select a folder'
placeholder='Assign to folder'
showSymbols
/>
);
+115 -198
View File
@@ -1,10 +1,7 @@
import * as React from 'react';
import TimeAgo from 'react-timeago';
import { shallow } from 'zustand/shallow';
import { cleanupEfficiency, Diff as TextDiff, makeDiff } from '@sanity/diff-match-patch';
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import ClearIcon from '@mui/icons-material/Clear';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
@@ -23,30 +20,19 @@ import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import { CloseableMenu } from '~/common/components/CloseableMenu';
import { DMessage } from '~/common/state/store-chats';
import { InlineError } from '~/common/components/InlineError';
import { InlineTextarea } from '~/common/components/InlineTextarea';
import { KeyStroke } from '~/common/components/KeyStroke';
import { Link } from '~/common/components/Link';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { cssRainbowColorKeyframes, lineHeightChatText } from '~/common/app.theme';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
import { prettyBaseModel } from '~/common/util/modelUtils';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { BlocksRenderer, editBlocksSx } from './blocks/BlocksRenderer';
import { useChatShowTextDiff } from '../../store-app-chat';
import { useSanityTextDiffs } from './blocks/RenderTextDiff';
import { RenderCode } from './RenderCode';
import { RenderHtml } from './RenderHtml';
import { RenderImage } from './RenderImage';
import { RenderLatex } from './RenderLatex';
import { RenderMarkdown } from './RenderMarkdown';
import { RenderText } from './RenderText';
import { RenderTextDiff } from './RenderTextDiff';
import { parseBlocks } from './blocks';
// How long is the user collapsed message
const USER_COLLAPSED_LINES: number = 8;
// Enable the menu on text selection
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
@@ -180,21 +166,6 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
return { errorMessage, isAssistantError };
}
function useSanityTextDiffs(text: string, diffText: string | undefined, enabled: boolean) {
const [diffs, setDiffs] = React.useState<TextDiff[] | null>(null);
React.useEffect(() => {
if (!diffText || !enabled)
return setDiffs(null);
setDiffs(
cleanupEfficiency(makeDiff(diffText, text, {
timeout: 1,
checkLines: true,
}), 4),
);
}, [text, diffText, enabled]);
return diffs;
}
export const ChatMessageMemo = React.memo(ChatMessage);
@@ -206,13 +177,14 @@ export const ChatMessageMemo = React.memo(ChatMessage);
* or collapsing long user messages.
*
*/
export function ChatMessage(props: {
function ChatMessage(props: {
message: DMessage,
showDate?: boolean, diffPreviousText?: string,
hideAvatars?: boolean, codeBackground?: string,
noMarkdown?: boolean, diagramMode?: boolean,
isBottom?: boolean, noBottomBorder?: boolean,
isImagining?: boolean, isSpeaking?: boolean,
diffPreviousText?: string,
isBottom?: boolean,
isMobile?: boolean,
isImagining?: boolean,
isSpeaking?: boolean,
blocksShowDate?: boolean,
onConversationBranch?: (messageId: string) => void,
onConversationRestartFrom?: (messageId: string, offset: number) => Promise<void>,
onConversationTruncate?: (messageId: string) => void,
@@ -221,11 +193,9 @@ export function ChatMessage(props: {
onTextDiagram?: (messageId: string, text: string) => Promise<void>
onTextImagine?: (text: string) => Promise<void>
onTextSpeak?: (text: string) => Promise<void>
sx?: SxProps,
}) {
// state
const [forceUserExpanded, setForceUserExpanded] = React.useState(false);
const [isHovering, setIsHovering] = React.useState(false);
const [opsMenuAnchor, setOpsMenuAnchor] = React.useState<HTMLElement | null>(null);
const [selMenuAnchor, setSelMenuAnchor] = React.useState<HTMLElement | null>(null);
@@ -233,10 +203,11 @@ export function ChatMessage(props: {
const [isEditing, setIsEditing] = React.useState(false);
// external state
const { cleanerLooks, renderMarkdown, doubleClickToEdit } = useUIPreferencesStore(state => ({
const { cleanerLooks, doubleClickToEdit, messageTextSize, renderMarkdown } = useUIPreferencesStore(state => ({
cleanerLooks: state.zenMode === 'cleaner',
renderMarkdown: state.renderMarkdown,
doubleClickToEdit: state.doubleClickToEdit,
messageTextSize: state.messageTextSize,
renderMarkdown: state.renderMarkdown,
}), shallow);
const [showDiff, setShowDiff] = useChatShowTextDiff();
const textDiffs = useSanityTextDiffs(props.message.text, props.diffPreviousText, showDiff);
@@ -257,10 +228,9 @@ export function ChatMessage(props: {
const fromAssistant = messageRole === 'assistant';
const fromSystem = messageRole === 'system';
const fromUser = messageRole === 'user';
const wasEdited = !!messageUpdated;
const showAvatars = props.hideAvatars !== true && !cleanerLooks;
const showAvatars = !cleanerLooks;
const textSel = selMenuText ? selMenuText : messageText;
const isSpecialT2I = textSel.startsWith('https://images.prodia.xyz/') || textSel.startsWith('/draw ') || textSel.startsWith('/imagine ') || textSel.startsWith('/img ');
@@ -275,36 +245,35 @@ export function ChatMessage(props: {
props.onMessageEdit(messageId, editedText);
};
const handleUncollapse = () => setForceUserExpanded(true);
// Operations Menu
const closeOperationsMenu = () => setOpsMenuAnchor(null);
const closeOpsMenu = () => setOpsMenuAnchor(null);
const handleOpsCopy = (e: React.MouseEvent) => {
copyToClipboard(textSel, 'Text');
e.preventDefault();
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
};
const handleOpsEdit = (e: React.MouseEvent) => {
const handleOpsEdit = React.useCallback((e: React.MouseEvent) => {
if (messageTyping && !isEditing) return; // don't allow editing while typing
setIsEditing(!isEditing);
e.preventDefault();
closeOperationsMenu();
};
closeOpsMenu();
}, [isEditing, messageTyping]);
const handleOpsConversationBranch = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation(); // to try to not steal the focus from the banched conversation
props.onConversationBranch && props.onConversationBranch(messageId);
closeOperationsMenu();
closeOpsMenu();
};
const handleOpsConversationRestartFrom = async (e: React.MouseEvent) => {
e.preventDefault();
closeOperationsMenu();
closeOpsMenu();
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
};
@@ -314,7 +283,7 @@ export function ChatMessage(props: {
e.preventDefault();
if (props.onTextDiagram) {
await props.onTextDiagram(messageId, textSel);
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
}
};
@@ -323,7 +292,7 @@ export function ChatMessage(props: {
e.preventDefault();
if (props.onTextImagine) {
await props.onTextImagine(textSel);
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
}
};
@@ -332,14 +301,14 @@ export function ChatMessage(props: {
e.preventDefault();
if (props.onTextSpeak) {
await props.onTextSpeak(textSel);
closeOperationsMenu();
closeOpsMenu();
closeSelectionMenu();
}
};
const handleOpsTruncate = (_e: React.MouseEvent) => {
props.onConversationTruncate && props.onConversationTruncate(messageId);
closeOperationsMenu();
closeOpsMenu();
};
const handleOpsDelete = (_e: React.MouseEvent) => {
@@ -395,6 +364,17 @@ export function ChatMessage(props: {
}, [openSelectionMenu]);
// Blocks renderer
const handleBlocksContextMenu = React.useCallback((event: React.MouseEvent) => {
handleMouseUp(event.nativeEvent);
}, [handleMouseUp]);
const handleBlocksDoubleClick = React.useCallback((event: React.MouseEvent) => {
doubleClickToEdit && props.onMessageEdit && handleOpsEdit(event);
}, [doubleClickToEdit, handleOpsEdit, props.onMessageEdit]);
// prettier upstream errors
const { isAssistantError, errorMessage } = React.useMemo(
() => explainErrorInMessage(messageText, fromAssistant, messageOriginLLM),
@@ -410,50 +390,19 @@ export function ChatMessage(props: {
[messageAvatar, messageOriginLLM, messagePurposeId, messageRole, messageSender, messageTyping, showAvatars],
);
// per-blocks css
const blockSx: SxProps = {
my: 'auto',
lineHeight: lineHeightChatText,
};
const typographySx: SxProps = {
lineHeight: lineHeightChatText,
};
const codeSx: SxProps = {
// backgroundColor: fromAssistant ? 'background.level1' : 'background.level1',
backgroundColor: props.codeBackground ? props.codeBackground : fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
boxShadow: 'xs',
fontFamily: 'code',
fontSize: '0.875rem',
fontVariantLigatures: 'none',
lineHeight: lineHeightChatText,
borderRadius: 'var(--joy-radius-sm)',
};
// user message truncation
let collapsedText = messageText;
let isCollapsed = false;
if (fromUser && !forceUserExpanded) {
const lines = messageText.split('\n');
if (lines.length > USER_COLLAPSED_LINES) {
collapsedText = lines.slice(0, USER_COLLAPSED_LINES).join('\n');
isCollapsed = true;
}
}
return (
<ListItem
sx={{
display: 'flex', flexDirection: !fromAssistant ? 'row-reverse' : 'row', alignItems: 'flex-start',
gap: { xs: 0, md: 1 }, px: { xs: 1, md: 2 }, py: 2,
gap: { xs: 0, md: 1 },
px: { xs: 1, md: 2 },
py: 2,
backgroundColor,
...(props.noBottomBorder !== true && {
borderBottom: '1px solid',
borderBottomColor: 'divider',
}),
borderBottom: '1px solid',
borderBottomColor: 'divider',
...(ENABLE_COPY_MESSAGE_OVERLAY && { position: 'relative' }),
'&:hover > button': { opacity: 1 },
...props.sx,
}}
>
@@ -499,73 +448,26 @@ export function ChatMessage(props: {
<InlineTextarea
initialText={messageText} onEdit={handleTextEdited}
sx={{
...blockSx,
flexGrow: 1,
}} />
sx={editBlocksSx}
/>
) : (
<Box
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
sx={{
...blockSx,
flexGrow: 0,
overflowX: 'auto',
...(!!props.diagramMode && {
// width: '100%',
boxShadow: 'md',
}),
}}>
<BlocksRenderer
text={messageText}
fromRole={messageRole}
renderTextAsMarkdown={renderMarkdown}
messageTextSize={messageTextSize}
errorMessage={errorMessage}
isBottom={props.isBottom}
isMobile={props.isMobile}
showDate={props.blocksShowDate === true ? messageUpdated || messageCreated || undefined : undefined}
renderTextDiff={textDiffs || undefined}
wasUserEdited={wasEdited}
onContextMenu={(props.onMessageEdit && ENABLE_SELECTION_RIGHT_CLICK_MENU) ? handleBlocksContextMenu : undefined}
onDoubleClick={(props.onMessageEdit && doubleClickToEdit) ? handleBlocksDoubleClick : undefined}
/>
{props.showDate === true && (
<Typography level='body-sm' sx={{ mx: 1.5, textAlign: fromAssistant ? 'left' : 'right' }}>
<TimeAgo date={messageUpdated || messageCreated} />
</Typography>
)}
{/* Warn about user-edited system message */}
{fromSystem && wasEdited && (
<Typography level='body-sm' color='warning' sx={{ mt: 1, mx: 1.5 }}>modified by user - auto-update disabled</Typography>
)}
{errorMessage && (
<Tooltip title={<Typography sx={{ maxWidth: 800 }}>{collapsedText}</Typography>} variant='soft'>
<InlineError error={errorMessage} />
</Tooltip>
)}
{/* sequence of render components, for each Block */}
{!errorMessage && parseBlocks(collapsedText, fromSystem, textDiffs)
.filter((block, _, blocks) => !props.diagramMode || block.type === 'code' || blocks.length === 1)
.map(
(block, index) =>
block.type === 'html'
? <RenderHtml key={'html-' + index} htmlBlock={block} sx={codeSx} />
: block.type === 'code'
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} noCopyButton={props.diagramMode} />
: block.type === 'image'
? <RenderImage key={'image-' + index} imageBlock={block} isFirst={!index} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsConversationRestartFrom} />
: block.type === 'latex'
? <RenderLatex key={'latex-' + index} latexBlock={block} sx={typographySx} />
: block.type === 'diff'
? <RenderTextDiff key={'latex-' + index} diffBlock={block} sx={typographySx} />
: (renderMarkdown && props.noMarkdown !== true && !fromSystem && !(fromUser && block.content.startsWith('/')))
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
: <RenderText key={'text-' + index} textBlock={block} sx={typographySx} />)}
{isCollapsed && (
<Button variant='plain' color='neutral' onClick={handleUncollapse}>... expand ...</Button>
)}
{/* import VisibilityIcon from '@mui/icons-material/Visibility'; */}
{/*<br />*/}
{/*<Chip variant='outlined' color='warning' sx={{ mt: 1, fontSize: '0.75em' }} startDecorator={<VisibilityIcon />}>*/}
{/* BlockAction*/}
{/*</Chip>*/}
</Box>
)}
@@ -587,9 +489,11 @@ export function ChatMessage(props: {
{/* Operations Menu (3 dots) */}
{!!opsMenuAnchor && (
<CloseableMenu
dense placement='bottom-end' sx={{ minWidth: 280 }}
open anchorEl={opsMenuAnchor} onClose={closeOperationsMenu}
dense placement='bottom-end'
open anchorEl={opsMenuAnchor} onClose={closeOpsMenu}
sx={{ minWidth: 280 }}
>
{/* Edit / Copy */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{!!props.onMessageEdit && (
<MenuItem variant='plain' disabled={messageTyping} onClick={handleOpsEdit} sx={{ flex: 1 }}>
@@ -603,6 +507,32 @@ export function ChatMessage(props: {
Copy
</MenuItem>
</Box>
{/* Delete / Branch / Truncate */}
{!!props.onMessageDelete && <ListDivider />}
{!!props.onMessageDelete && (
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Delete
<span style={{ opacity: 0.5 }}>message</span>
</MenuItem>
)}
{!!props.onConversationBranch && (
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
<ListItemDecorator>
<ForkRightIcon />
</ListItemDecorator>
Branch
{!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
</MenuItem>
)}
{!!props.onConversationTruncate && (
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Truncate
<span style={{ opacity: 0.5 }}>after this</span>
</MenuItem>
)}
{/* Diff Viewer */}
{!!props.diffPreviousText && <ListDivider />}
{!!props.diffPreviousText && (
<MenuItem onClick={handleOpsToggleShowDiff}>
@@ -611,10 +541,31 @@ export function ChatMessage(props: {
<Switch checked={showDiff} onChange={handleOpsToggleShowDiff} sx={{ ml: 'auto' }} />
</MenuItem>
)}
<ListDivider />
{/* Diagram / Draw / Speak */}
{!!props.onTextDiagram && <ListDivider />}
{!!props.onTextDiagram && (
<MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Diagram ...
</MenuItem>
)}
{!!props.onTextImagine && (
<MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
Draw ...
</MenuItem>
)}
{!!props.onTextSpeak && (
<MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
</MenuItem>
)}
{/* Restart/try */}
{!!props.onConversationRestartFrom && <ListDivider />}
{!!props.onConversationRestartFrom && (
<MenuItem onClick={handleOpsConversationRestartFrom}>
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <TelegramIcon />}</ListItemDecorator>
<ListItemDecorator>{fromAssistant ? <ReplayIcon color='primary' /> : <TelegramIcon color='primary' />}</ListItemDecorator>
{!fromAssistant
? <>Restart <span style={{ opacity: 0.5 }}>from here</span></>
: !props.isBottom
@@ -622,42 +573,7 @@ export function ChatMessage(props: {
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Retry
<KeyStroke combo='Ctrl + Shift + R' />
</Box>
}
</MenuItem>
)}
{!!props.onConversationBranch && (
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
<ListItemDecorator>
<ForkRightIcon />
</ListItemDecorator>
Branch {!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
</MenuItem>
)}
{!!props.onConversationBranch && <ListDivider />}
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Diagram ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
Draw ...
</MenuItem>}
{!!props.onTextSpeak && <MenuItem onClick={handleOpsSpeak} disabled={!couldSpeak || props.isSpeaking}>
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
Speak
</MenuItem>}
{!!props.onConversationRestartFrom && <ListDivider />}
{!!props.onConversationTruncate && (
<MenuItem onClick={handleOpsTruncate} disabled={props.isBottom}>
<ListItemDecorator><VerticalAlignBottomIcon /></ListItemDecorator>
Truncate <span style={{ opacity: 0.5 }}>after</span>
</MenuItem>
)}
{!!props.onMessageDelete && (
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
Delete <span style={{ opacity: 0.5 }}>message</span>
</Box>}
</MenuItem>
)}
</CloseableMenu>
@@ -666,8 +582,9 @@ export function ChatMessage(props: {
{/* Selection (Contextual) Menu */}
{!!selMenuAnchor && (
<CloseableMenu
dense placement='bottom-start' sx={{ minWidth: 220 }}
dense placement='bottom-start'
open anchorEl={selMenuAnchor} onClose={closeSelectionMenu}
sx={{ minWidth: 220 }}
>
<MenuItem onClick={handleOpsCopy} sx={{ flex: 1 }}>
<ListItemDecorator><ContentCopyIcon /></ListItemDecorator>
@@ -1,53 +0,0 @@
import * as React from 'react';
import { Box, styled } from '@mui/joy';
import { lineHeightChatText } from '~/common/app.theme';
import type { TextBlock } from './blocks';
/*
* For performance reasons, we style this component here and copy the equivalent of 'props.sx' (the lineHeight) locally.
*/
const RenderMarkdownBox = styled(Box)({
// same look as the other RenderComponents
marginInline: '0.75rem !important', // margin: 1.5 like other blocks
lineHeight: lineHeightChatText,
// patch the CSS
// fontFamily: `inherit !important`, // (not needed anymore, as CSS is under our control) use the default font family
// '--color-canvas-default': 'transparent !important', // (not needed anymore) remove the default background color
'& table': { width: 'inherit !important' }, // un-break auto-width (tables have 'max-content', which overflows)
});
// Dynamically import ReactMarkdown using React.lazy
const DynamicReactGFM = React.lazy(async () => {
const [markdownModule, remarkGfmModule] = await Promise.all([
import('react-markdown'),
import('remark-gfm'),
]);
// NOTE: extracted here instead of inline as a large performance optimization
const remarkPlugins = [remarkGfmModule.default];
// Pass the dynamically imported remarkGfm as children
const ReactMarkdownWithRemarkGfm = (props: any) =>
<markdownModule.default remarkPlugins={remarkPlugins} {...props} />;
return { default: ReactMarkdownWithRemarkGfm };
});
export const RenderMarkdown = (props: { textBlock: TextBlock }) => {
return (
<RenderMarkdownBox className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */ >
<React.Suspense fallback={<div>Loading...</div>}>
<DynamicReactGFM>
{props.textBlock.content}
</DynamicReactGFM>
</React.Suspense>
</RenderMarkdownBox>
);
};
@@ -0,0 +1,203 @@
import * as React from 'react';
import TimeAgo from 'react-timeago';
import type { Diff as TextDiff } from '@sanity/diff-match-patch';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, Tooltip, Typography } from '@mui/joy';
import type { DMessage } from '~/common/state/store-chats';
import type { UIMessageTextSize } from '~/common/state/store-ui';
import { InlineError } from '~/common/components/InlineError';
import { lineHeightChatCode, lineHeightChatText } from '~/common/app.theme';
import { RenderCodeMemo } from './code/RenderCode';
import { RenderHtml } from './RenderHtml';
import { RenderImage } from './RenderImage';
import { RenderLatex } from './RenderLatex';
import { RenderMarkdownMemo } from './RenderMarkdown';
import { RenderText } from './RenderText';
import { RenderTextDiff } from './RenderTextDiff';
import { areBlocksEqual, Block, parseMessageBlocks } from './blocks';
// How long is the user collapsed message
const USER_COLLAPSED_LINES: number = 8;
const blocksSx: SxProps = {
my: 'auto',
lineHeight: lineHeightChatText,
} as const;
export const editBlocksSx: SxProps = {
...blocksSx,
flexGrow: 1,
} as const;
const renderBlocksSx: SxProps = {
...blocksSx,
flexGrow: 0,
overflowX: 'auto',
} as const;
export function BlocksRenderer(props: {
// required
text: string;
fromRole: DMessage['role'];
messageTextSize?: UIMessageTextSize;
renderTextAsMarkdown: boolean;
renderTextDiff?: TextDiff[];
errorMessage?: React.ReactNode;
isBottom?: boolean;
isMobile?: boolean;
showDate?: number;
wasUserEdited?: boolean;
specialDiagramMode?: boolean;
onContextMenu?: (event: React.MouseEvent) => void;
onDoubleClick?: (event: React.MouseEvent) => void;
onImageRegenerate?: () => void;
}) {
// state
const [forceUserExpanded, setForceUserExpanded] = React.useState(false);
const prevBlocksRef = React.useRef<Block[]>([]);
// derived state
const { text: _text, errorMessage, renderTextDiff, wasUserEdited = false } = props;
const fromAssistant = props.fromRole === 'assistant';
const fromSystem = props.fromRole === 'system';
const fromUser = props.fromRole === 'user';
const handleTextUncollapse = React.useCallback(() => {
setForceUserExpanded(true);
}, []);
// Memo text, which could be 'collapsed' to a few lines in case of user messages
const { text, isTextCollapsed } = React.useMemo(() => {
if (fromUser && !forceUserExpanded) {
const textLines = _text.split('\n');
if (textLines.length > USER_COLLAPSED_LINES)
return { text: textLines.slice(0, USER_COLLAPSED_LINES).join('\n'), isTextCollapsed: true };
}
return { text: _text, isTextCollapsed: false };
}, [forceUserExpanded, fromUser, _text]);
// Memo the code style, to minimize re-renders
const scaledCodeSx: SxProps = React.useMemo(() => (
{
backgroundColor: props.specialDiagramMode ? 'background.surface' : fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
boxShadow: props.specialDiagramMode ? 'md' : 'xs',
fontFamily: 'code',
fontSize: props.messageTextSize === 'xs' ? '0.75rem' : props.messageTextSize === 'sm' ? '0.75rem' : '0.875rem',
fontVariantLigatures: 'none',
lineHeight: lineHeightChatCode,
borderRadius: 'var(--joy-radius-sm)',
}
), [fromAssistant, props.messageTextSize, props.specialDiagramMode]);
const scaledTypographySx: SxProps = React.useMemo(() => (
{
lineHeight: lineHeightChatText,
fontSize: (!props.messageTextSize || props.messageTextSize === 'md') ? undefined : props.messageTextSize,
}
), [props.messageTextSize]);
// Block splitter, with memoand special recycle of former blocks, to help React minimize render work
const blocks = React.useMemo(() => {
// split the complete input text into blocks
const newBlocks = errorMessage ? [] : parseMessageBlocks(text, fromSystem, renderTextDiff);
// recycle the previous blocks if they are the same, for stable references to React
const recycledBlocks: Block[] = [];
for (let i = 0; i < newBlocks.length; i++) {
const newBlock = newBlocks[i];
const prevBlock = prevBlocksRef.current[i];
// Check if the new block can be replaced by the previous block to maintain reference stability
if (prevBlock && areBlocksEqual(prevBlock, newBlock)) {
recycledBlocks.push(prevBlock);
} else {
// Once a block doesn't match, we use the new blocks from this point forward.
recycledBlocks.push(...newBlocks.slice(i));
break;
}
}
// Update prevBlocksRef with the current blocks for the next render
prevBlocksRef.current = recycledBlocks;
// Apply specialDiagramMode filter if applicable
return props.specialDiagramMode
? recycledBlocks.filter(block => block.type === 'code' || recycledBlocks.length === 1)
: recycledBlocks;
}, [errorMessage, fromSystem, props.specialDiagramMode, renderTextDiff, text]);
return (
<Box
onContextMenu={props.onContextMenu}
onDoubleClick={props.onDoubleClick}
sx={renderBlocksSx}
>
{!!props.showDate && (
<Typography level='body-sm' sx={{ mx: 1.5, textAlign: fromAssistant ? 'left' : 'right' }}>
<TimeAgo date={props.showDate} />
</Typography>
)}
{/* Warn about user-edited system message */}
{fromSystem && wasUserEdited && (
<Typography level='body-sm' color='warning' sx={{ mt: 1, mx: 1.5 }}>modified by user - auto-update disabled</Typography>
)}
{errorMessage ? (
<Tooltip title={<Typography sx={{ maxWidth: 800 }}>{text}</Typography>} variant='soft'>
<InlineError error={errorMessage} />
</Tooltip>
) : (
// sequence of render components, for each Block
blocks.map(
(block, index) =>
block.type === 'html'
? <RenderHtml key={'html-' + index} htmlBlock={block} sx={scaledCodeSx} />
: block.type === 'code'
? <RenderCodeMemo key={'code-' + index} codeBlock={block} isMobile={props.isMobile} noCopyButton={props.specialDiagramMode} sx={scaledCodeSx} />
: block.type === 'image'
? <RenderImage key={'image-' + index} imageBlock={block} isFirst={!index} allowRunAgain={props.isBottom === true} onRunAgain={props.onImageRegenerate} />
: block.type === 'latex'
? <RenderLatex key={'latex-' + index} latexBlock={block} sx={scaledTypographySx} />
: block.type === 'diff'
? <RenderTextDiff key={'latex-' + index} diffBlock={block} sx={scaledTypographySx} />
: (props.renderTextAsMarkdown && !fromSystem && !(fromUser && block.content.startsWith('/')))
? <RenderMarkdownMemo key={'text-md-' + index} textBlock={block} sx={scaledTypographySx} />
: <RenderText key={'text-' + index} textBlock={block} sx={scaledTypographySx} />)
)}
{isTextCollapsed && <Button variant='plain' color='neutral' onClick={handleTextUncollapse}>... expand ...</Button>}
{/* import VisibilityIcon from '@mui/icons-material/Visibility'; */}
{/*<br />*/}
{/*<Chip variant='outlined' color='warning' sx={{ mt: 1, fontSize: '0.75em' }} startDecorator={<VisibilityIcon />}>*/}
{/* BlockAction*/}
{/*</Chip>*/}
</Box>
);
}
@@ -1,14 +1,14 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, IconButton, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import WebIcon from '@mui/icons-material/Web';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { HtmlBlock } from './blocks';
import { overlayButtonsSx } from './RenderCode';
import type { HtmlBlock } from './blocks';
import { overlayButtonsSx } from './code/RenderCode';
// this is used by the blocks parser (for full text detection) and by the Code component (for inline rendering)
@@ -6,8 +6,8 @@ import ReplayIcon from '@mui/icons-material/Replay';
import { Link } from '~/common/components/Link';
import { ImageBlock } from './blocks';
import { overlayButtonsSx } from './RenderCode';
import type { ImageBlock } from './blocks';
import { overlayButtonsSx } from './code/RenderCode';
const mdImageReferenceRegex = /^!\[([^\]]*)]\(([^)]+)\)$/;
@@ -3,7 +3,7 @@ import * as React from 'react';
import { Box } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { LatexBlock } from './blocks';
import type { LatexBlock } from './blocks';
// Dynamically import the Katex functions
@@ -14,13 +14,15 @@ const RenderLatexDynamic = React.lazy(async () => {
};
});
export const RenderLatex = ({ latexBlock, sx }: { latexBlock: LatexBlock; sx?: SxProps; }) =>
export const RenderLatex = (props: { latexBlock: LatexBlock; sx?: SxProps; }) =>
<Box
sx={{
mx: 1.5,
...(sx || {}),
my: '0.5em',
textAlign: 'center',
...props.sx,
}}>
<React.Suspense fallback={<div/>}>
<RenderLatexDynamic latex={latexBlock.latex} />
<React.Suspense fallback={<div />}>
<RenderLatexDynamic latex={props.latexBlock.latex} />
</React.Suspense>
</Box>;
@@ -0,0 +1,134 @@
import * as React from 'react';
import { CSVLink } from 'react-csv';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, styled } from '@mui/joy';
import DownloadIcon from '@mui/icons-material/Download';
import { lineHeightChatText } from '~/common/app.theme';
import type { TextBlock } from './blocks';
/*
* For performance reasons, we style this component here and copy the equivalent of 'props.sx' (the lineHeight) locally.
*/
const RenderMarkdownBox = styled(Box)({
// same look as the other RenderComponents
marginInline: '0.75rem !important', // margin: 1.5 like other blocks
lineHeight: lineHeightChatText,
// patch the CSS
// fontFamily: `inherit !important`, // (not needed anymore, as CSS is under our control) use the default font family
// '--color-canvas-default': 'transparent !important', // (not needed anymore) remove the default background color
'& table': { width: 'inherit !important' }, // un-break auto-width (tables have 'max-content', which overflows)
});
// Dynamically import ReactMarkdown using React.lazy
const DynamicReactGFM = React.lazy(async () => {
const [markdownModule, remarkGfmModule] = await Promise.all([
import('react-markdown'),
import('remark-gfm'),
]);
// NOTE: extracted here instead of inline as a large performance optimization
const remarkPlugins = [remarkGfmModule.default];
//Extracts table data from jsx element in table renderer
const extractTableData = (children: React.JSX.Element) => {
// Function to extract text from a React element or component
const extractText = (element: any): String => {
// Base case: if the element is a string, return it
if (typeof element === 'string') {
return element;
}
// If the element has children, recursively extract text from them
if (element.props && element.props.children) {
if (Array.isArray(element.props.children)) {
return element.props.children.map(extractText).join('');
}
return extractText(element.props.children);
}
return '';
};
// Function to traverse and extract data from table rows and cells
const traverseAndExtract = (elements: any, tableData: any[] = []) => {
React.Children.forEach(elements, (element) => {
if (element.type === 'tr') {
const rowData = React.Children.map(element.props.children, (cell) => {
// Extract and return the text content of each cell
return extractText(cell);
});
tableData.push(rowData);
} else if (element.props && element.props.children) {
traverseAndExtract(element.props.children, tableData);
}
});
return tableData;
};
return traverseAndExtract(children);
};
interface TableRendererProps {
children: React.JSX.Element;
node?: any; // an optional field we want to not pass to the <table/> element
}
// Define a custom table renderer
const TableRenderer = ({ children, node, ...props }: TableRendererProps) => {
// Apply custom styles or modifications here
const tableData = extractTableData(children);
return (
<>
<table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: '0.5rem' }} {...props}>
{children}
</table>
<CSVLink filename='big-agi-export' data={tableData}>
<Button variant='outlined' color='neutral' size='md' endDecorator={<DownloadIcon />} sx={{
mb: '1rem',
backgroundColor: 'background.popup', // make this button 'pop' a bit from the page
}}>
Download table as .csv
</Button>
</CSVLink>
</>
);
};
// Use the custom renderer for tables
const components = {
table: TableRenderer,
// Add custom renderers for other elements if needed
};
// Pass the dynamically imported remarkGfm as children
const ReactMarkdownWithRemarkGfm = (props: any) =>
<markdownModule.default
remarkPlugins={remarkPlugins}
{...props}
components={components}
/>;
return { default: ReactMarkdownWithRemarkGfm };
});
function RenderMarkdown(props: { textBlock: TextBlock; sx?: SxProps; }) {
return (
<RenderMarkdownBox
className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */
sx={props.sx}
>
<React.Suspense fallback={<div>Loading...</div>}>
<DynamicReactGFM>
{props.textBlock.content}
</DynamicReactGFM>
</React.Suspense>
</RenderMarkdownBox>
);
}
export const RenderMarkdownMemo = React.memo(RenderMarkdown);
@@ -3,13 +3,15 @@ import * as React from 'react';
import { Chip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { extractChatCommand } from '../../commands/commands.registry';
import { extractChatCommand } from '../../../commands/commands.registry';
import type { TextBlock } from './blocks';
export const RenderText = (props: { textBlock: TextBlock; sx?: SxProps; }) => {
const elements = extractChatCommand(props.textBlock.content);
return (
<Typography
sx={{
@@ -18,7 +20,7 @@ export const RenderText = (props: { textBlock: TextBlock; sx?: SxProps; }) => {
alignItems: 'baseline',
overflowWrap: 'anywhere',
whiteSpace: 'break-spaces',
...(props.sx || {}),
...props.sx,
}}
>
{elements.map((element, index) =>
@@ -1,19 +1,49 @@
import * as React from 'react';
import { Diff as TextDiff, DIFF_DELETE, DIFF_INSERT } from '@sanity/diff-match-patch';
import { cleanupEfficiency, Diff as TextDiff, DIFF_DELETE, DIFF_INSERT, makeDiff } from '@sanity/diff-match-patch';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Typography, useTheme } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import { DiffBlock } from './blocks';
import type { DiffBlock } from './blocks';
export const RenderTextDiff = ({ diffBlock, sx }: { diffBlock: DiffBlock; sx?: SxProps; }) => {
export function useSanityTextDiffs(_text: string, _diffText: string | undefined, enabled: boolean) {
// state
const [diffs, setDiffs] = React.useState<TextDiff[] | null>(null);
const inputText = enabled ? _text : null;
const inputPrevText = enabled ? _diffText : null;
// async processing of diffs
React.useEffect(() => {
if (!inputText || !inputPrevText)
return setDiffs(null);
const callback = () => {
setDiffs(
cleanupEfficiency(makeDiff(inputPrevText, inputText, {
timeout: 1,
checkLines: true,
}), 4),
);
};
// slight delay to cancel the previous operation if too close to this
const timeout = setTimeout(callback, 200);
return () => clearTimeout(timeout);
}, [inputPrevText, inputText]);
return diffs;
}
export const RenderTextDiff = (props: { diffBlock: DiffBlock; sx?: SxProps; }) => {
// external state
const theme = useTheme();
// derived state
const textDiffs: TextDiff[] = diffBlock.textDiffs;
const textDiffs: TextDiff[] = props.diffBlock.textDiffs;
// text added
const styleAdd = {
@@ -44,7 +74,7 @@ export const RenderTextDiff = ({ diffBlock, sx }: { diffBlock: DiffBlock; sx?: S
whiteSpace: 'break-spaces',
display: 'block',
zIndex: 200,
...(sx || {}),
...props.sx,
}}
>
{textDiffs.map(([op, text], index) =>
@@ -1,9 +1,10 @@
import type { Diff as TextDiff } from '@sanity/diff-match-patch';
import { heuristicIsHtml } from './RenderHtml';
import { heuristicMarkdownImageReferenceBlocks, heuristicLegacyImageBlocks } from './RenderImage';
import { heuristicLegacyImageBlocks, heuristicMarkdownImageReferenceBlocks } from './RenderImage';
type Block = CodeBlock | DiffBlock | HtmlBlock | ImageBlock | LatexBlock | TextBlock;
// Block types
export type Block = CodeBlock | DiffBlock | HtmlBlock | ImageBlock | LatexBlock | TextBlock;
export type CodeBlock = { type: 'code'; blockTitle: string; blockCode: string; complete: boolean; };
export type DiffBlock = { type: 'diff'; textDiffs: TextDiff[] };
export type HtmlBlock = { type: 'html'; html: string; };
@@ -12,11 +13,33 @@ export type LatexBlock = { type: 'latex'; latex: string; };
export type TextBlock = { type: 'text'; content: string; }; // for Text or Markdown
export function parseBlocks(text: string, forceText: boolean, textDiffs: TextDiff[] | null): Block[] {
if (forceText)
export function areBlocksEqual(a: Block, b: Block): boolean {
if (a.type !== b.type)
return false;
switch (a.type) {
case 'code':
return a.blockTitle === (b as CodeBlock).blockTitle && a.blockCode === (b as CodeBlock).blockCode && a.complete === (b as CodeBlock).complete;
case 'diff':
return false; // diff blocks are never equal
case 'html':
return a.html === (b as HtmlBlock).html;
case 'image':
return a.url === (b as ImageBlock).url && a.alt === (b as ImageBlock).alt;
case 'latex':
return a.latex === (b as LatexBlock).latex;
case 'text':
return a.content === (b as TextBlock).content;
}
}
export function parseMessageBlocks(text: string, disableParsing: boolean, forceTextDiffs?: TextDiff[]): Block[] {
if (disableParsing)
return [{ type: 'text', content: text }];
if (textDiffs && textDiffs.length > 0)
return [{ type: 'diff', textDiffs }];
if (forceTextDiffs && forceTextDiffs.length >= 1)
return [{ type: 'diff', textDiffs: forceTextDiffs }];
// special case: this could be generated by a proxy that returns an HTML page instead of the API response
if (heuristicIsHtml(text))
@@ -33,8 +56,8 @@ export function parseBlocks(text: string, forceText: boolean, textDiffs: TextDif
return legacyImageBlocks;
const regexPatterns = {
codeBlock: /`{3,}([\w\\.+-_]+)?\n([\s\S]*?)(`{3,}\n?|$)/g,
latexBlock: /\$\$([\s\S]*?)\$\$/g,
codeBlock: /`{3,}([\w\x20\\.+-_]+)?\n([\s\S]*?)(`{3,}\n?|$)/g,
latexBlock: /\$\$([\s\S]*?)\$\$\n?/g,
// latexBlockOrInline: /\$\$([\s\S]*?)\$\$|\$([^$]*?)\$/g,
};
@@ -9,7 +9,7 @@ interface CodeBlockProps {
};
}
export function OpenInCodepen({ codeBlock }: CodeBlockProps): React.JSX.Element {
export function ButtonCodepen({ codeBlock }: CodeBlockProps): React.JSX.Element {
const { code, language } = codeBlock;
const hasCSS = language === 'css';
const hasJS = ['javascript', 'json', 'typescript'].includes(language || '');
@@ -9,7 +9,7 @@ interface CodeBlockProps {
};
}
export function OpenInReplit({ codeBlock }: CodeBlockProps): React.JSX.Element {
export function ButtonReplit({ codeBlock }: CodeBlockProps): React.JSX.Element {
const { language } = codeBlock;
const replitLanguageMap: Record<string, string> = {
@@ -1,36 +1,73 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, IconButton, Sheet, Tooltip, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import FitScreenIcon from '@mui/icons-material/FitScreen';
import HtmlIcon from '@mui/icons-material/Html';
import SchemaIcon from '@mui/icons-material/Schema';
import ShapeLineOutlinedIcon from '@mui/icons-material/ShapeLineOutlined';
import { copyToClipboard } from '~/common/util/clipboardUtils';
import { CodeBlock } from './blocks';
import { OpenInCodepen } from './OpenInCodepen';
import { OpenInReplit } from './OpenInReplit';
import { RenderCodeMermaid } from './RenderCodeMermaid';
import { heuristicIsHtml, IFrameComponent } from './RenderHtml';
import type { CodeBlock } from '../blocks';
import { ButtonCodepen } from './ButtonCodepen';
import { ButtonReplit } from './ButtonReplit';
import { heuristicIsHtml, IFrameComponent } from '../RenderHtml';
import { patchSvgString, RenderCodeMermaid } from './RenderCodeMermaid';
async function fetchPlantUmlSvg(plantUmlCode: string): Promise<string | null> {
// fetch the PlantUML SVG
let text: string = '';
try {
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
const { encode: plantUmlEncode } = await import('plantuml-encoder');
// retrieve and manually adapt the SVG, to remove the background
const encodedPlantUML: string = plantUmlEncode(plantUmlCode);
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
text = await response.text();
} catch (e) {
return null;
}
// validate/extract the SVG
const start = text.indexOf('<svg ');
const end = text.indexOf('</svg>');
if (start < 0 || end <= start)
throw new Error('Could not render PlantUML');
const svg = text
.slice(start, end + 6) // <svg ... </svg>
.replace('background:#FFFFFF;', ''); // transparent background
// check for syntax errors
if (svg.includes('>Syntax Error?</text>'))
throw new Error('syntax issue (it happens!). Please regenerate or change generator model.');
return svg;
}
export const overlayButtonsSx: SxProps = {
position: 'absolute', top: 0, right: 0, zIndex: 10,
display: 'flex', flexDirection: 'row', gap: 1,
opacity: 0, transition: 'opacity 0.2s',
'& > button': { backdropFilter: 'blur(12px)' },
// '& > button': {
// backgroundColor: 'background.level2',
// backdropFilter: 'blur(12px)',
// },
};
function RenderCodeImpl(props: {
codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps,
highlightCode: (inferredCodeLanguage: string | null, blockCode: string) => string,
inferCodeLanguage: (blockTitle: string, code: string) => string | null,
isMobile?: boolean,
}) {
// state
const [fitScreen, setFitScreen] = React.useState(!!props.isMobile);
const [showHTML, setShowHTML] = React.useState(false);
const [showMermaid, setShowMermaid] = React.useState(true);
const [showPlantUML, setShowPlantUML] = React.useState(true);
@@ -43,12 +80,11 @@ function RenderCodeImpl(props: {
} = props;
// heuristic for language, and syntax highlight
const { highlightedCode, inferredCodeLanguage } = React.useMemo(
() => {
const inferredCodeLanguage = inferCodeLanguage(blockTitle, blockCode);
const highlightedCode = highlightCode(inferredCodeLanguage, blockCode);
return { highlightedCode, inferredCodeLanguage };
}, [inferCodeLanguage, blockTitle, blockCode, highlightCode]);
const { highlightedCode, inferredCodeLanguage } = React.useMemo(() => {
const inferredCodeLanguage = inferCodeLanguage(blockTitle, blockCode);
const highlightedCode = highlightCode(inferredCodeLanguage, blockCode);
return { highlightedCode, inferredCodeLanguage };
}, [inferCodeLanguage, blockTitle, blockCode, highlightCode]);
// heuristics for specialized rendering
@@ -70,41 +106,14 @@ function RenderCodeImpl(props: {
const { data: plantUmlHtmlData, error: plantUmlError } = useQuery({
enabled: renderPlantUML,
queryKey: ['plantuml', blockCode],
queryFn: async () => {
// fetch the PlantUML SVG
let text: string = '';
try {
// Dynamically import the PlantUML encoder - it's a large library that slows down app loading
const { encode: plantUmlEncode } = await import('plantuml-encoder');
// retrieve and manually adapt the SVG, to remove the background
const encodedPlantUML: string = plantUmlEncode(blockCode);
const response = await fetch(`https://www.plantuml.com/plantuml/svg/${encodedPlantUML}`);
text = await response.text();
} catch (e) {
return null;
}
// validate/extract the SVG
const start = text.indexOf('<svg ');
const end = text.indexOf('</svg>');
if (start < 0 || end <= start)
throw new Error('Could not render PlantUML');
const svg = text
.slice(start, end + 6) // <svg ... </svg>
.replace('background:#FFFFFF;', ''); // transparent background
// check for syntax errors
if (svg.includes('>Syntax Error?</text>'))
throw new Error('syntax issue (it happens!). Please regenerate or change generator model.');
return svg;
},
queryFn: () => fetchPlantUmlSvg(blockCode),
staleTime: 24 * 60 * 60 * 1000, // 1 day
});
renderPlantUML = renderPlantUML && (!!plantUmlHtmlData || !!plantUmlError);
const isSVG = blockCode.startsWith('<svg') && blockCode.endsWith('</svg>');
const renderSVG = isSVG && showSVG;
const canScaleSVG = renderSVG && blockCode.includes('viewBox="');
const languagesCodepen = ['html', 'css', 'javascript', 'json', 'typescript'];
@@ -119,7 +128,11 @@ function RenderCodeImpl(props: {
};
return (
<Box sx={{ position: 'relative' /* for overlay buttons to stick properly */ }}>
<Box sx={{
position: 'relative', /* for overlay buttons to stick properly */
}}>
{/* Code render */}
<Box
component='code'
className={`language-${inferredCodeLanguage || 'unknown'}`}
@@ -128,6 +141,7 @@ function RenderCodeImpl(props: {
mx: 0, p: 1.5, // this block gets a thicker border
display: 'block',
overflowX: 'auto',
minWidth: 160,
'&:hover > .overlay-buttons': { opacity: 1 },
...(props.sx || {}),
}}>
@@ -146,14 +160,14 @@ function RenderCodeImpl(props: {
{renderHTML
? <IFrameComponent htmlString={blockCode} />
: renderMermaid
? <RenderCodeMermaid mermaidCode={blockCode} />
? <RenderCodeMermaid mermaidCode={blockCode} fitScreen={fitScreen} />
: <Box component='div'
dangerouslySetInnerHTML={{
__html:
renderSVG
? blockCode
? (patchSvgString(fitScreen, blockCode) || 'No SVG code')
: renderPlantUML
? (plantUmlHtmlData || (plantUmlError as string) || 'No PlantUML rendering.')
? (patchSvgString(fitScreen, plantUmlHtmlData) || (plantUmlError as string) || 'No PlantUML rendering.')
: highlightedCode,
}}
sx={{
@@ -162,43 +176,52 @@ function RenderCodeImpl(props: {
}}
/>}
{/* Code Buttons */}
{/* Buttons */}
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, p: 0.5 }}>
{isHTML && (
<Tooltip title={renderHTML ? 'Hide' : 'Show Web Page'} variant='solid'>
<IconButton variant={renderHTML ? 'solid' : 'outlined'} color='danger' onClick={() => setShowHTML(!showHTML)}>
<Tooltip title={renderHTML ? 'Hide' : 'Show Web Page'}>
<IconButton variant={renderHTML ? 'solid' : 'soft'} color='danger' onClick={() => setShowHTML(!showHTML)}>
<HtmlIcon />
</IconButton>
</Tooltip>
)}
{isMermaid && (
<Tooltip title={renderMermaid ? 'Show Code' : 'Render Mermaid'} variant='solid'>
<IconButton variant={renderMermaid ? 'solid' : 'outlined'} onClick={() => setShowMermaid(!showMermaid)}>
<Tooltip title={renderMermaid ? 'Show Code' : 'Render Mermaid'}>
<IconButton variant={renderMermaid ? 'solid' : 'soft'} onClick={() => setShowMermaid(!showMermaid)}>
<SchemaIcon />
</IconButton>
</Tooltip>
)}
{isPlantUML && (
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'} variant='solid'>
<IconButton variant={renderPlantUML ? 'solid' : 'outlined'} onClick={() => setShowPlantUML(!showPlantUML)}>
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'}>
<IconButton variant={renderPlantUML ? 'solid' : 'soft'} onClick={() => setShowPlantUML(!showPlantUML)}>
<SchemaIcon />
</IconButton>
</Tooltip>
)}
{isSVG && (
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'} variant='solid'>
<IconButton variant={renderSVG ? 'solid' : 'outlined'} onClick={() => setShowSVG(!showSVG)}>
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'}>
<IconButton variant={renderSVG ? 'solid' : 'soft'} onClick={() => setShowSVG(!showSVG)}>
<ShapeLineOutlinedIcon />
</IconButton>
</Tooltip>
)}
{canCodepen && <OpenInCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{canReplit && <OpenInReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{props.noCopyButton !== true && <Tooltip title='Copy Code' variant='solid'>
<IconButton variant='outlined' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>}
{((isMermaid && showMermaid) || (isPlantUML && showPlantUML) || (isSVG && showSVG && canScaleSVG)) && (
<Tooltip title={fitScreen ? 'Original Size' : 'Fit Screen'}>
<IconButton variant={fitScreen ? 'solid' : 'soft'} onClick={() => setFitScreen(on => !on)}>
<FitScreenIcon />
</IconButton>
</Tooltip>
)}
{canCodepen && <ButtonCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{canReplit && <ButtonReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
{props.noCopyButton !== true && (
<Tooltip title='Copy Code'>
<IconButton variant='soft' onClick={handleCopyToClipboard}>
<ContentCopyIcon />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
@@ -213,12 +236,17 @@ const RenderCodeDynamic = React.lazy(async () => {
const { highlightCode, inferCodeLanguage } = await import('./codePrism');
return {
default: (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
default: (props: { codeBlock: CodeBlock, isMobile?: boolean, noCopyButton?: boolean, sx?: SxProps }) =>
<RenderCodeImpl highlightCode={highlightCode} inferCodeLanguage={inferCodeLanguage} {...props} />,
};
});
export const RenderCode = (props: { codeBlock: CodeBlock, noCopyButton?: boolean, sx?: SxProps }) =>
<React.Suspense fallback={<Box component='code' sx={{ p: 1.5, display: 'block', ...(props.sx || {}) }} />}>
<RenderCodeDynamic {...props} />
</React.Suspense>;
function RenderCode(props: { codeBlock: CodeBlock, isMobile?: boolean, noCopyButton?: boolean, sx?: SxProps }) {
return (
<React.Suspense fallback={<Box component='code' sx={{ p: 1.5, display: 'block', ...props.sx }} />}>
<RenderCodeDynamic {...props} />
</React.Suspense>
);
}
export const RenderCodeMemo = React.memo(RenderCode);
@@ -107,10 +107,10 @@ function useMermaidLoader() {
}
export function RenderCodeMermaid(props: { mermaidCode: string }) {
export function RenderCodeMermaid(props: { mermaidCode: string, fitScreen: boolean }) {
// state
const [svgCode, setSvgCode] = React.useState<string | null>(null);
const [_svgCode, setSvgCode] = React.useState<string | null>(null);
const hasUnmounted = React.useRef(false);
const mermaidContainerRef = React.useRef<HTMLDivElement>(null);
@@ -157,8 +157,12 @@ export function RenderCodeMermaid(props: { mermaidCode: string }) {
<Box
component='div'
ref={mermaidContainerRef}
dangerouslySetInnerHTML={{ __html: svgCode || 'Loading Diagram...' }}
dangerouslySetInnerHTML={{ __html: patchSvgString(props.fitScreen, _svgCode) || 'Loading Diagram...' }}
/>
);
}
export function patchSvgString(fitScreen: boolean, svgCode?: string | null): string | null {
return fitScreen ? svgCode?.replace('<svg ', `<svg style="width: 100%; height: 100%; object-fit: contain" `) || null : svgCode || null;
}
@@ -27,13 +27,13 @@ interface AppChatPanesStore {
// state
chatPanes: ChatPane[];
chatPaneFocusIndex: number | null;
chatPaneInputMode: 'focused' | 'broadcast';
// actions
openConversationInFocusedPane: (conversationId: DConversationId) => void;
openConversationInSplitPane: (conversationId: DConversationId) => void;
navigateHistoryInFocusedPane: (direction: 'back' | 'forward') => boolean;
duplicatePane: (paneIndex: number) => void;
duplicateFocusedPane: (/*paneIndex: number*/) => void;
removeOtherPanes: () => void;
removePane: (paneIndex: number) => void;
setFocusedPane: (paneIndex: number) => void;
onConversationsChanged: (conversationIds: DConversationId[]) => void;
@@ -54,7 +54,6 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
// Initial state: no panes
chatPanes: [] as ChatPane[],
chatPaneFocusIndex: null as number | null,
chatPaneInputMode: 'focused' as 'focused' | 'broadcast',
openConversationInFocusedPane: (conversationId: DConversationId) => {
_set((state) => {
@@ -160,18 +159,18 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
return true;
},
duplicatePane: (paneIndex: number) =>
duplicateFocusedPane: (/*paneIndex: number*/) =>
_set(state => {
const { chatPanes } = state;
const { chatPanes, chatPaneFocusIndex: _srcIndex } = state;
// Validate index
if (paneIndex < 0 || paneIndex >= chatPanes.length) {
console.warn('Attempted to duplicate a pane with an out-of-range index:', paneIndex);
if (_srcIndex === null || _srcIndex < 0 || _srcIndex >= chatPanes.length) {
console.warn('Attempted to duplicate a pane with an out-of-range index:', _srcIndex);
return state; // Return the existing state without changes
}
// Clone the pane at the specified index, including a deep copy of the history array
const paneToDuplicate = chatPanes[paneIndex];
const paneToDuplicate = chatPanes[_srcIndex];
const duplicatedPane = {
...paneToDuplicate,
history: [...paneToDuplicate.history], // Deep copy of the history array
@@ -179,14 +178,27 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
// Insert the duplicated pane into the array, right after the original pane
const newPanes = [
...chatPanes.slice(0, paneIndex + 1),
...chatPanes.slice(0, _srcIndex + 1),
duplicatedPane,
...chatPanes.slice(paneIndex + 1),
...chatPanes.slice(_srcIndex + 1),
];
return {
chatPanes: newPanes,
chatPaneFocusIndex: paneIndex + 1,
chatPaneFocusIndex: _srcIndex + 1,
};
}),
removeOtherPanes: () =>
_set(state => {
const { chatPanes, chatPaneFocusIndex } = state;
if (chatPanes.length < 2)
return state;
const newPanes = [chatPanes[chatPaneFocusIndex ?? 0]];
return {
chatPanes: newPanes,
chatPaneFocusIndex: 0,
};
}),
@@ -267,7 +279,7 @@ const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
// play it safe, and make sure a pane exists, and is focused
return {
chatPanes: newPanes.length ? newPanes : [createPane(conversationIds[0] ?? null)],
chatPaneFocusIndex: (newPanes.length && chatPaneFocusIndex !== null && chatPaneFocusIndex < newPanes.length) ? state.chatPaneFocusIndex : 0,
chatPaneFocusIndex: (newPanes.length && chatPaneFocusIndex !== null && chatPaneFocusIndex < newPanes.length) ? chatPaneFocusIndex : 0,
};
}),
@@ -287,7 +299,6 @@ export function usePanesManager() {
onConversationsChanged,
openConversationInFocusedPane,
openConversationInSplitPane,
duplicatePane,
removePane,
setFocusedPane,
} = state;
@@ -299,8 +310,7 @@ export function usePanesManager() {
onConversationsChanged,
openConversationInFocusedPane,
openConversationInSplitPane,
paneIndex: chatPaneFocusIndex,
duplicatePane,
focusedPaneIndex: chatPaneFocusIndex,
removePane,
setFocusedPane,
};
@@ -319,4 +329,13 @@ export function usePanesManager() {
return {
...panesFunctions,
};
}
export function usePaneDuplicateOrClose() {
return useAppChatPanesStore(state => ({
canAddPane: state.chatPanes.length < 4,
isMultiPane: state.chatPanes.length > 1,
duplicateFocusedPane: state.duplicateFocusedPane,
removeOtherPanes: state.removeOtherPanes,
}), shallow);
}
@@ -1,56 +1,117 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, Button, Card, CardContent, Checkbox, IconButton, Input, List, ListItem, ListItemButton, Textarea, Tooltip, Typography } from '@mui/joy';
import ClearIcon from '@mui/icons-material/Clear';
import DoneIcon from '@mui/icons-material/Done';
import EditIcon from '@mui/icons-material/Edit';
import SearchIcon from '@mui/icons-material/Search';
import TelegramIcon from '@mui/icons-material/Telegram';
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { useChatLLM } from '~/modules/llms/store-llms';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { ExpanderControlledBox } from '~/common/components/ExpanderControlledBox';
import { lineHeightTextarea } from '~/common/app.theme';
import { navigateToPersonas } from '~/common/app.routes';
import { useChipBoolean } from '~/common/components/useChipBoolean';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../../data';
import { usePurposeStore } from './store-purposes';
// 'special' purpose IDs, for tile hiding purposes
const PURPOSE_ID_PERSONA_CREATOR = '__persona-creator__';
// Constants for tile sizes / grid width - breakpoints need to be computed here to work around
// the "flex box cannot shrink over wrapped content" issue
//
// Absolutely dislike this workaround, but it's the only way I found to make it work
const bpTileSize = { xs: 116, md: 125, xl: 130 };
const tileCols = [3, 4, 6];
const tileSpacing = 1;
const bpMaxWidth = Object.entries(bpTileSize).reduce((acc, [key, value], index) => {
acc[key] = tileCols[index] * (value + 8 * tileSpacing) - 8 * tileSpacing;
return acc;
}, {} as Record<string, number>);
const bpTileGap = { xs: 0.5, md: 1 };
// defined looks
const tileSize = 7.5; // rem
const tileGap = 0.5; // rem
// Add this utility function to get a random array element
const getRandomElement = <T, >(array: T[]): T | undefined =>
array.length > 0 ? array[Math.floor(Math.random() * array.length)] : undefined;
function Tile(props: {
text?: string,
imageUrl?: string,
symbol?: string,
isActive: boolean,
isEditMode: boolean,
isHidden?: boolean,
isHighlighted?: boolean,
onClick: () => void,
sx?: SxProps,
}) {
return (
<Button
variant={(!props.isEditMode && props.isActive) ? 'solid' : props.isHighlighted ? 'soft' : 'soft'}
color={(!props.isEditMode && props.isActive) ? 'primary' : props.isHighlighted ? 'primary' : 'neutral'}
onClick={props.onClick}
sx={{
aspectRatio: 1,
height: `${tileSize}rem`,
fontWeight: 500,
...((props.isEditMode || !props.isActive) ? {
boxShadow: props.isHighlighted ? '0 2px 8px -2px rgb(var(--joy-palette-primary-mainChannel) / 50%)' : 'sm',
backgroundColor: props.isHighlighted ? undefined : 'background.surface',
...(props.imageUrl && {
backgroundImage: `linear-gradient(rgba(255 255 255 /0.85), rgba(255 255 255 /1)), url(${props.imageUrl})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
}),
} : {}),
flexDirection: 'column', gap: 1,
...props.sx,
}}
>
{/* [Edit mode checkbox] */}
{props.isEditMode && (
<Checkbox
variant='soft' color='neutral'
checked={!props.isHidden}
// label={<Typography level='body-xs'>show</Typography>}
sx={{ position: 'absolute', left: `${tileGap}rem`, top: `${tileGap}rem` }}
/>
)}
{/* Icon and Text */}
{/*<Box sx={{ fontSize: '2rem' }}>*/}
{/* {props.symbol}*/}
{/*</Box>*/}
<Avatar
variant='plain'
src={props.imageUrl}
sx={{
'--Avatar-size': '3rem',
fontSize: '2rem',
borderRadius: props.imageUrl ? 'sm' : 0,
boxShadow: (props.imageUrl && !props.isActive) ? 'sm' : undefined,
}}
>
{props.symbol}
</Avatar>
<div>
{props.text}
</div>
</Button>
);
}
/**
* Purpose selector for the current chat. Clicking on any item activates it for the current chat.
*/
export function PersonaSelector(props: { conversationId: DConversationId, runExample: (example: string) => void }) {
// state
const [searchQuery, setSearchQuery] = React.useState('');
const [filteredIDs, setFilteredIDs] = React.useState<SystemPurposeId[] | null>(null);
const [editMode, setEditMode] = React.useState(false);
// external state
const showFinder = useUIPreferencesStore(state => state.showPurposeFinder);
const showFinder = useUIPreferencesStore(state => state.showPersonaFinder);
const [showExamples, showExamplescomponent] = useChipBoolean('Examples', false);
const [showPrompt, showPromptComponent] = useChipBoolean('Prompt', false);
const { systemPurposeId, setSystemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return {
@@ -59,228 +120,266 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
};
}, shallow);
const { hiddenPurposeIDs, toggleHiddenPurposeId } = usePurposeStore(state => ({ hiddenPurposeIDs: state.hiddenPurposeIDs, toggleHiddenPurposeId: state.toggleHiddenPurposeId }), shallow);
// safety check - shouldn't happen
if (!systemPurposeId || !setSystemPurposeId)
return null;
const { chatLLM } = useChatLLM();
const handleSearchClear = () => {
setSearchQuery('');
setFilteredIDs(null);
};
// derived state
const handleSearchOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
if (!query)
return handleSearchClear();
setSearchQuery(query);
// Filter results based on search term
const ids = Object.keys(SystemPurposes)
.filter(key => SystemPurposes.hasOwnProperty(key))
.filter(key => {
const purpose = SystemPurposes[key as SystemPurposeId];
return purpose.title.toLowerCase().includes(query.toLowerCase())
|| (typeof purpose.description === 'string' && purpose.description.toLowerCase().includes(query.toLowerCase()));
});
setFilteredIDs(ids as SystemPurposeId[]);
// If there's a search term, activate the first item
if (ids.length && !ids.includes(systemPurposeId))
handlePurposeChanged(ids[0] as SystemPurposeId);
};
const handleSearchOnKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key == 'Escape')
handleSearchClear();
};
const { selectedPurpose, fourExamples } = React.useMemo(() => {
const selectedPurpose: SystemPurposeData | null = systemPurposeId ? (SystemPurposes[systemPurposeId] ?? null) : null;
// const selectedExample = selectedPurpose?.examples?.length
// ? selectedPurpose.examples[Math.floor(Math.random() * selectedPurpose.examples.length)]
// : null;
const fourExamples = selectedPurpose?.examples?.slice(0, 4) ?? null;
return { selectedPurpose, fourExamples };
}, [systemPurposeId]);
const toggleEditMode = () => setEditMode(!editMode);
const unfilteredPurposeIDs = (filteredIDs && showFinder) ? filteredIDs : Object.keys(SystemPurposes) as SystemPurposeId[];
const visiblePurposeIDs = editMode ? unfilteredPurposeIDs : unfilteredPurposeIDs.filter(id => !hiddenPurposeIDs.includes(id));
const hidePersonaCreator = hiddenPurposeIDs.includes(PURPOSE_ID_PERSONA_CREATOR);
const handlePurposeChanged = (purposeId: SystemPurposeId | null) => {
if (purposeId)
// Handlers
const handlePurposeChanged = React.useCallback((purposeId: SystemPurposeId | null) => {
if (purposeId && setSystemPurposeId)
setSystemPurposeId(props.conversationId, purposeId);
};
}, [props.conversationId, setSystemPurposeId]);
const handleCustomSystemMessageChange = (v: React.ChangeEvent<HTMLTextAreaElement>): void => {
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
SystemPurposes['Custom'].systemMessage = v.target.value;
};
}, []);
const toggleEditMode = React.useCallback(() => setEditMode(on => !on), []);
// we show them all if the filter is clear (null)
const unfilteredPurposeIDs = (filteredIDs && showFinder) ? filteredIDs : Object.keys(SystemPurposes);
const purposeIDs = editMode ? unfilteredPurposeIDs : unfilteredPurposeIDs.filter(id => !hiddenPurposeIDs.includes(id));
// Search (filtering)
const hidePersonaCreator = hiddenPurposeIDs.includes(PURPOSE_ID_PERSONA_CREATOR);
const handleSearchClear = React.useCallback(() => {
setSearchQuery('');
setFilteredIDs(null);
}, []);
const selectedPurpose = purposeIDs.length ? (SystemPurposes[systemPurposeId] ?? null) : null;
const selectedExample = selectedPurpose?.examples && getRandomElement(selectedPurpose.examples) || null;
const handleSearchOnChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
if (!query)
return handleSearchClear();
return <>
// Filter results based on search term (title and description)
const lcQuery = query.toLowerCase();
const ids = (Object.keys(SystemPurposes) as SystemPurposeId[])
.filter(key => SystemPurposes.hasOwnProperty(key))
.filter(key => {
const purpose = SystemPurposes[key as SystemPurposeId];
return purpose.title.toLowerCase().includes(lcQuery)
|| (typeof purpose.description === 'string' && purpose.description.toLowerCase().includes(lcQuery));
});
{showFinder && <Box sx={{ p: 2 * tileSpacing }}>
<Input
fullWidth
variant='outlined' color='neutral'
value={searchQuery} onChange={handleSearchOnChange}
onKeyDown={handleSearchOnKeyDown}
placeholder='Search for purpose…'
startDecorator={<SearchIcon />}
endDecorator={searchQuery && (
<IconButton onClick={handleSearchClear}>
<ClearIcon />
</IconButton>
)}
sx={{
boxShadow: 'sm',
}}
/>
</Box>}
setSearchQuery(query);
setFilteredIDs(ids);
<Stack direction='column' sx={{ minHeight: '60vh', justifyContent: 'center', alignItems: 'center' }}>
// If there's a search term, activate the first item
// if (ids.length && systemPurposeId && !ids.includes(systemPurposeId))
// handlePurposeChanged(ids[0] as SystemPurposeId);
}, [handleSearchClear]);
<Box sx={{ maxWidth: bpMaxWidth }}>
const handleSearchOnKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key == 'Escape')
handleSearchClear();
}, [handleSearchClear]);
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 1 }}>
// safety check - shouldn't happen - this is set to null when the conversation is not found
if (!setSystemPurposeId)
return null;
return (
<Box sx={{
maxWidth: 'md',
minWidth: `${2 + 1 + tileSize * 2}rem`, // accomodate at least 2 columns (scroll-x in case)
mx: 'auto',
minHeight: '60svh',
display: 'grid',
px: { xs: 0.5, sm: 1, md: 2 },
py: 2,
}}>
{showFinder && <Box>
<Input
fullWidth
variant='outlined' color='neutral'
value={searchQuery} onChange={handleSearchOnChange}
onKeyDown={handleSearchOnKeyDown}
placeholder='Search for purpose…'
startDecorator={<SearchIcon />}
endDecorator={searchQuery && (
<IconButton onClick={handleSearchClear}>
<ClearIcon />
</IconButton>
)}
sx={{
boxShadow: 'sm',
}}
/>
</Box>}
<Box sx={{
my: 'auto',
// layout
display: 'grid',
gridTemplateColumns: `repeat(auto-fit, minmax(${tileSize}rem, ${tileSize}rem))`,
justifyContent: 'center', gap: `${tileGap}rem`,
}}>
{/* [row 0] ... Edit mode [ ] */}
<Box sx={{
gridColumn: '1 / -1',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<Typography level='title-sm'>
AI Persona
</Typography>
<Tooltip disableInteractive title={editMode ? 'Done Editing' : 'Edit Tiles'}>
<IconButton size='sm' onClick={toggleEditMode}>
<IconButton size='sm' onClick={toggleEditMode} sx={{ my: '-0.25rem' /* absorb the button padding */ }}>
{editMode ? <DoneIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={tileSpacing} sx={{ justifyContent: 'flex-start' }}>
{purposeIDs.map((spId) => (
<Grid key={spId}>
<Button
variant={(!editMode && systemPurposeId === spId) ? 'solid' : 'soft'}
color={(!editMode && systemPurposeId === spId) ? 'primary' : SystemPurposes[spId as SystemPurposeId]?.highlighted ? 'warning' : 'neutral'}
onClick={() => editMode
? toggleHiddenPurposeId(spId)
: handlePurposeChanged(spId as SystemPurposeId)
}
{/* Personas Tiles */}
{visiblePurposeIDs.map((spId: SystemPurposeId) => {
const isActive = systemPurposeId === spId;
const systemPurpose = SystemPurposes[spId];
return (
<Tile
key={'tile-' + spId}
text={systemPurpose?.title}
imageUrl={systemPurpose?.imageUri}
symbol={systemPurpose?.symbol}
isActive={isActive}
isEditMode={editMode}
isHidden={hiddenPurposeIDs.includes(spId)}
isHighlighted={systemPurpose?.highlighted}
onClick={() => editMode ? toggleHiddenPurposeId(spId) : handlePurposeChanged(spId)}
/>
);
})}
{/* Persona Creator Tile */}
{(editMode || !hidePersonaCreator) && (
<Tile
text='Persona Creator'
symbol='🎭'
isActive={false}
isEditMode={editMode}
isHidden={hidePersonaCreator}
onClick={() => editMode ? toggleHiddenPurposeId(PURPOSE_ID_PERSONA_CREATOR) : void navigateToPersonas()}
sx={{
boxShadow: 'xs',
backgroundColor: 'neutral.softDisabledBg',
}}
/>
)}
{/* [row -3] Description */}
<Box sx={{ gridColumn: '1 / -1', mt: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Description*/}
<Typography level='body-sm' sx={{ color: 'text.primary' }}>
{!selectedPurpose
? 'Cannot find the former persona' + (systemPurposeId ? ` "${systemPurposeId}"` : '')
: selectedPurpose?.description || 'No description available'}
</Typography>
{/* Examples Toggle */}
{fourExamples && showExamplescomponent}
{showPromptComponent}
</Box>
{/* [row -3] Example incipits */}
{systemPurposeId !== 'Custom' && (
<ExpanderControlledBox expanded={showExamples || showPrompt} sx={{ gridColumn: '1 / -1', pt: 1 }}>
{showExamples && (
<List
aria-label='Persona Conversation Starters'
sx={{
flexDirection: 'column',
fontWeight: 500,
// paddingInline: 1,
gap: bpTileGap,
height: bpTileSize,
width: bpTileSize,
...((editMode || systemPurposeId !== spId) ? {
boxShadow: 'md',
...(SystemPurposes[spId as SystemPurposeId]?.highlighted ? {} : { backgroundColor: 'background.surface' }),
} : {}),
// example items 2-col layout
display: 'grid',
gridTemplateColumns: `repeat(auto-fit, minmax(${tileSize * 2 + 1}rem, 1fr))`,
gap: 1,
}}
>
{editMode && (
<Checkbox
color='neutral'
checked={!hiddenPurposeIDs.includes(spId)}
// label={<Typography level='body-xs'>show</Typography>}
sx={{ position: 'absolute', left: 8, top: 8 }}
/>
)}
<div style={{ fontSize: '2rem' }}>
{SystemPurposes[spId as SystemPurposeId]?.symbol}
</div>
<div>
{SystemPurposes[spId as SystemPurposeId]?.title}
</div>
</Button>
</Grid>
))}
{/* Button to start the Persona Creator */}
{(editMode || !hidePersonaCreator) && <Grid>
<Button
variant='soft' color='neutral'
onClick={() => editMode
? toggleHiddenPurposeId(PURPOSE_ID_PERSONA_CREATOR)
: void navigateToPersonas()
}
sx={{
flexDirection: 'column',
fontWeight: 500,
// paddingInline: 1,
gap: bpTileGap,
height: bpTileSize,
width: bpTileSize,
// border: `1px dashed`,
// borderColor: 'neutral.softActiveBg',
boxShadow: 'xs',
backgroundColor: 'neutral.softDisabledBg',
}}
>
{editMode && (
<Checkbox
color='neutral'
checked={!hidePersonaCreator}
// label={<Typography level='body-xs'>show</Typography>}
sx={{ position: 'absolute', left: 8, top: 8 }}
/>
)}
<div>
<div style={{ fontSize: '2rem' }}>
🎭
</div>
{/*<SettingsAccessibilityIcon style={{ opacity: 0.5 }} />*/}
</div>
<div style={{ textAlign: 'center' }}>
Persona Creator
</div>
</Button>
</Grid>}
</Grid>
<Typography
level='body-sm'
sx={{
mt: selectedExample ? 1 : 3,
display: 'flex', alignItems: 'center', gap: 1,
// justifyContent: 'center',
'&:hover > button': { opacity: 1 },
}}>
{!selectedPurpose
? 'Oops! No AI persona found for your search.'
: (selectedExample
? <>
Example: {selectedExample}
<IconButton
color='primary'
onClick={() => props.runExample(selectedExample)}
sx={{ opacity: 0, transition: 'opacity 0.3s' }}
{fourExamples?.map((example, idx) => (
<ListItem
key={idx}
variant='soft'
sx={{
borderRadius: 'md',
// boxShadow: 'xs',
padding: '0.25rem 0.5rem',
backgroundColor: 'background.surface',
'& svg': { opacity: 0.1, transition: 'opacity 0.2s' },
'&:hover svg': { opacity: 1 },
}}
>
<TelegramIcon />
</IconButton>
</>
: selectedPurpose.description
<ListItemButton onClick={() => props.runExample(example)} sx={{ justifyContent: 'space-between' }}>
<Typography level='body-sm'>
{example}
</Typography>
<TelegramIcon color='primary' sx={{}} />
</ListItemButton>
</ListItem>
))}
</List>
)}
</Typography>
{showPrompt && (
<Card>
<CardContent>
<Typography level='title-sm'>
System Prompt
</Typography>
<Typography level='body-sm' sx={{ whiteSpace: 'break-spaces' }}>
{bareBonesPromptMixer(selectedPurpose?.systemMessage || 'No system message available', chatLLM?.id)}
</Typography>
</CardContent>
</Card>
)}
</ExpanderControlledBox>
)}
{/* [row -1] Custom Prompt box */}
{systemPurposeId === 'Custom' && (
<Textarea
variant='outlined' autoFocus placeholder={'Craft your custom system message here…'}
autoFocus
variant='outlined'
placeholder='Craft your custom system message here…'
minRows={3}
defaultValue={SystemPurposes['Custom']?.systemMessage} onChange={handleCustomSystemMessageChange}
defaultValue={SystemPurposes['Custom']?.systemMessage}
onChange={handleCustomSystemMessageChange}
endDecorator={
<Typography level='body-sm' sx={{ px: 0.75 }}>
Just start chatting when done.
</Typography>
}
sx={{
backgroundColor: 'background.level1',
gridColumn: '1 / -1',
backgroundColor: 'background.surface',
'&:focus-within': {
backgroundColor: 'background.popup',
},
lineHeight: lineHeightTextarea,
mt: 1,
}} />
}}
/>
)}
</Box>
</Stack>
</>;
</Box>
);
}
+172
View File
@@ -0,0 +1,172 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, IconButton, ListItemButton, ListItemDecorator } from '@mui/joy';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import SettingsIcon from '@mui/icons-material/Settings';
import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
import { findVendorById } from '~/modules/llms/vendors/vendors.registry';
import { DropdownItems, PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
function LLMDropdown(props: {
llms: DLLM[],
chatLlmId: DLLMId | null,
setChatLlmId: (llmId: DLLMId | null) => void,
placeholder?: string,
}) {
// external state
const { openLlmOptions, openModelsSetup } = useOptimaLayout();
// derived state
const { chatLlmId, llms, setChatLlmId } = props;
const handleChatLLMChange = React.useCallback((value: DLLMId | null) => {
value && setChatLlmId(value);
}, [setChatLlmId]);
const handleOpenLLMOptions = React.useCallback(() => {
return chatLlmId && openLlmOptions(chatLlmId);
}, [chatLlmId, openLlmOptions]);
const llmDropdownItems: DropdownItems = React.useMemo(() => {
const llmItems: DropdownItems = {};
let prevSourceId: DModelSourceId | null = null;
let sepCount = 0;
for (const llm of llms) {
// filter-out hidden models from the dropdown
if (!(!llm.hidden || llm.id === chatLlmId))
continue;
// add separators when changing sources
if (!prevSourceId || llm.sId !== prevSourceId) {
const llmVendor = findVendorById(llm._source?.vId ?? undefined);
const sourceName = llmVendor?.name || llm.sId;
llmItems[`sep-${llm.id}`] = {
type: 'separator',
title: sourceName,
icon: llmVendor?.Icon ? <llmVendor.Icon /> : undefined,
};
prevSourceId = llm.sId;
sepCount++;
}
// add the model item
llmItems[llm.id] = {
title: llm.label,
// icon: llm.id.startsWith('some vendor') ? <VendorIcon /> : undefined,
};
}
// if there's a single separator (i.e. only one source), remove it
if (sepCount === 1) {
for (const key in llmItems) {
if (key.startsWith('sep-')) {
delete llmItems[key];
break;
}
}
}
return llmItems;
}, [chatLlmId, llms]);
// "Model Options" button (only on the active item)
const llmDropdownButton = React.useMemo(() => (
<GoodTooltip title={
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
Model Options
<KeyStroke combo='Ctrl + Shift + O' sx={{ my: 0.5 }} />
</Box>
}>
<IconButton
variant='outlined' color='neutral'
onClick={handleOpenLLMOptions}
sx={{
ml: 'auto',
// mr: -0.5,
my: '-0.25rem' /* absorb the menuItem padding */,
backgroundColor: 'background.surface',
boxShadow: 'xs',
}}
>
<SettingsIcon sx={{ fontSize: 'xl' }} />
</IconButton>
</GoodTooltip>
), [handleOpenLLMOptions]);
// "Models Setup" button
const llmDropdownAppendOptions = React.useMemo(() => <>
{/*{chatLlmId && (*/}
{/* <ListItemButton key='menu-opt' onClick={handleOpenLLMOptions}>*/}
{/* <ListItemDecorator><SettingsIcon color='success' /></ListItemDecorator>*/}
{/* <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>*/}
{/* Options*/}
{/* <KeyStroke combo='Ctrl + Shift + O' />*/}
{/* </Box>*/}
{/* </ListItemButton>*/}
{/*)}*/}
<ListItemButton key='menu-llms' onClick={openModelsSetup}>
<ListItemDecorator><BuildCircleIcon color='success' /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
Models
<KeyStroke combo='Ctrl + Shift + M' sx={{ ml: 2 }} />
</Box>
</ListItemButton>
</>, [openModelsSetup]);
return (
<PageBarDropdownMemo
items={llmDropdownItems}
value={chatLlmId}
onChange={handleChatLLMChange}
placeholder={props.placeholder || 'Models …'}
appendOption={llmDropdownAppendOptions}
activeEndDecorator={llmDropdownButton}
/>
);
}
export function useChatLLMDropdown() {
// external state
const { llms, chatLLMId, setChatLLMId } = useModelsStore(state => ({
llms: state.llms, // NOTE: we don't need a deep comparison as we reference the same array
chatLLMId: state.chatLLMId,
setChatLLMId: state.setChatLLMId,
}), shallow);
const chatLLMDropdown = React.useMemo(
() => <LLMDropdown llms={llms} chatLlmId={chatLLMId} setChatLlmId={setChatLLMId} />,
[llms, chatLLMId, setChatLLMId],
);
return { chatLLMId, chatLLMDropdown };
}
/*export function useTempLLMDropdown(props: { initialLlmId: DLLMId | null }) {
// local state
const [llmId, setLlmId] = React.useState<DLLMId | null>(props.initialLlmId);
// external state
const llms = useModelsStore(state => state.llms, shallow);
const chatLLMDropdown = React.useMemo(
() => <LLMDropdown llms={llms} llmId={llmId} setLlmId={setLlmId} />,
[llms, llmId, setLlmId],
);
return { llmId, chatLLMDropdown };
}*/
@@ -0,0 +1,69 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { PageBarDropdownMemo } from '~/common/layout/optima/components/PageBarDropdown';
import { useUIPreferencesStore } from '~/common/state/store-ui';
function PersonaDropdown(props: {
systemPurposeId: SystemPurposeId | null,
setSystemPurposeId: (systemPurposeId: SystemPurposeId | null) => void,
}) {
// external state
const { zenMode } = useUIPreferencesStore(state => ({
zenMode: state.zenMode,
}), shallow);
const { setSystemPurposeId } = props;
const handleSystemPurposeChange = React.useCallback((value: string | null) => {
setSystemPurposeId(value as (SystemPurposeId | null));
}, [setSystemPurposeId]);
return (
<PageBarDropdownMemo
items={SystemPurposes}
value={props.systemPurposeId}
onChange={handleSystemPurposeChange}
showSymbols={zenMode !== 'cleaner'}
/>
);
}
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
// external state
const { systemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
return {
systemPurposeId: conversation?.systemPurposeId ?? null,
};
}, shallow);
const handleSetSystemPurposeId = React.useCallback((systemPurposeId: SystemPurposeId | null) => {
if (conversationId && systemPurposeId)
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
}, [conversationId]);
const personaDropdown = React.useMemo(() => {
if (!systemPurposeId) return null;
return (
<PersonaDropdown
systemPurposeId={systemPurposeId}
setSystemPurposeId={handleSetSystemPurposeId}
/>
);
},
[handleSetSystemPurposeId, systemPurposeId],
);
return { personaDropdown };
}
+4 -4
View File
@@ -1,6 +1,8 @@
import { DLLMId } from '~/modules/llms/store-llms';
import { DLLMId, getKnowledgeMapCutoff } from '~/modules/llms/store-llms';
import { SystemPurposeId, SystemPurposes } from '../../../data';
import { bareBonesPromptMixer } from '~/modules/persona/pmix/pmix';
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
@@ -19,9 +21,7 @@ export function updatePurposeInHistory(conversationId: string, history: DMessage
const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', '');
if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) {
systemMessage.purposeId = purposeId;
systemMessage.text = SystemPurposes[purposeId].systemMessage
.replaceAll('{{Cutoff}}', assistantLlmId.includes('1106') ? '2023-04' : '2021-09')
.replaceAll('{{Today}}', new Date().toISOString().split('T')[0]);
systemMessage.text = bareBonesPromptMixer(SystemPurposes[purposeId].systemMessage, assistantLlmId);
// HACK: this is a special case for the "Custom" persona, to set the message in stone (so it doesn't get updated when switching to another persona)
if (purposeId === 'Custom')
+1 -1
View File
@@ -4,7 +4,7 @@ import * as React from 'react';
export function Gallery() {
return (
<AppPlaceholder text='Drawing App is under development. v1.12 or v1.13.' />
<AppPlaceholder text='Drawing App is under development. v1.13 or v1.14.' />
);
}
+2
View File
@@ -3,6 +3,7 @@ import * as React from 'react';
import { Box } from '@mui/joy';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { DesignerPrompt, PromptDesigner } from './components/PromptDesigner';
import { ProviderConfigure } from './components/ProviderConfigure';
@@ -70,6 +71,7 @@ export function TextToImage(props: {
onDrawingStop={handleStopDrawing}
onPromptEnqueue={handlePromptEnqueue}
sx={{
backgroundColor: themeBgAppChatComposer,
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
@@ -0,0 +1,56 @@
import * as React from 'react';
import { Button, ButtonGroup, IconButton, Tooltip } from '@mui/joy';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import LightbulbOutlinedIcon from '@mui/icons-material/LightbulbOutlined';
// const desktopButtonLegend =
// <Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
// <b>From Idea</b><br />
// From Idea
// </Box>;
export function ButtonPromptFromIdea(props: {
isMobile?: boolean,
disabled: boolean,
onIdeaNext: () => void,
onIdeaUse: () => void,
}) {
const { onIdeaNext, onIdeaUse } = props;
const handleIdeaNext = React.useCallback((event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onIdeaNext();
}, [onIdeaNext]);
return props.isMobile ? null : (
<ButtonGroup
variant='soft' color='neutral'
disabled={props.disabled}
sx={{
// '--ButtonGroup-separatorSize': 0,
minWidth: 160,
}}
>
<Button
fullWidth onClick={handleIdeaNext}
startDecorator={<LightbulbOutlinedIcon />}
sx={{
// '--Button-gap': 'auto',
// minWidth: 100,
justifyContent: 'flex-start',
transition: 'background-color 0.2s, color 0.2s',
}}>
Idea
</Button>
<Tooltip disableInteractive title='Use Idea'>
<IconButton size='sm' onClick={onIdeaUse}>
<ArrowForwardIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
);
}
@@ -0,0 +1,21 @@
import * as React from 'react';
import { Button } from '@mui/joy';
import InsertPhotoOutlinedIcon from '@mui/icons-material/InsertPhotoOutlined';
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined';
export function ButtonPromptFromPlaceholder(props: { isMobile?: boolean, name: string, disabled?: boolean }) {
return props.isMobile ? null : (
<Button
disabled={props.disabled}
fullWidth variant='soft' color='neutral'
startDecorator={props.name === 'Chats' ? <ChatOutlinedIcon /> : <InsertPhotoOutlinedIcon />}
sx={{
justifyContent: 'flex-start',
transition: 'background-color 0.2s, color 0.2s',
minWidth: 160,
}}>
{props.name}
</Button>
);
}
+131 -35
View File
@@ -1,17 +1,24 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, ButtonGroup, Grid, IconButton, Textarea, Tooltip } from '@mui/joy';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import { Box, Button, ButtonGroup, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Textarea, Typography } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import KeyboardArrowLeftIcon from '@mui/icons-material/KeyboardArrowLeft';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import MoreTimeIcon from '@mui/icons-material/MoreTime';
import RemoveIcon from '@mui/icons-material/Remove';
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
import { animationStopEnter } from '../../chat/components/composer/Composer';
import { lineHeightTextarea } from '~/common/app.theme';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { animationStopEnter } from '../../chat/components/composer/Composer';
import { ButtonPromptFromIdea } from './ButtonPromptFromIdea';
import { ButtonPromptFromPlaceholder } from './ButtonPromptFromPlaceholder';
import { useDrawIdeas } from '../state/useDrawIdeas';
@@ -39,6 +46,7 @@ export function PromptDesigner(props: {
// state
const [nextPrompt, setNextPrompt] = React.useState<string>('');
const [tempCount, setTempCount] = React.useState<number>(2);
// external state
const { currentIdea, nextRandomIdea } = useDrawIdeas();
@@ -89,16 +97,16 @@ export function PromptDesigner(props: {
}, [enterIsNewline, handlePromptEnqueue, userHasText]);
// Ideas
const handleIdeaUse = React.useCallback(() => {
setNextPrompt(currentIdea.prompt);
}, [currentIdea.prompt]);
// PromptFx
const textEnrichComponents = React.useMemo(() => {
const handleIdeaUse = (event: React.MouseEvent) => {
event.preventDefault();
setNextPrompt(currentIdea.prompt);
// setUserHasChanged(false);
};
const handleClickMissing = (_event: React.MouseEvent) => {
alert('Not implemented yet');
};
@@ -121,16 +129,18 @@ export function PromptDesigner(props: {
}}>
{/* Change / Use idea */}
<ButtonGroup variant='soft' color='neutral' sx={{ borderRadius: 'sm' }}>
<Button className={promptButtonClass} disabled={userHasText} onClick={nextRandomIdea}>
Idea
</Button>
<Tooltip disableInteractive title='Use Idea'>
<IconButton onClick={handleIdeaUse}>
<ArrowDownwardIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
{/*{props.isMobile && (*/}
{/* <ButtonGroup variant='soft' color='neutral' sx={{ borderRadius: 'sm' }}>*/}
{/* <Button className={promptButtonClass} disabled={userHasText} onClick={handleIdeaNext}>*/}
{/* Idea*/}
{/* </Button>*/}
{/* <Tooltip disableInteractive title='Use Idea'>*/}
{/* <IconButton onClick={handleIdeaUse}>*/}
{/* <ArrowDownwardIcon />*/}
{/* </IconButton>*/}
{/* </Tooltip>*/}
{/* </ButtonGroup>*/}
{/*)}*/}
{/* PromptFx */}
<Button
@@ -141,19 +151,49 @@ export function PromptDesigner(props: {
onClick={handleClickMissing}
sx={{ borderRadius: 'sm' }}
>
Detail
Enhance
</Button>
<Button
variant='soft' color='success'
disabled={!userHasText}
className={promptButtonClass}
endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}
onClick={handleClickMissing}
sx={{ borderRadius: 'sm' }}
>
Restyle
</Button>
{/*<Button*/}
{/* variant='soft' color='success'*/}
{/* disabled={!userHasText}*/}
{/* className={promptButtonClass}*/}
{/* endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}*/}
{/* onClick={handleClickMissing}*/}
{/* sx={{ borderRadius: 'sm' }}*/}
{/*>*/}
{/* Restyle*/}
{/*</Button>*/}
<ButtonGroup sx={{ ml: 'auto' }}>
{tempCount > 1 && <IconButton onClick={() => setTempCount(count => count - 1)}>
<RemoveIcon />
</IconButton>}
{tempCount > 1 && <>
<IconButton>
<KeyboardArrowLeftIcon />
</IconButton>
<Button
sx={{
px: 0,
minWidth: '3rem',
pointerEvents: 'none',
fontSize: 'xs',
fontWeight: 600,
}}>
<Typography level='body-xs' color='danger' sx={{ fontWeight: 'lg' }}>
{tempCount > 1 ? `1 / ${tempCount}` : '1'}
</Typography>
</Button>
<IconButton>
<KeyboardArrowRightIcon />
</IconButton>
</>}
<IconButton onClick={() => setTempCount(count => count + 1)}>
<AddIcon />
</IconButton>
</ButtonGroup>
{/* Char counter */}
{/*<Typography level='body-sm' sx={{ ml: 'auto', mr: 1 }}>*/}
@@ -161,20 +201,58 @@ export function PromptDesigner(props: {
{/*</Typography>*/}
</Box>
);
}, [currentIdea.prompt, nextRandomIdea, userHasText]);
}, [tempCount, userHasText]);
return (
<Box aria-label='Drawing Prompt' component='section' sx={props.sx}>
<Grid container spacing={{ xs: 1, md: 2 }}>
{/* Prompt (Text) Box */}
<Grid xs={12} md={9}>
<Grid xs={12} md={9}><Box sx={{ display: 'flex', gap: { xs: 1, md: 2 } }}>
{props.isMobile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<ArrowForwardIcon />
</MenuButton>
<Menu placement='top'>
{/* Add From History? */}
{/*<MenuItem>*/}
{/* <ButtonPromptFromPlaceholder name='History' disabled />*/}
{/*</MenuItem>*/}
<MenuItem>
<ButtonPromptFromIdea disabled={userHasText} onIdeaNext={nextRandomIdea} onIdeaUse={handleIdeaUse} />
</MenuItem>
<MenuItem>
<ButtonPromptFromPlaceholder name='Image' disabled />
</MenuItem>
{/*<MenuItem>*/}
{/* <ButtonPromptFromPlaceholder name='Chat' disabled />*/}
{/*</MenuItem>*/}
</Menu>
</Dropdown>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<ButtonPromptFromIdea disabled={userHasText} onIdeaNext={nextRandomIdea} onIdeaUse={handleIdeaUse} />
<ButtonPromptFromPlaceholder name='Image' disabled />
{/*<ButtonPromptFromPlaceholder name='Chats' disabled />*/}
</Box>
)}
<Textarea
variant='outlined'
// size='sm'
autoFocus
minRows={props.isMobile ? 4 : 3}
minRows={props.isMobile ? 5 : 3}
maxRows={props.isMobile ? 6 : 8}
placeholder={currentIdea.prompt}
value={nextPrompt}
@@ -188,12 +266,14 @@ export function PromptDesigner(props: {
},
}}
sx={{
flexGrow: 1,
boxShadow: 'lg',
'&:focus-within': { backgroundColor: 'background.popup' },
lineHeight: lineHeightTextarea,
}}
/>
</Grid>
</Box></Grid>
{/* [Desktop: Right, Mobile: Bottom] Buttons */}
<Grid xs={12} md={3} spacing={1}>
@@ -212,7 +292,7 @@ export function PromptDesigner(props: {
justifyContent: 'space-between',
}}
>
Draw
Draw {tempCount > 1 ? `(${tempCount})` : ''}
</Button>
) : <>
<Button
@@ -243,6 +323,22 @@ export function PromptDesigner(props: {
Enqueue
</Button>
</>}
<ButtonGroup size='sm' variant='soft' sx={{ flex: 1, display: 'flex' }}>
<Button sx={{ flex: 1 }}>
1
</Button>
<Button sx={{ flex: 1 }}>
x2
</Button>
<Button color='primary' sx={{ flex: 1 }}>
x4
</Button>
<Button sx={{ flex: 1 }}>
xN
</Button>
</ButtonGroup>
</Box>
</Grid>
+1 -1
View File
@@ -4,7 +4,7 @@ import { FormControl, FormLabel, ListItemDecorator, Option, Select } from '@mui/
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon';
import { OpenAIIcon } from '~/common/components/icons/vendors/OpenAIIcon';
import { hideOnMobile } from '~/common/app.theme';
+6 -5
View File
@@ -4,7 +4,7 @@ import TimeAgo from 'react-timeago';
import { Box, Button, Card, CardContent, List, ListItem, Tooltip, Typography } from '@mui/joy';
import TelegramIcon from '@mui/icons-material/Telegram';
import { ChatMessage } from '../chat/components/message/ChatMessage';
import { ChatMessageMemo } from '../chat/components/message/ChatMessage';
import { ScrollToBottom } from '../chat/components/scroll-to-bottom/ScrollToBottom';
import { useChatShowSystemMessages } from '../chat/store-app-chat';
@@ -136,10 +136,11 @@ export function LinkChat(props: { conversation: DConversation, storedAt: Date, e
</ListItem>
{filteredMessages.map((message, idx) =>
<ChatMessage
key={'msg-' + message.id} message={message}
showDate={idx === 0 || idx === filteredMessages.length - 1}
onMessageEdit={text => message.text = text}
<ChatMessageMemo
key={'msg-' + message.id}
message={message}
blocksShowDate={idx === 0 || idx === filteredMessages.length - 1 /* first and last message */}
onMessageEdit={(_messageId, text: string) => message.text = text}
/>,
)}
+84 -53
View File
@@ -1,18 +1,18 @@
import * as React from 'react';
import { keyframes } from '@emotion/react';
import NextImage from 'next/image';
import TimeAgo from 'react-timeago';
import { Box, Button, Card, CardContent, Container, IconButton, Typography } from '@mui/joy';
import { AspectRatio, Box, Button, Card, CardContent, CardOverflow, Container, IconButton, Typography } from '@mui/joy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Brand } from '~/common/app.config';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { Link } from '~/common/components/Link';
import { ROUTE_INDEX } from '~/common/app.routes';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
import { newsCallout, NewsItems } from './news.data';
import { NewsItems, newsRoadmapCallout } from './news.data';
// number of news items to show by default, before the expander
const DEFAULT_NEWS_COUNT = 3;
@@ -52,85 +52,116 @@ export function AppNews() {
<Box sx={{
my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 4,
}}>
<Typography level='h1' sx={{ fontSize: '3rem' }}>
Welcome to {Brand.Title.Base} <Box component='span' sx={{ animation: `${cssColorKeyframes} 10s infinite` }}>{firstNews?.versionCode}</Box>!
<Typography level='h1' sx={{ fontSize: '2.9rem', mb: 4 }}>
Welcome to {Brand.Title.Base} <Box component='span' sx={{ animation: `${cssColorKeyframes} 10s infinite`, zIndex: 1 }}>{firstNews?.versionCode}</Box>!
</Typography>
<Typography>
<Typography sx={{ mb: 2 }} level='title-sm'>
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionCode}
</Typography>
<Box>
<Box sx={{ mb: 5 }}>
<Button
variant='solid' color='neutral' size='lg'
variant='solid' color='primary' size='lg'
component={Link} href={ROUTE_INDEX} noLinkStyle
endDecorator='✨'
sx={{ minWidth: 200 }}
sx={{
boxShadow: '0 8px 24px -4px rgb(var(--joy-palette-primary-mainChannel) / 20%)',
minWidth: 180,
}}
>
Sweet
Continue
</Button>
</Box>
{!!newsCallout && <Container disableGutters maxWidth='sm'>{newsCallout}</Container>}
{/*<Typography level='title-sm' sx={{ mb: 1, placeSelf: 'start', ml: 1 }}>*/}
{/* Here is what's new:*/}
{/*</Typography>*/}
{!!news && <Container disableGutters maxWidth='sm'>
<Container disableGutters maxWidth='sm'>
{news?.map((ni, idx) => {
// const firstCard = idx === 0;
const hasCardAfter = news.length < NewsItems.length;
const showExpander = hasCardAfter && (idx === news.length - 1);
const addPadding = false; //!firstCard; // || showExpander;
return <Card key={'news-' + idx} sx={{ mb: 2, minHeight: 32 }}>
<CardContent sx={{ position: 'relative', pr: addPadding ? 4 : 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 0 }}>
<GoodTooltip title={ni.versionName ? `${ni.versionName} ${ni.versionMoji || ''}` : null} placement='top-start'>
<Typography level='title-sm' component='div' sx={{ flexGrow: 1 }}>
{ni.text ? ni.text : ni.versionName ? `${ni.versionCode} · ` : `Version ${ni.versionCode}:`}
<Box component='span' sx={!idx ? {
animation: `${cssRainbowColorKeyframes} 5s infinite`,
fontWeight: 600,
} : {}}>
return <React.Fragment key={idx}>
{/* News Item */}
<Card key={'news-' + idx} sx={{ mb: 3, minHeight: 32, gap: 1 }}>
<CardContent sx={{ position: 'relative', pr: addPadding ? 4 : 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography level='title-sm' component='div'>
{ni.text ? ni.text : ni.versionName ? <><span style={{ fontWeight: 600 }}>{ni.versionCode}</span> · </> : `Version ${ni.versionCode}:`}
<Box
component='span'
sx={idx ? {} : {
animation: `${cssRainbowColorKeyframes} 5s infinite`,
fontWeight: 600,
zIndex: 1,
}}
>
{ni.versionName}
</Box>
</Typography>
</GoodTooltip>
{/*!firstCard &&*/ (
<Typography level='body-sm'>
<Typography level='body-sm' sx={{ ml: 'auto' }}>
{!!ni.versionDate && <TimeAgo date={ni.versionDate} />}
</Typography>
</Box>
{!!ni.items && (ni.items.length > 0) && (
<ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: '1.5rem' }}>
{ni.items.filter(item => item.dev !== true).map((item, idx) => <li key={idx}>
< Typography component='div' level='body-sm'>
{item.text}
</Typography>
</li>)}
</ul>
)}
{showExpander && (
<IconButton
variant='solid'
onClick={() => setLastNewsIdx(idx + 1)}
sx={{
position: 'absolute', right: 0, bottom: 0, mr: -1, mb: -1,
// backgroundColor: 'background.surface',
borderRadius: '50%',
}}
>
<ExpandMoreIcon />
</IconButton>
)}
</CardContent>
{!!ni.versionCoverImage && (
<CardOverflow sx={{
m: '0 calc(var(--CardOverflow-offset) - 1px) calc(var(--CardOverflow-offset) - 1px)',
}}>
<AspectRatio ratio='2'>
<NextImage
src={ni.versionCoverImage}
alt={`Cover image for ${ni.versionCode}`}
// commented: we scale the images to 600px wide (>300 px tall)
// sizes='(max-width: 1200px) 100vw, 50vw'
priority={idx === 0}
/>
</AspectRatio>
</CardOverflow>
)}
</Card>
{/* Inject the roadmap item here*/}
{idx === 0 && (
<Box sx={{ mb: 3 }}>
{newsRoadmapCallout}
</Box>
)}
{!!ni.items && (ni.items.length > 0) && (
<ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: '1.5rem' }}>
{ni.items.filter(item => item.dev !== true).map((item, idx) => <li key={idx}>
< Typography component='div' level='body-sm'>
{item.text}
</Typography>
</li>)}
</ul>
)}
{showExpander && (
<IconButton
variant='outlined'
onClick={() => setLastNewsIdx(idx + 1)}
sx={{
position: 'absolute', right: 0, bottom: 0, mr: -1, mb: -1,
backgroundColor: 'background.surface',
borderRadius: '50%',
}}
>
<ExpandMoreIcon />
</IconButton>
)}
</CardContent>
</Card>;
</React.Fragment>;
})}
</Container>}
</Container>
{/*<Typography sx={{ textAlign: 'center' }}>*/}
{/* Enjoy!*/}
+93 -57
View File
@@ -1,5 +1,7 @@
import * as React from 'react';
import { StaticImageData } from 'next/image';
import { SxProps } from '@mui/joy/styles/types';
import { Box, Button, Card, CardContent, Chip, Grid, Typography } from '@mui/joy';
import LaunchIcon from '@mui/icons-material/Launch';
@@ -8,17 +10,38 @@ import { Link } from '~/common/components/Link';
import { clientUtmSource } from '~/common/util/pwaUtils';
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
// Images
// An image of a capybara sculpted entirely from black cotton candy, set against a minimalist backdrop with splashes of bright, contrasting sparkles. The capybara is calling on a 3D origami old-school pink telephone and the camera is zooming on the telephone. Close up photography, bokeh, white background.
import coverV113 from '../../../public/images/covers/release-cover-v1.13.0.png';
import coverV112 from '../../../public/images/covers/release-cover-v1.12.0.png';
// update this variable every time you want to broadcast a new version to clients
export const incrementalVersion: number = 12.1;
export const incrementalVersion: number = 13;
const wowStyle: SxProps = {
textDecoration: 'underline',
textDecorationThickness: '0.4em',
textDecorationColor: 'rgba(var(--joy-palette-primary-lightChannel) / 1)',
// textDecorationColor: 'rgba(0 255 0 / 0.5)',
textDecorationSkipInk: 'none',
// textUnderlineOffset: '-0.5em',
};
function B(props: {
// one-of
href?: string,
issue?: number,
code?: string,
wow?: boolean,
children: React.ReactNode
}) {
const href = props.issue ? RIssues + '/' + props.issue : props.href;
const href =
props.issue ? `${Brand.URIs.OpenRepo}/issues/${props.issue}`
: props.code ? `${Brand.URIs.OpenRepo}/blob/main/${props.code}`
: props.href;
const boldText = (
<Typography component='span' color={!!href ? 'primary' : 'neutral'} sx={{ fontWeight: 600 }}>
{props.children}
@@ -27,20 +50,16 @@ function B(props: {
if (!href)
return boldText;
return (
<Link href={href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>
<Link href={href + clientUtmSource()} target='_blank' sx={props.wow ? wowStyle : undefined}>
{boldText} <LaunchIcon sx={{ mx: 0.5, fontSize: 16 }} />
</Link>
);
}
const { OpenRepo, OpenProject } = Brand.URIs;
const RCode = `${OpenRepo}/blob/main`;
const RIssues = `${OpenRepo}/issues`;
// callout, for special occasions
export const newsCallout =
<Card>
export const newsRoadmapCallout =
<Card variant='solid' invertedColors>
<CardContent sx={{ gap: 2 }}>
<Typography level='title-lg'>
Open Roadmap
@@ -53,7 +72,7 @@ export const newsCallout =
<Grid xs={12} sm={7}>
<Button
fullWidth variant='soft' color='primary' endDecorator={<LaunchIcon />}
component={Link} href={OpenProject} noLinkStyle target='_blank'
component={Link} href={Brand.URIs.OpenProject} noLinkStyle target='_blank'
>
Explore
</Button>
@@ -61,7 +80,7 @@ export const newsCallout =
<Grid xs={12} sm={5} sx={{ display: 'flex', flexAlign: 'center', justifyContent: 'center' }}>
<Button
fullWidth variant='plain' color='primary' endDecorator={<LaunchIcon />}
component={Link} href={RIssues + '/new?template=roadmap-request.md&title=%5BSuggestion%5D'} noLinkStyle target='_blank'
component={Link} href={Brand.URIs.OpenRepo + '/issues/new?template=roadmap-request.md&title=%5BSuggestion%5D'} noLinkStyle target='_blank'
>
Suggest a Feature
</Button>
@@ -71,18 +90,49 @@ export const newsCallout =
</Card>;
interface NewsItem {
versionCode: string;
versionName?: string;
versionMoji?: string;
versionDate?: Date;
versionCoverImage?: StaticImageData;
text?: string | React.JSX.Element;
items?: {
text: string | React.JSX.Element;
dev?: boolean;
issue?: number;
}[];
}
// news and feature surfaces
export const NewsItems: NewsItem[] = [
// still unannounced: phone calls, split windows, ...
{// 🆕
{
versionCode: '1.13.0',
versionName: 'Multi + Mind',
versionMoji: '🧠🔀',
versionDate: new Date('2024-02-08T07:47:00Z'),
versionCoverImage: coverV113,
items: [
{ text: <>Side-by-Side <B issue={208}>split windows</B>: multitask with parallel conversations</>, issue: 208 },
{ text: <><B issue={388} wow>Multi-Chat</B> mode: message all, all at once</>, issue: 388 },
{ text: <>Adjustable <B>text size</B>: denser chats</>, issue: 399 },
{ text: <>Export <B issue={392}>tables as CSV</B> files</>, issue: 392 },
{ text: <><B>Dev2</B> persona technology preview</> },
{ text: <>Better looking chats, spacing, fonts, menus</> },
{ text: <>More: video player, LM Studio tutorial, speedups, MongoDB (docs)</> },
],
},
{
versionCode: '1.12.0',
versionName: 'AGI Hotline',
versionMoji: '✨🗣️',
versionDate: new Date('2024-01-26T12:30:00Z'),
versionCoverImage: coverV112,
items: [
{ text: <><B issue={354}>Voice Call Personas</B>: save time, recap conversations</>, issue: 354 },
{ text: <><B issue={354} wow>Voice Call Personas</B>: save time, recap conversations</>, issue: 354 },
{ text: <>Updated <B issue={364}>OpenAI Models</B> to the 0125 release</>, issue: 364 },
{ text: <>Chats: Auto-<B issue={222}>Rename</B> and <B issue={360}>assign folders</B></>, issue: 222 },
{ text: <>Chats: Auto-<B issue={222} wow>Rename</B> and <B issue={360}>assign folders</B></>, issue: 222 },
{ text: <><B issue={356}>Link Sharing</B> makeover and control</>, issue: 356 },
{ text: <><B issue={358}>Accessibility</B> for screen readers</>, issue: 358 },
{ text: <>Export chats to <B>Markdown</B></>, issue: 337 },
@@ -98,12 +148,12 @@ export const NewsItems: NewsItem[] = [
versionMoji: '🌌🌠',
versionDate: new Date('2024-01-16T06:30:00Z'),
items: [
{ text: <><B href={RIssues + '/329'}>Search</B> past conversations (@joriskalz) 🔍</>, issue: 329 },
{ text: <>Quick <B href={RIssues + '/327'}>commands pane</B> (open with &apos;/&apos;)</>, issue: 327 },
{ text: <><B issue={329} wow>Search chats</B> (@joriskalz)</>, issue: 329 },
{ text: <>Quick <B issue={327}>commands pane</B> (open with &apos;/&apos;)</>, issue: 327 },
{ text: <><B>Together AI</B> Inference platform support</>, issue: 346 },
{ text: <>Persona creation: <B href={RIssues + '/301'}>history</B></>, issue: 301 },
{ text: <>Persona creation: fix <B href={RIssues + '/328'}>API timeouts</B></>, issue: 328 },
{ text: <>Support up to five <B href={RIssues + '/323'}>OpenAI-compatible</B> endpoints</>, issue: 323 },
{ text: <>Persona creation: <B issue={301}>history</B></>, issue: 301 },
{ text: <>Persona creation: fix <B issue={328}>API timeouts</B></>, issue: 328 },
{ text: <>Support up to five <B issue={323}>OpenAI-compatible</B> endpoints</>, issue: 323 },
],
},
{
@@ -112,8 +162,8 @@ export const NewsItems: NewsItem[] = [
// versionMoji: '🎊✨',
versionDate: new Date('2024-01-06T08:00:00Z'),
items: [
{ text: <><B href={RIssues + '/201'}>New UI</B> for desktop and mobile, enabling future expansions</>, issue: 201 },
{ text: <><B href={RIssues + '/321'}>Folder categorization</B> for conversation management</>, issue: 321 },
{ text: <><B issue={201} wow>New UI</B> for desktop and mobile, enabling future expansions</>, issue: 201 },
{ text: <><B issue={321} wow>Folder categorization</B> for conversation management</>, issue: 321 },
{ text: <><B>LM Studio</B> support and refined token management</> },
{ text: <>Draggable panes in split screen mode</>, issue: 308 },
{ text: <>Bug fixes and UI polish</> },
@@ -126,9 +176,9 @@ export const NewsItems: NewsItem[] = [
// versionMoji: '🎨🌌',
versionDate: new Date('2023-12-28T22:30:00Z'),
items: [
{ text: <><B href={RIssues + '/212'}>DALL·E 3</B> support (/draw), with advanced control</>, issue: 212 },
{ text: <><B href={RIssues + '/304'}>Perfect scrolling</B> UX, on all devices</>, issue: 304 },
{ text: <>Create personas <B href={RIssues + '/287'}>from text</B></>, issue: 287 },
{ text: <><B issue={212} wow>DALL·E 3</B> support (/draw), with advanced control</>, issue: 212 },
{ text: <><B issue={304} wow>Perfect scrolling</B> UX, on all devices</>, issue: 304 },
{ text: <>Create personas <B issue={287}>from text</B></>, issue: 287 },
{ text: <>Openrouter: auto-detect models, support free-tiers and rates</>, issue: 291 },
{ text: <>Image drawing: unified UX, including auto-prompting</> },
{ text: <>Fix layout on Firefox</>, issue: 255 },
@@ -141,10 +191,10 @@ export const NewsItems: NewsItem[] = [
// versionMoji: '🚀🌕🔙❤️',
versionDate: new Date('2023-12-20T09:30:00Z'),
items: [
{ text: <><B href={RIssues + '/275'}>Google Gemini</B> models support</> },
{ text: <><B href={RIssues + '/273'}>Mistral Platform</B> support</> },
{ text: <><B href={RIssues + '/270'}>Ollama chats</B> perfection</> },
{ text: <>Custom <B href={RIssues + '/280'}>diagrams instructions</B> (@joriskalz)</> },
{ text: <><B issue={275} wow>Google Gemini</B> models support</> },
{ text: <><B issue={273}>Mistral Platform</B> support</> },
{ text: <><B issue={270}>Ollama chats</B> perfection</> },
{ text: <>Custom <B issue={280}>diagrams instructions</B> (@joriskalz)</> },
{ text: <><B>Single-Tab</B> mode, enhances data integrity and prevents DB corruption</> },
{ text: <>Updated Ollama (v0.1.17) and OpenRouter models</> },
{ text: <>More: fixed shortcuts on Mac</> },
@@ -158,11 +208,11 @@ export const NewsItems: NewsItem[] = [
// versionDate: new Date('2023-12-11T06:00:00Z'), // 1.7.3
versionDate: new Date('2023-12-10T12:00:00Z'), // 1.7.0
items: [
{ text: <>Redesigned <B href={RIssues + '/251'}>attachments system</B>: drag, paste, link, snap, images, text, pdfs</> },
{ text: <>Desktop <B href={RIssues + '/253'}>webcam access</B> for direct image capture (Labs option)</> },
{ text: <>Independent browsing with <B href={RCode + '/docs/config-browse.md'}>Browserless</B> support</> },
{ text: <><B href={RIssues + '/256'}>Overheat</B> LLMs with higher temperature limits</> },
{ text: <>Enhanced security via <B href={RCode + '/docs/deploy-authentication.md'}>password protection</B></> },
{ text: <>New <B issue={251} wow>attachments system</B>: drag, paste, link, snap, images, text, pdfs</> },
{ text: <>Desktop <B issue={253}>webcam access</B> for direct image capture (Labs option)</> },
{ text: <>Independent browsing with <B code='/docs/config-browse.md'>Browserless</B> support</> },
{ text: <><B issue={256}>Overheat</B> LLMs with higher temperature limits</> },
{ text: <>Enhanced security via <B code='/docs/deploy-authentication.md'>password protection</B></> },
{ text: <>{platformAwareKeystrokes('Ctrl+Shift+O')}: quick access to model options</> },
{ text: <>Optimized voice input and performance</> },
{ text: <>Latest Ollama and Oobabooga models</> },
@@ -173,10 +223,10 @@ export const NewsItems: NewsItem[] = [
versionName: 'Surf\'s Up',
versionDate: new Date('2023-11-28T21:00:00Z'),
items: [
{ text: <><B href={RIssues + '/237'}>Web Browsing</B> support, see the <B href={RCode + '/docs/config-browse.md'}>browsing user guide</B></> },
{ text: <><B href={RIssues + '/235'}>Branching Discussions</B> at any message</> },
{ text: <><B href={RIssues + '/207'}>Keyboard Navigation</B>: use {platformAwareKeystrokes('Ctrl+Shift+Left/Right')} to navigate chats</> },
{ text: <><B href={RIssues + '/236'}>UI fixes</B> (thanks to the first sponsor)</> },
{ text: <><B issue={237} wow>Web Browsing</B> support, see the <B code='/docs/config-browse.md'>browsing user guide</B></> },
{ text: <><B issue={235}>Branching Discussions</B> at any message</> },
{ text: <><B issue={207}>Keyboard Navigation</B>: use {platformAwareKeystrokes('Ctrl+Shift+Left/Right')} to navigate chats</> },
{ text: <><B issue={236}>UI fixes</B> (thanks to the first sponsor)</> },
{ text: <>Added support for Anthropic Claude 2.1</> },
{ text: <>Large rendering performance optimization</> },
{ text: <>More: <Chip>/help</Chip>, import ChatGPT from source, new Flattener</> },
@@ -188,10 +238,10 @@ export const NewsItems: NewsItem[] = [
versionName: 'Loaded!',
versionDate: new Date('2023-11-19T21:00:00Z'),
items: [
{ text: <><B href={RIssues + '/190'}>Continued Voice</B> for hands-free interaction</> },
{ text: <><B href={RIssues + '/192'}>Visualization</B> Tool for data representations</> },
{ text: <><B href={RCode + '/docs/config-ollama.md'}>Ollama (guide)</B> local models support</> },
{ text: <><B href={RIssues + '/194'}>Text Tools</B> including highlight differences</> },
{ text: <><B issue={190} wow>Continued Voice</B> for hands-free interaction</> },
{ text: <><B issue={192}>Visualization</B> Tool for data representations</> },
{ text: <><B code='/docs/config-ollama.md'>Ollama (guide)</B> local models support</> },
{ text: <><B issue={194}>Text Tools</B> including highlight differences</> },
{ text: <><B href='https://mermaid.js.org/'>Mermaid</B> Diagramming Rendering</> },
{ text: <><B>OpenAI 1106</B> Chat Models</> },
{ text: <><B>SDXL</B> support with Prodia</> },
@@ -203,7 +253,7 @@ export const NewsItems: NewsItem[] = [
versionCode: '1.4.0',
items: [
{ text: <><B>Share and clone</B> conversations, with public links</> },
{ text: <><B href={RCode + '/docs/config-azure-openai.md'}>Azure</B> models, incl. gpt-4-32k</> },
{ text: <><B code='/docs/config-azure-openai.md'>Azure</B> models, incl. gpt-4-32k</> },
{ text: <><B>OpenRouter</B> models full support, incl. gpt-4-32k</> },
{ text: <>Latex Rendering</> },
{ text: <>Augmented Chat modes (Labs)</> },
@@ -226,7 +276,7 @@ export const NewsItems: NewsItem[] = [
{ text: <><B>Flattener</B> - 4-mode conversations summarizer</> },
{ text: <><B>Forking</B> - branch your conversations</> },
{ text: <><B>/s</B> and <B>/a</B> to append a <i>system</i> or <i>assistant</i> message</> },
{ text: <>Local LLMs with <Link href={RCode + '/docs/config-local-oobabooga.md'} target='_blank'>Oobabooga server</Link></> },
{ text: <>Local LLMs with <B code='/docs/config-local-oobabooga.md'>Oobabooga server</B></> },
{ text: 'NextJS STOP bug.. squashed, with Vercel!' },
],
},
@@ -240,17 +290,3 @@ export const NewsItems: NewsItem[] = [
],
},
];
interface NewsItem {
versionCode: string;
versionName?: string;
versionMoji?: string;
versionDate?: Date;
text?: string | React.JSX.Element;
items?: {
text: string | React.JSX.Element;
dev?: boolean;
issue?: number;
}[];
}
+2 -2
View File
@@ -5,7 +5,7 @@ import AddIcon from '@mui/icons-material/Add';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import SettingsAccessibilityIcon from '@mui/icons-material/SettingsAccessibility';
import { RenderMarkdown } from '../../chat/components/message/RenderMarkdown';
import { RenderMarkdownMemo } from '../../chat/components/message/blocks/RenderMarkdown';
import { LLMChainStep, useLLMChain } from '~/modules/aifn/useLLMChain';
@@ -79,7 +79,7 @@ export const PersonaPromptCard = (props: { content: string }) =>
<Alert variant='soft' color='success' sx={{ mb: 1 }}>
You may now copy the text below and use it as Custom prompt!
</Alert>
<RenderMarkdown textBlock={{ type: 'text', content: props.content }} />
<RenderMarkdownMemo textBlock={{ type: 'text', content: props.content }} />
</CardContent>
</Card>;
@@ -56,7 +56,7 @@ export function CreatorDrawerItem(props: {
<Box sx={{ overflow: 'hidden' }}>
{/* Title or System prompt (ellipsized) */}
<Typography level='title-sm' sx={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
<Typography level='title-sm' className='agi-ellipsize'>
{item.name || (item.systemPrompt?.slice(0, 40) + '...')}
</Typography>
+1 -1
View File
@@ -17,7 +17,7 @@ import { PreferencesTab } from '~/common/layout/optima/useOptimaLayout';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { AppChatSettingsAI } from './AppChatSettingsAI';
import { AppChatSettingsUI } from './AppChatSettingsUI';
import { AppChatSettingsUI } from './settings-ui/AppChatSettingsUI';
import { UxLabsSettings } from './UxLabsSettings';
import { VoiceSettings } from './VoiceSettings';
+4 -7
View File
@@ -1,13 +1,12 @@
import * as React from 'react';
import { ChatMessage } from '../chat/components/message/ChatMessage';
import { BlocksRenderer } from '../chat/components/message/blocks/BlocksRenderer';
import { GoodModal } from '~/common/components/GoodModal';
import { createDMessage } from '~/common/state/store-chats';
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
const shortcutsMd = `
const shortcutsMd = platformAwareKeystrokes(`
| Shortcut | Description |
|---------------------|-------------------------------------------------|
@@ -30,15 +29,13 @@ const shortcutsMd = `
| Ctrl + Shift + O | Options (current Chat Model) |
| Ctrl + Shift + ? | Shortcuts |
`.trim();
const shortcutsMessage = createDMessage('assistant', platformAwareKeystrokes(shortcutsMd));
`).trim();
export function ShortcutsModal(props: { onClose: () => void }) {
return (
<GoodModal open title='Desktop Shortcuts' onClose={props.onClose}>
<ChatMessage message={shortcutsMessage} hideAvatars noBottomBorder sx={{ p: 0, m: 0 }} />
<BlocksRenderer text={shortcutsMd} fromRole='assistant' renderTextAsMarkdown />
</GoodModal>
);
}
+16 -16
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
import { FormControl, Typography } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import ScreenshotMonitorIcon from '@mui/icons-material/ScreenshotMonitor';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
@@ -16,31 +16,31 @@ export function UxLabsSettings() {
// external state
const isMobile = useIsMobile();
const {
labsCameraDesktop, labsSplitBranching, //labsDrawing,
setLabsCameraDesktop, setLabsSplitBranching, //setLabsDrawing,
labsAttachScreenCapture, setLabsAttachScreenCapture,
labsCameraDesktop, setLabsCameraDesktop,
} = useUXLabsStore();
return <>
{!isMobile && <FormSwitchControl
title={<><AddAPhotoIcon color={labsCameraDesktop ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Webcam</>} description={labsCameraDesktop ? 'Enabled' : 'Disabled'}
title={<><ScreenshotMonitorIcon color={labsAttachScreenCapture ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Screen Capture</>} description={'v1.13 · ' + (labsAttachScreenCapture ? 'Enabled' : 'Disabled')}
checked={labsAttachScreenCapture} onChange={setLabsAttachScreenCapture}
/>}
{!isMobile && <FormSwitchControl
title={<><AddAPhotoIcon color={labsCameraDesktop ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Webcam</>} description={/*'v1.8 · ' +*/ (labsCameraDesktop ? 'Enabled' : 'Disabled')}
checked={labsCameraDesktop} onChange={setLabsCameraDesktop}
/>}
<FormSwitchControl
title={<><VerticalSplitIcon color={labsSplitBranching ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Split Branching</>} description={labsSplitBranching ? 'Enabled' : 'Disabled'}
checked={labsSplitBranching} onChange={setLabsSplitBranching}
/>
{/*<FormSwitchControl*/}
{/* title={<><AddAPhotoIcon color={labsDrawing ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Drawing</>} description={labsDrawing ? 'Enabled' : 'Disabled'}*/}
{/* checked={labsDrawing} onChange={setLabsDrawing}*/}
{/*/>*/}
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
<FormLabelStart title='Graduated' />
<FormLabelStart title='Graduated' description='Ex-labs' />
<Typography level='body-xs'>
<Link href='https://github.com/enricoros/big-AGI/issues/354' target='_blank'>Call AGI</Link> · <Link href='https://github.com/enricoros/big-AGI/issues/282' target='_blank'>Persona Creator</Link> · <Link href='https://github.com/enricoros/big-agi/issues/192' target='_blank'>Auto Diagrams</Link> · Imagine · Relative chat size · Text Tools · LLM Overheat
<Link href='https://github.com/enricoros/big-AGI/issues/208' target='_blank'>Split Chats</Link>
{' · '}<Link href='https://github.com/enricoros/big-AGI/issues/359' target='_blank'>Draw App</Link>
{' · '}<Link href='https://github.com/enricoros/big-AGI/issues/354' target='_blank'>Call AGI</Link>
{' · '}<Link href='https://github.com/enricoros/big-AGI/issues/282' target='_blank'>Persona Creator</Link>
{' · '}<Link href='https://github.com/enricoros/big-agi/issues/192' target='_blank'>Auto Diagrams</Link>
{' · '}Imagine · Relative chat size · Text Tools · LLM Overheat
</Typography>
</FormControl>
@@ -13,6 +13,8 @@ import { useIsMobile } from '~/common/components/useMatchMedia';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { SettingTextSize } from './SettingTextSize';
// configuration
const SHOW_PURPOSE_FINDER = false;
@@ -44,15 +46,17 @@ export function AppChatSettingsUI() {
centerMode, setCenterMode,
doubleClickToEdit, setDoubleClickToEdit,
enterIsNewline, setEnterIsNewline,
messageTextSize, setMessageTextSize,
renderMarkdown, setRenderMarkdown,
showPurposeFinder, setShowPurposeFinder,
showPersonaFinder, setShowPersonaFinder,
zenMode, setZenMode,
} = useUIPreferencesStore(state => ({
centerMode: state.centerMode, setCenterMode: state.setCenterMode,
doubleClickToEdit: state.doubleClickToEdit, setDoubleClickToEdit: state.setDoubleClickToEdit,
enterIsNewline: state.enterIsNewline, setEnterIsNewline: state.setEnterIsNewline,
messageTextSize: state.messageTextSize, setMessageTextSize: state.setMessageTextSize,
renderMarkdown: state.renderMarkdown, setRenderMarkdown: state.setRenderMarkdown,
showPurposeFinder: state.showPurposeFinder, setShowPurposeFinder: state.setShowPurposeFinder,
showPersonaFinder: state.showPersonaFinder, setShowPersonaFinder: state.setShowPersonaFinder,
zenMode: state.zenMode, setZenMode: state.setZenMode,
}), shallow);
@@ -62,7 +66,7 @@ export function AppChatSettingsUI() {
const handleRenderMarkdownChange = (event: React.ChangeEvent<HTMLInputElement>) => setRenderMarkdown(event.target.checked);
const handleShowSearchBarChange = (event: React.ChangeEvent<HTMLInputElement>) => setShowPurposeFinder(event.target.checked);
const handleShowSearchBarChange = (event: React.ChangeEvent<HTMLInputElement>) => setShowPersonaFinder(event.target.checked);
return <>
@@ -98,9 +102,9 @@ export function AppChatSettingsUI() {
{SHOW_PURPOSE_FINDER && <FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
<FormLabelStart title='Purpose finder'
description={showPurposeFinder ? 'Show search bar' : 'Hide search bar'} />
<Switch checked={showPurposeFinder} onChange={handleShowSearchBarChange}
endDecorator={showPurposeFinder ? 'On' : 'Off'}
description={showPersonaFinder ? 'Show search bar' : 'Hide search bar'} />
<Switch checked={showPersonaFinder} onChange={handleShowSearchBarChange}
endDecorator={showPersonaFinder ? 'On' : 'Off'}
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
</FormControl>}
@@ -113,6 +117,8 @@ export function AppChatSettingsUI() {
]}
value={zenMode} onChange={setZenMode} />
<SettingTextSize textSize={messageTextSize} onChangeTextSize={setMessageTextSize} />
{!isPwa() && !isMobile && (
<FormRadioControl
title='Page Size'
@@ -0,0 +1,56 @@
import * as React from 'react';
import { FormControl, IconButton, Step, Stepper } from '@mui/joy';
import type { UIMessageTextSize } from '~/common/state/store-ui';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
export function SettingTextSize({ textSize, onChangeTextSize }: {
textSize: UIMessageTextSize,
onChangeTextSize: (size: UIMessageTextSize) => void,
}) {
return (
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
<FormLabelStart title='Text Size'
description={textSize === 'xs' ? 'Extra Small' : textSize === 'sm' ? 'Small' : 'Default'} />
<Stepper sx={{
maxWidth: 160,
width: '100%',
fontWeight: 'initial',
'--Step-connectorThickness': '2px',
'--StepIndicator-size': '2rem',
}}>
{(['xs', 'sm', 'md'] as UIMessageTextSize[]).map(sizeKey => {
const isActive = sizeKey === textSize;
return (
<Step
key={sizeKey}
onClick={() => onChangeTextSize(sizeKey)}
indicator={
<IconButton
size='sm'
variant={isActive ? 'outlined' : 'soft'}
// color={isActive ? 'primary' : 'neutral'}
sx={{
// style
fontSize: sizeKey,
// borderRadius: !isActive ? '50%' : undefined,
borderRadius: '50%',
width: '1rem',
height: '1rem',
// style when active
'--variant-borderWidth': '2px',
borderColor: 'primary.solidBg',
}}
>
{'Aa' /* Nothing says 'font' more than this */ }
</IconButton>
}
/>
);
})}
</Stepper>
</FormControl>
);
}
+3 -2
View File
@@ -31,7 +31,6 @@ import SettingsIcon from '@mui/icons-material/Settings';
import { Brand } from '~/common/app.config';
import { hasNoChatLinkItems } from '~/modules/trade/link/store-link';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
// enable to show all items, for layout development
@@ -115,7 +114,7 @@ export const navItems: {
route: '/draw',
// hideOnMobile: true,
hideDrawer: true,
hideIcon: () => !useUXLabsStore.getState().labsDrawing,
_delete: true,
},
{
name: 'Cortex',
@@ -139,6 +138,8 @@ export const navItems: {
iconActive: WorkspacesIcon,
type: 'app',
route: '/workspace',
hideDrawer: true,
hideOnMobile: true,
_delete: true,
},
// <-- divider here -->
+16 -5
View File
@@ -9,7 +9,7 @@ export const hideOnMobile = { display: { xs: 'none', md: 'flex' } };
// export const hideOnDesktop = { display: { xs: 'flex', md: 'none' } };
// Dimensions
export const formLabelStartWidth = 150;
export const formLabelStartWidth = 140;
// Theme & Fonts
@@ -38,7 +38,7 @@ export const appTheme = extendTheme({
palette: {
neutral: {
plainColor: 'var(--joy-palette-neutral-800)', // [700 -> 800] Dropdown menu: increase text contrast a bit
solidBg: 'var(--joy-palette-neutral-700)', // [500 -> 700] AppBar background & Button[solid]
solidBg: 'var(--joy-palette-neutral-700)', // [500 -> 700] PageBar background & Button[solid]
solidHoverBg: 'var(--joy-palette-neutral-800)', // [600 -> 800] Buttons[solid]:hover
},
// primary [800] > secondary [700 -> 800] > tertiary [600] > icon [500 -> 700]
@@ -103,6 +103,15 @@ export const appTheme = extendTheme({
},
},
// JoyModal: {
// styleOverrides: {
// backdrop: {
// // backdropFilter: 'blur(2px)',
// backdropFilter: 'none',
// },
// },
// },
/**
* Switch: increase the size of the thumb, to a default iconButton
* NOTE: do not use anything else than 'md' size
@@ -111,9 +120,9 @@ export const appTheme = extendTheme({
styleOverrides: {
root: ({ ownerState }) => ({
...(ownerState.size === 'md' && {
'--Switch-trackWidth': '40px',
'--Switch-trackHeight': '24px',
'--Switch-thumbSize': '18px',
'--Switch-trackWidth': '36px',
'--Switch-trackHeight': '22px',
'--Switch-thumbSize': '17px',
}),
}),
},
@@ -126,11 +135,13 @@ export const themeBgAppDarker = 'background.level2';
export const themeBgAppChatComposer = 'background.surface';
export const lineHeightChatText = 1.75;
export const lineHeightChatCode = 1.75;
export const lineHeightTextarea = 1.75;
export const themeZIndexPageBar = 25;
export const themeZIndexDesktopDrawer = 26;
export const themeZIndexDesktopNav = 27;
export const themeZIndexOverMobileDrawer = 1301;
export const themeBreakpoints = appTheme.breakpoints.values;
+8 -3
View File
@@ -23,6 +23,7 @@ const Popup = styled(Popper)({
export function CloseableMenu(props: {
open: boolean, anchorEl: HTMLElement | null, onClose: () => void,
dense?: boolean,
bigIcons?: boolean,
// variant?: VariantProp,
// color?: ColorPaletteProp,
// size?: 'sm' | 'md' | 'lg',
@@ -76,9 +77,13 @@ export function CloseableMenu(props: {
// variant={props.variant} color={props.color}
onKeyDown={handleListKeyDown}
sx={{
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
'--ListItem-minHeight': props.dense ? '2.25rem' : '3rem',
'--ListItemDecorator-size': '2.75rem', // icon width
'--ListItem-minHeight': props.dense
? '2.25rem' /* 2.25 is the default */
: '2.5rem', /* we enlarge the default */
...(props.bigIcons && {
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
// '--ListItemDecorator-size': '2.75rem',
}),
backgroundColor: 'background.popup',
boxShadow: 'md',
...(props.maxHeightGapPx !== undefined ? { maxHeight: `calc(100dvh - ${props.maxHeightGapPx}px)`, overflowY: 'auto' } : {}),
@@ -0,0 +1,47 @@
import * as React from 'react';
import { Accordion, AccordionDetails, AccordionGroup, AccordionSummary, accordionSummaryClasses, Box } from '@mui/joy';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
export function ExpanderAccordion(props: { title?: React.ReactNode, icon?: React.ReactNode, startCollapsed?: boolean, children?: React.JSX.Element }) {
// state
const [expanded, setExpanded] = React.useState(props.startCollapsed !== true);
return (
<AccordionGroup>
<Accordion
// variant={expanded ? 'solid' : 'soft'}
expanded={expanded}
onChange={(_event, expanded) => setExpanded(expanded)}
>
<AccordionSummary
variant='soft'
indicator={<KeyboardArrowDownIcon />}
slotProps={{
indicator: {
sx: {
transition: 'transform 0.2s',
},
},
}}
sx={{
[`&.${accordionSummaryClasses.indicator}[aria-expanded='true']`]: {
transform: 'rotate(-180deg)',
},
}}
>
{props.icon} {props.title}
</AccordionSummary>
<AccordionDetails variant='solid'>
<Box sx={{ display: 'grid' }}>
{expanded && props.children}
</Box>
</AccordionDetails>
</Accordion>
</AccordionGroup>
);
}
@@ -0,0 +1,29 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, styled } from '@mui/joy';
const BoxCollapser = styled(Box)({
display: 'grid',
transition: 'grid-template-rows 0.2s cubic-bezier(.17,.84,.44,1)',
gridTemplateRows: '0fr',
'&[aria-expanded="true"]': {
gridTemplateRows: '1fr',
},
});
const BoxCollapsee = styled(Box)({
overflow: 'hidden',
});
export function ExpanderControlledBox(props: { expanded: boolean, children: React.ReactNode, sx?: SxProps }) {
return (
<BoxCollapser aria-expanded={props.expanded} sx={props.sx}>
<BoxCollapsee>
{props.children}
</BoxCollapsee>
</BoxCollapser>
);
}
+8 -2
View File
@@ -22,13 +22,17 @@ export function GoodModal(props: {
}) {
const showBottomClose = !!props.onClose && props.hideBottomClose !== true;
return (
<Modal open={props.open} onClose={props.onClose}>
<Modal
open={props.open}
onClose={props.onClose}
// slotProps={{ backdrop: { sx: { backdropFilter: 'blur(6px)' } } }}
>
<ModalOverflow sx={{ p: 1 }}>
<ModalDialog
sx={{
minWidth: { xs: 360, sm: 500, md: 600, lg: 700 },
maxWidth: 700,
display: 'flex', flexDirection: 'column', gap: 'var(--Card-padding)',
display: 'grid', gap: 'var(--Card-padding)',
...props.sx,
}}>
@@ -41,7 +45,9 @@ export function GoodModal(props: {
{props.dividers === true && <Divider />}
{/*<Box sx={{ maxHeight: '80lvh', overflowY: 'auto', display: 'grid', gap: 'var(--Card-padding)' }}>*/}
{props.children}
{/*</Box>*/}
{props.dividers === true && <Divider />}
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react';
import type { ReactPlayerProps } from 'react-player';
type VideoPlayerProps = ReactPlayerProps & {
// make the player responsive
responsive?: boolean;
// set this to not set the full URL
youTubeVideoId?: string;
};
const VideoPlayerDynamic = React.lazy(async () => {
// dynamically import react-player (saves 7kb but still..)
const { default: ReactPlayer } = await import('react-player');
return {
default: (props: ReactPlayerProps) => {
const { responsive, youTubeVideoId, ...baseProps } = props;
// responsive patch
if (responsive) {
baseProps.width = '100%';
baseProps.height = '100%';
}
// fill in the URL if we have a YouTube video ID
if (youTubeVideoId) {
baseProps.url = `https://www.youtube.com/watch?v=${youTubeVideoId}`;
}
return <ReactPlayer {...baseProps} />;
},
};
});
export function VideoPlayer(props: VideoPlayerProps) {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<VideoPlayerDynamic {...props} />
</React.Suspense>
);
}
@@ -0,0 +1,15 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { SvgIcon } from '@mui/joy';
/*
* Source: the PodcastsIcon from '@mui/icons-material/Podcasts';
*/
export function ChatMulticastOffIcon(props: { sx?: SxProps }) {
return (
<SvgIcon viewBox='0 0 24 24' width='24' height='24' {...props}>
<path d='M14 12c0 .74-.4 1.38-1 1.72V22h-2v-8.28c-.6-.35-1-.98-1-1.72 0-1.1.9-2 2-2s2 .9 2 2'></path>
</SvgIcon>
);
}
@@ -0,0 +1,15 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { SvgIcon } from '@mui/joy';
/*
* Source: the PodcastsIcon from '@mui/icons-material/Podcasts';
*/
export function ChatMulticastOnIcon(props: { sx?: SxProps }) {
return (
<SvgIcon viewBox='0 0 24 24' width='24' height='24' {...props}>
<path d='M14 12c0 .74-.4 1.38-1 1.72V22h-2v-8.28c-.6-.35-1-.98-1-1.72 0-1.1.9-2 2-2s2 .9 2 2m-2-6c-3.31 0-6 2.69-6 6 0 1.74.75 3.31 1.94 4.4l1.42-1.42C8.53 14.25 8 13.19 8 12c0-2.21 1.79-4 4-4s4 1.79 4 4c0 1.19-.53 2.25-1.36 2.98l1.42 1.42C17.25 15.31 18 13.74 18 12c0-3.31-2.69-6-6-6m0-4C6.48 2 2 6.48 2 12c0 2.85 1.2 5.41 3.11 7.24l1.42-1.42C4.98 16.36 4 14.29 4 12c0-4.41 3.59-8 8-8s8 3.59 8 8c0 2.29-.98 4.36-2.53 5.82l1.42 1.42C20.8 17.41 22 14.85 22 12c0-5.52-4.48-10-10-10'></path>
</SvgIcon>
);
}
@@ -1,22 +1,16 @@
import * as React from 'react';
import { PanelResizeHandle } from 'react-resizable-panels';
import { Box } from '@mui/joy';
import { Box, styled } from '@mui/joy';
import { themeBgApp } from '~/common/app.theme';
export function GoodPanelResizeHandler() {
return (
<PanelResizeHandle>
<Box sx={{
backgroundColor: themeBgApp,
height: '100%',
width: '4px',
'&:hover': {
backgroundColor: 'primary.softActiveBg',
},
}} />
</PanelResizeHandle>
);
}
export const PanelResizeInset = styled(Box)({
backgroundColor: themeBgApp,
width: '100%',
height: '100%',
minWidth: '0.25rem',
minHeight: '0.25rem',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: 'var(--joy-palette-primary-solidBg)',
},
});
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Chip, chipClasses } from '@mui/joy';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
function ChipExpander(props: {
text: React.ReactNode,
expand: boolean,
handleToggleExpand: () => void
}) {
return (
<Chip
variant={props.expand ? 'solid' : 'outlined'}
size='md'
onClick={props.handleToggleExpand}
endDecorator={<KeyboardArrowDownIcon />}
aria-expanded={props.expand}
sx={{
px: 1.5,
[`& .${chipClasses.endDecorator}`]: {
transition: 'transform 0.2s',
},
[`&[aria-expanded='true'] .${chipClasses.endDecorator}`]: {
transform: 'rotate(-180deg)',
},
}}
>
{props.text}
</Chip>
);
}
export function useChipBoolean(text: React.ReactNode, initialExpand = false): [boolean, React.JSX.Element] {
// state
const [expanded, setExpanded] = React.useState(initialExpand);
const component = React.useMemo(() => (
<ChipExpander text={text} expand={expanded} handleToggleExpand={() => setExpanded(on => !on)} />
), [expanded, text]);
return [expanded, component];
}
@@ -7,7 +7,7 @@ import * as React from 'react';
*/
class AloneDetector {
private readonly clientId: string;
private readonly broadcastChannel: BroadcastChannel;
private readonly bChannel: BroadcastChannel;
private aloneCallback: ((isAlone: boolean) => void) | null;
private aloneTimerId: number | undefined;
@@ -17,15 +17,15 @@ class AloneDetector {
this.clientId = Math.random().toString(36).substring(2, 10);
this.aloneCallback = onAlone;
this.broadcastChannel = new BroadcastChannel(channelName);
this.broadcastChannel.onmessage = this.handleIncomingMessage;
this.bChannel = new BroadcastChannel(channelName);
this.bChannel.onmessage = this.handleIncomingMessage;
}
public onUnmount(): void {
// close channel
this.broadcastChannel.onmessage = null;
this.broadcastChannel.close();
this.bChannel.onmessage = null;
this.bChannel.close();
// clear timeout
if (this.aloneTimerId)
@@ -38,7 +38,7 @@ class AloneDetector {
public checkIfAlone(): void {
// triggers other clients
this.broadcastChannel.postMessage({ type: 'CHECK', sender: this.clientId });
this.bChannel.postMessage({ type: 'CHECK', sender: this.clientId });
// if no response within 500ms, assume this client is alone
this.aloneTimerId = window.setTimeout(() => {
@@ -56,7 +56,7 @@ class AloneDetector {
switch (event.data.type) {
case 'CHECK':
this.broadcastChannel.postMessage({ type: 'ALIVE', sender: this.clientId });
this.bChannel.postMessage({ type: 'ALIVE', sender: this.clientId });
break;
case 'ALIVE':
+2 -5
View File
@@ -10,7 +10,6 @@ import { useModelsStore } from '~/modules/llms/store-llms';
import { AgiSquircleIcon } from '~/common/components/icons/AgiSquircleIcon';
import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav';
import { themeZIndexDesktopNav } from '~/common/app.theme';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import { BringTheLove } from './components/BringTheLove';
import { DesktopNavGroupBox, DesktopNavIcon, navItemClasses } from './components/DesktopNavIcon';
@@ -35,8 +34,6 @@ export function DesktopNav(props: { component: React.ElementType, currentApp?: N
showModelsSetup, openModelsSetup,
} = useOptimaLayout();
const noLLMs = useModelsStore(state => !state.llms.length);
// ignore the return value, this just makes sure that the nav is refreshed when UX Labs change - while "drawing" is in there
const labsDrawing = useUXLabsStore(state => state.labsDrawing);
// show/hide the pane when clicking on the logo
@@ -73,7 +70,7 @@ export function DesktopNav(props: { component: React.ElementType, currentApp?: N
</Tooltip>
);
});
}, [props.currentApp, isDrawerOpen, labsDrawing, toggleDrawer]);
}, [isDrawerOpen, props.currentApp, toggleDrawer]);
// External link items
@@ -134,7 +131,7 @@ export function DesktopNav(props: { component: React.ElementType, currentApp?: N
<InvertedBarCornerItem>
<Tooltip title={isDrawerOpen ? 'Close Drawer' /* for Aria reasons */ : 'Open Drawer'}>
<DesktopNavIcon disabled={!logoButtonTogglesPane} onClick={handleLogoButtonClick}>
<DesktopNavIcon disabled={!logoButtonTogglesPane} onClick={handleLogoButtonClick} className={navItemClasses.typeMenu}>
{logoButtonTogglesPane ? <MenuIcon /> : <AgiSquircleIcon inverted sx={{ color: 'white' }} />}
</DesktopNavIcon>
</Tooltip>
+1 -1
View File
@@ -19,7 +19,7 @@ import { PageWrapper } from './PageWrapper';
*
* Main functions:
* - modern responsive layout
* - core layout of the application, with the Nav, Panes, Appbar, etc.
* - core layout of the application, with the Nav, Panes, PageBar, etc.
* - the child(ren) of this layout are placed in the main content area
* - allows for pluggable components of children applications, via usePluggableOptimaLayout
* - overlays and displays various modals

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