Compare commits

...

164 Commits

Author SHA1 Message Date
Enrico Ros 4c79b95ddc Merge remote-tracking branch 'opensource/main-stable'
# Conflicts:
#	package-lock.json
2024-01-26 05:00:38 -08:00
Enrico Ros 720945f903 Maintainers template update 2024-01-26 04:41:41 -08:00
Enrico Ros 7ee8f218f6 Maintainers command update 2024-01-26 04:36:24 -08:00
Enrico Ros 72f9e01e60 Merge branch 'release-1.12.0' 2024-01-26 04:35:39 -08:00
Enrico Ros b4bae3ba20 1.12.0: Changelog 2024-01-26 04:35:23 -08:00
Enrico Ros 7c67dbd1f2 1.12.0: Readme Video 2024-01-26 04:33:01 -08:00
Enrico Ros ac8da8dfbf 1.12.0: Readme Update 2024-01-26 04:11:06 -08:00
Enrico Ros 1d778a699a Release Template update 2024-01-26 04:10:45 -08:00
Enrico Ros 0ac3033320 1.12.0: news.data.tsx 2024-01-26 04:10:33 -08:00
Enrico Ros c65aa99f9e 1.12.0: Version 2024-01-26 02:32:42 -08:00
Enrico Ros b22d54254a Nav: move back from the bottom to the menu 2024-01-26 02:14:00 -08:00
Enrico Ros 3eeb4aa157 Rounder numbers 2024-01-26 02:13:40 -08:00
Enrico Ros fac237638f Focus Mode: disable 2024-01-26 02:05:00 -08:00
Enrico Ros 7b617c5d03 Lints 2024-01-26 01:47:53 -08:00
Enrico Ros 3a579f3468 Roll deps 2024-01-26 01:47:41 -08:00
Enrico Ros 2bf407a989 Ollama: update available upstream models
Can't wait for https://github.com/ollama/ollama/issues/1473 enough.
2024-01-26 01:43:09 -08:00
Enrico Ros 18a16294bc Ollama: extract contextWindow from num_ctx. Closes #309
Note that from testing, only yarn-mistral has a number set that's not 4096,
while some models don't have parameters, don't have a 'num_ctx' value to parse
within, or have it set to 4096.
2024-01-26 01:04:55 -08:00
Enrico Ros db1346fe3e Ollama: extract Zod parsers 2024-01-26 00:01:35 -08:00
Enrico Ros 2b3477feb0 Drawing: disable by default, add option, and disable that too 2024-01-25 23:52:19 -08:00
Enrico Ros b7bc715b36 OpenAI models: 1106 visible 2024-01-25 17:18:54 -08:00
Enrico Ros bc237dee1c OpenAI models: sync with today's released/announced models 2024-01-25 17:12:12 -08:00
Enrico Ros 6131556bab OpenAI models: improve sorting 2024-01-25 17:11:19 -08:00
Enrico Ros 3d42bc51f3 Roll it 2024-01-25 14:49:13 -08:00
Enrico Ros 3f3f3c67bf Draw Mode: Begin some wiring 2024-01-25 04:31:26 -08:00
Enrico Ros eeaa87bde3 Draw Mode: Improve Layout 2024-01-25 03:58:52 -08:00
Enrico Ros f854f0182f Draw Mode: Extract ProviderConfigure 2024-01-25 03:52:37 -08:00
Enrico Ros 302e327d2d Remove hook dependency 2024-01-25 02:54:01 -08:00
Enrico Ros 2d18a81654 Draw Mode: Extract Prompt Designer 2024-01-25 02:40:52 -08:00
Enrico Ros 71a97e1c4e Draw Mode: Prompt Designer layout 2024-01-25 00:27:57 -08:00
Enrico Ros 542b47ba78 Draw Mode: propagate isMobile 2024-01-25 00:07:13 -08:00
Enrico Ros d27f269abc Draw Mode: ideas 2024-01-25 00:06:59 -08:00
Enrico Ros b0484e24af Style: adjust dividers 2024-01-24 23:06:18 -08:00
Enrico Ros fa8c4a30d8 Export: remove from the Chat Menu 2024-01-24 23:00:58 -08:00
Enrico Ros f6163b5a22 Export: to Drawer Item 2024-01-24 22:56:38 -08:00
Enrico Ros 8f945f11e7 Sharing: change a couple of strings 2024-01-24 21:53:07 -08:00
Enrico Ros fa7a7bdf1d Edit: use accessible Icons, no Text 2024-01-24 21:45:42 -08:00
Enrico Ros fe7a2caf2c Mobile Nav: land. 2024-01-24 16:45:19 -08:00
Enrico Ros 6ae11d07eb Nav: improvements 2024-01-24 15:49:33 -08:00
Enrico Ros 58896a7052 Export improvements and Export to Markdown, Closes #337 2024-01-24 15:26:00 -08:00
Enrico Ros 1f83210792 Ollama: track upstream ticket - https://github.com/ollama/ollama/issues/1473 2024-01-24 14:47:41 -08:00
Enrico Ros 0a4a452bee Optimize GithubMarkdown dark/light 2024-01-24 06:31:54 -08:00
Enrico Ros 8063ee34b3 GithubMarkdown: update from upstream 2024-01-24 06:05:46 -08:00
Enrico Ros 72e2fa41aa Accessibility: finish with some good improvements, #358 2024-01-24 04:59:01 -08:00
Enrico Ros 1c3f8ba8ec Accessibility: improve social links (was 2 tabIndexes), #358 2024-01-24 04:38:03 -08:00
Enrico Ros e1802cb0f8 Misc UX cleanups 2024-01-24 04:32:41 -08:00
Enrico Ros afeab71da1 Actiles: shall support Mobile now 2024-01-24 04:06:00 -08:00
Enrico Ros 8d492702f2 Cleanups 2024-01-24 03:20:49 -08:00
Enrico Ros 64e8cfcb03 Accessibility: Call: fix buttons, #358 2024-01-24 02:55:48 -08:00
Enrico Ros 2167d0ef1e Accessibility: improve HTML elements for manage models, preferences, closing the dialogs, model list, model selection, model unhide, #358 2024-01-24 02:33:16 -08:00
Enrico Ros 977b14494b Lint 2024-01-24 01:23:10 -08:00
Enrico Ros 3b408c8173 Optimization: sx stability 2024-01-24 00:49:55 -08:00
Enrico Ros 9547b25835 Debouncer: min 2 chars (no single-letter searches, as there are too many positives) 2024-01-24 00:49:45 -08:00
Enrico Ros 9c53557183 Chat Item Folder: fix button size 2024-01-24 00:29:45 -08:00
Enrico Ros 3cc8d48b75 Roll packages 2024-01-24 00:22:45 -08:00
Enrico Ros 71dbc653a9 Accessibility: use nav/aside/main/header. #358 2024-01-24 00:19:18 -08:00
Enrico Ros f1e8bf3d1f Wording 2024-01-23 22:16:58 -08:00
Enrico Ros 0c8dd4a4d9 Large Optimizations 2024-01-23 22:16:43 -08:00
Enrico Ros 4911f39793 Optimizations (more) 2024-01-23 21:40:44 -08:00
Enrico Ros daaf33a69e Optimizations 2024-01-23 21:14:41 -08:00
Enrico Ros 8b04d38ce3 Folder selection: remove fading - Closes #360 2024-01-23 20:45:22 -08:00
Enrico Ros 4a35701def Folders: change Folder from the Drawer Chat Item as well, #321 2024-01-23 20:36:54 -08:00
Enrico Ros 8800cae62f Remove unused setting 2024-01-23 18:56:45 -08:00
Enrico Ros aebf7b99f4 Folders: drawer cleanup code 2024-01-23 18:54:19 -08:00
Enrico Ros a9ea4070ff Folders: uniform 'active' name for folders and chats 2024-01-23 16:49:23 -08:00
Enrico Ros 3fb8d91ab1 Folders: minutia 2024-01-23 16:31:48 -08:00
Enrico Ros a9943e26af Folders: de-confuse variable 2024-01-23 16:23:24 -08:00
Enrico Ros 514ecedf1c Prompts store: improve deletion 2024-01-23 16:00:53 -08:00
Enrico Ros 74a277a6f3 Prompts store: stay (significantly)below localStorage quota 2024-01-23 15:56:03 -08:00
Enrico Ros b14cd47a7b Tiktoken [x4, port]: successfully defer the library load, with large interactivity improvements
The rationals that TTFP would be a more important metric than
awaiting the 1.2MB dependency at every page load

(cherry picked from commit 3a8195a02b)
(cherry picked from commit 808077bc2b)
(cherry picked from commit 76f6c7917c)
(cherry picked from commit fc1fc91845)
2024-01-23 05:03:20 -08:00
Enrico Ros c1a29d76d5 Roll 'tiktoken' (fka. @dqbd/tiktoken) 2024-01-23 05:01:17 -08:00
Enrico Ros 3a8195a02b TikToken delayed loading: debug removal 2024-01-23 04:56:27 -08:00
Enrico Ros f70b0474ad Disabled debug(prod) code 2024-01-23 04:53:15 -08:00
Enrico Ros 808077bc2b Tiktoken: successfully defer the library load, with large interactivity improvements 2024-01-23 04:52:44 -08:00
Enrico Ros 76f6c7917c Tiktoken: do not warm-up the encoding for the current model
The rationals that TTFP would be a more important metric than
awaiting the 1.2MB dependency at every page load
2024-01-23 03:04:47 -08:00
Enrico Ros fc1fc91845 Tiktoken: make sharing of the 100k explicit 2024-01-23 03:00:18 -08:00
Enrico Ros 72d5a8f5f0 Roll 'tiktoken' (fka. @dqbd/tiktoken) 2024-01-23 03:00:04 -08:00
Enrico Ros 53226da794 Roll Packages 2024-01-23 02:47:57 -08:00
Enrico Ros 638bd1e780 Roll NextJS 2024-01-23 02:10:59 -08:00
Enrico Ros 046d193af8 Chat Drawer: rename 2024-01-23 01:48:32 -08:00
Enrico Ros ff0cc09505 DALL·E: salvage successful requests, and throw a more descriptive errors. #353 2024-01-23 01:39:02 -08:00
Enrico Ros b52468dd54 Link: fix size 2024-01-22 19:03:10 -08:00
Enrico Ros 76cadaed18 Docs: fix npm start vs next start (thanks @motocycle) 2024-01-22 18:54:32 -08:00
Enrico Ros 2e68172fa9 Nav: bring back hide on mobile 2024-01-22 18:54:00 -08:00
Enrico Ros 4bbed2adb1 Link: option to show the deletion keys in the drawer 2024-01-22 18:12:52 -08:00
Enrico Ros fb4a62be16 Merge branch 'issue-356'. Fixes #356 2024-01-22 18:05:12 -08:00
Enrico Ros 5da3a887c4 Link: enable delete. Fixes #356 2024-01-22 18:04:13 -08:00
Enrico Ros 2df49977c2 Link: improve deletion 2024-01-22 18:03:40 -08:00
Enrico Ros d275ee0f7d LinkChat: improve appearance 2024-01-22 17:11:18 -08:00
Enrico Ros 19ec67bf3c Links: more uniform route names 2024-01-22 17:10:22 -08:00
Enrico Ros 9dc8aaa9aa Links: renames 2024-01-22 17:09:59 -08:00
Enrico Ros 15cfef0f8b Links: land to former conversations #356 2024-01-22 16:03:59 -08:00
Enrico Ros 695af02cee Nav: functions for nav/icon visibility #356 2024-01-22 15:53:42 -08:00
Enrico Ros 1ed86b6ebc Disable Nav items debug code 2024-01-22 15:41:25 -08:00
Enrico Ros e18ac02af9 Links: update text 2024-01-22 14:44:57 -08:00
Enrico Ros a4d89c9e2c Links: Import as New/Over #356 2024-01-22 14:43:28 -08:00
Enrico Ros 911c46ebe2 Bring back debug conv IDs. 2024-01-22 14:42:30 -08:00
Enrico Ros f0073133c3 Trade: update deletion key #356 2024-01-22 14:35:09 -08:00
Enrico Ros db3a435027 Trade: cleanups 2024-01-22 14:00:44 -08:00
Enrico Ros a94f2c6df3 Trade: move the store 2024-01-22 13:50:26 -08:00
Enrico Ros 0b7eaf69ba Trade: extract Publish (paste.gg) 2024-01-22 13:47:52 -08:00
Enrico Ros 326f49bafc Trade: extract ChatLinkManager 2024-01-22 13:32:58 -08:00
Enrico Ros 6195c8954d Draw: improve layout 2024-01-21 22:09:02 -08:00
Enrico Ros 1586377ead Draw: (used) selector 2024-01-21 20:12:45 -08:00
Enrico Ros 97b1f15121 Draw: TextToImage: layout 2024-01-21 19:23:34 -08:00
Enrico Ros 6d185119ac useToggleableBoolean: remember last state in-mem 2024-01-21 19:07:34 -08:00
Enrico Ros 296eff7278 Dall-E: disable advanced by default 2024-01-21 18:56:13 -08:00
Enrico Ros 84b1825895 Draw: Service Configuration 2024-01-21 18:52:27 -08:00
Enrico Ros a69c067530 Draw: select Service Provider 2024-01-21 18:35:43 -08:00
Enrico Ros 0043b39293 Preferences: remove hardcodings 2024-01-21 18:03:57 -08:00
Enrico Ros 8123c237e3 Draw: begin breakdown 2024-01-21 17:47:00 -08:00
Enrico Ros 5a0fb1bb63 Draw: t2i settings 2024-01-20 20:05:50 -08:00
Enrico Ros a507d53d34 Draw: keep the placeholder 2024-01-20 20:03:09 -08:00
Enrico Ros 60cbcdaedb Draw: header 2024-01-20 20:02:30 -08:00
Enrico Ros 96b4f502f1 Draw: placeholder. 2024-01-20 19:47:24 -08:00
Enrico Ros 846b3cddaf Fix possible exception when fetching from ElevenLabs 2024-01-20 19:41:28 -08:00
Enrico Ros 1b66dce9f0 Desktop Layout: show App separator + bits 2024-01-20 19:37:02 -08:00
Enrico Ros c7952ae974 Call: hide hidden personas (fixed) 2024-01-20 19:11:09 -08:00
Enrico Ros ed2284716b Call: hide hidden personas 2024-01-20 19:09:21 -08:00
Enrico Ros d64ed69371 Change hint text 2024-01-20 19:05:55 -08:00
Enrico Ros e73bf2ddec Call: persist all 3 settings 2024-01-20 18:59:12 -08:00
Enrico Ros 19609e5ccd Call: simplify~1 2024-01-20 18:41:38 -08:00
Enrico Ros 3adc2f4654 Call: allow for Gray UI, and cleanup CSS 2024-01-20 18:25:15 -08:00
Enrico Ros 2b95b6ace1 Calls: customizable Contacts page 2024-01-20 17:35:23 -08:00
Enrico Ros 5720de1224 Page Bar: Custom Title and big-AGI (=back) button 2024-01-20 17:21:49 -08:00
Enrico Ros 1b110f5a38 Remove Shared Page Drawer 2024-01-20 17:19:16 -08:00
Enrico Ros 0785961581 Call: add support link 2024-01-20 17:17:38 -08:00
Enrico Ros f1cc92727c Call: fix looping on missing Conversation 2024-01-19 22:40:39 -08:00
Enrico Ros b36197ffad Call: fix double-message on error 2024-01-19 22:35:12 -08:00
Enrico Ros eae3d78911 Call: take out of Beta
Also remove the option to call from the Dropdown menu, which
was an initial workaround anyway.
2024-01-19 22:04:41 -08:00
Enrico Ros 12a93fdcb7 Update Prodia Default 2024-01-19 21:55:07 -08:00
Enrico Ros c98ab8cb9d Call: do not display "Re:" if no call 2024-01-19 21:50:13 -08:00
Enrico Ros 8619a9ca1d Call App: style++. 2024-01-19 20:19:47 -08:00
Enrico Ros 2b182a4209 When the bar is shown, show the menu (for Dark/Light mode) 2024-01-19 15:19:32 -08:00
Enrico Ros ddc7d571d2 Call App: Done. Beauty & Function. #175. Closes #354. 2024-01-19 15:19:07 -08:00
Enrico Ros 3de693e5e3 Enabled the Call app 2024-01-18 18:41:01 -08:00
Enrico Ros 770fbdef72 Bits 2024-01-18 18:40:28 -08:00
Enrico Ros 80d9f458bb Style: fill active icons 2024-01-18 17:14:01 -08:00
Enrico Ros 52f91dd328 Style: fill active icons 2024-01-18 17:13:35 -08:00
Enrico Ros 22550f7efb Style: update icons 2024-01-18 16:59:54 -08:00
Enrico Ros f811b59919 Style: bring back the former behavior of the Links 2024-01-18 16:51:46 -08:00
Enrico Ros d2344e5010 Style the Desktop Nav Panel 2024-01-18 16:33:26 -08:00
Enrico Ros 6fee9a6238 Call: improve styling, honor dark/bright mode - #175 2024-01-17 16:58:53 -08:00
Enrico Ros 08730002a4 Call: some top-level structure 2024-01-17 16:01:16 -08:00
Enrico Ros 20adb796c0 Call: update strings 2024-01-17 15:36:13 -08:00
Enrico Ros 0e7cbfe0e4 Improve Mobile Insert - save space - looks better. Fixes #315 2024-01-17 14:48:21 -08:00
Enrico Ros 46ef5d9b45 Update roadmap request 2024-01-17 14:15:19 -08:00
Enrico Ros f249b39db5 Drawer: radius on mobile, and optimize 2024-01-17 14:01:39 -08:00
Enrico Ros 280bb2e424 Attachments: when pasted from cliboard (no ref), add a "<!DOCTYPE html>" to render as HTML block - Fixes #348. 2024-01-17 13:15:42 -08:00
Enrico Ros 8c206aedb9 Simplify Structural BgColors 2024-01-17 13:09:34 -08:00
Enrico Ros d74b7df41d Select All -> Select #. 2024-01-16 05:12:33 -08:00
Enrico Ros 571a04cf6c Rename/Auto-Name conversations and New UI Conversation Item. Fixes #222, Fixes #297. 2024-01-16 04:49:44 -08:00
Enrico Ros 216dae9423 InlineTextarea: support inverted soft colors 2024-01-16 04:49:02 -08:00
Enrico Ros ef09d50715 AutoTitle: make it force-able 2024-01-16 04:11:43 -08:00
Enrico Ros 1e851bbb6c Style: more consistent gaps and paddings across settings 2024-01-16 02:10:46 -08:00
Enrico Ros 3c63593141 Models list: stop the never ending scrolling by absorbing it on the LLMs list 2024-01-16 01:41:35 -08:00
Enrico Ros 6ef32e52ba Mic Continuation: stop if error. Also reset the error state on external manual stop. Fixes #302 2024-01-16 01:12:32 -08:00
Enrico Ros 682c168372 Attachments: further improve heuristics, mainly for powerpoint. #286 2024-01-16 01:02:28 -08:00
Enrico Ros 48f039517d Attachments: show when attaching a rich table or rich html. #286 2024-01-16 00:48:21 -08:00
Enrico Ros 7ebeea3550 Attachments: Excel: paste as Table/HTML/Text rather than image. Fixes #286 2024-01-16 00:38:17 -08:00
Enrico Ros a7a234ecca Attachments: debug option 2024-01-16 00:11:02 -08:00
Enrico Ros a237e53580 Roll pdfjs 2024-01-15 23:51:44 -08:00
Enrico Ros 584544d037 Roll packages 2024-01-15 23:37:18 -08:00
Enrico Ros a601dfa4cf VercelSpeedInsights: 10% sampling rate, to reduce Speed Insights volume 2024-01-15 23:36:57 -08:00
Enrico Ros dbee0d7b87 Update README.md 2024-01-15 22:30:07 -08:00
148 changed files with 5430 additions and 2317 deletions
+16 -14
View File
@@ -27,7 +27,7 @@ assignees: enricoros
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
- Release:
- [ ] merge onto main `git checkout main && git merge --no-ff release-1.2.3`
- [ ] re-tag `git tag -f v1.2.3 && git push opensource --tags -f'
- [ ] re-tag `git tag -f v1.2.3 && git push opensource --tags -f`
- [ ] verify deployment on Vercel
- [ ] verify container on GitHub Packages
- [ ] update the GitHub release
@@ -39,14 +39,13 @@ assignees: enricoros
### Links
- Milestone: https://github.com/enricoros/big-AGI/milestone/X
- GitHub release: https://github.com/enricoros/big-AGI/releases/tag/vX.Y.Z
- Former release task: https://github.com/enricoros/big-AGI/issues/XXX
- GitHub release: https://github.com/enricoros/big-AGI/releases/tag/v1.2.3
- Former release task: #...
## Artifacts Generation
```markdown
You help me generate the following collateral for the new release of my opensource application
called big-AGI. The new release is 1.2.3.
You help me generate the following collateral for the new release of my opensource application called big-AGI. The new release is 1.2.3.
To familiarize yourself with the application, the following are the Website and the GitHub README.md.
```
@@ -55,8 +54,7 @@ To familiarize yourself with the application, the following are the Website and
```markdown
I am announcing a new version, 1.2.3.
For reference, the following was the collateral for 1.1.0 (Discord announcement,
GitHub Release, in-app-news file news.data.tsx, changelog.md).
For reference, the following was the collateral for 1.1.0 (Discord announcement, GitHub Release, in-app-news file news.data.tsx).
```
- paste the former: `discord announcement`,
@@ -66,20 +64,24 @@ GitHub Release, in-app-news file news.data.tsx, changelog.md).
```markdown
The following are the new developments for 1.2.3:
- ...
- git log --pretty=format:"%h %an %B" v1.1.0..v1.2.3 | clip
```
- paste the link to the milestone (closed) and each individual issue (content will be downloaded)
- paste the git changelog `git log v1.1.0..v1.2.3 | clip`
- paste the output of the git log command
### news.data.tsx
```markdown
I need the following from you:
1. a table summarizing all the new features in 1.2.3 (description, significance, usefulness, do not link the commit, but have the issue number), which will be used for the artifacts later
2. after the table score each feature from a user impact and magnitude point of view
3. Improve the table, in decreasing order of importance for features, fixing any detail that's missing, in particular check if there are commits of significance from a user or developer point of view, which are not contained in the table
4. I want you then to update the news.data.tsx for the new release
1. a table summarizing all the new features in 1.2.3 with the following columns: 4 words description (exactly what it is), short description, usefulness (what it does for the user), significance, link to the issue number (not the commit)), which will be used for the artifacts later
2. then double-check the git log to see if there are any features of significance that are not in the table
3. then score each feature in terms of importance for users (1-10), relative impact of the feature (1-10, where 10 applies to the broadest user base), and novelty and uniqueness (1-10, where 10 is truly unique and novel from what exists already)
4. then improve the table, in decreasing order of importance for features, fixing any detail that's missing, in particular check if there are commits of significance from a user or developer point of view, which are not contained in the table
5. then I want you then to update the news.data.tsx for the new release
```
### Readme (and Changelog)
@@ -92,9 +94,9 @@ Attaching the in-app news, with my language for you to improve on, but keep the
### GitHub release
```markdown
Please create the 1.2.3 Release Notes for GitHub.
Please create the 1.2.3 Release Notes for GitHub, following the format of the 1.1.0 GitHub release notes attached before.
Use a truthful and honest tone, understanding that people's time and attention span is short.
Today is 2024-1-1.
Today is 2024-XXXX-YYYY.
```
Now paste-attachment the former release notes (or 1.5.0 which was accurate and great), including the new contributors and
+5 -4
View File
@@ -8,10 +8,11 @@ assignees: ''
---
**Why**
The reason behind the request - we love it to be framed for "users will be able to do x" rather than quick-aging hype-tech-of-the-day requests
(replace this text with yours) The reason behind the request - we love it to be framed for "users will be able to do x" rather than quick-aging hype-tech-of-the-day requests
**Concise description**
A clear and concise description of what you want to happen.
**Description**
Clear and concise description of what you want to happen.
**Requirements**
If you can, please detail the changes you expect in UX, user workflows, technology, architecture (if not, the reviewers will do it for you)
If you can, Please break-down the changes use cases, UX, technology, architecture, etc.
- [ ] ...
+14 -13
View File
@@ -21,6 +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
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
@@ -34,8 +47,6 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
### What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI
https://github.com/enricoros/big-AGI/assets/32999/fbb1be49-5c38-49c8-86fa-3705700f6c39
- **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
@@ -43,16 +54,6 @@ https://github.com/enricoros/big-AGI/assets/32999/fbb1be49-5c38-49c8-86fa-370570
- Large performance optimizations
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
### What's New in 1.9.0 · Dec 28, 2023 · Creative Horizons
- **DALL·E 3 integration** for enhanced image generation. [#212](https://github.com/enricoros/big-AGI/issues/212)
- **Perfect scrolling mechanics** across devices. [#304](https://github.com/enricoros/big-AGI/issues/304)
- Persona creation now supports **text input**. [#287](https://github.com/enricoros/big-AGI/pull/287)
- Openrouter updates for better model management and rate limit handling
- Image drawing UX improvements
- Layout fix for Firefox users
- Developer enhancements: Text2Image subsystem, Optima layout, ScrollToBottom library, Panes library, and Llms subsystem updates.
For full details and former releases, check out the [changelog](docs/changelog.md).
## ✨ Key Features 👊
@@ -112,7 +113,7 @@ after installing the required dependencies.
```bash
# .. repeat the steps above up to `npm install`, then:
npm run build
npm run start --port 3000
next start --port 3000
```
The app will be running on the specified port, e.g. `http://localhost:3000`.
+18 -3
View File
@@ -5,16 +5,31 @@ by release.
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
### 1.12.0 - Jan 2024
### 1.13.0 - Feb 2024
- milestone: [1.12.0](https://github.com/enricoros/big-agi/milestone/12)
- 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.12.0 · Jan 26, 2024 · AGI Hotline
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
- **Find chats**: search in titles and content, with frequency ranking. [#329](https://github.com/enricoros/big-AGI/issues/329)
- **Commands**: command auto-completion (type '/'). [#327](https://github.com/enricoros/big-AGI/issues/327)
- **[Together AI](https://www.together.ai/products#inference)** inference platform support. [#346](https://github.com/enricoros/big-AGI/issues/346)
- **[Together AI](https://www.together.ai/products#inference)** inference platform support (good speed and newer models). [#346](https://github.com/enricoros/big-AGI/issues/346)
- Persona Creator history, deletion, custom creation, fix llm API timeouts
- Enable adding up to five custom OpenAI-compatible endpoints
- Developer enhancements: new 'Actiles' framework
+5
View File
@@ -26,6 +26,11 @@ let nextConfig = {
return config;
},
// Uncomment the following leave console messages in production
// compiler: {
// removeConsole: false,
// },
};
// Validate environment variables, if set at build time. Will be actually read and used at runtime.
+788 -364
View File
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -1,42 +1,41 @@
{
"name": "big-agi",
"version": "1.11.0",
"version": "1.12.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"env:pull": "npx vercel env pull .env.development.local",
"postinstall": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio"
"db:studio": "prisma studio",
"vercel:env:pull": "npx vercel env pull .env.development.local"
},
"dependencies": {
"@dqbd/tiktoken": "^1.0.7",
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.3",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.2",
"@mui/joy": "^5.0.0-beta.20",
"@next/bundle-analyzer": "^14.0.4",
"@prisma/client": "^5.7.1",
"@mui/icons-material": "^5.15.6",
"@mui/joy": "^5.0.0-beta.24",
"@next/bundle-analyzer": "^14.1.0",
"@prisma/client": "^5.8.1",
"@sanity/diff-match-patch": "^3.1.1",
"@t3-oss/env-nextjs": "^0.7.1",
"@t3-oss/env-nextjs": "^0.8.0",
"@tanstack/react-query": "~4.36.1",
"@trpc/client": "10.44.1",
"@trpc/next": "10.44.1",
"@trpc/react-query": "10.44.1",
"@trpc/server": "10.44.1",
"@vercel/analytics": "^1.1.1",
"@vercel/speed-insights": "^1.0.2",
"@vercel/analytics": "^1.1.2",
"@vercel/speed-insights": "^1.0.8",
"browser-fs-access": "^0.35.0",
"eventsource-parser": "^1.1.1",
"idb-keyval": "^6.2.1",
"next": "^14.0.4",
"next": "^14.1.0",
"nprogress": "^0.2.0",
"pdfjs-dist": "4.0.269",
"pdfjs-dist": "4.0.379",
"plantuml-encoder": "^1.4.0",
"prismjs": "^1.29.0",
"react": "^18.2.0",
@@ -44,31 +43,32 @@
"react-dom": "^18.2.0",
"react-katex": "^3.0.1",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^1.0.5",
"react-resizable-panels": "^1.0.9",
"react-timeago": "^7.2.0",
"remark-gfm": "^4.0.0",
"superjson": "^2.2.1",
"tesseract.js": "^5.0.4",
"tiktoken": "^1.0.11",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zustand": "^4.4.7"
"zustand": "^4.5.0"
},
"devDependencies": {
"@cloudflare/puppeteer": "^0.0.5",
"@types/node": "^20.10.6",
"@types/node": "^20.11.7",
"@types/nprogress": "^0.2.3",
"@types/plantuml-encoder": "^1.4.2",
"@types/prismjs": "^1.26.3",
"@types/react": "^18.2.46",
"@types/react": "^18.2.48",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.18",
"@types/react-katex": "^3.0.4",
"@types/react-timeago": "^4.1.7",
"@types/uuid": "^9.0.7",
"@types/uuid": "^9.0.8",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4",
"prettier": "^3.1.1",
"prisma": "^5.7.1",
"eslint-config-next": "^14.1.0",
"prettier": "^3.2.4",
"prisma": "^5.8.1",
"typescript": "^5.3.3"
},
"engines": {
+1 -1
View File
@@ -45,7 +45,7 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
</ProviderTheming>
<VercelAnalytics debug={false} />
<VercelSpeedInsights debug={false} />
<VercelSpeedInsights debug={false} sampleRate={1 / 10} />
</>;
+10
View File
@@ -0,0 +1,10 @@
import * as React from 'react';
import { AppDraw } from '../src/apps/draw/AppDraw';
import { withLayout } from '~/common/layout/withLayout';
export default function DrawPage() {
return withLayout({ type: 'optima' }, <AppDraw />);
}
-2
View File
@@ -7,7 +7,6 @@ import { useModelsStore } from '~/modules/llms/store-llms';
import { InlineError } from '~/common/components/InlineError';
import { apiQuery } from '~/common/util/trpc.client';
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
import { themeBgApp } from '~/common/app.theme';
import { withLayout } from '~/common/layout/withLayout';
@@ -42,7 +41,6 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
return (
<Box sx={{
flexGrow: 1,
backgroundColor: themeBgApp,
overflowY: 'auto',
display: 'flex', justifyContent: 'center',
p: { xs: 3, md: 6 },
+2 -2
View File
@@ -1,6 +1,6 @@
import * as React from 'react';
import { AppChatLink } from '../../../src/apps/link/AppChatLink';
import { AppLinkChat } from '../../../src/apps/link/AppLinkChat';
import { useRouterQuery } from '~/common/app.routes';
import { withLayout } from '~/common/layout/withLayout';
@@ -11,5 +11,5 @@ export default function ChatLinkPage() {
// external state
const { chatLinkId } = useRouterQuery<{ chatLinkId: string | undefined }>();
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppChatLink linkId={chatLinkId || ''} />);
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppLinkChat chatLinkId={chatLinkId || null} />);
}
-2
View File
@@ -10,7 +10,6 @@ import { callBrowseFetchPage } from '~/modules/browse/browse.client';
import { LogoProgress } from '~/common/components/LogoProgress';
import { asValidURL } from '~/common/util/urlUtils';
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
import { themeBgApp } from '~/common/app.theme';
import { withLayout } from '~/common/layout/withLayout';
@@ -92,7 +91,6 @@ function AppShareTarget() {
return (
<Box sx={{
backgroundColor: themeBgApp,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
File diff suppressed because one or more lines are too long
+2 -4
View File
@@ -3,14 +3,13 @@ import * as React from 'react';
import { Box, Typography } from '@mui/joy';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { themeBgApp } from '~/common/app.theme';
import { useRouterRoute } from '~/common/app.routes';
/**
* https://github.com/enricoros/big-AGI/issues/299
*/
export function AppPlaceholder() {
export function AppPlaceholder(props: { text?: string }) {
// external state
const route = useRouterRoute();
@@ -21,7 +20,6 @@ export function AppPlaceholder() {
return (
<Box sx={{
flexGrow: 1,
backgroundColor: themeBgApp,
overflowY: 'auto',
p: { xs: 3, md: 6 },
border: '1px solid blue',
@@ -38,7 +36,7 @@ export function AppPlaceholder() {
{placeholderAppName}
</Typography>
<Typography>
Intelligent applications to help you learn, think, and do
{props.text || 'Intelligent applications to help you learn, think, and do'}
</Typography>
</Box>
+57 -22
View File
@@ -2,41 +2,76 @@ import * as React from 'react';
import { Container, Sheet } from '@mui/joy';
import { AppCallQueryParams, useRouterQuery } from '~/common/app.routes';
import { InlineError } from '~/common/components/InlineError';
import type { DConversationId } from '~/common/state/store-chats';
import { useRouterQuery } from '~/common/app.routes';
import { CallUI } from './CallUI';
import { CallWizard } from './CallWizard';
import { Contacts } from './Contacts';
import { Telephone } from './Telephone';
import { useAppCallStore } from './state/store-app-call';
/**
* Used to define the intent of the call from other apps (via query params) or
* from the contacts list (via the 'call' button).
*/
export interface AppCallIntent {
conversationId: DConversationId | null;
personaId: string;
backTo: 'app-chat' | 'app-call-contacts';
}
export function AppCall() {
// external state
const { conversationId, personaId } = useRouterQuery<AppCallQueryParams>();
// state
const [callIntent, setCallIntent] = React.useState<AppCallIntent | null>(null);
// derived state
const validInput = !!conversationId && !!personaId;
// external state
const grayUI = useAppCallStore(state => state.grayUI);
const query = useRouterQuery<Partial<AppCallIntent>>();
// [effect] set intent from the query parameters
React.useEffect(() => {
if (query.personaId) {
setCallIntent({
conversationId: query.conversationId ?? null,
personaId: query.personaId,
backTo: query.backTo || 'app-chat',
});
}
}, [query.backTo, query.conversationId, query.personaId]);
const hasIntent = !!callIntent && !!callIntent.personaId;
return (
<Sheet variant='solid' color='neutral' invertedColors sx={{
display: 'flex', flexDirection: 'column', justifyContent: 'center',
flexGrow: 1,
overflowY: 'auto',
minHeight: 96,
}}>
<Sheet
variant={grayUI ? 'solid' : 'soft'}
invertedColors={grayUI ? true : undefined}
sx={{
// take the full V-area (we're inside PageWrapper) and scroll as needed
flexGrow: 1,
overflowY: 'auto',
<Container maxWidth='sm' sx={{
display: 'flex', flexDirection: 'column',
alignItems: 'center',
minHeight: '80dvh', justifyContent: 'space-evenly',
gap: { xs: 2, md: 4 },
// container will take the full v-area
display: 'grid',
}}>
{!validInput && <InlineError error={`Something went wrong. ${conversationId}:${personaId}`} />}
<Container
maxWidth={hasIntent ? 'sm' : 'md'}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: hasIntent ? 'space-evenly' : undefined,
gap: hasIntent ? 1 : undefined,
}}>
{validInput && (
<CallWizard conversationId={conversationId}>
<CallUI conversationId={conversationId} personaId={personaId} />
{!hasIntent ? (
<Contacts setCallIntent={setCallIntent} />
) : (
<CallWizard conversationId={callIntent.conversationId}>
<Telephone callIntent={callIntent} backToContacts={() => setCallIntent(null)} />
</CallWizard>
)}
+25 -19
View File
@@ -1,5 +1,4 @@
import * as React from 'react';
import { keyframes } from '@emotion/react';
import { Box, Button, Card, CardContent, IconButton, ListItemDecorator, Typography } from '@mui/joy';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
@@ -10,14 +9,15 @@ import MicIcon from '@mui/icons-material/Mic';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
import WarningIcon from '@mui/icons-material/Warning';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
import { navigateBack } from '~/common/app.routes';
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useChatStore } from '~/common/state/store-chats';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUICounter } from '~/common/state/store-ui';
const cssRainbowBackgroundKeyframes = keyframes`
/*export const cssRainbowBackgroundKeyframes = keyframes`
100%, 0% {
background-color: rgb(128, 0, 0);
}
@@ -53,7 +53,7 @@ const cssRainbowBackgroundKeyframes = keyframes`
}
91% {
background-color: rgb(102, 0, 51);
}`;
}`;*/
function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: string, button?: React.JSX.Element }) {
return (
@@ -75,7 +75,8 @@ function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: s
}
export function CallWizard(props: { strict?: boolean, conversationId: string, children: React.ReactNode }) {
export function CallWizard(props: { strict?: boolean, conversationId: string | null, children: React.ReactNode }) {
// state
const [chatEmptyOverride, setChatEmptyOverride] = React.useState(false);
const [recognitionOverride, setRecognitionOverride] = React.useState(false);
@@ -85,12 +86,15 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
const recognition = useCapabilityBrowserSpeechRecognition();
const synthesis = useCapabilityElevenLabs();
const chatIsEmpty = useChatStore(state => {
if (!props.conversationId)
return false;
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
return !(conversation?.messages?.length);
});
const { novel, touch } = useUICounter('call-wizard');
// derived state
const outOfTheBlue = !props.conversationId;
const overriddenEmptyChat = chatEmptyOverride || !chatIsEmpty;
const overriddenRecognition = recognitionOverride || recognition.mayWork;
const allGood = overriddenEmptyChat && overriddenRecognition && synthesis.mayWork;
@@ -104,7 +108,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
const handleOverrideRecognition = () => setRecognitionOverride(true);
const handleConfigureElevenLabs = () => {
openPreferencesTab(3);
openPreferencesTab(PreferencesTab.Voice);
};
const handleFinishButton = () => {
@@ -118,16 +122,11 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
<Box sx={{ flexGrow: 0.5 }} />
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, lineHeight: '1.5em', textAlign: 'center' }}>
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, textAlign: 'center' }}>
Welcome to<br />
<Typography
component='span'
sx={{
backgroundColor: 'primary.solidActiveBg', mx: -0.5, px: 0.5,
animation: `${cssRainbowBackgroundKeyframes} 15s linear infinite`,
}}>
<Box component='span' sx={{ animation: `${cssRainbowColorKeyframes} 15s linear infinite` }}>
your first call
</Typography>
</Box>
</Typography>
<Box sx={{ flexGrow: 0.5 }} />
@@ -138,7 +137,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
</Typography>
{/* Chat Empty status */}
<StatusCard
{!outOfTheBlue && <StatusCard
icon={<ChatIcon />}
hasIssue={!overriddenEmptyChat}
text={overriddenEmptyChat ? 'Great! Your chat has messages.' : 'The chat is empty. Calls are effective when the caller has context.'}
@@ -147,7 +146,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
Ignore
</Button>
)}
/>
/>}
{/* Add the speech to text feature status */}
<StatusCard
@@ -199,14 +198,21 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
</Typography>
<IconButton
size='lg' variant={allGood ? 'soft' : 'solid'} color={allGood ? 'success' : 'danger'}
onClick={handleFinishButton} sx={{ borderRadius: '50px', mr: 0.5 }}
size='lg'
variant='solid' color={allGood ? 'success' : 'danger'}
onClick={handleFinishButton}
sx={{
borderRadius: '50px',
mr: 0.5,
// animation: `${cssRainbowBackgroundKeyframes} 15s linear infinite`,
// boxShadow: allGood ? 'md' : 'none',
}}
>
{allGood ? <ArrowForwardIcon sx={{ fontSize: '1.5em' }} /> : <CloseIcon sx={{ fontSize: '1.5em' }} />}
</IconButton>
</Box>
<Box sx={{ flexGrow: 0.5 }} />
<Box sx={{ flexGrow: 2 }} />
</>;
}
+345
View File
@@ -0,0 +1,345 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { keyframes } from '@emotion/react';
import type { SxProps } from '@mui/joy/styles/types';
import { Avatar, Box, Card, CardContent, Chip, IconButton, Link as MuiLink, ListDivider, MenuItem, Sheet, Switch, Typography } from '@mui/joy';
import CallIcon from '@mui/icons-material/Call';
import { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
import { MockPersona, useMockPersonas } from './state/useMockPersonas';
import { useAppCallStore } from './state/store-app-call';
// number of conversations to show before collapsing
const COLLAPSED_COUNT = 2;
export const niceShadowKeyframes = keyframes`
100%, 0% {
//background-color: rgb(102, 0, 51);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(183, 255, 0);
}
25% {
//background-color: rgb(76, 0, 76);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 251, 0);
//scale: 1.2;
}
50% {
//background-color: rgb(63, 0, 128);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgba(0, 255, 81);
//scale: 0.8;
}
75% {
//background-color: rgb(0, 0, 128);
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 153, 0);
}`;
const ContactCardAvatar = (props: { size: string, symbol?: string, imageUrl?: string, onClick?: () => void, sx?: SxProps }) =>
<Avatar
// variant='outlined'
onClick={props.onClick}
src={props.imageUrl}
sx={{
'--Avatar-size': props.size,
fontSize: props.size,
backgroundColor: 'background.popup',
boxShadow: !props.imageUrl ? 'sm' : null,
...props.sx,
}}
>
{/* As fallback, show the large Persona Symbol */}
{!props.imageUrl && <Box>{props.symbol}</Box>}
</Avatar>;
const ContactCardConversationCall = (props: { conversation: DConversation, onConversationClicked: (conversationId: DConversationId) => void, }) =>
<Chip
variant='plain' color='primary' size='sm'
endDecorator={<CallIcon />}
onClick={() => props.onConversationClicked(props.conversation.id)}
slotProps={{
root: {
sx: {
maxWidth: 'unset',
mx: -1,
px: 1,
py: 0.25,
},
},
}}
>
{conversationTitle(props.conversation, 'Chat')}
</Chip>;
function CallContactCard(props: {
persona: MockPersona,
callGrayUI: boolean,
conversations: DConversation[],
setCallIntent: (intent: AppCallIntent) => void,
}) {
// state
const [conversationsExpanded, setConversationsExpanded] = React.useState(false);
// derived state
const { persona, setCallIntent } = props;
const conversations = props.conversations.slice(0, conversationsExpanded ? undefined : COLLAPSED_COUNT);
const hasConversations = !!conversations.length;
const showExpander = props.conversations.length > COLLAPSED_COUNT && !conversationsExpanded;
const handleCallPersona = React.useCallback(() => setCallIntent({
conversationId: null,
personaId: persona.personaId,
backTo: 'app-call-contacts',
}), [persona.personaId, setCallIntent]);
const handleCallPersonaRe = React.useCallback((conversationId: DConversationId | null) => setCallIntent({
conversationId: conversationId,
personaId: persona.personaId,
backTo: 'app-call-contacts',
}), [persona.personaId, setCallIntent]);
return (
<Box sx={{ mt: 3.5 }}>
<Card sx={{
// boxShadow: 'lg',
height: '100%',
gap: 0,
}}>
{/* Persona Symbol - Overlapping */}
<ContactCardAvatar
size='6rem'
symbol={persona.symbol}
imageUrl={persona?.imageUri}
sx={{
mx: 'auto',
mt: '-2.5rem',
zIndex: 1,
}}
/>
<CardContent sx={{ my: 2, display: 'flex' }}>
{/* Persona Description */}
<Typography level='body-xs' sx={{ minHeight: '3em', mb: hasConversations ? 1.5 : undefined }}>
{typeof persona.description === 'string' ? persona.description : 'Custom persona'}
</Typography>
{/*{hasConversations && <Divider>*/}
{/*<Typography level='body-xs'>call about</Typography>*/}
{/*</Divider>}*/}
{/* Persona Recent Converstions */}
{conversations.map(conversation =>
<ContactCardConversationCall
key={conversation.id}
conversation={conversation}
onConversationClicked={handleCallPersonaRe}
/>,
)}
{showExpander && <Chip
variant='plain' color='primary' size='sm'
onClick={() => setConversationsExpanded(true)}
slotProps={{
root: {
sx: {
maxWidth: 'unset',
mx: -1,
px: 1,
py: 0.25,
},
},
}}
>
{`+${props.conversations.length - COLLAPSED_COUNT} more`}
</Chip>}
</CardContent>
{/*<Divider />*/}
{/* Bottom Name and "Call" Button */}
<Sheet
variant='soft' color='primary'
invertedColors={props.callGrayUI ? undefined : true}
sx={{
// emulate CardOverflow, because CardOverflow doesn't work well with Sheet/Inverted
// (there's also a potential top-level inversion)
'--variant-borderWidth': '1px',
'--CardOverflow-offset': 'calc(-1 * var(--Card-padding))',
'--CardOverflow-radius': 'calc(var(--Card-radius) - var(--variant-borderWidth, 0px))',
margin: '0 var(--CardOverflow-offset) var(--CardOverflow-offset)',
borderRadius: '0 0 var(--CardOverflow-radius) var(--CardOverflow-radius)',
padding: '0.5rem var(--Card-padding)',
// contents
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 1,
}}
>
<Typography level='title-md'>
{persona.title}
</Typography>
<MuiLink overlay onClick={handleCallPersona}>
<IconButton size='md' variant='soft' sx={{
// borderRadius: '50%',
ml: 'auto',
mr: -1,
}}>
<CallIcon />
</IconButton>
</MuiLink>
</Sheet>
</Card>
</Box>
);
}
function useConversationsByPersona() {
const conversations = useChatStore(state => state.conversations, shallow);
return React.useMemo(() => {
// group by personaId
const groupedConversations: { [personaId: string]: DConversation[] } = conversations.reduce((acc, conversation) => {
const personaId = conversation.systemPurposeId;
acc[personaId] = [...acc[personaId] || [], conversation];
return acc;
}, {} as { [personaId: string]: DConversation[] });
// sort conversations by time and limit to 3
Object.values(groupedConversations).forEach(conversations =>
conversations.sort((a, b) => (b.updated || b.created) - (a.updated || a.created)),
);
return groupedConversations;
}, [conversations]);
}
export function Contacts(props: { setCallIntent: (intent: AppCallIntent) => void }) {
// external state
const {
grayUI, toggleGrayUI,
showConversations, toggleShowConversations,
showSupport, toggleShowSupport,
} = useAppCallStore();
const { personas } = useMockPersonas();
const conversationsByPersona = useConversationsByPersona();
// pluggable UI
const menuItems = React.useMemo(() => <>
<MenuItem onClick={toggleShowConversations}>
Conversations
<Switch checked={showConversations} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem onClick={toggleShowSupport}>
Support
<Switch checked={showSupport} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem onClick={toggleGrayUI}>
Grayed UI
<Switch checked={grayUI} sx={{ ml: 'auto' }} />
</MenuItem>
</>, [grayUI, showConversations, showSupport, toggleGrayUI, toggleShowConversations, toggleShowSupport]);
usePluggableOptimaLayout(null, null, menuItems, 'CallUI');
return <>
{/* Header "Call AGI" */}
<Box sx={{
my: 6,
display: 'flex', alignItems: 'center',
gap: 3,
}}>
<IconButton
variant='soft' color='success'
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
pointerEvents: 'none',
backgroundColor: 'background.popup',
animation: `${niceShadowKeyframes} 5s infinite`,
}}>
<CallIcon />
</IconButton>
<Box>
<Typography level='title-lg'>
Call AGI
</Typography>
<Typography level='title-sm' sx={{ mt: 1 }}>
Explore ideas and ignite creativity
</Typography>
<Chip variant='outlined' size='sm' sx={{ px: 1, py: 0.5, mt: 0.25, ml: -1, textWrap: 'wrap' }}>
Out-of-the-blue, or within a conversation
</Chip>
</Box>
</Box>
<ListDivider>
Personas
</ListDivider>
{/* Personas Cards */}
<Box
sx={{
width: '100%',
my: 5,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
gap: { xs: 1, md: 2 },
}}
>
{personas.map((persona) =>
<CallContactCard
key={persona.personaId}
persona={persona}
callGrayUI={grayUI}
conversations={!showConversations ? [] : conversationsByPersona[persona.personaId] || []}
setCallIntent={props.setCallIntent}
/>,
)}
</Box>
{showSupport && <ListDivider sx={{ my: 1 }} />}
{showSupport && <GitHubProjectIssueCard
issue={354}
text='Call App: Support thread and compatibility matrix'
note={<>
Voice input uses the HTML Web Speech API, and speech output requires an ElevenLabs API Key.
</>}
// note2='Please report any issues you encounter'
sx={{
width: '100%',
mb: 2,
mt: 5,
}}
/>}
</>;
}
@@ -1,11 +1,10 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Card, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import { Box, Card, ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CallEndIcon from '@mui/icons-material/CallEnd';
import CallIcon from '@mui/icons-material/Call';
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined';
import MicIcon from '@mui/icons-material/Mic';
import MicNoneIcon from '@mui/icons-material/MicNone';
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
@@ -20,14 +19,16 @@ import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVo
import { Link } from '~/common/components/Link';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import type { AppCallIntent } from './AppCall';
import { CallAvatar } from './components/CallAvatar';
import { CallButton } from './components/CallButton';
import { CallMessage } from './components/CallMessage';
import { CallStatus } from './components/CallStatus';
import { launchAppChat, ROUTE_APP_CHAT } from '~/common/app.routes';
import { useAppCallStore } from './state/store-app-call';
function CallMenuItems(props: {
@@ -38,6 +39,7 @@ function CallMenuItems(props: {
}) {
// external state
const { grayUI, toggleGrayUI } = useAppCallStore();
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
@@ -63,8 +65,14 @@ function CallMenuItems(props: {
{voicesDropdown}
</MenuItem>
<ListDivider />
<MenuItem onClick={toggleGrayUI}>
Grayed UI
<Switch checked={grayUI} sx={{ ml: 'auto' }} />
</MenuItem>
<MenuItem component={Link} href='https://github.com/enricoros/big-agi/issues/175' target='_blank'>
<ListItemDecorator><ChatOutlinedIcon /></ListItemDecorator>
Voice Calls Feedback
</MenuItem>
@@ -72,9 +80,9 @@ function CallMenuItems(props: {
}
export function CallUI(props: {
conversationId: string,
personaId: string,
export function Telephone(props: {
callIntent: AppCallIntent,
backToContacts: () => void,
}) {
// state
@@ -89,14 +97,16 @@ export function CallUI(props: {
// external state
const { chatLLMId, chatLLMDropdown } = useChatLLMDropdown();
const { chatTitle, messages } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
const { chatTitle, reMessages } = useChatStore(state => {
const conversation = props.callIntent.conversationId
? state.conversations.find(conversation => conversation.id === props.callIntent.conversationId) ?? null
: null;
return {
chatTitle: conversation ? conversationTitle(conversation) : 'no conversation',
messages: conversation ? conversation.messages : [],
chatTitle: conversation ? conversationTitle(conversation) : null,
reMessages: conversation ? conversation.messages : null,
};
}, shallow);
const persona = SystemPurposes[props.personaId as SystemPurposeId] ?? undefined;
const persona = SystemPurposes[props.callIntent.personaId as SystemPurposeId] ?? undefined;
const personaCallStarters = persona?.call?.starters ?? undefined;
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
const personaSystemMessage = persona?.systemMessage ?? undefined;
@@ -193,8 +203,8 @@ export function CallUI(props: {
if (!chatLLMId) return;
// temp fix: when the chat has no messages, only assume a single system message
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = messages.length > 0
? messages
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = (reMessages && reMessages.length > 0)
? reMessages
: personaSystemMessage
? [{ role: 'system', text: personaSystemMessage }]
: [];
@@ -223,16 +233,18 @@ export function CallUI(props: {
error = err;
}).finally(() => {
setPersonaTextInterim(null);
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
if (finalText || error)
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
// fire/forget
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
if (finalText?.length >= 1)
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
});
return () => {
responseAbortController.current?.abort();
responseAbortController.current = null;
};
}, [isConnected, callMessages, chatLLMId, messages, personaVoiceId, personaSystemMessage]);
}, [isConnected, callMessages, chatLLMId, personaVoiceId, personaSystemMessage, reMessages]);
// [E] Message interrupter
const abortTrigger = isConnected && isRecordingSpeech;
@@ -294,7 +306,7 @@ export function CallUI(props: {
<CallStatus
callerName={isConnected ? undefined : personaName}
statusText={isRinging ? 'is calling you' : isDeclined ? 'call declined' : isEnded ? 'call ended' : callElapsedTime}
statusText={isRinging ? '' /*'is calling you'*/ : isDeclined ? 'call declined' : isEnded ? 'call ended' : callElapsedTime}
regardingText={chatTitle}
micError={!isMicEnabled} speakError={!isTTSEnabled}
/>
@@ -303,11 +315,18 @@ export function CallUI(props: {
{(isConnected || isEnded) && (
<Card variant='soft' sx={{
flexGrow: 1,
minHeight: '15dvh', maxHeight: '24dvh',
overflow: 'auto',
maxHeight: '24%',
minHeight: '15%',
width: '100%',
// style
backgroundColor: 'background.surface',
borderRadius: 'lg',
flexDirection: 'column-reverse',
boxShadow: 'sm',
// children
display: 'flex', flexDirection: 'column-reverse',
overflow: 'auto',
}}>
{/* Messages in reverse order, for auto-scroll from the bottom */}
@@ -344,18 +363,18 @@ export function CallUI(props: {
)}
{/* Call Buttons */}
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-evenly' }}>
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-evenly', gap: 4 }}>
{/* [ringing] Decline / Accept */}
{isRinging && <CallButton Icon={CallEndIcon} text='Decline' color='danger' onClick={() => setStage('declined')} />}
{isRinging && isEnabled && <CallButton Icon={CallIcon} text='Accept' color='success' variant='soft' onClick={() => setStage('connected')} />}
{isRinging && <CallButton Icon={CallEndIcon} text='Decline' color='danger' variant='solid' onClick={() => setStage('declined')} />}
{isRinging && isEnabled && <CallButton Icon={CallIcon} text='Accept' color='success' variant='solid' onClick={() => setStage('connected')} />}
{/* [Calling] Hang / PTT (mute not enabled yet) */}
{isConnected && <CallButton Icon={CallEndIcon} text='Hang up' color='danger' onClick={handleCallStop} />}
{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'} />
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'} sx={!isRecording ? { backgroundColor: 'background.surface' } : undefined} />
: null
// <CallButton disabled={true} Icon={MicOffIcon} onClick={() => setMicMuted(muted => !muted)}
// text={micMuted ? 'Muted' : 'Mute'}
@@ -363,7 +382,7 @@ export function CallUI(props: {
)}
{/* [ended] Back / Call Again */}
{(isEnded || isDeclined) && <Link noLinkStyle href={ROUTE_APP_CHAT}><CallButton Icon={ArrowBackIcon} text='Back' variant='soft' /></Link>}
{(isEnded || isDeclined) && <CallButton Icon={ArrowBackIcon} text='Back' variant='soft' onClick={() => props.callIntent.backTo === 'app-chat' ? navigateToIndex() : props.backToContacts()} />}
{(isEnded || isDeclined) && <CallButton Icon={CallIcon} text='Call Again' color='success' variant='soft' onClick={() => setStage('connected')} />}
</Box>
+5 -6
View File
@@ -16,17 +16,16 @@ const cssScaleKeyframes = keyframes`
}`;
export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging: boolean, onClick: () => void }) {
export function CallAvatar(props: { symbol: string, imageUrl?: string, isRinging?: boolean, onClick: () => void }) {
return (
<Avatar
variant='soft' color='neutral'
onClick={props.onClick}
src={props.imageUrl}
sx={{
'--Avatar-size': { xs: '160px', md: '200px' },
'--variant-borderWidth': '4px',
boxShadow: !props.imageUrl ? 'md' : null,
fontSize: { xs: '100px', md: '120px' },
'--Avatar-size': { xs: '10rem', md: '11.5rem' },
backgroundColor: 'background.popup',
boxShadow: !props.imageUrl ? 'sm' : null,
fontSize: { xs: '6rem', md: '7rem' },
}}
>
+12 -6
View File
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Box, ColorPaletteProp, IconButton, Typography, VariantProp } from '@mui/joy';
import { ColorPaletteProp, FormControl, IconButton, Typography, VariantProp } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
/**
@@ -14,9 +15,10 @@ export function CallButton(props: {
Icon: React.FC, text: string,
variant?: VariantProp, color?: ColorPaletteProp, disabled?: boolean,
onClick?: () => void,
sx?: SxProps,
}) {
return (
<Box
<FormControl
onClick={() => !props.disabled && props.onClick?.()}
sx={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
@@ -25,19 +27,23 @@ export function CallButton(props: {
>
<IconButton
disabled={props.disabled} variant={props.variant || 'solid'} color={props.color}
aria-label={props.text}
variant={props.variant || 'solid'} color={props.color}
disabled={props.disabled}
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
// boxShadow: 'lg',
}}>
...props.sx,
}}
>
<props.Icon />
</IconButton>
<Typography level='title-md' variant={props.disabled ? 'soft' : undefined}>
<Typography aria-hidden level='title-md' variant={props.disabled ? 'soft' : undefined}>
{props.text}
</Typography>
</Box>
</FormControl>
);
}
+7 -7
View File
@@ -15,7 +15,7 @@ import { InlineError } from '~/common/components/InlineError';
export function CallStatus(props: {
callerName?: string,
statusText: string,
regardingText?: string,
regardingText: string | null,
micError: boolean, speakError: boolean,
// llmComponent?: React.JSX.Element,
}) {
@@ -28,19 +28,19 @@ export function CallStatus(props: {
{/*{props.llmComponent}*/}
<Typography level='body-md' sx={{ textAlign: 'center' }}>
{!!props.statusText && <Typography level='body-md' sx={{ textAlign: 'center' }}>
{props.statusText}
</Typography>
</Typography>}
{!!props.regardingText && <Typography level='body-md' sx={{ textAlign: 'center', mt: 0 }}>
re: {props.regardingText}
{!!props.regardingText && <Typography level='body-md' sx={{ textAlign: 'center', mt: 1 }}>
Re: <Box component='span' sx={{ color: 'text.primary' }}>{props.regardingText}</Box>
</Typography>}
{props.micError && <InlineError
severity='danger' error='But this browser does not support speech recognition... 🤦‍♀️ - Try Chrome on Windows?' />}
severity='danger' error='Looks like this Browser may not support speech recognition. You can try Chrome on Windows or Android instead.' />}
{props.speakError && <InlineError
severity='danger' error='And text-to-speech is not configured... 🤦‍♀️ - Configure it in Settings?' />}
severity='danger' error='Text-to-speech does not appear to be configured. Please set it up in Preferences > Voice.' />}
</Box>
);
+35
View File
@@ -0,0 +1,35 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Call settings
interface AppCallStore {
grayUI: boolean;
toggleGrayUI: () => void;
showConversations: boolean;
toggleShowConversations: () => void;
showSupport: boolean;
toggleShowSupport: () => void;
}
export const useAppCallStore = create<AppCallStore>()(persist(
(_set, _get) => ({
grayUI: false,
toggleGrayUI: () => _set(state => ({ grayUI: !state.grayUI })),
showConversations: true,
toggleShowConversations: () => _set(state => ({ showConversations: !state.showConversations })),
showSupport: true,
toggleShowSupport: () => _set(state => ({ showSupport: !state.showSupport })),
}), {
name: 'app-app-call',
},
));
+31
View File
@@ -0,0 +1,31 @@
import * as React from 'react';
import { usePurposeStore } from '../../chat/components/persona-selector/store-purposes';
import { SystemPurposeData, SystemPurposeId, SystemPurposes } from '../../../data';
/**
* This is a 'mock' persona because Soon we'll have real personas definitions
* and stores. Until then, we just mimic a reactive system here.
*/
export interface MockPersona extends SystemPurposeData {
personaId: SystemPurposeId,
}
export function useMockPersonas(): { personas: MockPersona[], personaIDs: SystemPurposeId[] } {
// only react to hiddenPurposeIDs changes
const hiddenPurposeIDs = usePurposeStore(state => state.hiddenPurposeIDs);
// Dependency array is empty because SystemPurposes is constant
return React.useMemo(() => {
const personaIDs = Object.keys(SystemPurposes) as SystemPurposeId[];
const personas = personaIDs
.filter((key) => !hiddenPurposeIDs.includes(key))
.map((key) => ({
...SystemPurposes[key as SystemPurposeId],
personaId: key as SystemPurposeId,
}));
return { personas, personaIDs };
}, [hiddenPurposeIDs]);
}
+33 -35
View File
@@ -18,13 +18,13 @@ import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/commo
import { GoodPanelResizeHandler } from '~/common/components/panes/GoodPanelResizeHandler';
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
import { themeBgApp, themeBgAppChatComposer } from '~/common/app.theme';
import { themeBgAppChatComposer } from '~/common/app.theme';
import { useFolderStore } from '~/common/state/store-folders';
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 { ChatDrawerContentMemo } from './components/applayout/ChatDrawerItems';
import { ChatDrawerMemo } from './components/applayout/ChatDrawer';
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
import { ChatMessageList } from './components/ChatMessageList';
@@ -63,7 +63,7 @@ export function AppChat() {
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
const showNextTitle = React.useRef(false);
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
const [_selectedFolderId, setSelectedFolderId] = React.useState<string | null>(null);
const [_activeFolderId, setActiveFolderId] = React.useState<string | null>(null);
// external state
const theme = useTheme();
@@ -101,13 +101,12 @@ export function AppChat() {
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
const { folderConversationsCount, selectedFolderId } = useFolderStore(state => {
const selectedFolderId = state.useFolders ? _selectedFolderId : null;
const { activeFolderId, activeFolderConversationsCount } = useFolderStore(({ enableFolders, folders }) => {
const activeFolderId = enableFolders ? _activeFolderId : null;
const activeFolder = activeFolderId ? folders.find(folder => folder.id === activeFolderId) : null;
return {
folderConversationsCount: selectedFolderId
? state.folders.find(folder => folder.id === selectedFolderId)?.conversationIds.length || 0
: conversationsLength,
selectedFolderId,
activeFolderId: activeFolder?.id ?? null,
activeFolderConversationsCount: activeFolder ? activeFolder.conversationIds.length : conversationsLength,
};
});
@@ -147,7 +146,7 @@ export function AppChat() {
// Execution
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
const chatLLMId = getChatLLMId();
if (!chatModeId || !conversationId || !chatLLMId) return;
@@ -248,8 +247,9 @@ export function AppChat() {
return true;
};
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
await _handleExecute('generate-text', conversationId, history);
}, [_handleExecute]);
const handleMessageRegenerateLast = React.useCallback(async () => {
const focusedConversation = getConversation(focusedConversationId);
@@ -262,9 +262,9 @@ export function AppChat() {
}
}, [focusedConversationId, _handleExecute]);
const handleTextDiagram = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
const handleTextImagine = async (conversationId: DConversationId, messageText: string) => {
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string): Promise<void> => {
const conversation = getConversation(conversationId);
if (!conversation)
return;
@@ -273,11 +273,11 @@ export function AppChat() {
...conversation.messages,
createDMessage('user', imaginedPrompt),
]);
};
}, [_handleExecute]);
const handleTextSpeak = async (text: string) => {
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
await speakText(text);
};
}, []);
// Chat actions
@@ -289,18 +289,18 @@ export function AppChat() {
: prependNewConversation(focusedSystemPurposeId ?? undefined);
setFocusedConversationId(conversationId);
// if a folder is selected, add the new conversation to the folder
if (selectedFolderId && conversationId)
useFolderStore.getState().addConversationToFolder(selectedFolderId, conversationId);
// if a folder is active, add the new conversation to the folder
if (activeFolderId && conversationId)
useFolderStore.getState().addConversationToFolder(activeFolderId, conversationId);
// focus the composer
composerTextAreaRef.current?.focus();
}, [focusedSystemPurposeId, newConversationId, prependNewConversation, selectedFolderId, setFocusedConversationId]);
}, [activeFolderId, focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
const handleConversationExport = React.useCallback((conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId }), []);
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
showNextTitle.current = true;
@@ -331,13 +331,13 @@ export function AppChat() {
}
}, [clearConversationId, setMessages]);
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
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, selectedFolderId);
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, activeFolderId);
else
nextConversationId = deleteConversation(deleteConversationId);
setFocusedConversationId(nextConversationId);
@@ -345,7 +345,7 @@ export function AppChat() {
}
};
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
const handleConversationDelete = React.useCallback(
(conversationId: DConversationId, bypassConfirmation: boolean) => {
@@ -372,7 +372,7 @@ export function AppChat() {
['d', true, false, true, () => focusedConversationId && handleConversationDelete(focusedConversationId, false)],
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
], [focusedConversationId, handleConversationBranch, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
useGlobalShortcuts(shortcuts);
// Pluggable ApplicationBar components
@@ -387,8 +387,9 @@ export function AppChat() {
);
const drawerContent = React.useMemo(() =>
<ChatDrawerContentMemo
<ChatDrawerMemo
activeConversationId={focusedConversationId}
activeFolderId={activeFolderId}
disableNewButton={isFocusedChatEmpty}
onConversationActivate={setFocusedConversationId}
onConversationDelete={handleConversationDelete}
@@ -396,10 +397,9 @@ export function AppChat() {
onConversationImportDialog={handleConversationImportDialog}
onConversationNew={handleConversationNew}
onConversationsDeleteAll={handleConversationsDeleteAll}
selectedFolderId={selectedFolderId}
setSelectedFolderId={setSelectedFolderId}
setActiveFolderId={setActiveFolderId}
/>,
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, selectedFolderId, setFocusedConversationId],
[activeFolderId, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, setFocusedConversationId],
);
const menuItems = React.useMemo(() =>
@@ -411,10 +411,9 @@ export function AppChat() {
setIsMessageSelectionMode={setIsMessageSelectionMode}
onConversationBranch={handleConversationBranch}
onConversationClear={handleConversationClear}
onConversationExport={handleConversationExport}
onConversationFlatten={handleConversationFlatten}
/>,
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, isFocusedChatEmpty, isMessageSelectionMode],
);
usePluggableOptimaLayout(drawerContent, centerItems, menuItems, 'AppChat');
@@ -474,7 +473,6 @@ export function AppChat() {
onTextImagine={handleTextImagine}
onTextSpeak={handleTextSpeak}
sx={{
backgroundColor: themeBgApp,
minHeight: '100%', // ensures filling of the blank space on newer chats
}}
/>
@@ -555,10 +553,10 @@ export function AppChat() {
{!!deleteConversationId && <ConfirmationModal
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
? `Are you absolutely sure you want to delete ${selectedFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
? `Are you absolutely sure you want to delete ${activeFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
: 'Are you sure you want to delete this conversation?'}
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
? `Yes, delete all ${folderConversationsCount} conversations`
? `Yes, delete all ${activeFolderConversationsCount} conversations`
: 'Delete conversation'}
/>}
</>;
+1 -1
View File
@@ -9,7 +9,7 @@ export const CommandsReact: ICommandsProvider = {
getCommands: () => [{
primary: '/react',
arguments: ['prompt'],
description: 'Use the AI ReAct strategy to answer your query (as sidebar)',
description: 'Use the AI ReAct strategy to answer your query',
Icon: PsychologyIcon,
}],
+14 -13
View File
@@ -6,11 +6,11 @@ import { SxProps } from '@mui/joy/styles/types';
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { InlineError } from '~/common/components/InlineError';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { ChatMessageMemo } from './message/ChatMessage';
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
@@ -28,10 +28,10 @@ export function ChatMessageList(props: {
chatLLMContextTokens: number | null,
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => void,
onTextDiagram: (diagramConfig: DiagramConfig | null) => Promise<any>,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<any>,
onTextSpeak: (selectedText: string) => Promise<any>,
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
onTextSpeak: (selectedText: string) => Promise<void>,
sx?: SxProps,
}) {
@@ -62,8 +62,9 @@ export function ChatMessageList(props: {
// text actions
const handleRunExample = (text: string) =>
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
const handleRunExample = React.useCallback(async (text: string) => {
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
// message menu methods proxy
@@ -72,11 +73,11 @@ export function ChatMessageList(props: {
conversationId && onConversationBranch(conversationId, messageId);
}, [conversationId, onConversationBranch]);
const handleConversationRestartFrom = React.useCallback((messageId: string, offset: number) => {
const handleConversationRestartFrom = React.useCallback(async (messageId: string, offset: number) => {
const messages = getConversation(conversationId)?.messages;
if (messages) {
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
conversationId && onConversationExecuteHistory(conversationId, truncatedHistory);
conversationId && await onConversationExecuteHistory(conversationId, truncatedHistory);
}
}, [conversationId, onConversationExecuteHistory]);
@@ -97,12 +98,12 @@ export function ChatMessageList(props: {
}, [conversationId, editMessage]);
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
conversationId && await onTextDiagram({ conversationId: conversationId, messageId, text });
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
}, [conversationId, onTextDiagram]);
const handleTextImagine = React.useCallback(async (text: string) => {
if (!capabilityHasT2I)
return openPreferencesTab(2);
return openPreferencesTab(PreferencesTab.Draw);
if (conversationId) {
setIsImagining(true);
await onTextImagine(conversationId, text);
@@ -112,7 +113,7 @@ export function ChatMessageList(props: {
const handleTextSpeak = React.useCallback(async (text: string) => {
if (!isSpeakable)
return openPreferencesTab(3);
return openPreferencesTab(PreferencesTab.Voice);
setIsSpeaking(true);
await onTextSpeak(text);
setIsSpeaking(false);
@@ -1,78 +1,88 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, IconButton, ListDivider, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
import { Box, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import { DFolder, useFoldersToggle, useFolderStore } from '~/common/state/store-folders';
import DebounceInput from '~/common/components/DebounceInput';
import { CloseableMenu } from '~/common/components/CloseableMenu';
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 { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
import DebounceInput from '~/common/components/DebounceInput';
import { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
import { ChatFolderList } from './folder/ChatFolderList';
import { ChatDrawerItemMemo, ChatNavigationItemData } from './ChatNavigationItem';
import { ClearFolderText } from './folder/useFolderDropdown';
// type ListGrouping = 'off' | 'persona';
// this is here to make shallow comparisons work on the next hook
const noFolders: DFolder[] = [];
/*
* Lists folders and returns the active folder
*/
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders, toggleEnableFolders }) => {
// finds the active folder if any
const activeFolder = (enableFolders && activeFolderId)
? folders.find(folder => folder.id === activeFolderId) ?? null
: null;
return {
activeFolder,
allFolders: enableFolders ? folders : noFolders,
enableFolders,
toggleEnableFolders,
};
}, shallow);
/*
* 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 useChatNavigationItems = (activeConversationId: DConversationId | null, folderId: string | null): {
chatNavItems: ChatNavigationItemData[],
folders: DFolder[],
} => {
export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
useChatStore(({ conversations }) => {
// monitor folder changes
// NOTE: we're not checking for state.useFolders, as we strongly assume folderId to be null when folders are disabled
const { currentFolder, folders } = useFolderStore(state => {
const currentFolder = folderId ? state.folders.find(_f => _f.id === folderId) ?? null : null;
return {
folders: state.folders,
currentFolder,
};
}, shallow);
const activeConversations = activeFolder
? conversations.filter(_c => activeFolder.conversationIds.includes(_c.id))
: conversations;
// transform (folder) selected conversation into optimized 'navigation item' data
const chatNavItems: ChatNavigationItemData[] = useChatStore(state => {
const selectConversations = currentFolder
? state.conversations.filter(_c => currentFolder.conversationIds.includes(_c.id))
: state.conversations;
return selectConversations.map(_c => ({
return activeConversations.map((_c): ChatNavigationItemData => ({
conversationId: _c.id,
isActive: _c.id === activeConversationId,
isEmpty: !_c.messages.length && !_c.userTitle,
title: conversationTitle(_c),
folder: !allFolders.length
? undefined // don't show folder select if folders are disabled
: _c.id === activeConversationId // only show the folder for active conversation(s)
? allFolders.find(folder => folder.conversationIds.includes(_c.id)) ?? null
: null,
messageCount: _c.messages.length,
assistantTyping: !!_c.abortController,
systemPurposeId: _c.systemPurposeId,
}));
}, (a: ChatNavigationItemData[], b: ChatNavigationItemData[]) => {
}, (a, b) => {
// custom equality function to avoid unnecessary re-renders
return a.length === b.length && a.every((_a, i) => shallow(_a, b[i]));
});
return { chatNavItems, folders };
};
export const ChatDrawerMemo = React.memo(ChatDrawer);
export const ChatDrawerContentMemo = React.memo(ChatDrawerItems);
function ChatDrawerItems(props: {
function ChatDrawer(props: {
activeConversationId: DConversationId | null,
activeFolderId: string | null,
disableNewButton: boolean,
onConversationActivate: (conversationId: DConversationId) => void,
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
@@ -80,28 +90,26 @@ function ChatDrawerItems(props: {
onConversationImportDialog: () => void,
onConversationNew: () => void,
onConversationsDeleteAll: () => void,
selectedFolderId: string | null,
setSelectedFolderId: (folderId: string | null) => void,
setActiveFolderId: (folderId: string | null) => void,
}) {
const { onConversationActivate, onConversationDelete, onConversationExportDialog, onConversationNew } = props;
// local state
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
// const [grouping] = React.useState<ListGrouping>('off');
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
// external state
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
const { useFolders, toggleUseFolders } = useFoldersToggle();
const { chatNavItems, folders } = useChatNavigationItems(props.activeConversationId, props.selectedFolderId);
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId);
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
// derived state
const selectConversationsCount = chatNavItems.length;
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
const singleChat = selectConversationsCount === 1;
const softMaxReached = selectConversationsCount >= 50;
const softMaxReached = selectConversationsCount >= 10;
const handleButtonNew = React.useCallback(() => {
@@ -109,17 +117,38 @@ function ChatDrawerItems(props: {
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationNew]);
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
onConversationActivate(conversationId);
if (closeMenu)
closeDrawerOnMobile();
}, [closeDrawerOnMobile, onConversationActivate]);
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
!singleChat && conversationId && onConversationDelete(conversationId, true);
}, [onConversationDelete, singleChat]);
// Folder change request
const handleConversationFolderChange = React.useCallback((folderChangeRequest: FolderChangeRequest) => setFolderChangeRequest(folderChangeRequest), []);
const handleConversationFolderCancel = React.useCallback(() => setFolderChangeRequest(null), []);
const handleConversationFolderSet = React.useCallback((conversationId: DConversationId, nextFolderId: string | null) => {
// Remove conversation from existing folders
const { addConversationToFolder, folders, removeConversationFromFolder } = useFolderStore.getState();
folders.forEach(folder => folder.conversationIds.includes(conversationId) && removeConversationFromFolder(folder.id, conversationId));
// Add conversation to the selected folder
nextFolderId && addConversationToFolder(nextFolderId, conversationId);
// Close the menu
setFolderChangeRequest(null);
}, []);
// Filter chatNavItems based on the search query and rank them by search frequency
const filteredChatNavItems = React.useMemo(() => {
if (!debouncedSearchQuery) return chatNavItems;
@@ -172,34 +201,30 @@ function ChatDrawerItems(props: {
return <>
{/* Drawer Header */}
<PageDrawerHeader
title='Chats'
onClose={closeDrawer}
startButton={
<Tooltip title={useFolders ? 'Hide Folders' : 'Use Folders'}>
<IconButton onClick={toggleUseFolders}>
{useFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
</IconButton>
</Tooltip>
}
/>
<PageDrawerHeader title='Chats' onClose={closeDrawer}>
<Tooltip title={enableFolders ? 'Hide Folders' : 'Use Folders'}>
<IconButton onClick={toggleEnableFolders}>
{enableFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
</IconButton>
</Tooltip>
</PageDrawerHeader>
{/* Folders List */}
{/*<Box sx={{*/}
{/* display: 'grid',*/}
{/* gridTemplateRows: !useFolders ? '0fr' : '1fr',*/}
{/* gridTemplateRows: !enableFolders ? '0fr' : '1fr',*/}
{/* transition: 'grid-template-rows 0.42s cubic-bezier(.17,.84,.44,1)',*/}
{/* '& > div': {*/}
{/* padding: useFolders ? 2 : 0,*/}
{/* padding: enableFolders ? 2 : 0,*/}
{/* transition: 'padding 0.42s cubic-bezier(.17,.84,.44,1)',*/}
{/* overflow: 'hidden',*/}
{/* },*/}
{/*}}>*/}
{useFolders && (
{enableFolders && (
<ChatFolderList
folders={folders}
selectedFolderId={props.selectedFolderId}
onFolderSelect={props.setSelectedFolderId}
folders={allFolders}
activeFolderId={props.activeFolderId}
onFolderSelect={props.setActiveFolderId}
/>
)}
{/*</Box>*/}
@@ -207,10 +232,11 @@ function ChatDrawerItems(props: {
{/* Chats List */}
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
{useFolders && <ListDivider sx={{ mb: 0 }} />}
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
{/* Search Input Field */}
<DebounceInput
minChars={2}
onDebounce={setDebouncedSearchQuery}
debounceTimeout={300}
placeholder='Search...'
@@ -218,22 +244,24 @@ function ChatDrawerItems(props: {
sx={{ m: 2 }}
/>
<ListItemButton disabled={props.disableNewButton} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Box sx={{
// style
fontSize: 'sm',
fontWeight: 'lg',
// content
flexGrow: 1,
display: 'flex',
justifyContent: 'space-between',
gap: 1,
}}>
New chat
{/*<KeyStroke combo='Ctrl + Alt + N' sx={props.disableNewButton ? { opacity: 0.5 } : undefined} />*/}
</Box>
</ListItemButton>
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
<ListItemButton disabled={props.disableNewButton} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
<ListItemDecorator><AddIcon /></ListItemDecorator>
<Box sx={{
// style
fontSize: 'sm',
fontWeight: 'lg',
// content
flexGrow: 1,
display: 'flex',
justifyContent: 'space-between',
gap: 1,
}}>
New chat
{/*<KeyStroke combo='Ctrl + Alt + N' sx={props.disableNewButton ? { opacity: 0.5 } : undefined} />*/}
</Box>
</ListItemButton>
</ListItem>
{/*<ListDivider sx={{ mt: 0 }} />*/}
@@ -258,16 +286,17 @@ function ChatDrawerItems(props: {
item={item}
isLonely={singleChat}
showSymbols={showSymbols}
bottomBarBasis={(labsEnhancedUI || softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
bottomBarBasis={(softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
onConversationActivate={handleConversationActivate}
onConversationDelete={handleConversationDelete}
onConversationExport={onConversationExportDialog}
onConversationFolderChange={handleConversationFolderChange}
/>)}
</Box>
<ListDivider sx={{ mt: 0 }} />
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ListItemButton onClick={props.onConversationImportDialog} sx={{ flex: 1 }}>
<ListItemDecorator>
<FileUploadIcon />
@@ -293,5 +322,46 @@ function ChatDrawerItems(props: {
</PageDrawerList>
{/* [Menu] Chat Item Folder Change */}
{!!folderChangeRequest?.anchorEl && (
<CloseableMenu
open anchorEl={folderChangeRequest.anchorEl} onClose={handleConversationFolderCancel}
placement='bottom-start'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
{/* Folder Assignment Buttons */}
{allFolders.map(folder => {
const isRequestFolder = folder === folderChangeRequest.currentFolder;
return (
<ListItem
key={folder.id}
variant={isRequestFolder ? 'soft' : 'plain'}
onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, folder.id)}
>
<ListItemButton>
<ListItemDecorator>
<FolderIcon sx={{ color: folder.color }} />
</ListItemDecorator>
{folder.title}
</ListItemButton>
</ListItem>
);
})}
{/* Remove Folder Assignment */}
{!!folderChangeRequest.currentFolder && (
<ListItem onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, null)}>
<ListItemButton>
{ClearFolderText}
</ListItemButton>
</ListItem>
)}
</CloseableMenu>
)}
</>;
}
@@ -0,0 +1,339 @@
import * as React from 'react';
import { Avatar, Box, Divider, 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';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import EditIcon from '@mui/icons-material/Edit';
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 { 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';
// set to true to display the conversation IDs
// const DEBUG_CONVERSATION_IDS = false;
export const FadeInButton = styled(IconButton)({
opacity: 0.5,
transition: 'opacity 0.2s',
'&:hover': { opacity: 1 },
});
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem);
export interface ChatNavigationItemData {
conversationId: DConversationId;
isActive: boolean;
isEmpty: boolean;
title: string;
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
messageCount: number;
assistantTyping: boolean;
systemPurposeId: SystemPurposeId;
searchFrequency?: number;
}
export interface FolderChangeRequest {
conversationId: DConversationId;
anchorEl: HTMLButtonElement;
currentFolder: DFolder | null;
}
function ChatDrawerItem(props: {
item: ChatNavigationItemData,
isLonely: boolean,
showSymbols: boolean,
bottomBarBasis: number,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationDelete: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId) => void,
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
}) {
// state
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [deleteArmed, setDeleteArmed] = React.useState(false);
// derived state
const { onConversationExport, onConversationFolderChange } = props;
const { conversationId, isActive, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const isNew = messageCount === 0;
// [effect] auto-disarm when inactive
const shallClose = deleteArmed && !isActive;
React.useEffect(() => {
if (shallClose)
setDeleteArmed(false);
}, [shallClose]);
// Activate
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
// export
const handleConversationExport = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
conversationId && onConversationExport(conversationId);
}, [conversationId, onConversationExport]);
// Folder change
const handleFolderChangeBegin = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onConversationFolderChange({
conversationId,
anchorEl: event.currentTarget,
currentFolder: folder ?? null,
});
}, [conversationId, folder, onConversationFolderChange]);
// Title Edit
const handleTitleEditBegin = React.useCallback(() => setIsEditingTitle(true), []);
const handleTitleEditCancel = React.useCallback(() => {
setIsEditingTitle(false);
}, []);
const handleTitleEditChange = React.useCallback((text: string) => {
setIsEditingTitle(false);
useChatStore.getState().setUserTitle(conversationId, text.trim());
}, [conversationId]);
const handleTitleEditAuto = React.useCallback(() => {
conversationAutoTitle(conversationId, true);
}, [conversationId]);
// Delete
const handleDeleteButtonShow = React.useCallback(() => setDeleteArmed(true), []);
const handleDeleteButtonHide = React.useCallback(() => setDeleteArmed(false), []);
const handleConversationDelete = React.useCallback((event: React.MouseEvent) => {
if (deleteArmed) {
setDeleteArmed(false);
event.stopPropagation();
props.onConversationDelete(conversationId);
}
}, [conversationId, deleteArmed, props]);
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
const progress = props.bottomBarBasis ? 100 * (searchFrequency ?? messageCount) / props.bottomBarBasis : 0;
const titleRowComponent = React.useMemo(() => <>
{/* Symbol, if globally enabled */}
{props.showSymbols && <ListItemDecorator>
{assistantTyping
? (
<Avatar
alt='typing' variant='plain'
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
sx={{
width: '1.5rem',
height: '1.5rem',
borderRadius: 'var(--joy-radius-sm)',
}}
/>
) : (
<Typography>
{isNew ? '' : textSymbol}
</Typography>
)}
</ListItemDecorator>}
{/* Title */}
{!isEditingTitle ? (
<Typography
// level={isActive ? 'title-md' : 'body-md'}
onDoubleClick={handleTitleEditBegin}
sx={{
color: isActive ? 'text.primary' : 'text.secondary',
flex: 1,
}}
>
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
{title.trim() ? title : 'Chat'}{assistantTyping && '...'}
</Typography>
) : (
<InlineTextarea
invertedColors
initialText={title}
onEdit={handleTitleEditChange}
onCancel={handleTitleEditCancel}
sx={{
flexGrow: 1,
ml: -1.5, mr: -0.5,
}}
/>
)}
{/* Display search frequency if it exists and is greater than 0 */}
{searchFrequency && searchFrequency > 0 && (
<Box sx={{ ml: 1 }}>
<Typography level='body-sm'>
{searchFrequency}
</Typography>
</Box>
)}
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title]);
const progressBarFixedComponent = React.useMemo(() =>
progress > 0 && (
<Box sx={{
backgroundColor: 'neutral.softBg',
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
}} />
), [progress]);
return isActive ? (
// Active Conversation
<Sheet
variant={isActive ? 'solid' : 'plain'}
invertedColors={isActive}
sx={{
// common
'--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
// style
borderRadius: 'md',
mx: '0.25rem',
'&:hover > button': {
opacity: 1, // fade in buttons when hovering, but by default wash them out a bit
},
}}
>
<ListItem sx={{ border: 'none', display: 'grid', gap: 0, px: 'calc(var(--ListItem-paddingX) - 0.25rem)' }}>
{/* 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' }}>
<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>
<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 />
</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' }} />
</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>
</ListItem>
{/* Optional progress bar, underlay */}
{progressBarFixedComponent}
</Sheet>
) : (
// Inactive Conversation - click to activate
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
<ListItemButton
onClick={handleConversationActivate}
sx={{
border: 'none', // there's a default border of 1px and invisible.. hmm
position: 'relative', // for the progress bar
}}
>
{titleRowComponent}
{/* Optional progress bar, underlay */}
{progressBarFixedComponent}
</ListItemButton>
</ListItem>
);
}
@@ -5,14 +5,12 @@ import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlin
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
import ClearIcon from '@mui/icons-material/Clear';
import CompressIcon from '@mui/icons-material/Compress';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import ForkRightIcon from '@mui/icons-material/ForkRight';
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
import type { DConversationId } from '~/common/state/store-chats';
import { KeyStroke } from '~/common/components/KeyStroke';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { useUICounter } from '~/common/state/store-ui';
import { useChatShowSystemMessages } from '../../store-app-chat';
@@ -25,13 +23,11 @@ export function ChatMenuItems(props: {
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
onConversationClear: (conversationId: DConversationId) => void,
onConversationExport: (conversationId: DConversationId | null) => void,
onConversationFlatten: (conversationId: DConversationId) => void,
}) {
// external state
const { closePageMenu } = useOptimaDrawers();
const { touch: shareTouch } = useUICounter('export-share');
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
// derived state
@@ -53,12 +49,6 @@ export function ChatMenuItems(props: {
props.conversationId && props.onConversationBranch(props.conversationId, null);
};
const handleConversationExport = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.onConversationExport(!disabled ? props.conversationId : null);
shareTouch();
};
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
closeMenu(event);
props.conversationId && props.onConversationFlatten(props.conversationId);
@@ -107,13 +97,6 @@ export function ChatMenuItems(props: {
</span>
</MenuItem>
<MenuItem disabled={!props.hasConversations} onClick={handleConversationExport}>
<ListItemDecorator>
<FileDownloadIcon />
</ListItemDecorator>
Share / Export ...
</MenuItem>
<MenuItem disabled={disabled} onClick={handleConversationClear}>
<ListItemDecorator><ClearIcon /></ListItemDecorator>
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
@@ -1,195 +0,0 @@
import * as React from 'react';
import { Avatar, Box, IconButton, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
import { SxProps } from '@mui/joy/styles/types';
import CloseIcon from '@mui/icons-material/Close';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { InlineTextarea } from '~/common/components/InlineTextarea';
const DEBUG_CONVERSATION_IDs = false;
export const ChatDrawerItemMemo = React.memo(ChatNavigationItem);
export interface ChatNavigationItemData {
conversationId: DConversationId;
isActive: boolean;
isEmpty: boolean;
title: string;
messageCount: number;
assistantTyping: boolean;
systemPurposeId: SystemPurposeId;
searchFrequency?: number;
}
function ChatNavigationItem(props: {
item: ChatNavigationItemData,
isLonely: boolean,
showSymbols: boolean,
bottomBarBasis: number,
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
onConversationDelete: (conversationId: DConversationId) => void,
}) {
// state
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [deleteArmed, setDeleteArmed] = React.useState(false);
// derived state
const { conversationId, isActive, title, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
const isNew = messageCount === 0;
// auto-close the arming menu when clicking away
// NOTE: there currently is a bug (race condition) where the menu closes on a new item right after opening
// because the isActive prop is not yet updated
React.useEffect(() => {
if (deleteArmed && !isActive)
setDeleteArmed(false);
}, [deleteArmed, isActive]);
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
const handleTitleEdit = () => setIsEditingTitle(true);
const handleTitleEdited = (text: string) => {
setIsEditingTitle(false);
useChatStore.getState().setUserTitle(conversationId, text.trim());
};
const handleTitleEditCancel = () => {
setIsEditingTitle(false);
};
const handleDeleteButtonShow = (event: React.MouseEvent) => {
event.stopPropagation();
if (!isActive)
props.onConversationActivate(conversationId, false);
else
setDeleteArmed(true);
};
const handleDeleteButtonHide = () => setDeleteArmed(false);
const handleConversationDelete = (event: React.MouseEvent) => {
if (deleteArmed) {
setDeleteArmed(false);
event.stopPropagation();
props.onConversationDelete(conversationId);
}
};
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
const buttonSx: SxProps = isActive ? { color: 'white' } : {};
const progress = props.bottomBarBasis ? 100 * (searchFrequency ?? messageCount) / props.bottomBarBasis : 0;
return (
<ListItemButton
variant={isActive ? 'soft' : 'plain'} color='neutral'
onClick={!isActive ? handleConversationActivate : event => event.preventDefault()}
sx={{
// py: 0,
position: 'relative',
border: 'none', // note, there's a default border of 1px and invisible.. hmm
cursor: 'pointer',
'&:hover > button': { opacity: 1 },
}}
>
{/* Optional progress bar, underlay */}
{progress > 0 && (
<Box sx={{
backgroundColor: 'neutral.softBg',
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
}} />
)}
{/* Icon */}
{props.showSymbols && <ListItemDecorator>
{assistantTyping
? (
<Avatar
alt='typing' variant='plain'
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
sx={{
width: '1.5rem',
height: '1.5rem',
borderRadius: 'var(--joy-radius-sm)',
}}
/>
) : (
<Typography>
{isNew ? '' : textSymbol}
</Typography>
)}
</ListItemDecorator>}
{/* Text */}
{!isEditingTitle ? (
<Typography
level={isActive ? 'title-md' : 'body-md'}
onDoubleClick={handleTitleEdit}
sx={{ flex: 1 }}
>
{DEBUG_CONVERSATION_IDs ? conversationId.slice(0, 10) : (title.trim() ? title : 'Chat')}{assistantTyping && '...'}
</Typography>
) : (
<InlineTextarea initialText={title} onEdit={handleTitleEdited} onCancel={handleTitleEditCancel} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
)}
{/* // TODO: Commented code */}
{/* Edit */}
{/*<IconButton*/}
{/* onClick={() => props.onEditTitle(props.conversationId)}*/}
{/* sx={{*/}
{/* opacity: 0, transition: 'opacity 0.3s', ml: 'auto',*/}
{/* }}>*/}
{/* <EditIcon />*/}
{/*</IconButton>*/}
{/* Display search frequency if it exists and is greater than 0 */}
{searchFrequency && searchFrequency > 0 && (
<Box sx={{ ml: 1 }}>
<Typography level='body-sm'>
{searchFrequency}
</Typography>
</Box>
)}
{/* Delete Arming */}
{!props.isLonely && !deleteArmed && !searchFrequency && (
<IconButton
variant={isActive ? 'solid' : 'outlined'}
size='sm'
sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.2s', ...buttonSx }}
onClick={handleDeleteButtonShow}
>
<DeleteOutlineIcon />
</IconButton>
)}
{/* Delete / Cancel buttons */}
{!props.isLonely && deleteArmed && !searchFrequency && <>
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleConversationDelete}>
<DeleteOutlineIcon />
</IconButton>
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteButtonHide}>
<CloseIcon />
</IconButton>
</>}
</ListItemButton>
);
}
@@ -13,12 +13,12 @@ import { StrictModeDroppable } from './StrictModeDroppable';
export function ChatFolderList(props: {
folders: DFolder[];
activeFolderId: string | null;
onFolderSelect: (folderId: string | null) => void;
selectedFolderId: string | null;
}) {
// derived props
const { folders, onFolderSelect, selectedFolderId } = props;
const { folders, onFolderSelect, activeFolderId } = props;
// handlers
@@ -72,11 +72,11 @@ export function ChatFolderList(props: {
droppableId='folder'
renderClone={(provided, snapshot, rubric) => (
<FolderListItem
activeFolderId={activeFolderId}
folder={folders[rubric.source.index]}
onFolderSelect={onFolderSelect}
provided={provided}
snapshot={snapshot}
onFolderSelect={onFolderSelect}
selectedFolderId={selectedFolderId}
/>
)}
>
@@ -91,7 +91,7 @@ export function ChatFolderList(props: {
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
onFolderSelect(null);
}}
selected={selectedFolderId === null}
selected={!activeFolderId}
sx={{
border: 0,
justifyContent: 'space-between',
@@ -114,11 +114,11 @@ export function ChatFolderList(props: {
<Draggable key={folder.id} draggableId={folder.id} index={index}>
{(provided, snapshot) => (
<FolderListItem
activeFolderId={activeFolderId}
folder={folder}
onFolderSelect={onFolderSelect}
provided={provided}
snapshot={snapshot}
onFolderSelect={onFolderSelect}
selectedFolderId={selectedFolderId}
/>
)}
</Draggable>
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
import type { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuItem, Radio, radioClasses, RadioGroup, Sheet, Typography } from '@mui/joy';
import CloseIcon from '@mui/icons-material/Close';
@@ -14,17 +14,13 @@ import { DFolder, FOLDERS_COLOR_PALETTE, useFolderStore } from '~/common/state/s
import { InlineTextarea } from '~/common/components/InlineTextarea';
// Define the type for your props if you're using TypeScript
type RenderItemProps = {
export function FolderListItem(props: {
activeFolderId: string | null;
folder: DFolder;
onFolderSelect: (folderId: string | null) => void;
provided: DraggableProvided;
snapshot: DraggableStateSnapshot;
onFolderSelect: (folderId: string | null) => void;
selectedFolderId: string | null;
// Include any other props that RenderItem needs
};
export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, snapshot, onFolderSelect, selectedFolderId }) => {
}) {
// internal state
const [deleteArmed, setDeleteArmed] = useState(false);
@@ -34,6 +30,10 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLAnchorElement>(null);
// derived props
const { activeFolderId, folder, onFolderSelect, provided, snapshot } = props;
// Menu
const handleMenuOpen = (event: React.MouseEvent<HTMLAnchorElement>) => {
setMenuAnchorEl(event.currentTarget);
@@ -148,7 +148,7 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
handleFolderSelect(folder.id);
}}
selected={folder.id === selectedFolderId}
selected={folder.id === activeFolderId}
sx={{
border: 0,
justifyContent: 'space-between',
@@ -200,7 +200,8 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
{!!menuAnchorEl && (
<CloseableMenu
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
placement='top' zIndex={1301 /* need to be on top of the Modal on Mobile */}
placement='top'
zIndex={1301 /* need to be on top of the Modal on Mobile */}
sx={{ minWidth: 200 }}
>
@@ -316,4 +317,4 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
</ListItemButton>
</ListItem>
);
};
}
@@ -7,13 +7,14 @@ import { DropdownItems, PageBarDropdown } from '~/common/layout/optima/component
import { useFolderStore } from '~/common/state/store-folders';
const SPECIAL_ID_REMOVE = '_REMOVE_';
export const ClearFolderText = 'Clear Folder';
const SPECIAL_ID_CLEAR_FOLDER = '_REMOVE_';
export function useFolderDropdown(conversationId: DConversationId | null) {
// external state
const { folders, useFolders } = useFolderStore();
const { folders, enableFolders } = useFolderStore();
// Prepare items for the dropdown
@@ -28,8 +29,8 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
}, {} as DropdownItems);
// add one item representing no folder
items[SPECIAL_ID_REMOVE] = {
title: 'No Folder',
items[SPECIAL_ID_CLEAR_FOLDER] = {
title: ClearFolderText,
};
return items;
@@ -46,7 +47,8 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
}
});
// Add conversation to the selected folder
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
if (folderId !== SPECIAL_ID_CLEAR_FOLDER)
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
}
}, [conversationId, folders]);
@@ -57,7 +59,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
const folderDropdown = React.useMemo(() => {
// don't show the dropdown if folders are not enabled
if (!useFolders)
if (!enableFolders)
return null;
return (
@@ -69,7 +71,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
showSymbols
/>
);
}, [currentFolderId, folderItems, handleFolderChange, useFolders]);
}, [currentFolderId, enableFolders, folderItems, handleFolderChange]);
return { folderDropdown };
}
@@ -1,22 +1,16 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { ListItemButton, ListItemDecorator } from '@mui/joy';
import CallIcon from '@mui/icons-material/Call';
import { SystemPurposeId, SystemPurposes } from '../../../../data';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
import { launchAppCall } from '~/common/app.routes';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
function AppBarPersonaDropdown(props: {
systemPurposeId: SystemPurposeId | null,
setSystemPurposeId: (systemPurposeId: SystemPurposeId | null) => void,
onCall?: () => void,
}) {
// external state
@@ -29,23 +23,13 @@ function AppBarPersonaDropdown(props: {
// options
let appendOption: React.JSX.Element | undefined = undefined;
if (props.onCall) {
const enableCallOption = !!props.systemPurposeId;
appendOption = (
<ListItemButton color='primary' disabled={!enableCallOption} key='menu-call-persona' onClick={props.onCall} sx={{ minWidth: 160 }}>
<ListItemDecorator><CallIcon color={enableCallOption ? 'primary' : 'warning'} /></ListItemDecorator>
Call&nbsp; {!!props.systemPurposeId && SystemPurposes[props.systemPurposeId]?.symbol}
</ListItemButton>
);
}
// let appendOption: React.JSX.Element | undefined = undefined;
return (
<PageBarDropdown
items={SystemPurposes} showSymbols={zenMode !== 'cleaner'}
value={props.systemPurposeId} onChange={handleSystemPurposeChange}
appendOption={appendOption}
// appendOption={appendOption}
/>
);
@@ -54,7 +38,6 @@ function AppBarPersonaDropdown(props: {
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
// external state
const labsCalling = useUXLabsStore(state => state.labsCalling);
const { systemPurposeId } = useChatStore(state => {
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
return {
@@ -69,12 +52,8 @@ export function usePersonaIdDropdown(conversationId: DConversationId | null) {
if (conversationId && systemPurposeId)
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
}}
onCall={labsCalling ? () => {
if (conversationId && systemPurposeId)
launchAppCall(conversationId, systemPurposeId);
} : undefined}
/> : null,
[conversationId, labsCalling, systemPurposeId],
[conversationId, systemPurposeId],
);
return { personaDropdown };
+58 -32
View File
@@ -3,8 +3,9 @@ import { shallow } from 'zustand/shallow';
import { fileOpen, FileWithHandle } from 'browser-fs-access';
import { keyframes } from '@emotion/react';
import { Box, Button, ButtonGroup, Card, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Stack, 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';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import AutoModeIcon from '@mui/icons-material/AutoMode';
@@ -23,6 +24,7 @@ import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vend
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
import { DConversationId, useChatStore } from '~/common/state/store-chats';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
import { countModelTokens } from '~/common/util/token-counter';
import { launchAppCall } from '~/common/app.routes';
@@ -32,7 +34,6 @@ import { supportsClipboardRead } from '~/common/util/clipboardUtils';
import { useDebouncer } from '~/common/components/useDebouncer';
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
@@ -46,7 +47,7 @@ import { getTextBlockText, useLLMAttachments } from './attachments/useLLMAttachm
import { useAttachments } from './attachments/useAttachments';
import type { ComposerOutputMultiPart } from './composer.types';
import { ButtonAttachCameraMemo } from './buttons/ButtonAttachCamera';
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
import { ButtonCall } from './buttons/ButtonCall';
@@ -59,7 +60,7 @@ import { TokenProgressbarMemo } from './TokenProgressbar';
import { useComposerStartupText } from './store-composer';
const animationStopEnter = keyframes`
export const animationStopEnter = keyframes`
from {
opacity: 0;
transform: translateY(8px)
@@ -94,9 +95,8 @@ export function Composer(props: {
// external state
const isMobile = useIsMobile();
const { openPreferencesTab, setIsFocusedMode } = useOptimaLayout();
const { labsCalling, labsCameraDesktop } = useUXLabsStore(state => ({
labsCalling: state.labsCalling,
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
const { labsCameraDesktop } = useUXLabsStore(state => ({
labsCameraDesktop: state.labsCameraDesktop,
}), shallow);
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
@@ -130,7 +130,7 @@ export function Composer(props: {
const tokensComposerText = React.useMemo(() => {
if (!debouncedText || !chatLLMId)
return 0;
return countModelTokens(debouncedText, chatLLMId, 'composer text');
return countModelTokens(debouncedText, chatLLMId, 'composer text') ?? 0;
}, [chatLLMId, debouncedText]);
let tokensComposer = tokensComposerText + llmAttachments.tokenCountApprox;
if (tokensComposer > 0)
@@ -181,7 +181,7 @@ export function Composer(props: {
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
const handleDrawOptionsClicked = () => openPreferencesTab(2);
const handleDrawOptionsClicked = () => openPreferencesTab(PreferencesTab.Draw);
const handleTextImagineClicked = () => {
if (!composeText || !props.conversationId)
@@ -231,14 +231,15 @@ export function Composer(props: {
return [providerCommands(onActileCommandSelect)];
}, [onActileCommandSelect]);
const { actileComponent, actileInterceptKeydown } = useActileManager(actileProviders, props.composerTextAreaRef);
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
// Text typing
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setComposeText(e.target.value);
}, [setComposeText]);
isMobile && actileInterceptTextChange(e.target.value);
}, [actileInterceptTextChange, isMobile, setComposeText]);
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// disable keyboard handling if the actile is visible
@@ -265,6 +266,13 @@ export function Composer(props: {
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);
// Focus mode
// const handleFocusModeOn = React.useCallback(() => setIsFocusedMode(true), [setIsFocusedMode]);
// const handleFocusModeOff = React.useCallback(() => setIsFocusedMode(false), [setIsFocusedMode]);
// Mic typing & continuation mode
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
@@ -303,7 +311,7 @@ export function Composer(props: {
useGlobalShortcut('m', true, false, false, toggleRecording);
const micIsRunning = !!speechInterimResult;
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible;
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible && !isSpeechError;
const micColor: ColorPaletteProp = isSpeechError ? 'danger' : isRecordingSpeech ? 'primary' : isRecordingAudio ? 'primary' : 'neutral';
const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'soft' : 'soft'; //(isDesktop ? 'soft' : 'plain');
@@ -333,6 +341,8 @@ export function Composer(props: {
void attachAppendFile('camera', file);
}, [attachAppendFile]);
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
const handleAttachFilePicker = React.useCallback(async () => {
try {
const selectedFiles: FileWithHandle[] = await fileOpen({ multiple: true });
@@ -427,12 +437,12 @@ export function Composer(props: {
: props.isDeveloperMode
? 'Chat with me · drop source files · attach code...'
: props.capabilityHasT2I
? 'Chat · /react · /draw · drop text files...'
: 'Chat · /react · drop text files...';
? 'Chat · /react · /draw · drop files...'
: 'Chat · /react · drop files...';
return (
<Box sx={props.sx}>
<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) */}
@@ -440,19 +450,32 @@ export function Composer(props: {
{/* Vertical (insert) buttons */}
{isMobile ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{/* [mobile] Mic button */}
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
{/* Responsive Camera OCR button */}
<ButtonAttachCameraMemo isMobile onAttachImage={handleAttachCameraImage} />
<Dropdown>
<MenuButton slots={{ root: IconButton }}>
<AddCircleOutlineIcon />
</MenuButton>
<Menu>
{/* Responsive Camera OCR button */}
<MenuItem>
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
</MenuItem>
{/* Responsive Open Files button */}
<ButtonAttachFileMemo isMobile onAttachFilePicker={handleAttachFilePicker} />
{/* Responsive Open Files button */}
<MenuItem>
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
</MenuItem>
{/* Responsive Paste button */}
{supportsClipboardRead && <ButtonAttachClipboardMemo isMobile onClick={attachAppendClipboardItems} />}
{/* Responsive Paste button */}
{supportsClipboardRead && <MenuItem>
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
</MenuItem>}
</Menu>
</Dropdown>
</Box>
) : (
@@ -469,7 +492,7 @@ export function Composer(props: {
{supportsClipboardRead && <ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />}
{/* Responsive Camera OCR button */}
{labsCameraDesktop && <ButtonAttachCameraMemo onAttachImage={handleAttachCameraImage} />}
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
</Box>
)}
@@ -488,9 +511,11 @@ export function Composer(props: {
<Box sx={{ position: 'relative' }}>
<Textarea
variant='outlined' color={isDraw ? 'warning' : isReAct ? 'success' : 'neutral'}
variant='outlined'
color={isDraw ? 'warning' : isReAct ? 'success' : undefined}
autoFocus
minRows={isMobile ? 5 : 5} maxRows={10}
minRows={isMobile ? 4 : 5}
maxRows={isMobile ? 8 : 10}
placeholder={textPlaceholder}
value={composeText}
onChange={handleTextareaTextChange}
@@ -498,8 +523,8 @@ export function Composer(props: {
onDragStart={handleTextareaDragStart}
onKeyDown={handleTextareaKeyDown}
onPasteCapture={handleAttachCtrlV}
onFocusCapture={() => setIsFocusedMode(true)}
onBlurCapture={() => setIsFocusedMode(false)}
// onFocusCapture={handleFocusModeOn}
// onBlurCapture={handleFocusModeOff}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'send',
@@ -512,9 +537,7 @@ export function Composer(props: {
}}
sx={{
backgroundColor: 'background.level1',
'&:focus-within': {
backgroundColor: 'background.popup',
},
'&:focus-within': { backgroundColor: 'background.popup' },
lineHeight: lineHeightTextarea,
}} />
@@ -616,7 +639,7 @@ export function Composer(props: {
{/* [mobile] bottom-corner secondary button */}
{isMobile && (isChat
? <ButtonCall isMobile disabled={!labsCalling || !props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
? <ButtonCall isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
: isDraw
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
@@ -683,7 +706,7 @@ export function Composer(props: {
{isDesktop && <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1, justifyContent: 'flex-end' }}>
{/* [desktop] Call secondary button */}
{isChat && <ButtonCall disabled={!labsCalling || !props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
{isChat && <ButtonCall disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
{/* [desktop] Draw Options secondary button */}
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
@@ -703,6 +726,9 @@ export function Composer(props: {
/>
)}
{/* Camera */}
{cameraCaptureComponent}
{/* Actile */}
{actileComponent}
@@ -21,19 +21,18 @@ export function ActilePopup(props: {
const hasAnyIcon = props.items.some(item => !!item.Icon);
return (
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding>
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding noBottomPadding sx={{ minWidth: 320 }}>
{!!props.title && (
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
{/*<ListItemDecorator/>*/}
<Typography level='title-md'>
<Typography level='title-sm'>
{props.title}
</Typography>
</Sheet>
)}
{!props.items.length && (
<ListItem variant='soft' color='primary'>
<ListItem variant='soft' color='warning'>
<Typography level='body-md'>
No matching command
</Typography>
@@ -41,35 +40,39 @@ export function ActilePopup(props: {
)}
{props.items.map((item, idx) => {
const isActive = idx === props.activeItemIndex;
const labelBold = item.label.slice(0, props.activePrefixLength);
const labelNormal = item.label.slice(props.activePrefixLength);
return (
<ListItemButton
<ListItem
key={item.id}
variant={idx === props.activeItemIndex ? 'soft' : undefined}
variant={isActive ? 'soft' : undefined}
color={isActive ? 'primary' : undefined}
onClick={() => props.onItemClick(item)}
>
{hasAnyIcon && (
<ListItemDecorator>
{item.Icon ? <item.Icon /> : null}
</ListItemDecorator>
)}
<Box>
<ListItemButton>
{hasAnyIcon && (
<ListItemDecorator>
{item.Icon ? <item.Icon /> : null}
</ListItemDecorator>
)}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography level='title-md' color='primary'>
<span style={{ fontWeight: 600, textDecoration: 'underline' }}>{labelBold}</span>{labelNormal}
</Typography>
{item.argument && <Typography level='body-sm'>
{item.argument}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
<span style={{ fontWeight: 600, textDecoration: 'underline' }}>{labelBold}</span>{labelNormal}
</Typography>
{item.argument && <Typography level='body-sm'>
{item.argument}
</Typography>}
</Box>
{!!item.description && <Typography level='body-xs'>
{item.description}
</Typography>}
</Box>
{!!item.description && <Typography level='body-xs'>
{item.description}
</Typography>}
</Box>
</ListItemButton>
</ListItemButton>
</ListItem>
);
},
)}
@@ -13,6 +13,7 @@ type ActileProviderIds = 'actile-commands' | 'actile-attach-reference';
export interface ActileProvider {
id: ActileProviderIds;
title: string;
searchPrefix: string;
checkTriggerText: (trailingText: string) => boolean;
@@ -1,9 +1,10 @@
import { ActileItem, ActileProvider } from './ActileProvider';
//import { ActileItem, ActileProvider } from './ActileProvider';
export const providerAttachReference: ActileProvider = {
/*export const providerAttachReference: ActileProvider = {
id: 'actile-attach-reference',
title: 'Attach Reference',
searchPrefix: '@',
checkTriggerText: (trailingText: string) =>
trailingText.endsWith(' @'),
@@ -20,4 +21,4 @@ export const providerAttachReference: ActileProvider = {
onItemSelect: (item: ActileItem) => {
console.log('Selected item:', item);
},
};
};*/
@@ -5,6 +5,7 @@ import { findAllChatCommands } from '../../../commands/commands.registry';
export const providerCommands = (onItemSelect: (item: ActileItem) => void): ActileProvider => ({
id: 'actile-commands',
title: 'Chat Commands',
searchPrefix: '/',
checkTriggerText: (trailingText: string) =>
trailingText.trim() === '/',
@@ -40,6 +40,26 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
}, [activeItem, handlePopupItemClicked]);
const actileInterceptTextChange = React.useCallback((trailingText: string) => {
for (const provider of providers) {
if (provider.checkTriggerText(trailingText)) {
setProvider(provider);
setPopupOpen(true);
setActiveSearchString(provider.searchPrefix);
provider
.fetchItems()
.then(items => setItems(items))
.catch(error => {
handleClose();
console.error('Failed to fetch popup items:', error);
});
return true;
}
}
return false;
}, [handleClose, providers]);
const actileInterceptKeydown = React.useCallback((_event: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
// Popup open: Intercept
@@ -69,32 +89,10 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
}
// Popup closed: Check for triggers
// optimization
if (key !== '/' && key !== '@')
return false;
const trailingText = (currentTarget.value || '') + key;
return actileInterceptTextChange(trailingText);
// check all rules to find one that triggers
for (const provider of providers) {
if (provider.checkTriggerText(trailingText)) {
setProvider(provider);
setPopupOpen(true);
setActiveSearchString(key);
provider
.fetchItems()
.then(items => setItems(items))
.catch(error => {
handleClose();
console.error('Failed to fetch popup items:', error);
});
return true;
}
}
return false;
}, [activeItems.length, handleClose, handleEnterKey, popupOpen, providers]);
}, [actileInterceptTextChange, activeItems.length, handleClose, handleEnterKey, popupOpen]);
const actileComponent = React.useMemo(() => {
@@ -114,5 +112,6 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
return {
actileComponent,
actileInterceptKeydown,
actileInterceptTextChange,
};
};
@@ -87,6 +87,13 @@ function attachmentConverterIcon(attachment: Attachment) {
}
function attachmentLabelText(attachment: Attachment): string {
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
if (converter && attachment.label === 'Rich Text') {
if (converter.id === 'rich-text-table')
return 'Rich Table';
if (converter.id === 'rich-text')
return 'Rich HTML';
}
return ellipsizeFront(attachment.label, 24);
}
@@ -255,7 +255,7 @@ export async function attachmentPerformConversion(attachment: Readonly<Attachmen
outputs.push({
type: 'text-block',
text: input.altData!,
title: ref,
title: ref || '\n<!DOCTYPE html>',
collapsible: true,
});
break;
@@ -24,7 +24,7 @@ import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
// see how we fare on budget
if (chatLLMId) {
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger');
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger') ?? 0;
// simple trigger for the reduction dialog
if (newTextTokens > remainingTokens) {
@@ -10,6 +10,10 @@ import { getClipboardItems } from '~/common/util/clipboardUtils';
import { AttachmentSourceOriginDTO, AttachmentSourceOriginFile, useAttachmentsStore } from './store-attachments';
// enable to debug attachment operations
const ATTACHMENTS_DEBUG_INTAKE = false;
export const useAttachments = (enableLoadURLs: boolean) => {
// state
@@ -24,17 +28,30 @@ export const useAttachments = (enableLoadURLs: boolean) => {
// Creation helpers
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) =>
createAttachment({
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
})
, [createAttachment]);
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) => {
if (ATTACHMENTS_DEBUG_INTAKE)
console.log('attachAppendFile', origin, fileWithHandle, overrideFileName);
return createAttachment({
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
});
}, [createAttachment]);
const attachAppendDataTransfer = React.useCallback((dt: DataTransfer, method: AttachmentSourceOriginDTO, attachText: boolean): 'as_files' | 'as_url' | 'as_text' | false => {
// https://github.com/enricoros/big-AGI/issues/286
const textHtml = dt.getData('text/html') || '';
const heuristicIsExcel = textHtml.includes('"urn:schemas-microsoft-com:office:excel"');
// noinspection HttpUrlsUsage
const heuristicIsPowerPoint = textHtml.includes('xmlns:m="http://schemas.microsoft.com/office/20') && textHtml.includes('<meta name=Generator content="Microsoft PowerPoint');
const heuristicBypassImage = heuristicIsExcel || heuristicIsPowerPoint;
if (ATTACHMENTS_DEBUG_INTAKE)
console.log('attachAppendDataTransfer', dt.types, dt.items, dt.files, textHtml);
// attach File(s)
if (dt.files.length >= 1) {
if (dt.files.length >= 1 && !heuristicBypassImage /* special case: ignore images from Microsoft Office pastes (prioritize the HTML paste) */) {
// rename files from a common prefix, to better relate them (if the transfer contains a list of paths)
let overrideFileNames: string[] = [];
if (dt.types.includes('text/plain')) {
@@ -68,7 +85,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
}
// attach as Text/Html (further conversion, e.g. to markdown is done later)
const textHtml = dt.getData('text/html') || '';
if (attachText && (textHtml || textPlain)) {
void createAttachment({
media: 'text', method, textPlain, textHtml,
@@ -100,13 +116,20 @@ export const useAttachments = (enableLoadURLs: boolean) => {
return;
}
// loop on all the possible attachments
// loop on all the clipboard items
for (const clipboardItem of clipboardItems) {
// https://github.com/enricoros/big-AGI/issues/286
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
const heuristicBypassImage = textHtml.startsWith('<table ');
if (ATTACHMENTS_DEBUG_INTAKE)
console.log(' - attachAppendClipboardItems.item:', clipboardItem, textHtml, heuristicBypassImage);
// attach as image
let imageAttached = false;
for (const mimeType of clipboardItem.types) {
if (mimeType.startsWith('image/')) {
if (mimeType.startsWith('image/') && !heuristicBypassImage) {
try {
const imageBlob = await clipboardItem.getType(mimeType);
const imageName = mimeType.replace('image/', 'clipboard.').replaceAll('/', '.') || 'clipboard.png';
@@ -136,7 +159,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
}
// attach as Text
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
if (textHtml || textPlain) {
void createAttachment({
media: 'text', method: 'clipboard-read', textPlain, textHtml,
@@ -78,7 +78,7 @@ function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: Compo
const tokenCountApprox = llmForTokenCount
? attachmentOutputs.reduce((acc, output) => {
if (output.type === 'text-block')
return acc + countModelTokens(output.text, llmForTokenCount, 'attachments tokens count');
return acc + (countModelTokens(output.text, llmForTokenCount, 'attachments tokens count') ?? 0);
console.warn('Unhandled token preview for output type:', output.type);
return acc;
}, 0)
@@ -15,33 +15,37 @@ const attachCameraLegend = (isMobile: boolean) =>
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
function ButtonAttachCamera(props: { isMobile?: boolean, onAttachImage: (file: File) => void }) {
function ButtonAttachCamera(props: { isMobile?: boolean, onOpenCamera: () => void }) {
return props.isMobile ? (
<IconButton onClick={props.onOpenCamera}>
<AddAPhotoIcon />
</IconButton>
) : (
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
<Button fullWidth variant='plain' color='neutral' onClick={props.onOpenCamera} startDecorator={<AddAPhotoIcon />}
sx={{ justifyContent: 'flex-start' }}>
Camera
</Button>
</Tooltip>
);
}
export function useCameraCaptureModal(onAttachImage: (file: File) => void) {
// state
const [open, setOpen] = React.useState(false);
return <>
const openCamera = React.useCallback(() => setOpen(true), []);
{/* The Button */}
{props.isMobile ? (
<IconButton onClick={() => setOpen(true)}>
<AddAPhotoIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
<Button fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
sx={{ justifyContent: 'flex-start' }}>
Camera
</Button>
</Tooltip>
)}
const cameraCaptureComponent = React.useMemo(() => open && (
<CameraCaptureModal
onCloseModal={() => setOpen(false)}
onAttachImage={onAttachImage}
/>
), [open, onAttachImage]);
{/* The actual capture dialog, which will stream the video */}
{open && (
<CameraCaptureModal
onCloseModal={() => setOpen(false)}
onAttachImage={props.onAttachImage}
/>
)}
</>;
return {
openCamera,
cameraCaptureComponent,
};
}
@@ -22,7 +22,7 @@ function ButtonAttachClipboard(props: { isMobile?: boolean, onClick: () => void
<ContentPasteGoIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={pasteClipboardLegend}>
<Tooltip disableInteractive variant='solid' placement='top-start' title={pasteClipboardLegend}>
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onClick}
sx={{ justifyContent: 'flex-start' }}>
Paste
@@ -19,7 +19,7 @@ function ButtonAttachFile(props: { isMobile?: boolean, onAttachFilePicker: () =>
<AttachFileOutlinedIcon />
</IconButton>
) : (
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachFileLegend}>
<Button fullWidth variant='plain' color='neutral' onClick={props.onAttachFilePicker} startDecorator={<AttachFileOutlinedIcon />}
sx={{ justifyContent: 'flex-start' }}>
File
@@ -16,7 +16,7 @@ export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onCl
<CallIcon />
</IconButton>
) : (
<Tooltip variant='solid' arrow placement='right' title={callConversationLegend}>
<Tooltip disableInteractive variant='solid' arrow placement='right' title={callConversationLegend}>
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={props.sx}>
Call
</Button>
@@ -48,7 +48,7 @@ import { parseBlocks } from './blocks';
// How long is the user collapsed message
const USER_COLLAPSED_LINES: number = 8;
// Enable the automatic menu on text selection
// Enable the menu on text selection
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
// Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu.
@@ -214,7 +214,7 @@ export function ChatMessage(props: {
isBottom?: boolean, noBottomBorder?: boolean,
isImagining?: boolean, isSpeaking?: boolean,
onConversationBranch?: (messageId: string) => void,
onConversationRestartFrom?: (messageId: string, offset: number) => void,
onConversationRestartFrom?: (messageId: string, offset: number) => Promise<void>,
onConversationTruncate?: (messageId: string) => void,
onMessageDelete?: (messageId: string) => void,
onMessageEdit?: (messageId: string, text: string) => void,
@@ -302,10 +302,10 @@ export function ChatMessage(props: {
closeOperationsMenu();
};
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
const handleOpsConversationRestartFrom = async (e: React.MouseEvent) => {
e.preventDefault();
props.onConversationRestartFrom && props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
closeOperationsMenu();
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
};
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
@@ -495,16 +495,18 @@ export function ChatMessage(props: {
{/* Edit / Blocks */}
{isEditing
{isEditing ? (
? <InlineTextarea
<InlineTextarea
initialText={messageText} onEdit={handleTextEdited}
sx={{
...blockSx,
flexGrow: 1,
}} />
: <Box
) : (
<Box
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
sx={{
@@ -550,7 +552,7 @@ export function ChatMessage(props: {
: 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} sx={typographySx} />
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
: <RenderText key={'text-' + index} textBlock={block} sx={typographySx} />)}
{isCollapsed && (
@@ -564,7 +566,7 @@ export function ChatMessage(props: {
{/*</Chip>*/}
</Box>
}
)}
{/* Overlay copy icon */}
@@ -635,7 +637,7 @@ export function ChatMessage(props: {
{!!props.onConversationBranch && <ListDivider />}
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Visualize ...
Diagram ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
@@ -673,7 +675,7 @@ export function ChatMessage(props: {
</MenuItem>
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
Visualize ...
Diagram ...
</MenuItem>}
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
@@ -1,48 +1,53 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, useTheme } from '@mui/joy';
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 ReactMarkdown = React.lazy(async () => {
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={[remarkGfmModule.default]} {...props} />
);
const ReactMarkdownWithRemarkGfm = (props: any) =>
<markdownModule.default remarkPlugins={remarkPlugins} {...props} />;
return { default: ReactMarkdownWithRemarkGfm };
});
export const RenderMarkdown = (props: { textBlock: TextBlock, sx?: SxProps }) => {
const theme = useTheme();
export const RenderMarkdown = (props: { textBlock: TextBlock }) => {
return (
<Box
className={`markdown-body ${theme.palette.mode === 'dark' ? 'markdown-body-dark' : 'markdown-body-light'}`}
sx={{
mx: '0.75rem !important', // margin: 1.5 like other blocks
'& table': {
width: 'inherit !important', // un-break auto-width (tables have 'max-content', which overflows)
},
'--color-canvas-default': 'transparent !important', // remove the default background color
// NOTE: the following are not needed because the CSS is under our control, and we
// disabled the redefintions there
// fontFamily: `inherit !important`, // use the default font family
...(props.sx || {}),
}}>
{/* Using React.Suspense / React.Lazy loading this */}
<RenderMarkdownBox className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */ >
<React.Suspense fallback={<div>Loading...</div>}>
<ReactMarkdown>{props.textBlock.content}</ReactMarkdown>
<DynamicReactGFM>
{props.textBlock.content}
</DynamicReactGFM>
</React.Suspense>
</Box>
</RenderMarkdownBox>
);
};
@@ -1,8 +1,10 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Typography } from '@mui/joy';
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, 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';
@@ -144,13 +146,15 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
<Box sx={{ maxWidth: bpMaxWidth }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 2, mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 1 }}>
<Typography level='title-sm'>
AI Persona
</Typography>
<Button variant='plain' color='neutral' size='sm' onClick={toggleEditMode}>
{editMode ? 'Done' : 'Edit'}
</Button>
<Tooltip disableInteractive title={editMode ? 'Done Editing' : 'Edit Tiles'}>
<IconButton size='sm' onClick={toggleEditMode}>
{editMode ? <DoneIcon /> : <EditIcon />}
</IconButton>
</Tooltip>
</Box>
<Grid container spacing={tileSpacing} sx={{ justifyContent: 'flex-start' }}>
+2 -2
View File
@@ -1,7 +1,7 @@
import { DLLMId } from '~/modules/llms/store-llms';
import { SystemPurposeId } from '../../../data';
import { autoSuggestions } from '~/modules/aifn/autosuggestions/autoSuggestions';
import { autoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
import { llmStreamingChatGenerate } from '~/modules/llms/llm.client';
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
@@ -42,7 +42,7 @@ export async function runAssistantUpdatingState(conversationId: string, history:
startTyping(conversationId, null);
if (autoTitleChat)
autoTitle(conversationId);
conversationAutoTitle(conversationId, false);
if (autoSuggestDiagrams || autoSuggestQuestions)
autoSuggestions(conversationId, assistantMessageId, autoSuggestDiagrams, autoSuggestQuestions);
+72
View File
@@ -0,0 +1,72 @@
import * as React from 'react';
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { useRouterQuery } from '~/common/app.routes';
import { DrawHeading } from './components/DrawHeading';
import { DrawUnconfigured } from './components/DrawUnconfigured';
import { Gallery } from './Gallery';
import { TextToImage } from './TextToImage';
export interface AppDrawIntent {
backTo: 'app-chat';
}
export function AppDraw() {
// state
const [_drawIntent, setDrawIntent] = React.useState<AppDrawIntent | null>(null);
const [section, setSection] = React.useState<number>(0);
// external state
const isMobile = useIsMobile();
const query = useRouterQuery<Partial<AppDrawIntent>>();
const { activeProviderId, mayWork, providers, setActiveProviderId } = useCapabilityTextToImage();
// [effect] set intent from the query parameters
React.useEffect(() => {
if (query.backTo) {
setDrawIntent({
backTo: query.backTo || 'app-chat',
});
}
}, [query]);
// const hasIntent = !!drawIntent && !!drawIntent.backTo;
// usePluggableOptimaLayout(null, null, null, 'aa');
return <>
{/* The container is a 100dvh, flex column with App bg (see `pageCoreSx`) */}
<DrawHeading
section={section}
setSection={setSection}
showSections
sx={{
px: { xs: 1, md: 2 },
py: { xs: 1, md: 6 },
}}
/>
{!mayWork && <DrawUnconfigured />}
{mayWork && <Gallery />}
{mayWork && (
<TextToImage
isMobile={isMobile}
providers={providers}
activeProviderId={activeProviderId}
setActiveProviderId={setActiveProviderId}
/>
)}
</>;
}
+10
View File
@@ -0,0 +1,10 @@
import { AppPlaceholder } from '../AppPlaceholder';
import * as React from 'react';
export function Gallery() {
return (
<AppPlaceholder text='Drawing App is under development. v1.12 or v1.13.' />
);
}
+80
View File
@@ -0,0 +1,80 @@
import * as React from 'react';
import { Box } from '@mui/joy';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { DesignerPrompt, PromptDesigner } from './components/PromptDesigner';
import { ProviderConfigure } from './components/ProviderConfigure';
export function TextToImage(props: {
isMobile: boolean,
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void
}) {
// state
const [prompts, setPrompts] = React.useState<DesignerPrompt[]>([]);
const handleStopDrawing = React.useCallback(() => {
setPrompts([]);
}, []);
const handlePromptEnqueue = React.useCallback((prompt: DesignerPrompt) => {
setPrompts(prompts => [...prompts, prompt]);
}, []);
return <>
<ProviderConfigure
providers={props.providers}
activeProviderId={props.activeProviderId}
setActiveProviderId={props.setActiveProviderId}
sx={{
p: { xs: 1, md: 2 },
}}
/>
{/* Placeholder */}
<Box sx={{
flexGrow: 1,
overflowY: 'auto',
backgroundColor: 'background.level2',
// border: '1px solid blue',
p: { xs: 1, md: 2 },
}}>
<Box sx={{
my: 'auto',
display: 'flex', flexDirection: 'column', alignItems: 'center',
border: '1px solid red',
minHeight: '300px',
}}>
{prompts.map((prompt, index) => (
<Box key={index} sx={{
border: '1px solid green',
width: '100%',
}}>
{prompt.prompt}
</Box>
))}
</Box>
</Box>
<PromptDesigner
isMobile={props.isMobile}
queueLength={prompts.length}
onDrawingStop={handleStopDrawing}
onPromptEnqueue={handlePromptEnqueue}
sx={{
borderTop: `1px solid`,
borderTopColor: 'divider',
p: { xs: 1, md: 2 },
}}
/>
</>;
}
+94
View File
@@ -0,0 +1,94 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, ButtonGroup, Chip, Divider, IconButton, Typography } from '@mui/joy';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import { niceShadowKeyframes } from '../../call/Contacts';
export function DrawHeading(props: {
section: number,
setSection: (section: number) => void,
showSections?: boolean,
sx?: SxProps,
}) {
return (
<Box sx={{
display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 3,
...props.sx,
}}>
{/* Flashy Button */}
<IconButton
variant='soft' color='success'
sx={{
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
borderRadius: '50%',
pointerEvents: 'none',
backgroundColor: 'background.popup',
animation: `${niceShadowKeyframes} 5s infinite`,
}}>
<FormatPaintIcon />
</IconButton>
{/* Messaging */}
<Box>
<Typography level='title-lg'>
Draw with AI
</Typography>
<Typography level='title-sm' sx={{ mt: 1 }}>
Turn your ideas into images
</Typography>
<Chip variant='outlined' size='sm' sx={{ px: 1, py: 0.5, mt: 0.25, ml: -1, textWrap: 'wrap' }}>
Multi-models, AI assisted
</Chip>
</Box>
{/* Section Selector*/}
{props.showSections && (
<Divider sx={{ flex: 1 }}>
<ButtonGroup
// color='primary'
size='sm'
orientation='horizontal'
sx={{
mx: 'auto',
backgroundColor: 'background.surface',
boxShadow: 'sm',
'& > button': {
minWidth: 104,
},
}}
>
<Button
variant={props.section === 0 ? 'solid' : 'plain'}
onClick={() => props.setSection(0)}
>
Generate
</Button>
<Button
disabled
variant={props.section === 1 ? 'solid' : 'plain'}
onClick={() => props.setSection(1)}
>
Refine
</Button>
{/*<Button*/}
{/* disabled*/}
{/* variant={props.section === 2 ? 'solid' : 'plain'}*/}
{/* onClick={() => props.setSection(1)}*/}
{/*>*/}
{/* Gallery*/}
{/*</Button>*/}
</ButtonGroup>
</Divider>
)}
</Box>
);
}
@@ -0,0 +1,30 @@
import * as React from 'react';
import { Button, Card, CardActions, CardContent, Typography } from '@mui/joy';
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
export function DrawUnconfigured() {
// external state
const { openPreferencesTab } = useOptimaLayout();
const handleConfigure = () => openPreferencesTab(PreferencesTab.Draw);
return (
<Card variant='outlined' color='warning'>
<CardContent>
<Typography>
<strong>Text-to-Image</strong> does not seem available.
Please configure one service, such as an OpenAI LLM service, or the Prodia service.
</Typography>
</CardContent>
<CardActions buttonFlex='0'>
<Button onClick={handleConfigure} sx={{ minWidth: '160px' }}>
Configure
</Button>
</CardActions>
</Card>
);
}
+256
View File
@@ -0,0 +1,256 @@
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 AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import MoreTimeIcon from '@mui/icons-material/MoreTime';
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
import { lineHeightTextarea } from '~/common/app.theme';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { animationStopEnter } from '../../chat/components/composer/Composer';
import { useDrawIdeas } from '../state/useDrawIdeas';
const promptButtonClass = 'PromptDesigner-button';
export interface DesignerPrompt {
prompt: string,
// tags: string[],
// effects: string[],
// style: string[],
// detail: string[],
// restyle: string[],
// [key: string]: string[],
}
export function PromptDesigner(props: {
isMobile: boolean,
queueLength: number,
onDrawingStop: () => void,
onPromptEnqueue: (prompt: DesignerPrompt) => void,
sx?: SxProps,
}) {
// state
const [nextPrompt, setNextPrompt] = React.useState<string>('');
// external state
const { currentIdea, nextRandomIdea } = useDrawIdeas();
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
// derived state
const userHasText = !!nextPrompt;
const nonEmptyPrompt = nextPrompt || currentIdea.prompt;
const queueLength = props.queueLength;
const qBusy = queueLength > 0;
// Drawing
const { onDrawingStop, onPromptEnqueue } = props;
const handleDrawStop = React.useCallback(() => {
onDrawingStop();
}, [onDrawingStop]);
const handlePromptEnqueue = React.useCallback(() => {
setNextPrompt('');
onPromptEnqueue({
prompt: nonEmptyPrompt,
});
}, [nonEmptyPrompt, onPromptEnqueue]);
// Typing
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNextPrompt(e.target.value);
// setUserHasChanged(true);
}, []);
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Check for the primary Draw key
if (e.key !== 'Enter')
return;
// Shift: toggles the 'enter is newline'
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
if (userHasText)
handlePromptEnqueue();
return e.preventDefault();
}
}, [enterIsNewline, handlePromptEnqueue, userHasText]);
// 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');
};
return (
// PromptFx Buttons
<Box sx={{
flex: 1,
margin: 1,
// layout
display: 'flex', flexFlow: 'row wrap', alignItems: 'center', gap: 1,
// Buttons (tagged by class)
[`& .${promptButtonClass}`]: {
'--Button-gap': '1.2rem',
transition: 'background-color 0.2s, color 0.2s',
minWidth: 100,
},
}}>
{/* Change / Use idea */}
<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>
{/* PromptFx */}
<Button
variant='soft' color='success'
disabled={!userHasText}
className={promptButtonClass}
endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}
onClick={handleClickMissing}
sx={{ borderRadius: 'sm' }}
>
Detail
</Button>
<Button
variant='soft' color='success'
disabled={!userHasText}
className={promptButtonClass}
endDecorator={<AutoFixHighIcon sx={{ fontSize: '20px' }} />}
onClick={handleClickMissing}
sx={{ borderRadius: 'sm' }}
>
Restyle
</Button>
{/* Char counter */}
{/*<Typography level='body-sm' sx={{ ml: 'auto', mr: 1 }}>*/}
{/* {!!nonEmptyPrompt?.length && nonEmptyPrompt.length.toLocaleString()}*/}
{/*</Typography>*/}
</Box>
);
}, [currentIdea.prompt, nextRandomIdea, 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}>
<Textarea
variant='outlined'
// size='sm'
autoFocus
minRows={props.isMobile ? 4 : 3}
maxRows={props.isMobile ? 6 : 8}
placeholder={currentIdea.prompt}
value={nextPrompt}
onChange={handleTextareaTextChange}
onKeyDown={handleTextareaKeyDown}
startDecorator={textEnrichComponents}
slotProps={{
textarea: {
enterKeyHint: enterIsNewline ? 'enter' : 'send',
// ref: props.designerTextAreaRef,
},
}}
sx={{
boxShadow: 'lg',
'&:focus-within': { backgroundColor: 'background.popup' },
lineHeight: lineHeightTextarea,
}}
/>
</Grid>
{/* [Desktop: Right, Mobile: Bottom] Buttons */}
<Grid xs={12} md={3} spacing={1}>
<Box sx={{ display: 'grid', gap: 1 }}>
{/* Draw */}
{!qBusy ? (
<Button
key='draw-queue'
variant='solid' color='primary'
endDecorator={<FormatPaintIcon />}
onClick={handlePromptEnqueue}
sx={{
animation: `${animationStopEnter} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-primary-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Draw
</Button>
) : <>
<Button
key='draw-terminate'
variant='soft' color='warning'
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
onClick={handleDrawStop}
sx={{
// animation: `${animationStopEnter} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-warning-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Stop
</Button>
<Button
key='draw-queueup'
variant='soft'
color='primary'
endDecorator={<MoreTimeIcon sx={{ fontSize: 18 }} />}
onClick={handlePromptEnqueue}
sx={{
animation: `${animationStopEnter} 0.1s ease-out`,
boxShadow: !props.isMobile ? `0 8px 24px -4px rgb(var(--joy-palette-primary-mainChannel) / 20%)` : 'none',
justifyContent: 'space-between',
}}
>
Enqueue
</Button>
</>}
</Box>
</Grid>
</Grid> {/* Prompt Designer */}
{/* Modals... */}
{/* ... */}
</Box>
);
}
@@ -0,0 +1,88 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Button, Card, CardContent } from '@mui/joy';
import ConstructionIcon from '@mui/icons-material/Construction';
import { DallESettings } from '~/modules/t2i/dalle/DallESettings';
import { ProdiaSettings } from '~/modules/t2i/prodia/ProdiaSettings';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { ProviderSelect } from './ProviderSelect';
export function ProviderConfigure(props: {
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void,
sx?: SxProps,
}) {
// state
const [_open, setOpen] = React.useState(false);
// derived state
const { activeProviderId, providers } = props;
const { ProviderConfig } = React.useMemo(() => {
const provider = providers.find(provider => provider.id === activeProviderId);
const ProviderConfig: React.FC | null = provider?.vendor === 'openai' ? DallESettings : provider?.vendor === 'prodia' ? ProdiaSettings : null;
return {
ProviderConfig,
};
}, [activeProviderId, providers]);
const open = _open && !!ProviderConfig;
const handleToggleOpen = React.useCallback(() => {
setOpen(on => !on);
}, []);
return (
<Box
sx={{
flex: 0,
display: 'grid',
...props.sx,
}}
>
{/* Service / Options Button */}
<Box sx={{ display: 'flex', flexFlow: 'row wrap', gap: 1 }}>
<ProviderSelect
providers={props.providers}
activeProviderId={props.activeProviderId}
setActiveProviderId={props.setActiveProviderId}
/>
<Button
variant={open ? 'solid' : 'outlined'}
color={open ? 'primary' : 'neutral'}
endDecorator={<ConstructionIcon />}
onClick={handleToggleOpen}
sx={{ backgroundColor: open ? undefined : 'background.surface' }}
>
Options
</Button>
</Box>
{/* Service-Specific Configuration */}
{open && (
<Card variant='outlined' sx={{ my: 1, borderTopColor: 'primary.softActiveBg' }}>
<CardContent sx={{ gap: 2 }}>
<ProviderConfig />
</CardContent>
</Card>
)}
</Box>
);
}
@@ -0,0 +1,62 @@
import * as React from 'react';
import { FormControl, FormLabel, ListItemDecorator, Option, Select } from '@mui/joy';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import type { TextToImageProvider } from '~/common/components/useCapabilities';
import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon';
import { hideOnMobile } from '~/common/app.theme';
export function ProviderSelect(props: {
providers: TextToImageProvider[],
activeProviderId: string | null,
setActiveProviderId: (providerId: (string | null)) => void
}) {
// create the options
const providerOptions = React.useMemo(() => {
const options: { label: string, value: string, configured: boolean, Icon?: React.FC }[] = [];
props.providers.forEach(provider => {
options.push({
label: provider.label + (provider.painter !== provider.label ? ` ${provider.painter}` : ''),
value: provider.id,
configured: provider.configured,
Icon: provider.vendor === 'openai' ? OpenAIIcon : FormatPaintIcon,
});
});
return options;
}, [props.providers]);
return (
<FormControl orientation='horizontal' sx={{ justifyContent: 'start', alignItems: 'center' }}>
<FormLabel sx={hideOnMobile}>
Service:
</FormLabel>
<Select
variant='outlined'
value={props.activeProviderId}
placeholder='Select a service'
onChange={(_event, value) => value && props.setActiveProviderId(value)}
// startDecorator={<FormatPaintIcon sx={{ display: { xs: 'none', sm: 'inherit' } }} />}
sx={{
minWidth: '12rem',
}}
>
{providerOptions.map(option => (
<Option key={option.value} value={option.value} disabled={!option.configured}>
<ListItemDecorator>
{!!option.Icon && <option.Icon />}
</ListItemDecorator>
{option.label}
{!option.configured && ' (not configured)'}
</Option>
))}
</Select>
</FormControl>
);
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from 'react';
interface DrawIdea {
author: string,
prompt: string,
short: string,
score: number,
}
/**
* The following are drawing ideas, offered to people.
* Generated with: https://github.com/enricoros/big-AGI/issues/311#issuecomment-1909473441
*/
const allIdeas: DrawIdea[] = [
{ author: 'Beatriz', prompt: 'An intricate book nook with miniature worlds nestled between classic tomes, casting a magical glow over a cozy reading corner.', short: 'Magical book nook miniature', score: 46 },
{ author: 'Charlie', prompt: 'A powerful black-and-white portrait of diverse hands united, each marked with a word of hope, capturing the essence of solidarity.', short: 'United hands with words of hope', score: 45 },
{ author: 'Disha', prompt: 'A serene garden oasis with a violin resting against an ancient tree, as if the music itself could make flowers bloom.', short: 'Garden oasis with violin', score: 43 },
{ author: 'Fatima', prompt: 'A night sky canvas with constellations drawn by the city lights below, a blend of urban design and celestial wonder.', short: 'Night sky and city light constellations', score: 46 },
{ author: 'Hana', prompt: 'A vibrant mural of the Earth, with real plants growing out of the painting, blurring the lines between art and environmental activism.', short: 'Earth mural with real plants', score: 47 },
{ author: 'Julia', prompt: 'A child\'s hand gently holding a bird, with the shadow cast forming a heart, capturing a moment of pure connection with nature.', short: 'Heart shadow with bird in hand', score: 49 },
{ author: 'Julia', prompt: 'A whimsical photo of a deck of cards mid-shuffle, with birds seemingly flying out of the fanned cards into a sunset sky.', short: 'Cards with birds in sunset', score: 48 },
{ author: 'Lina', prompt: 'A stop-motion of a pottery wheel spinning, each frame capturing a different historical era\'s pottery style coming to life.', short: 'Pottery wheel through historical eras', score: 45 },
{ author: 'Mason', prompt: 'A heartwarming snapshot of a loyal golden retriever patiently waiting at a train station, its reflection mirroring in the glossy floor, encapsulating the themes of loyalty and anticipation.', short: 'Loyal dog awaiting its owner', score: 47 },
{ author: 'Nia', prompt: 'A fairytale book with plants growing from the pages, creating a living story that captures the imagination of both young and old.', short: 'Fairytale book with living plants', score: 50 },
{ author: 'Omar', prompt: 'A building being \'drawn\' in the sky by a crane, as if architecture is being sketched in real-time.', short: 'Building \'drawn\' in the sky', score: 43 },
{ author: 'Priya', prompt: 'A photo capturing the fluid motion of a traditional dance, with colorful fabric swirling around the dancer like a living painting.', short: 'Traditional dance with colorful fabric', score: 43 },
{ author: 'Quin', prompt: 'A breathtaking summit view with a single flag planted, the colors of which morph into a vibrant time-lapse of the sky changing.', short: 'Summit view with time-lapse sky', score: 45 },
{ author: 'Quin', prompt: 'A cliffside yoga pose with the sun setting into the ocean below, embodying the perfect balance between adventure and tranquility.', short: 'Cliffside yoga at sunset', score: 48 },
{ author: 'Rosa', prompt: 'An experiment in color: vibrant chemical reactions captured in crystal-clear glassware, showcasing the beauty of science.', short: 'Colorful science reactions', score: 48 },
{ author: 'Samir', prompt: 'A stunning photo of ancient script carved into a mountain, juxtaposed with the modern skyline in the distance.', short: 'Ancient script and modern skyline', score: 48 },
{ author: 'Sofia', prompt: 'A whimsical and vibrant image of a capybara sculpted entirely from pink cotton candy, set against a minimalist backdrop with splashes of bright, contrasting colors.', short: 'Cotton candy capybara in color splashes', score: 49 },
{ author: 'Tanya', prompt: 'A mural blending street art with digital pixels, where the physical wall seems to dissolve into a virtual game world.', short: 'Street art to digital game world mural', score: 45 },
{ author: 'Tanya', prompt: 'A paintbrush touching a canvas, where each stroke animates into a scene from an indie game, illustrating the art behind the code.', short: 'Animated indie game art', score: 50 },
].sort(() => Math.random() - 0.5); // shuffle the ideas, once
function _randomDrawIdea() {
return allIdeas[Math.floor(Math.random() * allIdeas.length)];
}
export function useDrawIdeas() {
// state
const [currentIdea, setCurrentIdea] = React.useState<DrawIdea>(_randomDrawIdea());
const nextRandomIdea = React.useCallback(() => {
setCurrentIdea(prevIdea => {
let nextIdea = _randomDrawIdea();
while (nextIdea === prevIdea)
nextIdea = _randomDrawIdea();
return nextIdea;
});
}, []);
return { allIdeas, currentIdea, nextRandomIdea };
}
-108
View File
@@ -1,108 +0,0 @@
import * as React from 'react';
import Head from 'next/head';
import { useQuery } from '@tanstack/react-query';
import { Box, Typography } from '@mui/joy';
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
import { Brand } from '~/common/app.config';
import { InlineError } from '~/common/components/InlineError';
import { LogoProgress } from '~/common/components/LogoProgress';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle } from '~/common/state/store-chats';
import { themeBgAppDarker } from '~/common/app.theme';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { AppChatLinkDrawerContent } from './AppChatLinkDrawerContent';
import { AppChatLinkMenuItems } from './AppChatLinkMenuItems';
import { ViewChatLink } from './ViewChatLink';
const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) =>
<Box sx={{
backgroundColor: props.backgroundColor,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
{props.children}
</Box>;
const ShowLoading = () =>
<Centerer backgroundColor={themeBgAppDarker}>
<LogoProgress showProgress={true} />
<Typography level='title-sm' sx={{ mt: 2 }}>
Loading Chat...
</Typography>
</Centerer>;
const ShowError = (props: { error: any }) =>
<Centerer backgroundColor={themeBgAppDarker}>
<InlineError error={props.error} severity='warning' />
</Centerer>;
/**
* Fetches the object using tRPC
* Note: we don't have react-query for the Node functions, so we use the immediate API here,
* and wrap it in a react-query hook
*/
async function fetchStoredChatV1(objectId: string) {
// fetch
const result = await apiAsyncNode.trade.storageGet.query({ objectId });
if (result.type === 'error')
throw result.error;
// validate a CHAT_V1
const { dataType, dataObject, storedAt, expiresAt } = result;
if (dataType !== 'CHAT_V1')
throw new Error('Unsupported data type: ' + dataType);
// convert to DConversation
const restored = createConversationFromJsonV1(dataObject as any);
if (!restored)
throw new Error('Could not restore conversation');
return { conversation: restored, storedAt, expiresAt };
}
export function AppChatLink(props: { linkId: string }) {
// external state
const { data, isError, error, isLoading } = useQuery({
enabled: !!props.linkId,
queryKey: ['chat-link', props.linkId],
queryFn: () => fetchStoredChatV1(props.linkId),
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
});
// const hasLinkItems = useHasChatLinkItems();
// pluggable UI
const drawerContent = React.useMemo(() => <AppChatLinkDrawerContent />, []);
const menuItems = React.useMemo(() => <AppChatLinkMenuItems />, []);
usePluggableOptimaLayout(drawerContent, null, menuItems, 'AppChatLink');
const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Chat Link';
return <>
<Head>
<title>{capitalizeFirstLetter(pageTitle)} · {Brand.Title.Base} 🚀</title>
</Head>
{isLoading
? <ShowLoading />
: isError
? <ShowError error={error} />
: !!data?.conversation
? <ViewChatLink conversation={data.conversation} storedAt={data.storedAt} expiresAt={data.expiresAt} />
: <Centerer backgroundColor={themeBgAppDarker} />}
</>;
}
@@ -1,72 +0,0 @@
import * as React from 'react';
import TimeAgo from 'react-timeago';
import { Box, ListDivider, ListItem, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useChatLinkItems } from '~/modules/trade/store-module-trade';
import { Brand } from '~/common/app.config';
import { Link } from '~/common/components/Link';
import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
/**
* Drawer Items are all the links already shared, for quick access.
* This is stores in the Trade Store (local storage).
*/
export function AppChatLinkDrawerContent() {
// external state
const { closeDrawerOnMobile } = useOptimaDrawers();
const chatLinkItems = useChatLinkItems()
.slice()
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const notEmpty = chatLinkItems.length > 0;
return <PageDrawerList>
{notEmpty && (
<ListItemButton
onClick={closeDrawerOnMobile}
component={Link} href={ROUTE_INDEX} noLinkStyle
>
<ListItemDecorator><ArrowBackIcon /></ListItemDecorator>
{Brand.Title.Base}
</ListItemButton>
)}
{notEmpty && <ListDivider />}
<ListItem>
<Typography level='body-sm'>
{notEmpty ? 'Links shared by you' : 'No prior shared links'}
</Typography>
</ListItem>
{notEmpty && <Box sx={{ overflowY: 'auto' }}>
{chatLinkItems.map(item => (
<ListItemButton
key={'chat-link-' + item.objectId}
component={Link} href={getChatLinkRelativePath(item.objectId)} noLinkStyle
sx={{
display: 'flex', flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Typography level='title-sm'>
{item.chatTitle || 'Untitled Chat'}
</Typography>
<Typography level='body-xs'>
<TimeAgo date={item.createdAt} />
</Typography>
</ListItemButton>
))}
</Box>}
</PageDrawerList>;
}
+267
View File
@@ -0,0 +1,267 @@
import * as React from 'react';
import Head from 'next/head';
import { useQuery } from '@tanstack/react-query';
import { Box, Button, Card, CardContent, Divider, Input, Typography } from '@mui/joy';
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
import { forgetChatLinkItem, useSharedChatLinkItems } from '~/modules/trade/link/store-link';
import { Brand } from '~/common/app.config';
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
import { GoodModal } from '~/common/components/GoodModal';
import { InlineError } from '~/common/components/InlineError';
import { LogoProgress } from '~/common/components/LogoProgress';
import { apiAsyncNode } from '~/common/util/trpc.client';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle } from '~/common/state/store-chats';
import { themeBgAppDarker } from '~/common/app.theme';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { LinkChat } from './LinkChat';
import { LinkChatDrawer } from './LinkChatDrawer';
import { LinkChatMenuItems } from './LinkChatMenuItems';
import { addSnackbar } from '~/common/components/useSnackbarsStore';
import { navigateToChatLinkList } from '~/common/app.routes';
const SPECIAL_LIST_PAGE_ID = 'list';
const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) =>
<Box sx={{
backgroundColor: props.backgroundColor,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
flexGrow: 1,
}}>
{props.children}
</Box>;
const ListPlaceholder = (props: { hasLinks: boolean }) =>
<Box sx={{ p: { xs: 3, md: 6 } }}>
<Card>
<CardContent>
<Typography level='title-md'>
Shared Conversations
</Typography>
<Typography level='body-sm'>
{props.hasLinks
? 'Here you can see formely exported shared conversations. Please select a conversation from the drawer.'
: 'No shared conversations found. Please export a conversation from this browser first.'}
</Typography>
</CardContent>
</Card>
</Box>;
const ShowLoading = () =>
<Centerer backgroundColor={themeBgAppDarker}>
<LogoProgress showProgress={true} />
<Typography level='title-sm' sx={{ mt: 2 }}>
Loading Chat...
</Typography>
</Centerer>;
const ShowError = (props: { error: any }) =>
<Centerer backgroundColor={themeBgAppDarker}>
<InlineError error={props.error} severity='warning' />
</Centerer>;
/**
* Fetches the object using tRPC
* Note: we don't have react-query for the Node functions, so we use the immediate API here,
* and wrap it in a react-query hook
*/
async function fetchStoredChatV1(objectId: string | null) {
if (!objectId)
throw new Error('No Stored Chat');
// fetch
const result = await apiAsyncNode.trade.storageGet.query({ objectId });
if (result.type === 'error')
throw result.error;
// validate a CHAT_V1
const { dataType, dataObject, storedAt, expiresAt } = result;
if (dataType !== 'CHAT_V1')
throw new Error('Unsupported data type: ' + dataType);
// convert to DConversation
const restored = createConversationFromJsonV1(dataObject as any);
if (!restored)
throw new Error('Could not restore conversation');
return { conversation: restored, storedAt, expiresAt };
}
export function AppLinkChat(props: { chatLinkId: string | null }) {
// state
const [deleteConfirmId, setDeleteConfirmId] = React.useState<string | null>(null);
const [deleteConfirmKey, setDeleteConfirmKey] = React.useState<string | null>(null);
const [showDeletionKeys, setShowDeletionKeys] = React.useState<boolean>(false);
// derived state 1
const isListPage = props.chatLinkId === SPECIAL_LIST_PAGE_ID;
const linkId = isListPage ? null : props.chatLinkId;
// external state
const sharedChatLinkItems = useSharedChatLinkItems();
const { data, isError, error, isLoading } = useQuery({
enabled: !!linkId,
queryKey: ['chat-link', linkId],
queryFn: () => fetchStoredChatV1(linkId),
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
});
// derived state 2
const hasLinks = sharedChatLinkItems.length > 0;
const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Shared Chat'; // also the (nav) App title
const handleDelete = React.useCallback(async (objectId: string, deletionKey: string) => {
setDeleteConfirmId(null);
setDeleteConfirmKey(null);
// delete from storage
let err: string | null = null;
try {
const response = await apiAsyncNode.trade.storageDelete.mutate({ objectId, deletionKey });
if (response.type === 'error')
err = response.error || 'unknown error';
} catch (error: any) {
err = error?.message ?? error?.toString() ?? 'unknown error';
}
// delete from local store
if (!err)
forgetChatLinkItem(objectId);
// UI feedback
addSnackbar({
key: err ? 'chatlink-deletion-issue' : 'chatlink-deletion-success',
type: err ? 'issue' : 'success',
message: err ? 'Could not delete link: ' + err : 'Link deleted successfully',
});
// move to the list page
if (!err)
void navigateToChatLinkList();
}, []);
// Delete: ID confirmation
const handleConfirmDeletion = React.useCallback((linkId: string) => linkId && setDeleteConfirmId(linkId), []);
const handleCancelDeletion = React.useCallback(() => setDeleteConfirmId(null), []);
// Delete: Key confirmation
const handleConfirmDeletionKey = React.useCallback(() => {
if (!deleteConfirmId) return;
// if we already have the key, we can delete right away
const item = sharedChatLinkItems.find(i => i.objectId === deleteConfirmId);
let deletionKey = (item && item.deletionKey) ? item.deletionKey : null;
if (deletionKey)
return handleDelete(deleteConfirmId, deletionKey);
// otherwise ask for the key
setDeleteConfirmKey('');
}, [deleteConfirmId, handleDelete, sharedChatLinkItems]);
const handleCancelDeletionKey = React.useCallback(() => {
setDeleteConfirmId(null);
setDeleteConfirmKey(null);
}, []);
const handleDeletionKeyConfirmed = React.useCallback(() => {
deleteConfirmId && deleteConfirmKey && handleDelete(deleteConfirmId, deleteConfirmKey);
}, [deleteConfirmId, deleteConfirmKey, handleDelete]);
// pluggable UI
const drawerContent = React.useMemo(() => <LinkChatDrawer
activeLinkId={linkId}
sharedChatLinkItems={sharedChatLinkItems}
showDeletionKeys={showDeletionKeys}
onDeleteLink={handleConfirmDeletion}
/>, [handleConfirmDeletion, linkId, sharedChatLinkItems, showDeletionKeys]);
const menuItems = React.useMemo(() => <LinkChatMenuItems
activeLinkId={linkId}
showDeletionKeys={showDeletionKeys}
onDeleteLink={handleConfirmDeletion}
onToggleDeletionKeys={() => setShowDeletionKeys(on => !on)}
/>, [handleConfirmDeletion, linkId, showDeletionKeys]);
usePluggableOptimaLayout(drawerContent, null, menuItems, 'AppChatLink');
return <>
<Head>
<title>{capitalizeFirstLetter(pageTitle)} · {Brand.Title.Base} 🚀</title>
</Head>
{isListPage
? <ListPlaceholder hasLinks={hasLinks} />
: isLoading
? <ShowLoading />
: isError
? <ShowError error={error} />
: !!data?.conversation
? <LinkChat conversation={data.conversation} storedAt={data.storedAt} expiresAt={data.expiresAt} />
: <Centerer backgroundColor={themeBgAppDarker} />}
{/* Delete confirmation */}
{!!deleteConfirmId && (deleteConfirmKey === null) && (
<ConfirmationModal
onClose={handleCancelDeletion} onPositive={handleConfirmDeletionKey}
confirmationText='Are you sure you want to delete this link?'
positiveActionText={'Yes, Delete'}
/>
)}
{/* Deletion Key Input */}
{!!deleteConfirmId && (deleteConfirmKey !== null) && (
<GoodModal
open title='Enter Deletion Key'
titleStartDecorator={<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />}
onClose={handleCancelDeletionKey}
hideBottomClose
>
<Divider />
<Typography level='body-md'>
You need to enter the original deletion key to delete this conversation.
</Typography>
<Input
value={deleteConfirmKey}
onChange={event => setDeleteConfirmKey(event.target.value)}
sx={{ flexGrow: 1 }}
/>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
<Button autoFocus variant='plain' color='neutral' onClick={handleCancelDeletionKey}>
Cancel
</Button>
<Button
variant='solid' color='danger'
disabled={!deleteConfirmKey.trim()}
onClick={handleDeletionKeyConfirmed}
sx={{ lineHeight: '1.5em' }}
>
Delete
</Button>
</Box>
</GoodModal>
)}
</>;
}
@@ -1,7 +1,7 @@
import * as React from 'react';
import TimeAgo from 'react-timeago';
import { Box, Button, Card, List, ListItem, Tooltip, Typography } from '@mui/joy';
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';
@@ -9,6 +9,7 @@ import { ScrollToBottom } from '../chat/components/scroll-to-bottom/ScrollToBott
import { useChatShowSystemMessages } from '../chat/store-app-chat';
import { Brand } from '~/common/app.config';
import { capitalizeFirstLetter } from '~/common/util/textUtils';
import { conversationTitle, DConversation, useChatStore } from '~/common/state/store-chats';
import { launchAppChat } from '~/common/app.routes';
import { themeBgAppDarker } from '~/common/app.theme';
@@ -18,7 +19,7 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
/**
* Renders a chat link view with conversation details and messages.
*/
export function ViewChatLink(props: { conversation: DConversation, storedAt: Date, expiresAt: Date | null }) {
export function LinkChat(props: { conversation: DConversation, storedAt: Date, expiresAt: Date | null }) {
// state
const [cloning, setCloning] = React.useState<boolean>(false);
@@ -76,23 +77,26 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
py: { xs: 4, md: 5, xl: 6 },
}}>
{/* Heading */}
<Box sx={{
{/* Title Card */}
<Card sx={{
display: 'flex', flexDirection: 'column',
backgroundColor: 'background.level1', borderRadius: 'xl', boxShadow: 'xs',
gap: 1,
px: { xs: 2.5, md: 3.5 },
py: { xs: 2, md: 3 },
// backgroundColor: 'background.level1',
// borderRadius: 'xl',
// boxShadow: 'xs',
px: 2.5,
maxWidth: '100%',
// animation: `${cssMagicSwapKeyframes} 0.4s cubic-bezier(0.22, 1, 0.36, 1)`,
}}>
<Typography level='h3' startDecorator={<TelegramIcon sx={{ fontSize: 'xl3', mr: 0.5 }} />}>
{conversationTitle(props.conversation, 'Chat')}
</Typography>
<Typography level='title-sm'>
Uploaded <TimeAgo date={props.storedAt} />
{!!props.expiresAt && <>, expires <TimeAgo date={props.expiresAt} /></>}.
</Typography>
</Box>
<CardContent sx={{ gap: 1 }}>
<Typography level='h4' startDecorator={<TelegramIcon sx={{ fontSize: 'xl2' }} />}>
{capitalizeFirstLetter(conversationTitle(props.conversation, 'Chat'))}
</Typography>
<Typography level='body-xs'>
Uploaded <TimeAgo date={props.storedAt} />
{!!props.expiresAt && <>, expires <TimeAgo date={props.expiresAt} /></>}.
</Typography>
</CardContent>
</Card>
{/* Messages */}
<Card sx={{
@@ -143,7 +147,7 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
px: { xs: 2.5, md: 3.5 }, py: 2,
}}>
<Typography level='body-sm' ref={listBottomRef}>
Like the chat? Clone it and keep the talk going! 🚀
Like the chat? Import it and keep the talk going! 🚀
</Typography>
</ListItem>
@@ -164,7 +168,9 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
boxShadow: 'md',
}}
>
Clone on {Brand.Title.Base}
{hasExistingChat
? `Import as New`
: `Import on ${Brand.Title.Base}`}
</Button>
{hasExistingChat && (
@@ -175,7 +181,7 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
endDecorator={<TelegramIcon />}
onClick={() => handleClone(true)}
>
Replace Existing
Import Over
</Button>
</Tooltip>
)}
+100
View File
@@ -0,0 +1,100 @@
import * as React from 'react';
import TimeAgo from 'react-timeago';
import { Box, ListDivider, ListItem, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import type { SharedChatLinkItem } from '~/modules/trade/link/store-link';
import { Link } from '~/common/components/Link';
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
import { getChatLinkRelativePath } from '~/common/app.routes';
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
/**
* Drawer Items are all the links already shared, for quick access.
* This is stores in the Trade Store (local storage).
*/
export function LinkChatDrawer(props: {
activeLinkId: string | null,
sharedChatLinkItems: SharedChatLinkItem[]
showDeletionKeys: boolean,
onDeleteLink: (linkId: string) => void,
}) {
// external state
const { closeDrawer } = useOptimaDrawers();
// derived state
const { activeLinkId, onDeleteLink } = props;
const chatLinkItems = props.sharedChatLinkItems.toSorted((a, b) => b.createdAt.localeCompare(a.createdAt));
const hasLinks = chatLinkItems.length > 0;
const handleDeleteLink = React.useCallback(() => {
activeLinkId && onDeleteLink(activeLinkId);
}, [activeLinkId, onDeleteLink]);
return <>
<PageDrawerHeader
title='Your Shared Links'
onClose={closeDrawer}
/>
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
<ListItem>
<Typography level='body-sm'>
{hasLinks ? 'Links shared by you' : 'No prior shared links'}
</Typography>
</ListItem>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
{hasLinks && <Box sx={{ overflowY: 'auto' }}>
{chatLinkItems.map(item => (
<ListItemButton
key={'chat-link-' + item.objectId}
variant={activeLinkId === item.objectId ? 'soft' : undefined}
component={Link} href={getChatLinkRelativePath(item.objectId)} noLinkStyle
sx={{
display: 'flex', flexDirection: 'column',
alignItems: 'flex-start',
}}
>
<Box>
<Typography level='title-sm'>
{item.chatTitle || 'Untitled Chat'}
</Typography>
{props.showDeletionKeys && <Typography level='body-xs'>
Deletion Key: {item.deletionKey}
</Typography>}
<Typography level='body-xs'>
<TimeAgo date={item.createdAt} />
</Typography>
</Box>
</ListItemButton>
))}
</Box>}
</Box>
<ListDivider sx={{ mt: 0 }} />
<ListItemButton disabled={!hasLinks || !activeLinkId} onClick={handleDeleteLink}>
<ListItemDecorator>
<DeleteOutlineIcon />
</ListItemDecorator>
Delete
</ListItemButton>
</PageDrawerList>
</>;
}
@@ -1,7 +1,8 @@
import * as React from 'react';
import { shallow } from 'zustand/shallow';
import { MenuItem, Switch, Typography } from '@mui/joy';
import { ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import { useUIPreferencesStore } from '~/common/state/store-ui';
@@ -11,7 +12,12 @@ import { useChatShowSystemMessages } from '../chat/store-app-chat';
/**
* Menu Items are the settings for the chat.
*/
export function AppChatLinkMenuItems() {
export function LinkChatMenuItems(props: {
activeLinkId: string | null,
showDeletionKeys: boolean,
onDeleteLink: (linkId: string) => void,
onToggleDeletionKeys: () => void,
}) {
// external state
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
@@ -25,9 +31,18 @@ export function AppChatLinkMenuItems() {
const handleRenderSystemMessageChange = (event: React.ChangeEvent<HTMLInputElement>) => setShowSystemMessages(event.target.checked);
const handleRenderMarkdownChange = (event: React.ChangeEvent<HTMLInputElement>) => setRenderMarkdown(event.target.checked);
const handleZenModeChange = (event: React.ChangeEvent<HTMLInputElement>) => setZenMode(event.target.checked ? 'cleaner' : 'clean');
const { activeLinkId, onDeleteLink } = props;
const handleDeleteLink = React.useCallback(() => {
activeLinkId && onDeleteLink(activeLinkId);
}, [activeLinkId, onDeleteLink]);
const zenOn = zenMode === 'cleaner';
@@ -66,5 +81,24 @@ export function AppChatLinkMenuItems() {
/>
</MenuItem>
<MenuItem onClick={props.onToggleDeletionKeys} sx={{ justifyContent: 'space-between' }}>
<Typography>
Show Keys
</Typography>
<Switch
checked={props.showDeletionKeys}
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }}
/>
</MenuItem>
<ListDivider />
<MenuItem onClick={handleDeleteLink} sx={{ justifyContent: 'space-between' }}>
Delete
<ListItemDecorator>
<DeleteOutlineIcon />
</ListItemDecorator>
</MenuItem>
</>;
}
+1 -2
View File
@@ -10,7 +10,7 @@ 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, themeBgApp } from '~/common/app.theme';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
import { newsCallout, NewsItems } from './news.data';
@@ -44,7 +44,6 @@ export function AppNews() {
<Box sx={{
flexGrow: 1,
backgroundColor: themeBgApp,
overflowY: 'auto',
display: 'flex', justifyContent: 'center',
p: { xs: 3, md: 6 },
+40 -7
View File
@@ -10,14 +10,29 @@ import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
// update this variable every time you want to broadcast a new version to clients
export const incrementalVersion: number = 12;
export const incrementalVersion: number = 12.1;
function B(props: {
href?: string,
issue?: number,
children: React.ReactNode
}) {
const href = props.issue ? RIssues + '/' + props.issue : props.href;
const boldText = (
<Typography component='span' color={!!href ? 'primary' : 'neutral'} sx={{ fontWeight: 600 }}>
{props.children}
</Typography>
);
if (!href)
return boldText;
return (
<Link href={href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>
{boldText} <LaunchIcon sx={{ mx: 0.5, fontSize: 16 }} />
</Link>
);
}
const B = (props: { href?: string, children: React.ReactNode }) => {
const boldText = <Typography color={!!props.href ? 'primary' : 'neutral'} sx={{ fontWeight: 600 }}>{props.children}</Typography>;
return props.href ?
<Link href={props.href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>{boldText} <LaunchIcon sx={{ ml: 1 }} /></Link> :
boldText;
};
const { OpenRepo, OpenProject } = Brand.URIs;
const RCode = `${OpenRepo}/blob/main`;
@@ -59,6 +74,24 @@ export const newsCallout =
// news and feature surfaces
export const NewsItems: NewsItem[] = [
// still unannounced: phone calls, split windows, ...
{// 🆕
versionCode: '1.12.0',
versionName: 'AGI Hotline',
versionMoji: '✨🗣️',
versionDate: new Date('2024-01-26T12:30:00Z'),
items: [
{ text: <><B issue={354}>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: <><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 },
{ text: <>Paste <B>tables from Excel</B></>, issue: 286 },
{ text: <>Large optimizations</> },
{ text: <>Ollama updates</>, issue: 309 },
{ text: <>Over <B>150 commits</B> and <B>7,000+ lines changed</B> for development enhancements</>, dev: true },
],
},
{
versionCode: '1.11.0',
versionName: 'Singularity',
+3 -6
View File
@@ -1,8 +1,6 @@
import * as React from 'react';
import { Container, ListDivider, Sheet, Typography } from '@mui/joy';
import { themeBgApp } from '~/common/app.theme';
import { Box, Container, ListDivider, Typography } from '@mui/joy';
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { Creator } from './creator/Creator';
@@ -31,10 +29,9 @@ export function AppPersonas() {
return (
<Sheet sx={{
<Box sx={{
flexGrow: 1,
overflowY: 'auto',
backgroundColor: themeBgApp,
p: { xs: 3, md: 6 },
}}>
@@ -52,6 +49,6 @@ export function AppPersonas() {
</Container>
</Sheet>
</Box>
);
}
+11 -9
View File
@@ -12,7 +12,7 @@ import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
import { CreatorDrawerItem } from './CreatorDrawerItem';
import { deleteSimplePersona, useSimplePersonas } from '../store-app-personas';
import { deleteSimplePersona, deleteSimplePersonas, useSimplePersonas } from '../store-app-personas';
export function CreatorDrawer(props: {
@@ -79,10 +79,7 @@ export function CreatorDrawer(props: {
}, [simplePersonas]);
const handleSelectionDelete = React.useCallback(() => {
selectedIds.forEach(simplePersonaId => {
deleteSimplePersona(simplePersonaId);
});
// clear the selection after deletion
deleteSimplePersonas(selectedIds);
setSelectedIds(new Set());
}, [selectedIds]);
@@ -93,14 +90,15 @@ export function CreatorDrawer(props: {
<PageDrawerHeader
title={selectMode ? 'Selection Mode' : 'Recent'}
onClose={selectMode ? handleSelectionClose : closeDrawer}
startButton={(!hasPersonas || selectMode) ? undefined :
>
{hasPersonas && !selectMode && (
<Tooltip title={selectMode ? 'Done' : 'Select'}>
<IconButton onClick={selectMode ? handleSelectionClose : () => setSelectMode(true)}>
{selectMode ? <DoneIcon /> : <CheckBoxOutlineBlankIcon />}
</IconButton>
</Tooltip>
}
/>
)}
</PageDrawerHeader>
<PageDrawerList
variant='plain'
@@ -118,7 +116,11 @@ export function CreatorDrawer(props: {
startDecorator={selectedIds.size === simplePersonas.length ? <CheckBoxOutlineBlankIcon /> : <CheckBoxIcon />}
onClick={handleSelectionInvert}
>
{selectedIds.size === simplePersonas.length ? 'Select None' : selectedIds.size !== 0 ? 'Invert' : 'Select All'}
{selectedIds.size === simplePersonas.length
? 'Select None'
: selectedIds.size === 0
? `Select ${simplePersonas.length.toLocaleString() || 'All'}`
: 'Invert'}
</Button>
<Button
variant='solid'
+1 -1
View File
@@ -36,7 +36,7 @@ export function FromText(props: {
required
variant='outlined'
minRows={4} maxRows={8}
placeholder='Paste your text here...'
placeholder='Paste your text (e.g. tweets, social media, etc.) here...'
value={text}
onChange={event => setText(event.target.value)}
sx={{
+30 -13
View File
@@ -4,6 +4,9 @@ import { shallow } from 'zustand/shallow';
import { createBase36Uid } from '~/common/util/textUtils';
// constraint the max number of saved prompts, to stay below localStorage quota
const MAX_SAVED_PROMPTS = 100;
/**
* Very simple personas store for the "Persona Creator" - note that we shall
@@ -41,6 +44,7 @@ interface AppPersonasStore {
// actions
prependSimplePersona: (systemPrompt: string, inputText: string, inputProvenance?: SimplePersonaProvenance, llmLabel?: string) => void;
deleteSimplePersona: (id: string) => void;
deleteSimplePersonas: (ids: Set<string>) => void;
}
@@ -54,25 +58,34 @@ const useAppPersonasStore = create<AppPersonasStore>()(persist(
simplePersonas: [],
prependSimplePersona: (systemPrompt: string, inputText: string, inputProvenance?: SimplePersonaProvenance, llmLabel?: string) =>
_set(state => ({
simplePersonas: [
{
id: createBase36Uid(state.simplePersonas.map(persona => persona.id)),
systemPrompt,
creationDate: new Date().toISOString(),
inputProvenance,
inputText,
llmLabel,
},
...state.simplePersonas,
],
})),
_set(state => {
const newPersona: SimplePersona = {
id: createBase36Uid(state.simplePersonas.map(persona => persona.id)),
systemPrompt,
creationDate: new Date().toISOString(),
inputProvenance,
// to save bytes, do not save input text when from YouTube
inputText: inputProvenance?.type === 'youtube' ? '' : inputText,
llmLabel,
};
return {
simplePersonas: [
newPersona,
...state.simplePersonas.slice(0, MAX_SAVED_PROMPTS - 1),
],
};
}),
deleteSimplePersona: (simplePersonaId: string) =>
_set(state => ({
simplePersonas: state.simplePersonas.filter(persona => persona.id !== simplePersonaId),
})),
deleteSimplePersonas: (simplePersonaIds: Set<string>) =>
_set(state => ({
simplePersonas: state.simplePersonas.filter(persona => !simplePersonaIds.has(persona.id)),
})),
}),
{
name: 'app-app-personas',
@@ -98,4 +111,8 @@ export function prependSimplePersona(systemPrompt: string, inputText: string, in
export function deleteSimplePersona(simplePersonaId: string) {
useAppPersonasStore.getState().deleteSimplePersona(simplePersonaId);
}
export function deleteSimplePersonas(simplePersonaIds: Set<string>) {
useAppPersonasStore.getState().deleteSimplePersonas(simplePersonaIds);
}
+11 -14
View File
@@ -13,7 +13,7 @@ import { ProdiaSettings } from '~/modules/t2i/prodia/ProdiaSettings';
import { T2ISettings } from '~/modules/t2i/T2ISettings';
import { GoodModal } from '~/common/components/GoodModal';
import { settingsGap } from '~/common/app.theme';
import { PreferencesTab } from '~/common/layout/optima/useOptimaLayout';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { AppChatSettingsAI } from './AppChatSettingsAI';
@@ -87,7 +87,7 @@ function Topic(props: { title?: string, icon?: string | React.ReactNode, startCo
)}
<AccordionDetails>
<Stack sx={{ gap: settingsGap, border: 'none' }}>
<Stack sx={{ gap: 'calc(var(--Card-padding) / 2)', border: 'none' }}>
{props.children}
</Stack>
</AccordionDetails>
@@ -122,9 +122,6 @@ export function SettingsModal(props: {
👉 See Shortcuts
</Button>
)}
sx={{
'--Card-padding': { xs: '8px', sm: '16px', lg: '24px' },
}}
>
<Divider />
@@ -150,13 +147,13 @@ export function SettingsModal(props: {
},
}}
>
<Tab disableIndicator value={1} sx={tabFixSx}>Chat</Tab>
<Tab disableIndicator value={3} sx={tabFixSx}>Voice</Tab>
<Tab disableIndicator value={2} sx={tabFixSx}>Draw</Tab>
<Tab disableIndicator value={4} sx={tabFixSx}>Tools</Tab>
<Tab disableIndicator value={PreferencesTab.Chat} sx={tabFixSx}>Chat</Tab>
<Tab disableIndicator value={PreferencesTab.Voice} sx={tabFixSx}>Voice</Tab>
<Tab disableIndicator value={PreferencesTab.Draw} sx={tabFixSx}>Draw</Tab>
<Tab disableIndicator value={PreferencesTab.Tools} sx={tabFixSx}>Tools</Tab>
</TabList>
<TabPanel value={1} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<TabPanel value={PreferencesTab.Chat} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<Topics>
<Topic>
<AppChatSettingsUI />
@@ -170,7 +167,7 @@ export function SettingsModal(props: {
</Topics>
</TabPanel>
<TabPanel value={3} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<TabPanel value={PreferencesTab.Voice} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<Topics>
<Topic icon='🎙️' title='Voice settings'>
<VoiceSettings />
@@ -181,7 +178,7 @@ export function SettingsModal(props: {
</Topics>
</TabPanel>
<TabPanel value={2} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<TabPanel value={PreferencesTab.Draw} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<Topics>
<Topic>
<T2ISettings />
@@ -190,12 +187,12 @@ export function SettingsModal(props: {
<DallESettings />
</Topic>
<Topic icon='🖍️' title='Prodia API' startCollapsed>
<ProdiaSettings />
<ProdiaSettings noSkipKey />
</Topic>
</Topics>
</TabPanel>
<TabPanel value={4} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<TabPanel value={PreferencesTab.Tools} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
<Topics>
<Topic icon={<SearchIcon />} title='Browsing' startCollapsed>
<BrowseSettings />
+5 -11
View File
@@ -2,7 +2,6 @@ import * as React from 'react';
import { FormControl, Typography } from '@mui/joy';
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
import CallIcon from '@mui/icons-material/Call';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
@@ -17,17 +16,12 @@ export function UxLabsSettings() {
// external state
const isMobile = useIsMobile();
const {
labsCalling, labsCameraDesktop, /*labsEnhancedUI,*/ labsSplitBranching,
setLabsCalling, setLabsCameraDesktop, /*setLabsEnhancedUI,*/ setLabsSplitBranching,
labsCameraDesktop, labsSplitBranching, //labsDrawing,
setLabsCameraDesktop, setLabsSplitBranching, //setLabsDrawing,
} = useUXLabsStore();
return <>
<FormSwitchControl
title={<><CallIcon color={labsCalling ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Voice Calls</>} description={labsCalling ? 'Call AGI' : 'Disabled'}
checked={labsCalling} onChange={setLabsCalling}
/>
{!isMobile && <FormSwitchControl
title={<><AddAPhotoIcon color={labsCameraDesktop ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Webcam</>} description={labsCameraDesktop ? 'Enabled' : 'Disabled'}
checked={labsCameraDesktop} onChange={setLabsCameraDesktop}
@@ -39,14 +33,14 @@ export function UxLabsSettings() {
/>
{/*<FormSwitchControl*/}
{/* title='Enhanced UI' description={labsEnhancedUI ? 'Enabled' : 'Disabled'}*/}
{/* checked={labsEnhancedUI} onChange={setLabsEnhancedUI}*/}
{/* 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' />
<Typography level='body-xs'>
<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/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>
+101 -40
View File
@@ -1,43 +1,68 @@
import type { FunctionComponent } from 'react';
// App icons
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
import CallIcon from '@mui/icons-material/Call';
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
import Diversity2Icon from '@mui/icons-material/Diversity2';
import Diversity2OutlinedIcon from '@mui/icons-material/Diversity2Outlined';
import EventNoteIcon from '@mui/icons-material/EventNote';
import EventNoteOutlinedIcon from '@mui/icons-material/EventNoteOutlined';
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
import GitHubIcon from '@mui/icons-material/GitHub';
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
import ImageIcon from '@mui/icons-material/Image';
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
import IosShareIcon from '@mui/icons-material/IosShare';
import SettingsIcon from '@mui/icons-material/Settings';
import TelegramIcon from '@mui/icons-material/Telegram';
import IosShareOutlinedIcon from '@mui/icons-material/IosShareOutlined';
import TextsmsIcon from '@mui/icons-material/Textsms';
import TextsmsOutlinedIcon from '@mui/icons-material/TextsmsOutlined';
import WorkspacesIcon from '@mui/icons-material/Workspaces';
import WorkspacesOutlinedIcon from '@mui/icons-material/WorkspacesOutlined';
// Link icons
import GitHubIcon from '@mui/icons-material/GitHub';
import { DiscordIcon } from '~/common/components/icons/DiscordIcon';
// Modal icons
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
import SettingsIcon from '@mui/icons-material/Settings';
import { Brand } from '~/common/app.config';
import { DiscordIcon } from '~/common/components/icons/DiscordIcon';
import { hasNoChatLinkItems } from '~/modules/trade/link/store-link';
import { useUXLabsStore } from '~/common/state/store-ux-labs';
// enable to show all items, for layout development
const SHOW_ALL_APPS = false;
const SPECIAL_DIVIDER = '__DIVIDER__';
// Nav items
interface ItemBase {
name: string,
icon: FunctionComponent,
iconActive?: FunctionComponent,
tooltip?: string,
}
export interface NavItemApp extends ItemBase {
type: 'app',
route: string,
drawer?: string | true, // true: can make use of the drawer, string: also set the title
landingRoute?: string, // specify a different route than the nextjs page router route, to land to
barTitle?: string, // set to override the name as the bar title (unless custom bar content is used)
hideOnMobile?: boolean, // set to true to hide the icon on mobile, unless this is the active app
hideIcon?: boolean
| (() => boolean), // set to true to hide the icon, unless this is the active app
hideBar?: boolean, // set to true to hide the page bar
hideNav?: boolean, // set to hide the Nav bar (note: must have a way to navigate back)
automatic?: boolean, // only accessible by the machine
hideDrawer?: boolean, // set to true to hide the drawer
hideNav?: boolean
| (() => boolean), // set to hide the Nav bar (note: must have a way to navigate back)
fullWidth?: boolean, // set to true to override the user preference
hide?: boolean, // delete from the UI
_delete?: boolean, // delete from the UI
}
export interface NavItemModal extends ItemBase {
@@ -57,8 +82,8 @@ export interface NavItemExtLink extends ItemBase {
export const navItems: {
apps: NavItemApp[]
modals: NavItemModal[]
apps: NavItemApp[],
modals: NavItemModal[],
links: NavItemExtLink[],
} = {
@@ -66,74 +91,98 @@ export const navItems: {
apps: [
{
name: 'Chat',
icon: TelegramIcon,
icon: TextsmsOutlinedIcon,
iconActive: TextsmsIcon,
type: 'app',
route: '/',
drawer: true,
},
{
name: 'Call',
icon: CallIcon,
barTitle: 'Voice Calls',
icon: CallOutlinedIcon,
iconActive: CallIcon,
type: 'app',
route: '/call',
drawer: 'Recent Calls',
automatic: true,
hideDrawer: true,
fullWidth: true,
},
{
name: 'Draw',
icon: FormatPaintIcon,
barTitle: 'Generate Images',
icon: FormatPaintOutlinedIcon,
iconActive: FormatPaintIcon,
type: 'app',
route: '/draw',
hide: true,
// hideOnMobile: true,
hideDrawer: true,
hideIcon: () => !useUXLabsStore.getState().labsDrawing,
},
{
name: 'Cortex',
icon: AutoAwesomeIcon,
icon: AutoAwesomeOutlinedIcon,
iconActive: AutoAwesomeIcon,
type: 'app',
route: '/cortex',
automatic: true,
hide: true,
_delete: true,
},
{
name: 'Patterns',
icon: AccountTreeIcon,
icon: AccountTreeOutlinedIcon,
iconActive: AccountTreeIcon,
type: 'app',
route: '/patterns',
hide: true,
_delete: true,
},
{
name: 'Workspace',
icon: WorkspacesIcon,
icon: WorkspacesOutlinedIcon,
iconActive: WorkspacesIcon,
type: 'app',
route: '/workspace',
hide: true,
_delete: true,
},
// <-- divider here -->
{
name: SPECIAL_DIVIDER,
type: 'app',
route: SPECIAL_DIVIDER,
icon: () => null,
},
{
name: 'Personas',
icon: Diversity2Icon,
icon: Diversity2OutlinedIcon,
iconActive: Diversity2Icon,
type: 'app',
route: '/personas',
drawer: true,
hideBar: true,
},
{
name: 'Media Library',
icon: ImageOutlinedIcon,
iconActive: ImageIcon,
type: 'app',
route: '/media',
_delete: true,
},
{
name: 'Shared Chat',
icon: IosShareOutlinedIcon,
iconActive: IosShareIcon,
type: 'app',
route: '/link/chat/[chatLinkId]',
landingRoute: '/link/chat/list',
hideOnMobile: true,
hideIcon: hasNoChatLinkItems,
hideNav: hasNoChatLinkItems,
},
{
name: 'News',
icon: EventNoteIcon,
icon: EventNoteOutlinedIcon,
iconActive: EventNoteIcon,
type: 'app',
route: '/news',
hideBar: true,
},
// non-user-selectable ('automatic') Apps
{
name: 'Shared Chat',
icon: IosShareIcon,
type: 'app',
route: '/link/chat/[chatLinkId]',
drawer: 'Shared Chats',
automatic: true,
hideNav: true,
hideDrawer: true,
},
],
@@ -178,4 +227,16 @@ export const navItems: {
};
// apply UI filtering right away - do it here, once, and for all
navItems.apps = navItems.apps.filter(app => !app.hide || SHOW_ALL_APPS);
navItems.apps = navItems.apps.filter(app => !app._delete || SHOW_ALL_APPS);
export function checkDivider(app?: NavItemApp) {
return app?.name === SPECIAL_DIVIDER;
}
export function checkVisibileIcon(app: NavItemApp, isMobile: boolean, currentApp?: NavItemApp) {
return app.hideOnMobile && isMobile ? false : app === currentApp ? true : typeof app.hideIcon === 'function' ? !app.hideIcon() : !app.hideIcon;
}
export function checkVisibleNav(app?: NavItemApp) {
return !app ? false : typeof app.hideNav === 'function' ? !app.hideNav() : !app.hideNav;
}
+8 -7
View File
@@ -6,6 +6,7 @@
import Router, { useRouter } from 'next/router';
import type { AppCallIntent } from '../apps/call/AppCall';
import type { DConversationId } from '~/common/state/store-chats';
import { isBrowser } from './util/pwaUtils';
@@ -13,7 +14,7 @@ import { isBrowser } from './util/pwaUtils';
export const ROUTE_INDEX = '/';
export const ROUTE_APP_CHAT = '/';
export const ROUTE_APP_CALL = '/call';
export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId';
export const ROUTE_APP_LINK_CHAT = '/link/chat/[chatLinkId]';
export const ROUTE_APP_NEWS = '/news';
export const ROUTE_APP_PERSONAS = '/personas';
const ROUTE_CALLBACK_OPENROUTER = '/link/callback_openrouter';
@@ -33,7 +34,8 @@ export const getCallbackUrl = (source: 'openrouter') => {
return callbackUrl.toString();
};
export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT.replace(':linkId', chatLinkId);
export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT
.replace('[chatLinkId]', chatLinkId);
export function useRouterQuery<TQuery>(): TQuery {
const { query } = useRouter();
@@ -54,6 +56,8 @@ export const navigateToNews = navigateFn(ROUTE_APP_NEWS);
export const navigateToPersonas = navigateFn(ROUTE_APP_PERSONAS);
export const navigateToChatLinkList = navigateFn(ROUTE_APP_LINK_CHAT.replace('[chatLinkId]', 'list'));
export const navigateBack = Router.back;
export const reloadPage = () => isBrowser && window.location.reload();
@@ -83,10 +87,6 @@ export const launchAppChat = async (conversationId?: DConversationId) => {
);
};
export interface AppCallQueryParams {
conversationId: string;
personaId: string;
}
export function launchAppCall(conversationId: string, personaId: string) {
void Router.push(
@@ -95,7 +95,8 @@ export function launchAppCall(conversationId: string, personaId: string) {
query: {
conversationId,
personaId,
} satisfies AppCallQueryParams,
backTo: 'app-chat',
} satisfies AppCallIntent,
},
// ROUTE_APP_CALL,
).then();
+1 -2
View File
@@ -9,8 +9,7 @@ export const hideOnMobile = { display: { xs: 'none', md: 'flex' } };
// export const hideOnDesktop = { display: { xs: 'flex', md: 'none' } };
// Dimensions
export const settingsGap = 2;
export const settingsCol1Width = 150;
export const formLabelStartWidth = 150;
// Theme & Fonts
+5
View File
@@ -4,11 +4,13 @@ import ClearIcon from '@mui/icons-material/Clear';
import SearchIcon from '@mui/icons-material/Search';
type DebounceInputProps = Omit<InputProps, 'onChange'> & {
minChars?: number;
onDebounce: (value: string) => void;
debounceTimeout: number;
};
const DebounceInput: React.FC<DebounceInputProps> = ({
minChars,
onDebounce,
debounceTimeout,
...rest
@@ -25,6 +27,9 @@ const DebounceInput: React.FC<DebounceInputProps> = ({
}
timerRef.current = setTimeout(() => {
// Don't call onDebounce if the input value is too short
if (newValue && minChars && newValue?.length < minChars)
return;
onDebounce(newValue); // Call onDebounce after the debounce timeout
}, debounceTimeout);
};
@@ -0,0 +1,35 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Card, Link as MuiLink, Typography } from '@mui/joy';
import GitHubIcon from '@mui/icons-material/GitHub';
export const GitHubProjectIssueCard = (props: {
issue: number,
text: string,
note?: string | React.ReactNode,
note2?: string | React.ReactNode,
sx?: SxProps
}) =>
<Card variant='outlined' color='primary' sx={props.sx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<GitHubIcon />
<Typography level='body-sm'>
<MuiLink overlay href={`https://github.com/enricoros/big-AGI/issues/${props.issue}`} target='_blank'>
big-AGI #{props.issue}
</MuiLink>
{' · '}{props.text}.
</Typography>
</Box>
{!!props.note && (
<Typography level='body-sm' sx={{ mt: 1 }}>
{props.note}
</Typography>
)}
{!!props.note2 && (
<Typography level='body-sm' sx={{ mt: 1 }}>
{props.note2}
</Typography>
)}
</Card>;
+5 -5
View File
@@ -28,15 +28,15 @@ export function GoodModal(props: {
sx={{
minWidth: { xs: 360, sm: 500, md: 600, lg: 700 },
maxWidth: 700,
display: 'flex', flexDirection: 'column', gap: 3,
display: 'flex', flexDirection: 'column', gap: 'var(--Card-padding)',
...props.sx,
}}>
{!props.noTitleBar && <Box sx={{ mb: -1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography level={props.strongerTitle !== true ? 'title-md' : 'title-lg'} startDecorator={props.titleStartDecorator}>
{!props.noTitleBar && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography component='h1' level={props.strongerTitle !== true ? 'title-md' : 'title-lg'} startDecorator={props.titleStartDecorator}>
{props.title || ''}
</Typography>
{!!props.onClose && <ModalClose sx={{ position: 'static', mr: -1 }} />}
{!!props.onClose && <ModalClose aria-label='Close Dialog' sx={{ position: 'static', my: -1, mr: -0.5 }} />}
</Box>}
{props.dividers === true && <Divider />}
@@ -47,7 +47,7 @@ export function GoodModal(props: {
{(!!props.startButton || showBottomClose) && <Box sx={{ mt: 'auto', display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'space-between' }}>
{props.startButton}
{showBottomClose && <Button variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
{showBottomClose && <Button aria-label='Close Dialog' variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
Close
</Button>}
</Box>}
+1
View File
@@ -17,6 +17,7 @@ export const GoodTooltip = (props: {
<Tooltip
title={props.title}
placement={props.placement}
disableInteractive
variant={(props.isError || props.isWarning) ? 'soft' : undefined}
color={props.isError ? 'danger' : props.isWarning ? 'warning' : undefined}
sx={{
+5 -2
View File
@@ -7,7 +7,9 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
export function InlineTextarea(props: {
initialText: string, placeholder?: string,
initialText: string,
placeholder?: string,
invertedColors?: boolean,
onEdit: (text: string) => void,
onCancel?: () => void,
sx?: SxProps,
@@ -35,7 +37,8 @@ export function InlineTextarea(props: {
return (
<Textarea
variant='soft' color='warning'
variant={props.invertedColors ? 'plain' : 'soft'}
color={props.invertedColors ? 'primary' : 'warning'}
autoFocus
minRows={1}
placeholder={props.placeholder}
@@ -5,7 +5,7 @@ import { SxProps } from '@mui/joy/styles/types';
import InfoIcon from '@mui/icons-material/Info';
import { GoodTooltip } from '~/common/components/GoodTooltip';
import { settingsCol1Width } from '~/common/app.theme';
import { formLabelStartWidth } from '~/common/app.theme';
/**
@@ -23,7 +23,7 @@ export const FormLabelStart = (props: {
<FormLabel
onClick={props.onClick}
sx={{
minWidth: settingsCol1Width,
minWidth: formLabelStartWidth,
...(!!props.onClick && { cursor: 'pointer', textDecoration: 'underline' }),
...props.sx,
}}
@@ -290,11 +290,12 @@ export const useSpeechRecognition = (onResultCallback: (result: SpeechResult) =>
}, []);
const toggleRecording = React.useCallback(() => {
if (refStarted.current)
if (refStarted.current || isSpeechError) {
stopRecording();
else
setIsSpeechError(false);
} else
startRecording();
}, [startRecording, stopRecording]);
}, [isSpeechError, startRecording, stopRecording]);
return {
+14 -12
View File
@@ -2,10 +2,9 @@ import * as React from 'react';
import { Box, Sheet, styled } from '@mui/joy';
import type { NavItemApp } from '~/common/app.nav';
import { checkVisibleNav, NavItemApp } from '~/common/app.nav';
import { themeZIndexDesktopDrawer } from '~/common/app.theme';
import { PageDrawer } from './PageDrawer';
import { useOptimaDrawers } from './useOptimaDrawers';
import { useOptimaLayout } from './useOptimaLayout';
@@ -33,19 +32,23 @@ const DesktopDrawerTranslatingSheet = styled(Sheet)(({ theme }) => ({
zIndex: themeZIndexDesktopDrawer,
// styling
backgroundColor: 'transparent',
// borderTopRightRadius: 'var(--AGI-Optima-Radius)',
// borderBottomRightRadius: 'var(--AGI-Optima-Radius)',
// contain: 'strict',
boxShadow: theme.shadow.md,
// content layout
display: 'flex',
flexDirection: 'column',
}));
})) as typeof Sheet;
export function DesktopDrawer(props: { currentApp?: NavItemApp }) {
export function DesktopDrawer(props: { component: React.ElementType, currentApp?: NavItemApp }) {
// external state
const { isDrawerOpen, closeDrawer, openDrawer } = useOptimaDrawers();
const { appPaneContent } = useOptimaLayout();
const { appDrawerContent } = useOptimaLayout();
// local state
const [softDrawerUnmount, setSoftDrawerUnmount] = React.useState(false);
@@ -71,14 +74,14 @@ export function DesktopDrawer(props: { currentApp?: NavItemApp }) {
// Desktop-only?: close the drawer if the current app doesn't use it
const currentAppUsesDrawer = !!props.currentApp?.drawer;
const currentAppUsesDrawer = !props.currentApp?.hideDrawer;
React.useEffect(() => {
if (!currentAppUsesDrawer)
closeDrawer();
}, [closeDrawer, currentAppUsesDrawer]);
// [special case] remove in the future
const shallOpenNavForSharedLink = !!props.currentApp?.drawer && !!props.currentApp?.hideNav;
const shallOpenNavForSharedLink = !props.currentApp?.hideDrawer && checkVisibleNav(props.currentApp);
React.useEffect(() => {
if (shallOpenNavForSharedLink)
openDrawer();
@@ -93,17 +96,16 @@ export function DesktopDrawer(props: { currentApp?: NavItemApp }) {
>
<DesktopDrawerTranslatingSheet
component={props.component}
sx={{
transform: isDrawerOpen ? 'none' : 'translateX(-100%)',
}}
>
{/* [UX Responsiveness] Keep Mounted for now */}
{(!softDrawerUnmount || isDrawerOpen || !UNMOUNT_DELAY_MS) && (
<PageDrawer currentApp={props.currentApp} onClose={closeDrawer}>
{appPaneContent}
</PageDrawer>
)}
{(!softDrawerUnmount || isDrawerOpen || !UNMOUNT_DELAY_MS) &&
appDrawerContent
}
</DesktopDrawerTranslatingSheet>
+55 -107
View File
@@ -1,94 +1,30 @@
import * as React from 'react';
import Router from 'next/router';
import { Box, IconButton, styled, Tooltip } from '@mui/joy';
import type { SxProps } from '@mui/joy/styles/types';
import { Divider, Tooltip } from '@mui/joy';
import MenuIcon from '@mui/icons-material/Menu';
import { useModelsStore } from '~/modules/llms/store-llms';
import { AgiSquircleIcon } from '~/common/components/icons/AgiSquircleIcon';
import { NavItemApp, navItems } from '~/common/app.nav';
import { cssRainbowColorKeyframes, themeZIndexDesktopNav } from '~/common/app.theme';
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';
import { InvertedBar, InvertedBarCornerItem } from './components/InvertedBar';
import { useOptimaDrawers } from './useOptimaDrawers';
import { useOptimaLayout } from './useOptimaLayout';
import { BringTheLove } from '~/common/layout/optima/components/BringTheLove';
// Nav Group
const DesktopNavGroupButton = styled(Box)({
// flex column
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
// nav items, reduce the marginBlock a little
'--GroupMarginY': '0.125rem',
// style
// backgroundColor: 'rgba(0 0 0 / 0.5)',
// borderRadius: '1rem',
// paddingBlock: '0.5rem',
// overflow: 'hidden',
});
// Nav Item
const navItemClasses = {
active: 'NavButton-active',
paneOpen: 'NavButton-paneOpen',
attractive: 'NavButton-attractive',
const desktopNavBarSx: SxProps = {
zIndex: themeZIndexDesktopNav,
};
const DesktopNavItem = styled(IconButton)(({ theme }) => ({
// --Bar is defined in InvertedBar
'--MarginX': '0.25rem',
// IconButton customization: the objective is to have a square button, with a smaller group margin,
// and with the nice little animation on pane open and hover
'--IconButton-size': 'calc(var(--Bar) - 2 * var(--MarginX))',
'--Icon-fontSize': '1.5rem',
// border: '1px solid red',
borderRadius: 'calc(var(--IconButton-size) / 2)',
marginBlock: 'var(--GroupMarginY)',
//marginInline: .. not needd because we center the items
padding: 0,
transition: 'border-radius 0.4s, margin 0.2s, padding 0.2s',
[`&:hover`]: {
// backgroundColor: theme.palette.primary.softHoverBg,
},
// pane open: show a connected half
[`&.${navItemClasses.paneOpen}`]: {
// squircle animation
borderStartEndRadius: 0,
borderEndEndRadius: 0,
marginLeft: 'calc(2 * var(--MarginX))',
paddingRight: 'calc(2 * var(--MarginX))',
},
[`&.${navItemClasses.paneOpen}:hover`]: {
borderRadius: 'calc(var(--IconButton-size) / 2)',
marginLeft: 0,
paddingRight: 0,
},
// attractive: attract the user to click on this element
[`&.${navItemClasses.attractive}`]: {
animation: `${cssRainbowColorKeyframes} 5s infinite`,
transform: 'scale(1.4)',
},
}));
export function DesktopNav(props: { currentApp?: NavItemApp }) {
export function DesktopNav(props: { component: React.ElementType, currentApp?: NavItemApp }) {
// external state
const {
@@ -99,11 +35,13 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
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
const appUsesPane = !!props.currentApp?.drawer;
const logoButtonTogglesPane = (appUsesPane && !isDrawerOpen) || isDrawerOpen;
const appUsesDrawer = !props.currentApp?.hideDrawer;
const logoButtonTogglesPane = (appUsesDrawer && !isDrawerOpen) || isDrawerOpen;
const handleLogoButtonClick = React.useCallback(() => {
if (logoButtonTogglesPane)
toggleDrawer();
@@ -112,25 +50,30 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
// App items
const navAppItems = React.useMemo(() => {
return navItems.apps.filter(app => !app.hideNav /* .automatic */).map(item => {
const isActive = item === props.currentApp;
const isPanelable = isActive && !!item.drawer;
const isPaneOpen = isPanelable && isDrawerOpen;
const isNotForUser = !!item.automatic && !isActive;
return (
<Tooltip disableInteractive enterDelay={600} key={'n-m-' + item.route.slice(1)} title={item.name}>
<DesktopNavItem
disabled={isNotForUser}
variant={isActive ? 'soft' : undefined}
onClick={isPanelable ? toggleDrawer : () => Router.push(item.route)}
className={`${isActive ? navItemClasses.active : ''} ${isPaneOpen ? navItemClasses.paneOpen : ''}`}
>
<item.icon />
</DesktopNavItem>
</Tooltip>
);
});
}, [props.currentApp, isDrawerOpen, toggleDrawer]);
return navItems.apps
.filter(_app => checkVisibileIcon(_app, false, props.currentApp))
.map((app, appIdx) => {
const isActive = app === props.currentApp;
const isDrawerable = isActive && !app.hideDrawer;
const isPaneOpen = isDrawerable && isDrawerOpen;
if (checkDivider(app))
return <Divider key={'div-' + appIdx} sx={{ my: 1, width: '50%', mx: 'auto' }} />;
return (
<Tooltip key={'n-m-' + app.route.slice(1)} disableInteractive enterDelay={600} title={app.name}>
<DesktopNavIcon
variant={isActive ? 'solid' : undefined}
onClick={isDrawerable ? toggleDrawer : () => Router.push(app.landingRoute || app.route)}
className={`${navItemClasses.typeApp} ${isActive ? navItemClasses.active : ''} ${isPaneOpen ? navItemClasses.paneOpen : ''}`}
>
{/*{(isActive && app.iconActive) ? <app.iconActive /> : <app.icon />}*/}
<app.icon />
</DesktopNavIcon>
</Tooltip>
);
});
}, [props.currentApp, isDrawerOpen, labsDrawing, toggleDrawer]);
// External link items
@@ -168,13 +111,13 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
return (
<Tooltip followCursor key={'n-m-' + item.overlayId} title={isAttractive ? 'Add Language Models - REQUIRED' : item.name}>
<DesktopNavItem
<DesktopNavIcon
variant={isActive ? 'soft' : undefined}
onClick={showModal}
className={`${isActive ? navItemClasses.active : ''} ${isAttractive ? navItemClasses.attractive : ''}`}
className={`${navItemClasses.typeLinkOrModal} ${isActive ? navItemClasses.active : ''} ${isAttractive ? navItemClasses.attractive : ''}`}
>
<item.icon />
</DesktopNavItem>
{(isActive && item.iconActive) ? <item.iconActive /> : <item.icon />}
</DesktopNavIcon>
</Tooltip>
);
});
@@ -182,24 +125,29 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
return (
<InvertedBar id='desktop-nav' direction='vertical' sx={{ zIndex: themeZIndexDesktopNav }}>
<InvertedBar
id='desktop-nav'
component={props.component}
direction='vertical'
sx={desktopNavBarSx}
>
<InvertedBarCornerItem>
<Tooltip title={isDrawerOpen ? 'Close' : 'Open Drawer'}>
<DesktopNavItem disabled={!logoButtonTogglesPane} onClick={handleLogoButtonClick}>
<Tooltip title={isDrawerOpen ? 'Close Drawer' /* for Aria reasons */ : 'Open Drawer'}>
<DesktopNavIcon disabled={!logoButtonTogglesPane} onClick={handleLogoButtonClick}>
{logoButtonTogglesPane ? <MenuIcon /> : <AgiSquircleIcon inverted sx={{ color: 'white' }} />}
</DesktopNavItem>
</DesktopNavIcon>
</Tooltip>
</InvertedBarCornerItem>
<DesktopNavGroupButton>
<DesktopNavGroupBox>
{navAppItems}
</DesktopNavGroupButton>
</DesktopNavGroupBox>
<DesktopNavGroupButton>
<DesktopNavGroupBox sx={{ mb: 'calc(2 * var(--GroupMarginY))' }}>
{navExtLinkItems}
{navModalItems}
</DesktopNavGroupButton>
</DesktopNavGroupBox>
</InvertedBar>
);
+15 -6
View File
@@ -4,19 +4,20 @@ import { Drawer } from '@mui/joy';
import type { NavItemApp } from '~/common/app.nav';
import { PageDrawer } from './PageDrawer';
import { useOptimaDrawers } from './useOptimaDrawers';
import { useOptimaLayout } from './useOptimaLayout';
export function MobileDrawer(props: { currentApp?: NavItemApp }) {
export function MobileDrawer(props: { component: React.ElementType, currentApp?: NavItemApp }) {
// external state
const { appPaneContent } = useOptimaLayout();
const { appDrawerContent } = useOptimaLayout();
const { isDrawerOpen, closeDrawer } = useOptimaDrawers();
return (
<Drawer
id='mobile-drawer'
component={props.component}
open={isDrawerOpen}
onClose={closeDrawer}
sx={{
@@ -27,11 +28,19 @@ export function MobileDrawer(props: { currentApp?: NavItemApp }) {
// boxSizing: 'border-box',
// },
}}
slotProps={{
content: {
sx: {
// style: round the right drawer corners
backgroundColor: 'transparent',
borderTopRightRadius: 'var(--AGI-Optima-Radius)',
borderBottomRightRadius: 'var(--AGI-Optima-Radius)',
},
},
}}
>
<PageDrawer currentApp={props.currentApp} onClose={closeDrawer}>
{appPaneContent}
</PageDrawer>
{appDrawerContent}
</Drawer>
);
+52 -20
View File
@@ -1,37 +1,69 @@
import * as React from 'react';
import Router from 'next/router';
import { Typography } from '@mui/joy';
import type { SxProps } from '@mui/joy/styles/types';
import type { NavItemApp } from '~/common/app.nav';
import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav';
import { InvertedBar, InvertedBarCornerItem } from './components/InvertedBar';
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
import { InvertedBar } from './components/InvertedBar';
import { MobileNavGroupBox, MobileNavIcon, mobileNavItemClasses } from './components/MobileNavIcon';
export function MobileNav(props: { currentApp?: NavItemApp, hideOnFocusMode?: boolean }) {
export function MobileNav(props: {
component: React.ElementType,
currentApp?: NavItemApp,
hideOnFocusMode?: boolean,
sx?: SxProps,
}) {
// external state
const { isFocusedMode } = useOptimaLayout();
// const { isFocusedMode } = useOptimaLayout();
// App items
const navAppItems = React.useMemo(() => {
return navItems.apps
.filter(app => checkVisibileIcon(app, true, undefined))
.map((app) => {
const isActive = app === props.currentApp;
if (checkDivider(app)) {
// return <Divider key={'div-' + appIdx} sx={{ mx: 1, height: '50%', my: 'auto' }} />;
return null;
}
return (
<MobileNavIcon
key={'n-m-' + app.route.slice(1)}
aria-label={app.name}
variant={isActive ? 'solid' : undefined}
onClick={() => Router.push(app.landingRoute || app.route)}
className={`${mobileNavItemClasses.typeApp} ${isActive ? mobileNavItemClasses.active : ''}`}
>
{/*{(isActive && app.iconActive) ? <app.iconActive /> : <app.icon />}*/}
<app.icon />
</MobileNavIcon>
);
});
}, [props.currentApp]);
// NOTE: this may be abrupt a little
if (isFocusedMode && props.hideOnFocusMode)
return null;
// if (isFocusedMode && props.hideOnFocusMode)
// return null;
return (
<InvertedBar
id='mobile-nav' direction='horizontal'
sx={{
justifyContent: 'space-around',
}}
id='mobile-nav'
component={props.component}
direction='horizontal'
sx={props.sx}
>
<InvertedBarCornerItem sx={{ width: 'auto' }}>
<Typography level='title-sm'>
Chat
</Typography>
</InvertedBarCornerItem>
<Typography>
FIXME: MobileNav
</Typography>
<MobileNavGroupBox>
{navAppItems}
</MobileNavGroupBox>
</InvertedBar>
);
}
+63 -36
View File
@@ -1,17 +1,22 @@
import * as React from 'react';
import Router from 'next/router';
import { Button, ButtonGroup, ListItem } from '@mui/joy';
import { NavItemApp, navItems } from '~/common/app.nav';
import { Button, ButtonGroup, Divider, ListItem, Tooltip, VariantProp } from '@mui/joy';
import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav';
import { BringTheLove } from './components/BringTheLove';
export function MobileNavListItem(props: { currentApp?: NavItemApp }) {
/**
* This is used from the Menu of the Pagebar, to have nav items on Mobile, before we add
* a dedicated Mobile Navigation bar.
*/
export function MobileNavListItem(props: { variant?: VariantProp, currentApp?: NavItemApp, hideApps?: boolean, hideSocial?: boolean }) {
return (
<ListItem
variant='solid'
variant={props.variant}
sx={{
'--ListItem-minHeight': 'var(--AGI-Nav-width)',
gap: 1,
@@ -19,40 +24,62 @@ export function MobileNavListItem(props: { currentApp?: NavItemApp }) {
>
{/* Group 1: Apps */}
<ButtonGroup
variant='solid'
sx={{
'--ButtonGroup-separatorSize': 0,
'--ButtonGroup-connected': 0,
gap: 1,
}}
>
{navItems.apps.filter(app => ['Chat', 'Personas', 'News'].includes(app.name)).map(app =>
<Button
key={'app-' + app.name}
disabled={!!app.automatic}
size='sm'
variant={app == props.currentApp ? 'soft' : 'solid'}
onClick={() => Router.push(app.route)}
>
{app == props.currentApp ? app.name : <app.icon />}
</Button>,
)}
</ButtonGroup>
{!props.hideApps && (
<ButtonGroup
component='nav'
variant={props.variant}
sx={{
'--ButtonGroup-separatorSize': 0,
'--ButtonGroup-connected': 0,
gap: 1,
}}
>
{navItems.apps
.filter(app => checkVisibileIcon(app, true, undefined))
.map((app) => {
const isActive = app === props.currentApp;
if (checkDivider(app))
return null;
// return <Divider orientation='vertical' key={'div-' + appIdx} />;
return (
<Tooltip key={'n-m-' + app.route.slice(1)} disableInteractive enterDelay={600} title={app.name}>
<Button
key={'app-' + app.name}
size='sm'
variant={isActive ? 'soft' : 'solid'}
onClick={() => Router.push(app.landingRoute || app.route)}
>
{/*{isActive ? app.name : <app.icon />}*/}
{(isActive && app.name.length <= 4) ? app.name : <app.icon />}
{/*{(isActive && app.iconActive) ? <app.iconActive /> : <app.icon />}*/}
</Button>
</Tooltip>
);
})}
</ButtonGroup>
)}
{!props.hideApps && <Divider orientation='vertical' sx={{ my: 1.25 }} />}
{/* Group 2: Social Links */}
<ButtonGroup
variant='solid'
sx={{
'--ButtonGroup-separatorSize': 0,
'--ButtonGroup-connected': 0,
ml: 'auto',
}}
>
{navItems.links.map(item =>
<BringTheLove key={'love-' + item.name} text={item.name} icon={item.icon} link={item.href} />,
)}
</ButtonGroup>
{!props.hideSocial && (
<ButtonGroup
variant={props.variant}
size='sm'
sx={{
'--ButtonGroup-separatorSize': 0,
'--ButtonGroup-connected': 0,
ml: 'auto',
gap: 0.5,
}}
>
{navItems.links.map(item =>
<BringTheLove key={'love-' + item.name} text={item.name} icon={item.icon} link={item.href} />,
)}
</ButtonGroup>
)}
</ListItem>
);
+10 -10
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
import { useRouter } from 'next/router';
import { PanelGroup } from 'react-resizable-panels';
import { navItems } from '~/common/app.nav';
import { checkVisibleNav, navItems } from '~/common/app.nav';
import { useIsMobile } from '~/common/components/useMatchMedia';
import { DesktopDrawer } from './DesktopDrawer';
@@ -11,7 +11,7 @@ import { MobileDrawer } from './MobileDrawer';
import { Modals } from './Modals';
import { OptimaDrawerProvider } from './useOptimaDrawers';
import { OptimaLayoutProvider } from './useOptimaLayout';
import { PageContainer } from './PageContainer';
import { PageWrapper } from './PageWrapper';
/**
@@ -40,24 +40,24 @@ export function OptimaLayout(props: { suspendAutoModelsSetup?: boolean, children
{isMobile ? <>
<PageContainer isMobile currentApp={currentApp}>
<PageWrapper component='main' isMobile currentApp={currentApp}>
{props.children}
</PageContainer>
</PageWrapper>
<MobileDrawer currentApp={currentApp} />
<MobileDrawer component='aside' currentApp={currentApp} />
</> : (
<PanelGroup direction='horizontal' id='desktop-layout'>
<PanelGroup direction='horizontal' id='root-layout'>
{!currentApp?.hideNav && <DesktopNav currentApp={currentApp} />}
{checkVisibleNav(currentApp) && <DesktopNav component='nav' currentApp={currentApp} />}
<DesktopDrawer currentApp={currentApp} />
<DesktopDrawer component='aside' currentApp={currentApp} />
{/*<Panel defaultSize={100}>*/}
<PageContainer currentApp={currentApp}>
<PageWrapper component='main' currentApp={currentApp}>
{props.children}
</PageContainer>
</PageWrapper>
{/*</Panel>*/}
</PanelGroup>
+29 -26
View File
@@ -9,7 +9,7 @@ import MenuIcon from '@mui/icons-material/Menu';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import type { NavItemApp } from '~/common/app.nav';
import { checkVisibleNav, NavItemApp } from '~/common/app.nav';
import { AgiSquircleIcon } from '~/common/components/icons/AgiSquircleIcon';
import { Brand } from '~/common/app.config';
import { CloseableMenu } from '~/common/components/CloseableMenu';
@@ -22,24 +22,20 @@ import { useOptimaDrawers } from './useOptimaDrawers';
import { useOptimaLayout } from './useOptimaLayout';
function PageBarItemsFallback() {
return (
const PageBarItemsFallback = (props: { currentApp?: NavItemApp }) =>
<Box sx={{
display: 'flex',
alignItems: 'center',
gap: { xs: 1, md: 2 },
}}>
<Link href={ROUTE_INDEX}>
<AgiSquircleIcon inverted sx={{
width: 32,
height: 32,
color: 'white',
}} />
<Typography sx={{
ml: { xs: 1, md: 2 },
color: 'white',
textDecoration: 'none',
}}>
{Brand.Title.Base}
</Typography>
<AgiSquircleIcon inverted sx={{ width: 32, height: 32, color: 'white' }} />
</Link>
);
}
<Typography level='title-md'>
{props.currentApp?.barTitle || props.currentApp?.name || Brand.Title.Base}
</Typography>
</Box>;
function CommonMenuItems(props: { onClose: () => void }) {
@@ -92,7 +88,7 @@ function CommonMenuItems(props: { onClose: () => void }) {
/**
* The top bar of the application, with pluggable Left and Right menus, and Center component
*/
export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx?: SxProps }) {
export function PageBar(props: { component: React.ElementType, currentApp?: NavItemApp, isMobile?: boolean, sx?: SxProps }) {
// state
// const [value, setValue] = React.useState<ContainedAppType>('chat');
@@ -100,7 +96,7 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
// external state
const {
appBarItems, appPaneContent, appMenuItems,
appBarItems, appDrawerContent, appMenuItems,
} = useOptimaLayout();
const {
openDrawer,
@@ -126,18 +122,22 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
{/* transition: 'grid-template-rows 1.42s linear',*/}
{/*}}>*/}
<InvertedBar direction='horizontal' sx={props.sx}>
<InvertedBar
component={props.component}
direction='horizontal'
sx={props.sx}
>
{/* [Mobile] Drawer button */}
{(!!props.isMobile || props.currentApp?.hideNav) && (
{(!!props.isMobile || !checkVisibleNav(props.currentApp)) && (
<InvertedBarCornerItem>
{(!appPaneContent || props.currentApp?.hideNav) ? (
{(!appDrawerContent || !checkVisibleNav(props.currentApp)) ? (
<IconButton component={Link} href={ROUTE_INDEX} noLinkStyle>
<ArrowBackIcon />
</IconButton>
) : (
<IconButton disabled={!appPaneContent} onClick={openDrawer}>
<IconButton disabled={!appDrawerContent} onClick={openDrawer}>
<MenuIcon />
</IconButton>
)}
@@ -152,12 +152,15 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
display: 'flex', flexFlow: 'row wrap', justifyContent: 'center', alignItems: 'center',
my: 'auto',
}}>
{desktopHide ? null : !!appBarItems ? appBarItems : <PageBarItemsFallback />}
{appBarItems
? appBarItems
: <PageBarItemsFallback currentApp={props.currentApp} />
}
</Box>
{/* Page Menu Anchor */}
<InvertedBarCornerItem>
<IconButton disabled={!pageMenuAnchor || (!appMenuItems && !props.isMobile)} onClick={openPageMenu} ref={pageMenuAnchor}>
<IconButton disabled={!pageMenuAnchor /*|| (!appMenuItems && !props.isMobile)*/} onClick={openPageMenu} ref={pageMenuAnchor}>
<MoreVertIcon />
</IconButton>
</InvertedBarCornerItem>
@@ -183,7 +186,7 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
{/* [Mobile] Nav is implemented at the bottom of the Page Menu (for now) */}
{!!props.isMobile && !!appMenuItems && <ListDivider sx={{ mb: 0 }} />}
{!!props.isMobile && <MobileNavListItem currentApp={props.currentApp} />}
{!!props.isMobile && <MobileNavListItem variant='solid' currentApp={props.currentApp} />}
</CloseableMenu>
+61
View File
@@ -0,0 +1,61 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Box } from '@mui/joy';
import { themeBgApp, themeZIndexPageBar } from '~/common/app.theme';
import type { NavItemApp } from '~/common/app.nav';
// import { MobileNav } from './MobileNav';
import { PageBar } from './PageBar';
const pageCoreSx: SxProps = {
// background: 'url(/images/big-agi-background-3.png) no-repeat center bottom fixed',
backgroundColor: themeBgApp,
height: '100dvh',
display: 'flex', flexDirection: 'column',
};
const pageCoreBarSx: SxProps = {
zIndex: themeZIndexPageBar,
};
const pageCoreMobileNavSx: SxProps = {
flex: 0,
};
export const PageCore = (props: {
component: React.ElementType,
currentApp?: NavItemApp,
isMobile?: boolean,
children: React.ReactNode,
}) =>
<Box
component={props.component}
sx={pageCoreSx}
>
{/* Responsive page bar (pluggable App Center Items and App Menu) */}
<PageBar
component='header'
currentApp={props.currentApp}
isMobile={props.isMobile}
sx={pageCoreBarSx}
/>
{/* Page (NextJS) must make the assumption they're in a flex-col layout */}
{props.children}
{/* [Mobile] Nav bar at the bottom */}
{/*{!!props.isMobile && (*/}
{/* <MobileNav*/}
{/* component='nav'*/}
{/* currentApp={props.currentApp}*/}
{/* hideOnFocusMode*/}
{/* sx={pageCoreMobileNavSx}*/}
{/* />*/}
{/*)}*/}
</Box>;
-26
View File
@@ -1,26 +0,0 @@
import * as React from 'react';
import type { NavItemApp } from '~/common/app.nav';
import { PageDrawerHeader } from './components/PageDrawerHeader';
export function PageDrawer(props: {
currentApp?: NavItemApp,
onClose: () => void,
children?: React.ReactNode,
}) {
// derived state
const drawerTitle = typeof props.currentApp?.drawer === 'string' ? props.currentApp.drawer : false;
return <>
{/* Drawer Header */}
{drawerTitle && <PageDrawerHeader title={drawerTitle} onClose={props.onClose} />}
{/* Pluggable Drawer Content */}
{props.children}
</>;
}
@@ -4,42 +4,18 @@ import { Box, Container } from '@mui/joy';
import type { NavItemApp } from '~/common/app.nav';
import { isPwa } from '~/common/util/pwaUtils';
import { themeZIndexPageBar } from '~/common/app.theme';
import { useUIPreferencesStore } from '~/common/state/store-ui';
import { PageBar } from './PageBar';
import { PageCore } from './PageCore';
import { useOptimaDrawers } from './useOptimaDrawers';
const PageCore = (props: { currentApp?: NavItemApp, isMobile?: boolean, children: React.ReactNode }) =>
<Box sx={{
display: 'flex', flexDirection: 'column',
height: '100dvh',
}}>
{/* Responsive page bar (pluggable App Center Items and App Menu) */}
<PageBar
currentApp={props.currentApp}
isMobile={props.isMobile}
sx={{
zIndex: themeZIndexPageBar,
}}
/>
{/* Page (NextJS) must make the assumption they're in a flex-col layout */}
{props.children}
{/* [Mobile] Nav bar at the bottom */}
{/* FIXME: TEMP: Disable mobilenav */}
{/*{props.isMobile && <MobileNav hideOnFocusMode currentApp={props.currentApp} />}*/}
</Box>;
/**
* Loaded Application component, fromt the NextJS page router, wrapped in a Container for centering.
* Wraps the NextJS Page Component (from the pages router).
* - mobile: just the 100dvh pageCore
* - desktop: animated left margin (sync with the drawer) and centering via the Container, then the PageCore
*/
export function PageContainer(props: { currentApp?: NavItemApp, isMobile?: boolean, children: React.ReactNode }) {
export function PageWrapper(props: { component: React.ElementType, currentApp?: NavItemApp, isMobile?: boolean, children: React.ReactNode }) {
// external state
const { isDrawerOpen } = useOptimaDrawers();
@@ -50,7 +26,7 @@ export function PageContainer(props: { currentApp?: NavItemApp, isMobile?: boole
// mobile: no outer containers
if (props.isMobile)
return (
<PageCore isMobile currentApp={props.currentApp}>
<PageCore component={props.component} isMobile currentApp={props.currentApp}>
{props.children}
</PageCore>
);
@@ -87,7 +63,7 @@ export function PageContainer(props: { currentApp?: NavItemApp, isMobile?: boole
}}
>
<PageCore currentApp={props.currentApp}>
<PageCore component={props.component} currentApp={props.currentApp}>
{props.children}
</PageCore>
@@ -1,46 +1,50 @@
import * as React from 'react';
import type { SxProps } from '@mui/joy/styles/types';
import { Button, IconButton, Tooltip } from '@mui/joy';
import { Button, Tooltip } from '@mui/joy';
import { Link } from '~/common/components/Link';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
import { DesktopNavIcon, navItemClasses } from './DesktopNavIcon';
export function BringTheLove(props: { text: string, link: string, asIcon?: boolean, icon: React.FC, sx?: SxProps }) {
// state
const [loved, setLoved] = React.useState(false);
const icon = loved ? '❤️' : <props.icon /> ?? null; // '❤️' : '🤍';
// reset loved after 5 seconds
// reset loved after 6.9 seconds
React.useEffect(() => {
if (loved) {
const timer = setTimeout(() => setLoved(false), 5000);
const timer = setTimeout(() => setLoved(false), 6900 + 420);
return () => clearTimeout(timer);
}
}, [loved]);
const icon = loved ? '❤️' : <props.icon /> ?? null; // '❤️' : '🤍';
return (
<Tooltip followCursor title={props.text}>
{props.asIcon ? (
<IconButton
<DesktopNavIcon
variant='solid'
size='sm'
className={navItemClasses.typeLinkOrModal}
component={Link} href={props.link} target='_blank'
onClick={() => setLoved(true)}
component={Link} href={props.link} target='_blank' noLinkStyle
sx={{
'&:hover': { animation: `${cssRainbowColorKeyframes} 5s linear infinite` },
background: 'transparent',
textDecoration: 'none',
...props.sx,
// color: 'text.tertiary',
'&:hover': {
animation: `${cssRainbowColorKeyframes} 5s linear infinite`,
},
}}
>
{icon}
</IconButton>
</DesktopNavIcon>
) : (
<Button
onClick={() => setLoved(true)}
component={Link} href={props.link} target='_blank' noLinkStyle
onClick={() => setLoved(true)}
sx={{
'&:hover': { animation: `${cssRainbowColorKeyframes} 5s linear infinite` },
background: 'transparent',
@@ -0,0 +1,86 @@
import { Box, IconButton, styled } from '@mui/joy';
import { cssRainbowColorKeyframes } from '~/common/app.theme';
export const DesktopNavGroupBox = styled(Box)({
// flex column
display: 'flex',
flexDirection: 'column',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
// nav items, reduce the marginBlock a little
'--GroupMarginY': '0.125rem',
// style
// backgroundColor: 'rgba(0 0 0 / 0.5)',
// borderRadius: '1rem',
// paddingBlock: '0.5rem',
// overflow: 'hidden',
});
export const navItemClasses = {
typeApp: 'NavButton-typeApp',
typeLinkOrModal: 'NavButton-typeLink',
active: 'NavButton-active',
paneOpen: 'NavButton-paneOpen',
attractive: 'NavButton-attractive',
};
export const DesktopNavIcon = styled(IconButton)(({ theme }) => ({
// --Bar is defined in InvertedBar
'--MarginX': '0.25rem',
// border: '1px solid red',
marginBlock: 'var(--GroupMarginY)',
//marginInline: .. not needd because we center the items
padding: 0,
[`&.${navItemClasses.typeApp},&.${navItemClasses.typeLinkOrModal}`]: {
'--Icon-fontSize': '1.25rem',
},
// [`&.${navItemClasses.typeLinkOrModal}`]: {
// borderRadius: '50%',
// },
[`&.${navItemClasses.typeApp}`]: {
'--IconButton-size': 'calc(var(--Bar) - 2 * var(--MarginX))',
transition: 'border-radius 0.4s, margin 0.2s, padding 0.2s', // background-color 0.3s, color 0.2s
},
[`&.${navItemClasses.typeApp}:hover`]: {
backgroundColor: 'var(--variant-solidHoverBg)',
// backgroundColor: theme.palette.neutral.softHoverBg,
color: theme.palette.neutral.softColor,
},
// app active (non hover)
// [`&.${navItemClasses.typeApp}.${navItemClasses.active}`]: {},
// pane open: show a connected half
[`&.${navItemClasses.paneOpen}`]: {
// squircle animation
borderStartStartRadius: 'calc(var(--IconButton-size) / 4)',
borderEndStartRadius: 'calc(var(--IconButton-size) / 4)',
borderStartEndRadius: 0,
borderEndEndRadius: 0,
marginLeft: 'calc(2 * var(--MarginX))',
paddingRight: 'calc(2 * var(--MarginX))',
},
[`&.${navItemClasses.paneOpen}:hover`]: {
borderRadius: 'var(--joy-radius-md, 0.5rem)',
marginLeft: 0,
paddingRight: 0,
},
// attractive: attract the user to click on this element
[`&.${navItemClasses.attractive}`]: {
animation: `${cssRainbowColorKeyframes} 5s infinite`,
transform: 'scale(1.4)',
},
})) as typeof IconButton;
@@ -1,6 +1,6 @@
import * as React from 'react';
import type { SxProps, VariantProp } from '@mui/joy/styles/types';
import type { SxProps } from '@mui/joy/styles/types';
import { Box, Sheet, styled, useTheme } from '@mui/joy';
@@ -13,7 +13,7 @@ export const InvertedBarCornerItem = styled(Box)({
});
const InvertedBarBase = styled(Sheet)({
const StyledSheet = styled(Sheet)({
// customization
'--Bar': 'var(--AGI-Nav-width)',
@@ -21,14 +21,14 @@ const InvertedBarBase = styled(Sheet)({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
});
}) as typeof Sheet;
// This is the AppBar and the MobileAppNav and DesktopNav
export const InvertedBar = (props: {
id?: string,
component: React.ElementType,
direction: 'horizontal' | 'vertical',
variant?: VariantProp,
sx?: SxProps
children: React.ReactNode,
}) => {
@@ -36,26 +36,33 @@ export const InvertedBar = (props: {
// check for dark mode
const theme = useTheme();
const isDark = theme?.palette.mode === 'dark';
const variant = isDark ? 'soft' : props.variant || 'solid';
return <InvertedBarBase
id={props.id}
variant={variant}
invertedColors={variant === 'solid' ? true : undefined}
sx={
props.direction === 'horizontal'
? {
// minHeight: 'var(--Bar)',
flexDirection: 'row',
// overflow: 'hidden',
...props.sx,
} : {
// minWidth: 'var(--Bar)',
flexDirection: 'column',
...props.sx,
}
}
>
{props.children}
</InvertedBarBase>;
// memoize the Sx for stability, based on direction
const sx: SxProps = React.useMemo(() => (
props.direction === 'horizontal'
? {
// minHeight: 'var(--Bar)',
flexDirection: 'row',
// overflow: 'hidden',
...props.sx,
} : {
// minWidth: 'var(--Bar)',
flexDirection: 'column',
...props.sx,
}
), [props.direction, props.sx]);
return (
<StyledSheet
id={props.id}
component={props.component}
variant={isDark ? 'soft' : 'solid'}
invertedColors={!isDark ? true : undefined}
sx={sx}
>
{props.children}
</StyledSheet>
);
};
@@ -0,0 +1,51 @@
import { Box, IconButton, styled } from '@mui/joy';
export const MobileNavGroupBox = styled(Box)({
// layout
flex: 1,
minHeight: 'var(--Bar)',
// contents
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-evenly',
alignItems: 'center',
// style
// backgroundColor: 'rgba(0 0 0 / 0.5)', // darken bg
// debug
// '& > *': { border: '1px solid red' },
});
export const mobileNavItemClasses = {
typeApp: 'NavButton-typeApp',
active: 'NavButton-active',
};
export const MobileNavIcon = styled(IconButton)(({ theme }) => ({
// custom vars
'--MarginY': '0.5rem',
'--ExtraPadX': '1rem',
// IconButton customization
'--Icon-fontSize': '1.25rem',
'--IconButton-size': 'calc(var(--Bar) - 2 * var(--MarginY))',
paddingInline: 'var(--ExtraPadX)',
border: 'none',
[`&.${mobileNavItemClasses.typeApp}:hover`]: {
backgroundColor: 'var(--variant-solidHoverBg)',
// backgroundColor: theme.palette.neutral.softHoverBg,
color: theme.palette.neutral.softColor,
},
// app active (non hover)
// [`&.${mobileNavItemClasses.typeApp}.${mobileNavItemClasses.active}`]: {
// backgroundColor: ...
// },
})) as typeof IconButton;

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