mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
379 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c79b95ddc | |||
| 720945f903 | |||
| 7ee8f218f6 | |||
| 72f9e01e60 | |||
| b4bae3ba20 | |||
| 7c67dbd1f2 | |||
| ac8da8dfbf | |||
| 1d778a699a | |||
| 0ac3033320 | |||
| c65aa99f9e | |||
| b22d54254a | |||
| 3eeb4aa157 | |||
| fac237638f | |||
| 7b617c5d03 | |||
| 3a579f3468 | |||
| 2bf407a989 | |||
| 18a16294bc | |||
| db1346fe3e | |||
| 2b3477feb0 | |||
| b7bc715b36 | |||
| bc237dee1c | |||
| 6131556bab | |||
| 3d42bc51f3 | |||
| 3f3f3c67bf | |||
| eeaa87bde3 | |||
| f854f0182f | |||
| 302e327d2d | |||
| 2d18a81654 | |||
| 71a97e1c4e | |||
| 542b47ba78 | |||
| d27f269abc | |||
| b0484e24af | |||
| fa8c4a30d8 | |||
| f6163b5a22 | |||
| 8f945f11e7 | |||
| fa7a7bdf1d | |||
| fe7a2caf2c | |||
| 6ae11d07eb | |||
| 58896a7052 | |||
| 1f83210792 | |||
| 0a4a452bee | |||
| 8063ee34b3 | |||
| 72e2fa41aa | |||
| 1c3f8ba8ec | |||
| e1802cb0f8 | |||
| afeab71da1 | |||
| 8d492702f2 | |||
| 64e8cfcb03 | |||
| 2167d0ef1e | |||
| 977b14494b | |||
| 3b408c8173 | |||
| 9547b25835 | |||
| 9c53557183 | |||
| 3cc8d48b75 | |||
| 71dbc653a9 | |||
| f1e8bf3d1f | |||
| 0c8dd4a4d9 | |||
| 4911f39793 | |||
| daaf33a69e | |||
| 8b04d38ce3 | |||
| 4a35701def | |||
| 8800cae62f | |||
| aebf7b99f4 | |||
| a9ea4070ff | |||
| 3fb8d91ab1 | |||
| a9943e26af | |||
| 514ecedf1c | |||
| 74a277a6f3 | |||
| b14cd47a7b | |||
| c1a29d76d5 | |||
| 3a8195a02b | |||
| f70b0474ad | |||
| 808077bc2b | |||
| 76f6c7917c | |||
| fc1fc91845 | |||
| 72d5a8f5f0 | |||
| 53226da794 | |||
| 638bd1e780 | |||
| 046d193af8 | |||
| ff0cc09505 | |||
| b52468dd54 | |||
| 76cadaed18 | |||
| 2e68172fa9 | |||
| 4bbed2adb1 | |||
| fb4a62be16 | |||
| 5da3a887c4 | |||
| 2df49977c2 | |||
| d275ee0f7d | |||
| 19ec67bf3c | |||
| 9dc8aaa9aa | |||
| 15cfef0f8b | |||
| 695af02cee | |||
| 1ed86b6ebc | |||
| e18ac02af9 | |||
| a4d89c9e2c | |||
| 911c46ebe2 | |||
| f0073133c3 | |||
| db3a435027 | |||
| a94f2c6df3 | |||
| 0b7eaf69ba | |||
| 326f49bafc | |||
| 6195c8954d | |||
| 1586377ead | |||
| 97b1f15121 | |||
| 6d185119ac | |||
| 296eff7278 | |||
| 84b1825895 | |||
| a69c067530 | |||
| 0043b39293 | |||
| 8123c237e3 | |||
| 5a0fb1bb63 | |||
| a507d53d34 | |||
| 60cbcdaedb | |||
| 96b4f502f1 | |||
| 846b3cddaf | |||
| 1b66dce9f0 | |||
| c7952ae974 | |||
| ed2284716b | |||
| d64ed69371 | |||
| e73bf2ddec | |||
| 19609e5ccd | |||
| 3adc2f4654 | |||
| 2b95b6ace1 | |||
| 5720de1224 | |||
| 1b110f5a38 | |||
| 0785961581 | |||
| f1cc92727c | |||
| b36197ffad | |||
| eae3d78911 | |||
| 12a93fdcb7 | |||
| c98ab8cb9d | |||
| 8619a9ca1d | |||
| 2b182a4209 | |||
| ddc7d571d2 | |||
| 3de693e5e3 | |||
| 770fbdef72 | |||
| 80d9f458bb | |||
| 52f91dd328 | |||
| 22550f7efb | |||
| f811b59919 | |||
| d2344e5010 | |||
| 6fee9a6238 | |||
| 08730002a4 | |||
| 20adb796c0 | |||
| 0e7cbfe0e4 | |||
| 46ef5d9b45 | |||
| f249b39db5 | |||
| 280bb2e424 | |||
| 8c206aedb9 | |||
| d74b7df41d | |||
| 571a04cf6c | |||
| 216dae9423 | |||
| ef09d50715 | |||
| 1e851bbb6c | |||
| 3c63593141 | |||
| 6ef32e52ba | |||
| 682c168372 | |||
| 48f039517d | |||
| 7ebeea3550 | |||
| a7a234ecca | |||
| a237e53580 | |||
| 584544d037 | |||
| a601dfa4cf | |||
| dbee0d7b87 | |||
| ff4857b9ac | |||
| 5b557705e7 | |||
| cd70c4dd84 | |||
| 9eb2ef05de | |||
| 8fae15d343 | |||
| bca5a1ac78 | |||
| d899fb7e3b | |||
| 0f05b70e3b | |||
| 7b121a3a95 | |||
| d4e414f99c | |||
| a7f322ef38 | |||
| d4494bf2e0 | |||
| 78cf74e3f2 | |||
| cfaed03603 | |||
| a8e3183733 | |||
| 9395db0fd5 | |||
| 8c75061178 | |||
| de0cdded87 | |||
| d225541da2 | |||
| 7a0008de5a | |||
| 0bdd817d6d | |||
| d606975584 | |||
| af56c2c1af | |||
| 73de7df0fb | |||
| 3ca80d6a6e | |||
| eb9e5362fe | |||
| 45d1ca7437 | |||
| e492ccfb04 | |||
| d01b6acd51 | |||
| eec81d5d73 | |||
| 03423ce58c | |||
| e2e7ea972d | |||
| 91b770d2c8 | |||
| 79500e6d8b | |||
| 4ede66cf2b | |||
| 40bff32442 | |||
| 3fc8e8efa0 | |||
| 12ea5f218d | |||
| d47c0e45af | |||
| 298d0201d2 | |||
| a6bde2377e | |||
| 76778c5ab7 | |||
| 11565f5ac8 | |||
| 6c5131996b | |||
| 9b4301cd90 | |||
| c73bbaf0d4 | |||
| 163257e052 | |||
| cf689ca9a9 | |||
| 4a65389b71 | |||
| 5de7762238 | |||
| 06655ced46 | |||
| 60a775b869 | |||
| 5a3645bd43 | |||
| 54d37e663a | |||
| f4c056fa9f | |||
| 8f53fa7407 | |||
| 2f9a4ea00f | |||
| ee7dae827e | |||
| 6fe94e344a | |||
| 3376867966 | |||
| 4a8a2b9c5d | |||
| 7f84160a62 | |||
| fb5b349866 | |||
| f5c7b96ff6 | |||
| 7c430cc5c8 | |||
| 8c7d069189 | |||
| f50d040d8a | |||
| aa10f87c7d | |||
| 4e96a5b5e5 | |||
| 329456f287 | |||
| 6f8368d7cb | |||
| 9c2b0cb7ca | |||
| 1e15c4c4d1 | |||
| 9f209526a0 | |||
| 60ab9bd239 | |||
| 70e51b2e71 | |||
| 2d6edde12c | |||
| d2fb0c2425 | |||
| 122bbf0034 | |||
| e79449b38c | |||
| fcad6495e1 | |||
| 330d35a24c | |||
| a8ec58c732 | |||
| 8054c8b328 | |||
| 7d6f2317e4 | |||
| 10dd83bb2b | |||
| 7bf285f26a | |||
| fde7a8cd9b | |||
| 49ae5abba5 | |||
| f50ae4e7e2 | |||
| 99ff5cd7ad | |||
| f80facb191 | |||
| ea8d2fff3e | |||
| e3f1a5c54d | |||
| fdafc1207b | |||
| 5d3971c21f | |||
| f8a4002a41 | |||
| 38a3eeef21 | |||
| bf54807fb2 | |||
| 1aaabec28f | |||
| 8ec3927f02 | |||
| 73f201b8ac | |||
| 0b61c9a49e | |||
| ee82911d8f | |||
| 89fa3fe633 | |||
| da56db7502 | |||
| 1d0f99a9a5 | |||
| 8254443d29 | |||
| e1d6536102 | |||
| c9fbbc1ab1 | |||
| ae2e9b8f56 | |||
| 64ca896ea7 | |||
| 9bed685fe2 | |||
| 9432084342 | |||
| 0b7ffd16ab | |||
| 3437888bf4 | |||
| 9b02be8861 | |||
| 953d8434c3 | |||
| f9484ee3e9 | |||
| 4a3956d743 | |||
| 785139e7bc | |||
| d45fbff28d | |||
| fce6ecaf5f | |||
| 847d199dd8 | |||
| 274525a727 | |||
| 4d807ecf5c | |||
| 37a25f0117 | |||
| 7d5ab95c20 | |||
| 7fe8dd776f | |||
| 0a85d8d104 | |||
| cfd563b200 | |||
| 311a8d0ba0 | |||
| 06cd386c6e | |||
| 2632133ba4 | |||
| 1fe43cdc2e | |||
| e76939fb5d | |||
| 5f4250e3d2 | |||
| 5653044b1e | |||
| d4da34561d | |||
| fa25e830d5 | |||
| c90139923c | |||
| fa5007cb3b | |||
| b979e1313c | |||
| 1f1bf65c14 | |||
| 2bc6a15256 | |||
| dbcdbaa893 | |||
| d0ac1d8e1a | |||
| 3929e501d8 | |||
| fa3ae7b821 | |||
| 79052f988c | |||
| 18e6e235f3 | |||
| 388e897466 | |||
| e05a3bc3e9 | |||
| 5bb832f83d | |||
| 43cb19df83 | |||
| 1d770ce012 | |||
| 550e3e0173 | |||
| 043a5f48e8 | |||
| 0b69e0a9d1 | |||
| 5d8d752693 | |||
| e7067ed4fb | |||
| d181e27555 | |||
| 47d8b220a3 | |||
| cc5e310174 | |||
| 8006f578cd | |||
| a303bf7224 | |||
| dc0ca6d5bc | |||
| 2db3917c1c | |||
| 0c2ae290b0 | |||
| 24dcfeb952 | |||
| acd7a24cff | |||
| 88c29cf32c | |||
| 26f472b396 | |||
| 68c5e0b940 | |||
| 03fca40b74 | |||
| 35aff7798e | |||
| 6a8cf08ef0 | |||
| 53a9f9acef | |||
| d4c02dde1d | |||
| 660fda8485 | |||
| 049dfec794 | |||
| 2e6f1939dc | |||
| f3b1e4698a | |||
| 34e0102d82 | |||
| 3f5aed6f9b | |||
| 29647ad106 | |||
| 9426a45b88 | |||
| 5b52544c6c | |||
| fc1c15ba87 | |||
| e973fce3f7 | |||
| 99759654f2 | |||
| 390a1effb1 | |||
| f357291560 | |||
| c3a8b7e859 | |||
| 8931544349 | |||
| 865e420e34 | |||
| 574c2b936e | |||
| 4f6a596cc7 | |||
| edd36ea780 | |||
| 5a325b98ee | |||
| 8f6e2a3b5f | |||
| cf2fc96107 | |||
| 8837a1fc65 | |||
| 91970f088e | |||
| f59f77e50a | |||
| 50b1f00b5a | |||
| 4f98a8a319 | |||
| fb8aa3936b | |||
| 335876555f | |||
| 7da3b1f4c4 | |||
| e80bc4cea7 | |||
| 448755ff8d | |||
| 3a4c23840a | |||
| 13c69111f9 | |||
| 0b9feb9fda |
@@ -1,25 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Omg what's happening?
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
Where is it happening?
|
||||
- Which device [Mobile/Desktop, os version]:
|
||||
- Which browser:
|
||||
- Which website:
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots / context**
|
||||
If applicable, please add screenshots or additional context
|
||||
@@ -0,0 +1,32 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a report to help us improve
|
||||
title: '[BUG]'
|
||||
labels: [ 'type: bug' ]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for reporting a bug.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: (required) Please provide a clear description. Please also provide the steps to reproduce.
|
||||
placeholder: 'Concise description + steps to reproduce.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Device and browser
|
||||
description: '(required) Please specify your Mobile/Desktop device, OS version, browser.'
|
||||
placeholder: 'Device: (e.g., iPhone 16, Pixel 9, PC, Macbook...), OS: (e.g., iOS 17, Windows 12), Browser: (e.g., Chrome 119, Safari 18, Firefox..)'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots and more
|
||||
placeholder: 'Attach screenshots, or add any additional context here.'
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Willingness to Contribute
|
||||
description: We appreciate contributions - would you be willing to submit a pull request?
|
||||
options:
|
||||
- label: '🙋♂️ Yes, I would like to contribute a fix.'
|
||||
@@ -23,15 +23,15 @@ assignees: enricoros
|
||||
- [ ] Update the release version in package.json, and `npm i`
|
||||
- [ ] Update in-app News [src/apps/news/news.data.tsx](/src/apps/news/news.data.tsx)
|
||||
- [ ] Update the in-app News version number
|
||||
- [ ] Update the readme with the new release
|
||||
- [ ] Update the README.md with the new release
|
||||
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
|
||||
- Release:
|
||||
- [ ] merge onto main
|
||||
- [ ] 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`
|
||||
- [ ] verify deployment on Vercel
|
||||
- [ ] verify container on GitHub Packages
|
||||
- create a GitHub release
|
||||
- [ ] name it 'vX.Y.Z'
|
||||
- [ ] copy the release notes and link appropriate artifacts
|
||||
- [ ] update the GitHub release
|
||||
- [ ] push as stable `git push opensource main:main-stable`
|
||||
- Announce:
|
||||
- [ ] Discord announcement
|
||||
- [ ] Twitter announcement
|
||||
@@ -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,28 +64,39 @@ 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
|
||||
### 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)
|
||||
|
||||
```markdown
|
||||
I need you to update the README.md and the with the new release.
|
||||
Attaching the in-app news, with my language for you to improve on, but keep the tone.
|
||||
```
|
||||
|
||||
### 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
|
||||
@@ -96,5 +105,6 @@ some stats (# of commits, etc.), and roll it for the new release.
|
||||
### Discord announcement
|
||||
|
||||
```markdown
|
||||
Can you generate my 1.2.3 big-AGI discord announcement from the GitHub Release announcement, and the in-app News?
|
||||
Can you generate my 1.2.3 big-AGI discord announcement from the GitHub Release announcement?
|
||||
Please keep the formatting and stye of the discord announcement for 1.1.0, but with the new messaging above.
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
- [ ] ...
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# BIG-AGI 🧠✨
|
||||
|
||||
Welcome to big-AGI 👋, the GPT application for professionals that need function, form,
|
||||
simplicity, and speed. Powered by the latest models from 8 vendors and
|
||||
simplicity, and speed. Powered by the latest models from 11 vendors and
|
||||
open-source model servers, `big-AGI` offers best-in-class Voice and Chat with AI Personas,
|
||||
visualizations, coding, drawing, calling, and quite more -- all in a polished UX.
|
||||
|
||||
@@ -21,32 +21,40 @@ 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.9.0 · Dec 28, 2023 · Creative Horizons
|
||||
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
|
||||
|
||||
- **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.
|
||||
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
|
||||
|
||||
### What's New in 1.8.0 · Dec 20, 2023
|
||||
- **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)
|
||||
|
||||
- **Google Gemini Support**: Use the newest Google models. [#275](https://github.com/enricoros/big-agi/issues/275)
|
||||
- **Mistral Platform**: Mixtral and future models support. [#273](https://github.com/enricoros/big-agi/issues/273)
|
||||
- **Diagram Instructions**. Thanks to @joriskalz! [#280](https://github.com/enricoros/big-agi/pull/280)
|
||||
- Ollama Chats: Enhanced chatting experience. [#270](https://github.com/enricoros/big-agi/issues/270)
|
||||
- Mac Shortcuts Fix: Improved UX on Mac
|
||||
- **Single-Tab Mode**: Data integrity with single window. [#268](https://github.com/enricoros/big-agi/issues/268)
|
||||
- **Updated Models**: Latest Ollama (v0.1.17) and OpenRouter models
|
||||
- Official Downloads: Easy access to the latest big-AGI on [big-AGI.com](https://big-agi.com)
|
||||
- For developers: [troubleshot networking](https://github.com/enricoros/big-AGI/issues/276#issuecomment-1858591483), fixed Vercel deployment, cleaned up the LLMs/Streaming framework
|
||||
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
|
||||
|
||||
### What's New in... ?
|
||||
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
|
||||
|
||||
> [To The Moon And Back, Attachment Theory, Surf's Up, Loaded, and more releases...](docs/changelog.md).
|
||||
> Check out the [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2)
|
||||
- **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 (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
|
||||
|
||||
### What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI
|
||||
|
||||
- **New UI**: for both desktop and mobile, sets the stage for future scale. [#201](https://github.com/enricoros/big-AGI/issues/201)
|
||||
- **Conversation Folders**: enhanced conversation organization. [#321](https://github.com/enricoros/big-AGI/issues/321)
|
||||
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
|
||||
- Resizable panes in split-screen conversations.
|
||||
- Large performance optimizations
|
||||
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
|
||||
|
||||
For full details and former releases, check out the [changelog](docs/changelog.md).
|
||||
|
||||
## ✨ Key Features 👊
|
||||
|
||||
@@ -105,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`.
|
||||
|
||||
+35
-2
@@ -5,11 +5,44 @@ by release.
|
||||
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
### 1.10.0 - Jan 2024
|
||||
### 1.13.0 - Feb 2024
|
||||
|
||||
- milestone: [1.10.0](https://github.com/enricoros/big-agi/milestone/10)
|
||||
- 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 (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
|
||||
|
||||
### What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI
|
||||
|
||||
- **New UI**: for both desktop and mobile, sets the stage for future scale. [#201](https://github.com/enricoros/big-AGI/issues/201)
|
||||
- **Conversation Folders**: enhanced conversation organization. [#321](https://github.com/enricoros/big-AGI/issues/321)
|
||||
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
|
||||
- Resizable panes in split-screen conversations.
|
||||
- Large performance optimizations
|
||||
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
|
||||
|
||||
### 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)
|
||||
|
||||
+25
-1
@@ -50,10 +50,34 @@ Now you can use the following connection string in `big-AGI`: `ws://127.0.0.1:92
|
||||
You can also browse to [http://127.0.0.1:9222](http://127.0.0.1:9222) to see the Browserless debug viewer
|
||||
and configure some options.
|
||||
|
||||
The chat agent won't be able to access the web sites if the browserless container does not have direct Internet access. You can resolve the issue by defining internet proxy for the running container. You can then use the evironment file in the a `docker-compose.yaml
|
||||
|
||||
```
|
||||
browserless:
|
||||
image: browserless/chrome:latest
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "9222:3000" # Map host's port 9222 to container's port 3000
|
||||
environment:
|
||||
- MAX_CONCURRENT_SESSIONS=10
|
||||
```
|
||||
|
||||
You can then add the proyy lines to your `.env` file.
|
||||
|
||||
```
|
||||
https_proxy=http://PROXY-IP:PROXY-PORT
|
||||
http_proxy=http://PROXY-IP:PROXY-PORT
|
||||
```
|
||||
|
||||
This is how you can define it in a one liner docker
|
||||
`docker run --env https_proxy=http://PROXY-IP:PROXY-PORT --env http_proxy=http://PROXY-IP:PROXY-PORT -p 9222:3000 browserless/chrome:latest `
|
||||
|
||||
Note: if you are using `docker-compose`, please see the
|
||||
[docker/docker-compose-browserless.yaml](docker/docker-compose-browserless.yaml) file for an example
|
||||
on how to run `big-AGI` and Browserless simultaneously in a single application.
|
||||
|
||||
|
||||
### 🌐 Your own Chrome browser
|
||||
|
||||
***EXPERIMENTAL - UNTESTED*** - You can use your own Chrome browser as a browsing service, by configuring it to expose
|
||||
@@ -84,4 +108,4 @@ If you encounter any issues or have questions about configuring the browse funct
|
||||
|
||||
---
|
||||
|
||||
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
|
||||
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
|
||||
|
||||
@@ -28,6 +28,7 @@ GEMINI_API_KEY=
|
||||
MISTRAL_API_KEY=
|
||||
OLLAMA_API_HOST=
|
||||
OPENROUTER_API_KEY=
|
||||
TOGETHERAI_API_KEY=
|
||||
|
||||
# Model Observability: Helicone
|
||||
HELICONE_API_KEY=
|
||||
@@ -85,6 +86,7 @@ requiring the user to enter an API key
|
||||
| `MISTRAL_API_KEY` | The API key for Mistral | Optional |
|
||||
| `OLLAMA_API_HOST` | Changes the backend host for the Ollama vendor. See [config-ollama.md](config-ollama.md) | |
|
||||
| `OPENROUTER_API_KEY` | The API key for OpenRouter | Optional |
|
||||
| `TOGETHERAI_API_KEY` | The API key for Together AI | Optional |
|
||||
|
||||
### Model Observability: Helicone
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+955
-382
File diff suppressed because it is too large
Load Diff
+29
-26
@@ -1,71 +1,74 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.9.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.1",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.1",
|
||||
"@mui/joy": "^5.0.0-beta.19",
|
||||
"@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",
|
||||
"@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.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",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"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.3",
|
||||
"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.5",
|
||||
"@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.45",
|
||||
"@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": {
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as React from 'react';
|
||||
import Head from 'next/head';
|
||||
import { MyAppProps } from 'next/app';
|
||||
import { Analytics as VercelAnalytics } from '@vercel/analytics/react';
|
||||
import { SpeedInsights as VercelSpeedInsights } from '@vercel/speed-insights/next';
|
||||
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
@@ -9,6 +11,8 @@ import { apiQuery } from '~/common/util/trpc.client';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import '~/common/styles/CodePrism.css';
|
||||
import '~/common/styles/GithubMarkdown.css';
|
||||
import '~/common/styles/NProgress.css';
|
||||
import '~/common/styles/app.styles.css';
|
||||
|
||||
import { ProviderBackendAndNoSSR } from '~/common/providers/ProviderBackendAndNoSSR';
|
||||
import { ProviderBootstrapLogic } from '~/common/providers/ProviderBootstrapLogic';
|
||||
@@ -41,6 +45,7 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
|
||||
</ProviderTheming>
|
||||
|
||||
<VercelAnalytics debug={false} />
|
||||
<VercelSpeedInsights debug={false} sampleRate={1 / 10} />
|
||||
|
||||
</>;
|
||||
|
||||
|
||||
+5
-5
@@ -5,7 +5,7 @@ import createEmotionServer from '@emotion/server/create-instance';
|
||||
import { getInitColorSchemeScript } from '@mui/joy/styles';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { bodyFontClassName, createEmotionCache } from '~/common/app.theme';
|
||||
import { createEmotionCache } from '~/common/app.theme';
|
||||
|
||||
|
||||
interface MyDocumentProps extends DocumentProps {
|
||||
@@ -14,7 +14,7 @@ interface MyDocumentProps extends DocumentProps {
|
||||
|
||||
export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
|
||||
return (
|
||||
<Html lang='en' className={bodyFontClassName}>
|
||||
<Html lang='en'>
|
||||
<Head>
|
||||
{/* Meta (missing Title, set by the App or Page) */}
|
||||
<meta name='description' content={Brand.Meta.Description} />
|
||||
@@ -51,9 +51,9 @@ export default function MyDocument({ emotionStyleTags }: MyDocumentProps) {
|
||||
{emotionStyleTags}
|
||||
</Head>
|
||||
<body>
|
||||
{getInitColorSchemeScript()}
|
||||
<Main />
|
||||
<NextScript />
|
||||
{getInitColorSchemeScript()}
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
|
||||
@@ -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 />);
|
||||
}
|
||||
+4
-1
@@ -6,9 +6,12 @@ import { useRedirectToNewsOnUpdates } from '../src/apps/news/news.hooks';
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
export default function ChatPage() {
|
||||
export default function IndexPage() {
|
||||
// show the News page if there are unseen updates
|
||||
useRedirectToNewsOnUpdates();
|
||||
|
||||
// TODO: This Index page will point to the Dashboard (or a landing page) soon
|
||||
// For now it offers the chat experience, but this will change. #299
|
||||
|
||||
return withLayout({ type: 'optima' }, <AppChat />);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
|
||||
@@ -7,8 +6,7 @@ import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
import { navigateToIndex } from '~/common/app.routes';
|
||||
import { themeBgApp } from '~/common/app.theme';
|
||||
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
@@ -43,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 },
|
||||
@@ -86,9 +83,8 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
|
||||
*/
|
||||
export default function CallbackPage() {
|
||||
|
||||
// get the 'code=...' from the URL
|
||||
const { query } = useRouter();
|
||||
const { code: openRouterCode } = query;
|
||||
// external state - get the 'code=...' from the URL
|
||||
const { code } = useRouterQuery<{ code: string | undefined }>();
|
||||
|
||||
return withLayout({ type: 'plain' }, <CallbackOpenRouterPage openRouterCode={openRouterCode as (string | undefined)} />);
|
||||
return withLayout({ type: 'plain' }, <CallbackOpenRouterPage openRouterCode={code} />);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
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';
|
||||
|
||||
|
||||
export default function ChatLinkPage() {
|
||||
const { query } = useRouter();
|
||||
const chatLinkId = query?.chatLinkId as string ?? '';
|
||||
|
||||
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppChatLink linkId={chatLinkId} />);
|
||||
// external state
|
||||
const { chatLinkId } = useRouterQuery<{ chatLinkId: string | undefined }>();
|
||||
|
||||
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppLinkChat chatLinkId={chatLinkId || null} />);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Alert, Box, Button, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
@@ -10,8 +9,7 @@ import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { LogoProgress } from '~/common/components/LogoProgress';
|
||||
import { asValidURL } from '~/common/util/urlUtils';
|
||||
import { navigateToIndex } from '~/common/app.routes';
|
||||
import { themeBgApp } from '~/common/app.theme';
|
||||
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
@@ -32,8 +30,10 @@ function AppShareTarget() {
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const { query } = useRouter();
|
||||
|
||||
const { url: queryUrl, text: queryText } = useRouterQuery<{
|
||||
url: string | string[] | undefined,
|
||||
text: string | string[] | undefined,
|
||||
}>();
|
||||
|
||||
const queueComposerTextAndLaunchApp = React.useCallback((text: string) => {
|
||||
setComposerStartupText(text);
|
||||
@@ -44,11 +44,11 @@ function AppShareTarget() {
|
||||
// Detect the share Intent from the query
|
||||
React.useEffect(() => {
|
||||
// skip when query is not parsed yet
|
||||
if (!Object.keys(query).length)
|
||||
let queryTextItem = queryUrl || queryText || null;
|
||||
if (!queryTextItem)
|
||||
return;
|
||||
|
||||
// single item from the query
|
||||
let queryTextItem: string[] | string | null = query.url || query.text || null;
|
||||
if (Array.isArray(queryTextItem))
|
||||
queryTextItem = queryTextItem[0];
|
||||
|
||||
@@ -59,9 +59,9 @@ function AppShareTarget() {
|
||||
else if (queryTextItem)
|
||||
setIntentText(queryTextItem);
|
||||
else
|
||||
setErrorMessage('No text or url. Received: ' + JSON.stringify(query));
|
||||
setErrorMessage('No text or url. Received: ' + JSON.stringify({ queryText, queryUrl }));
|
||||
|
||||
}, [query.url, query.text, query]);
|
||||
}, [queryText, queryUrl]);
|
||||
|
||||
|
||||
// Text -> Composer
|
||||
@@ -91,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
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { useRouterRoute } from '~/common/app.routes';
|
||||
|
||||
|
||||
/**
|
||||
* https://github.com/enricoros/big-AGI/issues/299
|
||||
*/
|
||||
export function AppPlaceholder(props: { text?: string }) {
|
||||
|
||||
// external state
|
||||
const route = useRouterRoute();
|
||||
|
||||
// derived state
|
||||
const placeholderAppName = capitalizeFirstLetter(route.replace('/', '') || 'Home');
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
p: { xs: 3, md: 6 },
|
||||
border: '1px solid blue',
|
||||
}}>
|
||||
|
||||
<Box sx={{
|
||||
my: 'auto',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
gap: 4,
|
||||
border: '1px solid red',
|
||||
}}>
|
||||
|
||||
<Typography level='h1'>
|
||||
{placeholderAppName}
|
||||
</Typography>
|
||||
<Typography>
|
||||
{props.text || 'Intelligent applications to help you learn, think, and do'}
|
||||
</Typography>
|
||||
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+58
-24
@@ -1,43 +1,77 @@
|
||||
import * as React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { Container, Sheet } from '@mui/joy';
|
||||
|
||||
import { AppCallQueryParams } 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 { query } = useRouter();
|
||||
|
||||
// derived state
|
||||
const { conversationId, personaId } = query as any as AppCallQueryParams;
|
||||
const validInput = !!conversationId && !!personaId;
|
||||
// state
|
||||
const [callIntent, setCallIntent] = React.useState<AppCallIntent | null>(null);
|
||||
|
||||
// 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. ${JSON.stringify(query)}`} />}
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,22 +75,26 @@ 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);
|
||||
|
||||
// external state
|
||||
const { openPreferences } = useOptimaLayout();
|
||||
const { openPreferencesTab } = useOptimaLayout();
|
||||
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 = () => {
|
||||
openPreferences(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' }}
|
||||
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 }} />
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -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,12 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
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';
|
||||
@@ -21,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 { ROUTE_APP_CHAT } from '~/common/app.routes';
|
||||
import { useAppCallStore } from './state/store-app-call';
|
||||
|
||||
|
||||
function CallMenuItems(props: {
|
||||
@@ -39,6 +39,7 @@ function CallMenuItems(props: {
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { grayUI, toggleGrayUI } = useAppCallStore();
|
||||
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
|
||||
|
||||
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
|
||||
@@ -64,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>
|
||||
|
||||
@@ -73,9 +80,9 @@ function CallMenuItems(props: {
|
||||
}
|
||||
|
||||
|
||||
export function CallUI(props: {
|
||||
conversationId: string,
|
||||
personaId: string,
|
||||
export function Telephone(props: {
|
||||
callIntent: AppCallIntent,
|
||||
backToContacts: () => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -89,16 +96,17 @@ export function CallUI(props: {
|
||||
const responseAbortController = React.useRef<AbortController | null>(null);
|
||||
|
||||
// external state
|
||||
const { push: routerPush } = useRouter();
|
||||
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;
|
||||
@@ -178,9 +186,7 @@ export function CallUI(props: {
|
||||
// command: close the call
|
||||
case 'Goodbye.':
|
||||
setStage('ended');
|
||||
setTimeout(() => {
|
||||
void routerPush(ROUTE_APP_CHAT);
|
||||
}, 2000);
|
||||
setTimeout(launchAppChat, 2000);
|
||||
return;
|
||||
// command: regenerate answer
|
||||
case 'Retry.':
|
||||
@@ -197,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 }]
|
||||
: [];
|
||||
@@ -227,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, routerPush]);
|
||||
}, [isConnected, callMessages, chatLLMId, personaVoiceId, personaSystemMessage, reMessages]);
|
||||
|
||||
// [E] Message interrupter
|
||||
const abortTrigger = isConnected && isRecordingSpeech;
|
||||
@@ -298,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}
|
||||
/>
|
||||
@@ -307,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 */}
|
||||
@@ -348,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'}
|
||||
@@ -367,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>
|
||||
@@ -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' },
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
));
|
||||
@@ -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]);
|
||||
}
|
||||
+184
-159
@@ -1,46 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||
|
||||
import { Box, useTheme } from '@mui/joy';
|
||||
import { useTheme } from '@mui/joy';
|
||||
|
||||
import { CmdRunBrowse } from '~/modules/browse/browse.client';
|
||||
import { CmdRunReact } from '~/modules/aifn/react/react';
|
||||
import { CmdRunT2I, useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
|
||||
import { DiagramConfig, DiagramsModal } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal';
|
||||
import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
|
||||
import { getChatLLMId, useChatLLM } from '~/modules/llms/store-llms';
|
||||
import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
import { useChatLLM, useModelsStore } from '~/modules/llms/store-llms';
|
||||
import { useCapabilityTextToImage } from '~/modules/t2i/t2i.client';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
|
||||
import { GoodPanelResizeHandler } from '~/common/components/panes/GoodPanelResizeHandler';
|
||||
import { 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 { ChatDrawerItemsMemo } 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';
|
||||
import { CmdAddRoleMessage, CmdHelp, createCommandsHelpMessage, extractCommands } from './editors/commands';
|
||||
import { Composer } from './components/composer/Composer';
|
||||
import { Ephemerals } from './components/Ephemerals';
|
||||
import { ScrollToBottom } from './components/scroll-to-bottom/ScrollToBottom';
|
||||
import { ScrollToBottomButton } from './components/scroll-to-bottom/ScrollToBottomButton';
|
||||
import { usePanesManager } from './components/panes/usePanesManager';
|
||||
|
||||
import { extractChatCommand, findAllChatCommands } from './commands/commands.registry';
|
||||
import { runAssistantUpdatingState } from './editors/chat-stream';
|
||||
import { runBrowseUpdatingState } from './editors/browse-load';
|
||||
import { runImageGenerationUpdatingState } from './editors/image-generate';
|
||||
import { runReActUpdatingState } from './editors/react-tangent';
|
||||
|
||||
|
||||
/**
|
||||
* Mode: how to treat the input from the Composer
|
||||
*/
|
||||
@@ -64,6 +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 [_activeFolderId, setActiveFolderId] = React.useState<string | null>(null);
|
||||
|
||||
// external state
|
||||
const theme = useTheme();
|
||||
@@ -90,6 +90,7 @@ export function AppChat() {
|
||||
isChatEmpty: isFocusedChatEmpty,
|
||||
areChatsEmpty,
|
||||
newConversationId,
|
||||
conversationsLength,
|
||||
_remove_systemPurposeId: focusedSystemPurposeId,
|
||||
prependNewConversation,
|
||||
branchConversation,
|
||||
@@ -100,10 +101,18 @@ export function AppChat() {
|
||||
|
||||
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
|
||||
|
||||
const { activeFolderId, activeFolderConversationsCount } = useFolderStore(({ enableFolders, folders }) => {
|
||||
const activeFolderId = enableFolders ? _activeFolderId : null;
|
||||
const activeFolder = activeFolderId ? folders.find(folder => folder.id === activeFolderId) : null;
|
||||
return {
|
||||
activeFolderId: activeFolder?.id ?? null,
|
||||
activeFolderConversationsCount: activeFolder ? activeFolder.conversationIds.length : conversationsLength,
|
||||
};
|
||||
});
|
||||
|
||||
// Window actions
|
||||
|
||||
const panesConversationIDs = chatPanes.length > 0 ? chatPanes.map(pane => pane.conversationId) : [null];
|
||||
const panesConversationIDs = chatPanes.length > 0 ? chatPanes.map((pane) => pane.conversationId) : [null];
|
||||
const isSplitPane = chatPanes.length > 1;
|
||||
|
||||
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
|
||||
@@ -135,39 +144,45 @@ export function AppChat() {
|
||||
}
|
||||
}, [focusedChatNumber, focusedChatTitle]);
|
||||
|
||||
|
||||
// Execution
|
||||
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
|
||||
const { chatLLMId } = useModelsStore.getState();
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
const chatLLMId = getChatLLMId();
|
||||
if (!chatModeId || !conversationId || !chatLLMId) return;
|
||||
|
||||
// "/command ...": overrides the chat mode
|
||||
const lastMessage = history.length > 0 ? history[history.length - 1] : null;
|
||||
if (lastMessage?.role === 'user') {
|
||||
const pieces = extractCommands(lastMessage.text);
|
||||
if (pieces.length == 2 && pieces[0].type === 'cmd' && pieces[1].type === 'text') {
|
||||
const [command, prompt] = [pieces[0].value, pieces[1].value];
|
||||
if (CmdRunT2I.includes(command)) {
|
||||
setMessages(conversationId, history);
|
||||
return await runImageGenerationUpdatingState(conversationId, prompt);
|
||||
}
|
||||
if (CmdRunReact.includes(command) && chatLLMId) {
|
||||
setMessages(conversationId, history);
|
||||
return await runReActUpdatingState(conversationId, prompt, chatLLMId);
|
||||
}
|
||||
if (CmdRunBrowse.includes(command) && prompt?.trim() && useBrowseStore.getState().enableCommandBrowse) {
|
||||
setMessages(conversationId, history);
|
||||
return await runBrowseUpdatingState(conversationId, prompt);
|
||||
}
|
||||
if (CmdAddRoleMessage.includes(command)) {
|
||||
lastMessage.role = command.startsWith('/s') ? 'system' : command.startsWith('/a') ? 'assistant' : 'user';
|
||||
lastMessage.sender = 'Bot';
|
||||
lastMessage.text = prompt;
|
||||
return setMessages(conversationId, history);
|
||||
}
|
||||
if (CmdHelp.includes(command)) {
|
||||
return setMessages(conversationId, [...history, createCommandsHelpMessage()]);
|
||||
const chatCommand = extractChatCommand(lastMessage.text)[0];
|
||||
if (chatCommand && chatCommand.type === 'cmd') {
|
||||
switch (chatCommand.providerId) {
|
||||
case 'ass-browse':
|
||||
setMessages(conversationId, history);
|
||||
return await runBrowseUpdatingState(conversationId, chatCommand.params!);
|
||||
|
||||
case 'ass-t2i':
|
||||
setMessages(conversationId, history);
|
||||
return await runImageGenerationUpdatingState(conversationId, chatCommand.params!);
|
||||
|
||||
case 'ass-react':
|
||||
setMessages(conversationId, history);
|
||||
return await runReActUpdatingState(conversationId, chatCommand.params!, chatLLMId);
|
||||
|
||||
case 'chat-alter':
|
||||
Object.assign(lastMessage, {
|
||||
role: chatCommand.command.startsWith('/s') ? 'system' : chatCommand.command.startsWith('/a') ? 'assistant' : 'user',
|
||||
sender: 'Bot',
|
||||
text: chatCommand.params || '',
|
||||
} satisfies Partial<DMessage>);
|
||||
return setMessages(conversationId, history);
|
||||
|
||||
case 'cmd-help':
|
||||
const chatCommandsText = findAllChatCommands()
|
||||
.map(cmd => ` - ${cmd.primary}` + (cmd.alternatives?.length ? ` (${cmd.alternatives.join(', ')})` : '') + `: ${cmd.description}`)
|
||||
.join('\n');
|
||||
const helpMessage = createDMessage('assistant', 'Available Chat Commands:\n' + chatCommandsText);
|
||||
helpMessage.originLLM = Brand.Title.Base;
|
||||
return setMessages(conversationId, [...history, helpMessage]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,9 +199,10 @@ export function AppChat() {
|
||||
case 'generate-image':
|
||||
if (!lastMessage?.text)
|
||||
break;
|
||||
// also add a 'fake' user message with the '/draw' command
|
||||
setMessages(conversationId, history.map(message => message.id !== lastMessage.id ? message : {
|
||||
...message,
|
||||
text: `${CmdRunT2I[0]} ${lastMessage.text}`,
|
||||
text: `/draw ${lastMessage.text}`,
|
||||
}));
|
||||
return await runImageGenerationUpdatingState(conversationId, lastMessage.text);
|
||||
|
||||
@@ -204,7 +220,6 @@ export function AppChat() {
|
||||
}, [focusedSystemPurposeId, setMessages]);
|
||||
|
||||
const handleComposerAction = (chatModeId: ChatModeId, conversationId: DConversationId, multiPartMessage: ComposerOutputMultiPart): boolean => {
|
||||
|
||||
// validate inputs
|
||||
if (multiPartMessage.length !== 1 || multiPartMessage[0].type !== 'text-block') {
|
||||
addSnackbar({
|
||||
@@ -232,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);
|
||||
@@ -246,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;
|
||||
@@ -257,27 +273,34 @@ 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
|
||||
|
||||
const handleConversationNew = React.useCallback(() => {
|
||||
|
||||
// activate an existing new conversation if present, or create another
|
||||
setFocusedConversationId(newConversationId
|
||||
const conversationId = newConversationId
|
||||
? newConversationId
|
||||
: prependNewConversation(focusedSystemPurposeId ?? undefined),
|
||||
);
|
||||
: prependNewConversation(focusedSystemPurposeId ?? undefined);
|
||||
setFocusedConversationId(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, setFocusedConversationId]);
|
||||
|
||||
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
|
||||
}, [activeFolderId, focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
|
||||
|
||||
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
|
||||
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
|
||||
|
||||
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;
|
||||
@@ -301,7 +324,6 @@ export function AppChat() {
|
||||
|
||||
const handleConversationFlatten = (conversationId: DConversationId) => setFlattenConversationId(conversationId);
|
||||
|
||||
|
||||
const handleConfirmedClearConversation = React.useCallback(() => {
|
||||
if (clearConversationId) {
|
||||
setMessages(clearConversationId, []);
|
||||
@@ -309,14 +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);
|
||||
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, activeFolderId);
|
||||
else
|
||||
nextConversationId = deleteConversation(deleteConversationId);
|
||||
setFocusedConversationId(nextConversationId);
|
||||
@@ -324,20 +345,20 @@ export function AppChat() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
|
||||
if (bypassConfirmation)
|
||||
setFocusedConversationId(deleteConversation(conversationId));
|
||||
else
|
||||
setDeleteConversationId(conversationId);
|
||||
}, [deleteConversation, setFocusedConversationId]);
|
||||
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
|
||||
|
||||
const handleConversationDelete = React.useCallback(
|
||||
(conversationId: DConversationId, bypassConfirmation: boolean) => {
|
||||
if (bypassConfirmation) setFocusedConversationId(deleteConversation(conversationId));
|
||||
else setDeleteConversationId(conversationId);
|
||||
},
|
||||
[deleteConversation, setFocusedConversationId],
|
||||
);
|
||||
|
||||
// Shortcuts
|
||||
|
||||
const handleOpenChatLlmOptions = React.useCallback(() => {
|
||||
const { chatLLMId } = useModelsStore.getState();
|
||||
const chatLLMId = getChatLLMId();
|
||||
if (!chatLLMId) return;
|
||||
openLlmOptions(chatLLMId);
|
||||
}, [openLlmOptions]);
|
||||
@@ -346,15 +367,14 @@ export function AppChat() {
|
||||
['o', true, true, false, handleOpenChatLlmOptions],
|
||||
['r', true, true, false, handleMessageRegenerateLast],
|
||||
['n', true, false, true, handleConversationNew],
|
||||
['b', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationBranch(focusedConversationId, null)],
|
||||
['x', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationClear(focusedConversationId)],
|
||||
['b', true, false, true, () => isFocusedChatEmpty || (focusedConversationId && handleConversationBranch(focusedConversationId, null))],
|
||||
['x', true, false, true, () => isFocusedChatEmpty || (focusedConversationId && handleConversationClear(focusedConversationId))],
|
||||
['d', true, false, true, () => focusedConversationId && handleConversationDelete(focusedConversationId, false)],
|
||||
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
|
||||
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
|
||||
// Pluggable ApplicationBar components
|
||||
|
||||
const centerItems = React.useMemo(() =>
|
||||
@@ -366,17 +386,20 @@ export function AppChat() {
|
||||
[focusedConversationId, isSplitPane, toggleSplitPane],
|
||||
);
|
||||
|
||||
const drawerItems = React.useMemo(() =>
|
||||
<ChatDrawerItemsMemo
|
||||
const drawerContent = React.useMemo(() =>
|
||||
<ChatDrawerMemo
|
||||
activeConversationId={focusedConversationId}
|
||||
activeFolderId={activeFolderId}
|
||||
disableNewButton={isFocusedChatEmpty}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
onConversationExportDialog={handleConversationExport}
|
||||
onConversationImportDialog={handleConversationImportDialog}
|
||||
onConversationNew={handleConversationNew}
|
||||
onConversationsDeleteAll={handleConversationsDeleteAll}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
/>,
|
||||
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, setFocusedConversationId],
|
||||
[activeFolderId, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, setFocusedConversationId],
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() =>
|
||||
@@ -388,99 +411,92 @@ 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(drawerItems, centerItems, menuItems, 'AppChat');
|
||||
usePluggableOptimaLayout(drawerContent, centerItems, menuItems, 'AppChat');
|
||||
|
||||
return <>
|
||||
|
||||
<PanelGroup direction='horizontal'>
|
||||
<PanelGroup
|
||||
direction='horizontal'
|
||||
id='app-chat-panels'
|
||||
>
|
||||
|
||||
{panesConversationIDs.map((_conversationId, idx, panels) => <React.Fragment key={`chat-pane-${idx}-${panels.length}-${_conversationId}`}>
|
||||
|
||||
<Panel
|
||||
id={'chat-pane-' + _conversationId}
|
||||
order={idx}
|
||||
collapsible
|
||||
defaultSize={panels.length > 0 ? Math.round(100 / panels.length) : undefined}
|
||||
minSize={20}
|
||||
onClick={() => setFocusedPane(idx)}
|
||||
onCollapse={() => setTimeout(() => removePane(idx), 50)}
|
||||
style={{
|
||||
// for anchoring the scroll button in place
|
||||
position: 'relative',
|
||||
// border only for active pane (if two or more panes)
|
||||
...(panesConversationIDs.length < 2 ? {}
|
||||
: (_conversationId === focusedConversationId)
|
||||
? { border: `2px solid ${theme.palette.primary.solidBg}` }
|
||||
: { border: `2px solid ${theme.palette.background.level1}` }),
|
||||
}}
|
||||
>
|
||||
|
||||
<ScrollToBottom
|
||||
bootToBottom
|
||||
stickToBottom
|
||||
sx={{
|
||||
// allows the content to be scrolled (all browsers)
|
||||
overflowY: 'auto',
|
||||
// actually make sure this scrolls & fills
|
||||
height: '100%',
|
||||
{panesConversationIDs.map((_conversationId, idx, panels) =>
|
||||
<React.Fragment key={`chat-pane-${idx}-${panels.length}-${_conversationId}`}>
|
||||
<Panel
|
||||
id={'chat-pane-' + _conversationId}
|
||||
order={idx}
|
||||
collapsible
|
||||
defaultSize={panels.length > 0 ? Math.round(100 / panels.length) : undefined}
|
||||
minSize={20}
|
||||
onClick={(event) => {
|
||||
const setFocus = chatPanes.length < 2 || !event.altKey;
|
||||
setFocusedPane(setFocus ? idx : -1);
|
||||
}}
|
||||
onCollapse={() => setTimeout(() => removePane(idx), 50)}
|
||||
style={{
|
||||
// for anchoring the scroll button in place
|
||||
position: 'relative',
|
||||
// border only for active pane (if two or more panes)
|
||||
...(panesConversationIDs.length < 2
|
||||
? {}
|
||||
: (_conversationId === focusedConversationId)
|
||||
? { border: `2px solid ${theme.palette.primary.solidBg}` }
|
||||
: { border: `2px solid ${theme.palette.background.level1}` }),
|
||||
}}
|
||||
>
|
||||
|
||||
<ChatMessageList
|
||||
conversationId={_conversationId}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
chatLLMContextTokens={chatLLM?.contextTokens}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationExecuteHistory={handleConversationExecuteHistory}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
<ScrollToBottom
|
||||
bootToBottom
|
||||
stickToBottom
|
||||
sx={{
|
||||
backgroundColor: themeBgApp,
|
||||
minHeight: '100%', // ensures filling of the blank space on newer chats
|
||||
}}
|
||||
/>
|
||||
|
||||
<Ephemerals
|
||||
conversationId={_conversationId}
|
||||
sx={{
|
||||
// TODO: Fixme post panels?
|
||||
// flexGrow: 0.1,
|
||||
flexShrink: 0.5,
|
||||
// allows the content to be scrolled (all browsers)
|
||||
overflowY: 'auto',
|
||||
minHeight: 64,
|
||||
}} />
|
||||
// actually make sure this scrolls & fills
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Visibility and actions are handled via Context */}
|
||||
<ScrollToBottomButton />
|
||||
<ChatMessageList
|
||||
conversationId={_conversationId}
|
||||
capabilityHasT2I={capabilityHasT2I}
|
||||
chatLLMContextTokens={chatLLM?.contextTokens ?? null}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationExecuteHistory={handleConversationExecuteHistory}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={{
|
||||
minHeight: '100%', // ensures filling of the blank space on newer chats
|
||||
}}
|
||||
/>
|
||||
|
||||
</ScrollToBottom>
|
||||
<Ephemerals
|
||||
conversationId={_conversationId}
|
||||
sx={{
|
||||
// TODO: Fixme post panels?
|
||||
// flexGrow: 0.1,
|
||||
flexShrink: 0.5,
|
||||
overflowY: 'auto',
|
||||
minHeight: 64,
|
||||
}}
|
||||
/>
|
||||
|
||||
</Panel>
|
||||
{/* Visibility and actions are handled via Context */}
|
||||
<ScrollToBottomButton />
|
||||
</ScrollToBottom>
|
||||
</Panel>
|
||||
|
||||
{/* Panel Separators & Resizers */}
|
||||
{idx < panels.length - 1 && (
|
||||
<PanelResizeHandle>
|
||||
<Box sx={{
|
||||
backgroundColor: themeBgApp,
|
||||
height: '100%',
|
||||
width: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.softActiveBg',
|
||||
},
|
||||
}} />
|
||||
</PanelResizeHandle>
|
||||
)}
|
||||
{/* Panel Separators & Resizers */}
|
||||
{idx < panels.length - 1 && <GoodPanelResizeHandler />}
|
||||
|
||||
</React.Fragment>)}
|
||||
</React.Fragment>)}
|
||||
|
||||
</PanelGroup>
|
||||
|
||||
@@ -498,8 +514,8 @@ export function AppChat() {
|
||||
borderTop: `1px solid`,
|
||||
borderTopColor: 'divider',
|
||||
p: { xs: 1, md: 2 },
|
||||
}} />
|
||||
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Diagrams */}
|
||||
{!!diagramConfig && <DiagramsModal config={diagramConfig} onClose={() => setDiagramConfig(null)} />}
|
||||
@@ -514,25 +530,34 @@ export function AppChat() {
|
||||
)}
|
||||
|
||||
{/* Import / Export */}
|
||||
{!!tradeConfig && <TradeModal config={tradeConfig} onConversationActivate={setFocusedConversationId} onClose={() => setTradeConfig(null)} />}
|
||||
|
||||
{!!tradeConfig && (
|
||||
<TradeModal
|
||||
config={tradeConfig}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onClose={() => setTradeConfig(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [confirmation] Reset Conversation */}
|
||||
{!!clearConversationId && <ConfirmationModal
|
||||
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
|
||||
confirmationText={'Are you sure you want to discard all messages?'} positiveActionText={'Clear conversation'}
|
||||
/>}
|
||||
{!!clearConversationId && (
|
||||
<ConfirmationModal
|
||||
open
|
||||
onClose={() => setClearConversationId(null)}
|
||||
onPositive={handleConfirmedClearConversation}
|
||||
confirmationText='Are you sure you want to discard all messages?'
|
||||
positiveActionText='Clear conversation'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* [confirmation] Delete All */}
|
||||
{!!deleteConversationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? 'Are you absolutely sure you want to delete ALL conversations? This action cannot be undone.'
|
||||
? `Are you 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'
|
||||
? `Yes, delete all ${activeFolderConversationsCount} conversations`
|
||||
: 'Delete conversation'}
|
||||
/>}
|
||||
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsAlter: ICommandsProvider = {
|
||||
id: 'chat-alter',
|
||||
rank: 20,
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/assistant',
|
||||
alternatives: ['/a'],
|
||||
arguments: ['text'],
|
||||
description: 'Injects assistant response',
|
||||
}, {
|
||||
primary: '/system',
|
||||
alternatives: ['/s'],
|
||||
arguments: ['text'],
|
||||
description: 'Injects system message',
|
||||
}],
|
||||
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsBrowse: ICommandsProvider = {
|
||||
id: 'ass-browse',
|
||||
rank: 25,
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/browse',
|
||||
arguments: ['URL'],
|
||||
description: 'Assistant will download the web page',
|
||||
Icon: LanguageIcon,
|
||||
}],
|
||||
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsDraw: ICommandsProvider = {
|
||||
id: 'ass-t2i',
|
||||
rank: 10,
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/draw',
|
||||
alternatives: ['/imagine', '/img'],
|
||||
arguments: ['prompt'],
|
||||
description: 'Assistant will draw the text',
|
||||
Icon: FormatPaintIcon,
|
||||
}],
|
||||
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsHelp: ICommandsProvider = {
|
||||
id: 'cmd-help',
|
||||
rank: 99,
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/help',
|
||||
alternatives: ['/?'],
|
||||
description: 'Display this list of commands',
|
||||
}],
|
||||
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import PsychologyIcon from '@mui/icons-material/Psychology';
|
||||
|
||||
import type { ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
export const CommandsReact: ICommandsProvider = {
|
||||
id: 'ass-react',
|
||||
rank: 15,
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/react',
|
||||
arguments: ['prompt'],
|
||||
description: 'Use the AI ReAct strategy to answer your query',
|
||||
Icon: PsychologyIcon,
|
||||
}],
|
||||
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FunctionComponent } from 'react';
|
||||
import type { CommandsProviderId } from './commands.registry';
|
||||
|
||||
|
||||
export interface ChatCommand {
|
||||
primary: string; // The primary command
|
||||
alternatives?: string[]; // Alternative commands
|
||||
arguments?: string[]; // Arguments for the command
|
||||
description: string; // Description of what the command does
|
||||
// usage?: string; // Example of how to use the command
|
||||
Icon?: FunctionComponent; // Icon to display next to the command
|
||||
}
|
||||
|
||||
|
||||
export interface ICommandsProvider {
|
||||
id: CommandsProviderId; // Unique identifier for the command provider
|
||||
rank: number; // Rank of the provider, used to sort the providers in the UI
|
||||
|
||||
// Function to get commands with their alternatives and details
|
||||
getCommands: () => ChatCommand[];
|
||||
|
||||
// Function to execute a command with optional parameters
|
||||
// executeCommand: (command: string, params?: string[]) => Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ChatCommand, ICommandsProvider } from './ICommandsProvider';
|
||||
|
||||
import { CommandsAlter } from './CommandsAlter';
|
||||
import { CommandsBrowse } from './CommandsBrowse';
|
||||
import { CommandsDraw } from './CommandsDraw';
|
||||
import { CommandsHelp } from './CommandsHelp';
|
||||
import { CommandsReact } from './CommandsReact';
|
||||
|
||||
|
||||
export type CommandsProviderId = 'ass-browse' | 'ass-t2i' | 'ass-react' | 'chat-alter' | 'cmd-help';
|
||||
|
||||
type TextCommandPiece =
|
||||
| { type: 'text'; value: string; }
|
||||
| { type: 'cmd'; providerId: CommandsProviderId, command: string; params?: string, isError?: boolean };
|
||||
|
||||
|
||||
const ChatCommandsProviders: Record<CommandsProviderId, ICommandsProvider> = {
|
||||
'ass-browse': CommandsBrowse,
|
||||
'ass-react': CommandsReact,
|
||||
'ass-t2i': CommandsDraw,
|
||||
'chat-alter': CommandsAlter,
|
||||
'cmd-help': CommandsHelp,
|
||||
};
|
||||
|
||||
export function findAllChatCommands(): ChatCommand[] {
|
||||
return Object.values(ChatCommandsProviders)
|
||||
.sort((a, b) => a.rank - b.rank)
|
||||
.map(p => p.getCommands())
|
||||
.flat();
|
||||
}
|
||||
|
||||
export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
const inputTrimmed = input.trim();
|
||||
|
||||
// quick exit: command does not start with '/'
|
||||
if (!inputTrimmed.startsWith('/'))
|
||||
return [{ type: 'text', value: input }];
|
||||
|
||||
// Find the first space to separate the command from its parameters (if any)
|
||||
const firstSpaceIndex = inputTrimmed.indexOf(' ');
|
||||
const potentialCommand = inputTrimmed.substring(0, firstSpaceIndex >= 0 ? firstSpaceIndex : inputTrimmed.length);
|
||||
|
||||
// Check if the potential command is an actual command
|
||||
for (const provider of Object.values(ChatCommandsProviders)) {
|
||||
for (const cmd of provider.getCommands()) {
|
||||
if (cmd.primary === potentialCommand || cmd.alternatives?.includes(potentialCommand)) {
|
||||
|
||||
// command needs arguments: take the rest of the input as parameters
|
||||
if (cmd.arguments?.length) {
|
||||
const params = firstSpaceIndex >= 0 ? inputTrimmed.substring(firstSpaceIndex + 1) : '';
|
||||
return [{ type: 'cmd', providerId: provider.id, command: potentialCommand, params: params || undefined, isError: !params || undefined }];
|
||||
}
|
||||
|
||||
// command without arguments, treat any text after as a separate text piece
|
||||
const pieces: TextCommandPiece[] = [{ type: 'cmd', providerId: provider.id, command: potentialCommand, params: undefined }];
|
||||
const textAfterCommand = firstSpaceIndex >= 0 ? inputTrimmed.substring(firstSpaceIndex + 1) : '';
|
||||
if (textAfterCommand)
|
||||
pieces.push({ type: 'text', value: textAfterCommand });
|
||||
return pieces;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No command found, return the entire input as text
|
||||
return [{ type: 'text', value: input }];
|
||||
}
|
||||
@@ -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';
|
||||
@@ -25,13 +25,13 @@ import { useScrollToBottom } from './scroll-to-bottom/useScrollToBottom';
|
||||
export function ChatMessageList(props: {
|
||||
conversationId: DConversationId | null,
|
||||
capabilityHasT2I: boolean,
|
||||
chatLLMContextTokens?: number,
|
||||
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,
|
||||
}) {
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ChatMessageList(props: {
|
||||
|
||||
// external state
|
||||
const { notifyBooting } = useScrollToBottom();
|
||||
const { openPreferences } = useOptimaLayout();
|
||||
const { openPreferencesTab } = useOptimaLayout();
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const { conversationMessages, historyTokenCount, editMessage, deleteMessage, setMessages } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
@@ -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,26 +98,26 @@ 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 openPreferences(2);
|
||||
return openPreferencesTab(PreferencesTab.Draw);
|
||||
if (conversationId) {
|
||||
setIsImagining(true);
|
||||
await onTextImagine(conversationId, text);
|
||||
setIsImagining(false);
|
||||
}
|
||||
}, [capabilityHasT2I, conversationId, onTextImagine, openPreferences]);
|
||||
}, [capabilityHasT2I, conversationId, onTextImagine, openPreferencesTab]);
|
||||
|
||||
const handleTextSpeak = React.useCallback(async (text: string) => {
|
||||
if (!isSpeakable)
|
||||
return openPreferences(3);
|
||||
return openPreferencesTab(PreferencesTab.Voice);
|
||||
setIsSpeaking(true);
|
||||
await onTextSpeak(text);
|
||||
setIsSpeaking(false);
|
||||
}, [isSpeakable, onTextSpeak, openPreferences]);
|
||||
}, [isSpeakable, onTextSpeak, openPreferencesTab]);
|
||||
|
||||
|
||||
// operate on the local selection set
|
||||
@@ -209,7 +210,7 @@ export function ChatMessageList(props: {
|
||||
<CleanerMessage
|
||||
key={'sel-' + message.id}
|
||||
message={message}
|
||||
remainingTokens={(props.chatLLMContextTokens || 0) - historyTokenCount}
|
||||
remainingTokens={props.chatLLMContextTokens ? (props.chatLLMContextTokens - historyTokenCount) : undefined}
|
||||
selected={selectedMessages.has(message.id)} onToggleSelected={handleSelectMessage}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Grid, IconButton, Sheet, Stack, styled, Typography, useTheme } from '@mui/joy';
|
||||
import { Box, Grid, IconButton, Sheet, styled, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
|
||||
import { lineHeightChatText } from '~/common/app.theme';
|
||||
|
||||
|
||||
const StateLine = styled(Typography)(({ theme }) => ({
|
||||
@@ -15,7 +16,7 @@ const StateLine = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: theme.fontFamily.code,
|
||||
marginLeft: theme.spacing(1),
|
||||
lineHeight: 2,
|
||||
lineHeight: lineHeightChatText,
|
||||
}));
|
||||
|
||||
function isPrimitive(value: any): boolean {
|
||||
@@ -52,11 +53,11 @@ function StateRenderer(props: { state: object }) {
|
||||
const entries = Object.entries(props.state);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
Internal State
|
||||
<Box>
|
||||
<Typography fontSize='smaller' sx={{ mb: 1 }}>
|
||||
## Internal State
|
||||
</Typography>
|
||||
<Sheet>
|
||||
<Sheet sx={{ p: 1 }}>
|
||||
{!entries && <Typography level='body-sm'>No state variables</Typography>}
|
||||
{entries.map(([key, value]) =>
|
||||
isPrimitive(value)
|
||||
@@ -68,13 +69,12 @@ function StateRenderer(props: { state: object }) {
|
||||
: <Typography key={'state-' + key} level='body-sm'>{key}: {value}</Typography>,
|
||||
)}
|
||||
</Sheet>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function EphemeralItem({ conversationId, ephemeral }: { conversationId: string, ephemeral: DEphemeral }) {
|
||||
const theme = useTheme();
|
||||
return <Box
|
||||
sx={{
|
||||
p: { xs: 1, md: 2 },
|
||||
@@ -84,8 +84,8 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
}}>
|
||||
|
||||
{/* Title */}
|
||||
{ephemeral.title && <Typography>
|
||||
{ephemeral.title} <b>Development Tools</b>
|
||||
{ephemeral.title && <Typography level='title-sm' sx={{ mb: 1.5 }}>
|
||||
{ephemeral.title} Development Tools
|
||||
</Typography>}
|
||||
|
||||
{/* Vertical | split */}
|
||||
@@ -93,7 +93,7 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
|
||||
{/* Left pane (console) */}
|
||||
<Grid xs={12} md={ephemeral.state ? 6 : 12}>
|
||||
<Typography fontSize='smaller' sx={{ overflowWrap: 'anywhere', whiteSpace: 'break-spaces', lineHeight: 1.75 }}>
|
||||
<Typography fontSize='smaller' sx={{ overflowWrap: 'anywhere', whiteSpace: 'break-spaces', lineHeight: lineHeightChatText }}>
|
||||
{ephemeral.text}
|
||||
</Typography>
|
||||
</Grid>
|
||||
@@ -102,8 +102,8 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
{!!ephemeral.state && <Grid
|
||||
xs={12} md={6}
|
||||
sx={{
|
||||
borderLeft: { md: `1px solid ${theme.palette.divider}` },
|
||||
borderTop: { xs: `1px solid ${theme.palette.divider}`, md: 'none' },
|
||||
borderLeft: { md: `1px dashed` },
|
||||
borderTop: { xs: `1px dashed`, md: 'none' },
|
||||
}}>
|
||||
<StateRenderer state={ephemeral.state} />
|
||||
</Grid>}
|
||||
@@ -123,10 +123,15 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
</Box>;
|
||||
}
|
||||
|
||||
// const dashedBorderSVG = encodeURIComponent(`
|
||||
// <svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'>
|
||||
// <rect x='0' y='0' width='100%' height='100%' fill='none' stroke='currentColor' stroke-width='2' stroke-dasharray='16, 2' />
|
||||
// </svg>
|
||||
// `);
|
||||
|
||||
|
||||
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
|
||||
// global state
|
||||
const theme = useTheme();
|
||||
const ephemerals = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return conversation ? conversation.ephemerals : [];
|
||||
@@ -138,7 +143,9 @@ export function Ephemerals(props: { conversationId: DConversationId | null, sx?:
|
||||
<Sheet
|
||||
variant='soft' color='success' invertedColors
|
||||
sx={{
|
||||
border: `4px dashed ${theme.palette.divider}`,
|
||||
// backgroundImage: `url("data:image/svg+xml,${dashedBorderSVG.replace('currentColor', '%23A1E8A1')}")`,
|
||||
// backgroundSize: '100% 100%',
|
||||
// backgroundRepeat: 'no-repeat',
|
||||
...(props.sx || {}),
|
||||
}}>
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
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 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 { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
|
||||
import { ChatFolderList } from './folder/ChatFolderList';
|
||||
import { ClearFolderText } from './folder/useFolderDropdown';
|
||||
|
||||
|
||||
// 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 useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
|
||||
useChatStore(({ conversations }) => {
|
||||
|
||||
const activeConversations = activeFolder
|
||||
? conversations.filter(_c => activeFolder.conversationIds.includes(_c.id))
|
||||
: conversations;
|
||||
|
||||
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, b) => {
|
||||
// custom equality function to avoid unnecessary re-renders
|
||||
return a.length === b.length && a.every((_a, i) => shallow(_a, b[i]));
|
||||
});
|
||||
|
||||
|
||||
export const ChatDrawerMemo = React.memo(ChatDrawer);
|
||||
|
||||
function ChatDrawer(props: {
|
||||
activeConversationId: DConversationId | null,
|
||||
activeFolderId: string | null,
|
||||
disableNewButton: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId) => void,
|
||||
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
|
||||
onConversationExportDialog: (conversationId: DConversationId | null) => void,
|
||||
onConversationImportDialog: () => void,
|
||||
onConversationNew: () => void,
|
||||
onConversationsDeleteAll: () => void,
|
||||
setActiveFolderId: (folderId: string | null) => void,
|
||||
}) {
|
||||
|
||||
const { onConversationActivate, onConversationDelete, onConversationExportDialog, onConversationNew } = props;
|
||||
|
||||
// local state
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
|
||||
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
|
||||
|
||||
// external state
|
||||
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
|
||||
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId);
|
||||
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
||||
|
||||
// derived state
|
||||
const selectConversationsCount = chatNavItems.length;
|
||||
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
|
||||
const singleChat = selectConversationsCount === 1;
|
||||
const softMaxReached = selectConversationsCount >= 10;
|
||||
|
||||
|
||||
const handleButtonNew = React.useCallback(() => {
|
||||
onConversationNew();
|
||||
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;
|
||||
return chatNavItems
|
||||
.map(item => {
|
||||
// Get the conversation by ID
|
||||
const conversation = useChatStore.getState().conversations.find(c => c.id === item.conversationId);
|
||||
// Calculate the frequency of the search term in the title and messages
|
||||
const titleFrequency = (item.title.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
|
||||
const messageFrequency = conversation?.messages.reduce((count, message) => {
|
||||
return count + (message.text.toLowerCase().match(new RegExp(debouncedSearchQuery.toLowerCase(), 'g')) || []).length;
|
||||
}, 0) || 0;
|
||||
// Return the item with the searchFrequency property
|
||||
return {
|
||||
...item,
|
||||
searchFrequency: titleFrequency + messageFrequency,
|
||||
};
|
||||
})
|
||||
// Exclude items with a searchFrequency of 0
|
||||
.filter(item => item.searchFrequency > 0)
|
||||
// Sort the items by searchFrequency in descending order
|
||||
.sort((a, b) => b.searchFrequency! - a.searchFrequency!);
|
||||
}, [chatNavItems, debouncedSearchQuery]);
|
||||
|
||||
|
||||
// basis for the underline bar
|
||||
const bottomBarBasis = filteredChatNavItems.reduce((longest, _c) => Math.max(longest, _c.searchFrequency ?? _c.messageCount), 1);
|
||||
|
||||
|
||||
// grouping
|
||||
/*let sortedIds = conversationIDs;
|
||||
if (grouping === 'persona') {
|
||||
const conversations = useChatStore.getState().conversations;
|
||||
|
||||
// group conversations by persona
|
||||
const groupedConversations: { [personaId: string]: string[] } = {};
|
||||
conversations.forEach(conversation => {
|
||||
const persona = conversation.systemPurposeId;
|
||||
if (persona) {
|
||||
if (!groupedConversations[persona])
|
||||
groupedConversations[persona] = [];
|
||||
groupedConversations[persona].push(conversation.id);
|
||||
}
|
||||
});
|
||||
|
||||
// flatten grouped conversations
|
||||
sortedIds = Object.values(groupedConversations).flat();
|
||||
}*/
|
||||
|
||||
return <>
|
||||
|
||||
{/* Drawer Header */}
|
||||
<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: !enableFolders ? '0fr' : '1fr',*/}
|
||||
{/* transition: 'grid-template-rows 0.42s cubic-bezier(.17,.84,.44,1)',*/}
|
||||
{/* '& > div': {*/}
|
||||
{/* padding: enableFolders ? 2 : 0,*/}
|
||||
{/* transition: 'padding 0.42s cubic-bezier(.17,.84,.44,1)',*/}
|
||||
{/* overflow: 'hidden',*/}
|
||||
{/* },*/}
|
||||
{/*}}>*/}
|
||||
{enableFolders && (
|
||||
<ChatFolderList
|
||||
folders={allFolders}
|
||||
activeFolderId={props.activeFolderId}
|
||||
onFolderSelect={props.setActiveFolderId}
|
||||
/>
|
||||
)}
|
||||
{/*</Box>*/}
|
||||
|
||||
{/* Chats List */}
|
||||
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
|
||||
|
||||
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
|
||||
|
||||
{/* Search Input Field */}
|
||||
<DebounceInput
|
||||
minChars={2}
|
||||
onDebounce={setDebouncedSearchQuery}
|
||||
debounceTimeout={300}
|
||||
placeholder='Search...'
|
||||
aria-label='Search'
|
||||
sx={{ m: 2 }}
|
||||
/>
|
||||
|
||||
<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 }} />*/}
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/*<ListItem sticky sx={{ justifyContent: 'space-between', boxShadow: 'sm' }}>*/}
|
||||
{/* <Typography level='body-sm'>*/}
|
||||
{/* Conversations*/}
|
||||
{/* </Typography>*/}
|
||||
{/* <ToggleButtonGroup variant='soft' size='sm' value={grouping} onChange={(_event, newValue) => newValue && setGrouping(newValue)}>*/}
|
||||
{/* <IconButton value='off'>*/}
|
||||
{/* <AccessTimeIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* <IconButton value='persona'>*/}
|
||||
{/* <PersonIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* </ToggleButtonGroup>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
{filteredChatNavItems.map(item =>
|
||||
<ChatDrawerItemMemo
|
||||
key={'nav-' + item.conversationId}
|
||||
item={item}
|
||||
isLonely={singleChat}
|
||||
showSymbols={showSymbols}
|
||||
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 />
|
||||
</ListItemDecorator>
|
||||
Import
|
||||
{/*<OpenAIIcon sx={{ ml: 'auto' }} />*/}
|
||||
</ListItemButton>
|
||||
|
||||
<ListItemButton disabled={!nonEmptyChats} onClick={() => props.onConversationExportDialog(props.activeConversationId)} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator>
|
||||
<FileDownloadIcon />
|
||||
</ListItemDecorator>
|
||||
Export
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
|
||||
<ListItemButton disabled={!nonEmptyChats} onClick={props.onConversationsDeleteAll}>
|
||||
<ListItemDecorator>
|
||||
<DeleteOutlineIcon />
|
||||
</ListItemDecorator>
|
||||
Delete {selectConversationsCount >= 2 ? `all ${selectConversationsCount} chats` : 'chat'}
|
||||
</ListItemButton>
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, ListDivider, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon';
|
||||
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ChatNavigationItemMemo } from './ChatNavigationItem';
|
||||
|
||||
|
||||
// type ListGrouping = 'off' | 'persona';
|
||||
|
||||
export const ChatDrawerItemsMemo = React.memo(ChatDrawerItems);
|
||||
|
||||
function ChatDrawerItems(props: {
|
||||
activeConversationId: DConversationId | null,
|
||||
disableNewButton: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId) => void,
|
||||
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
|
||||
onConversationImportDialog: () => void,
|
||||
onConversationNew: () => void,
|
||||
onConversationsDeleteAll: () => void,
|
||||
}) {
|
||||
|
||||
// local state
|
||||
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
|
||||
// const [grouping] = React.useState<ListGrouping>('off');
|
||||
|
||||
// external state
|
||||
const { closeAppDrawer } = useOptimaLayout();
|
||||
const conversations = useChatStore(state => state.conversations, shallow);
|
||||
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
||||
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
|
||||
|
||||
// derived state
|
||||
const maxChatMessages = conversations.reduce((longest, _c) => Math.max(longest, _c.messages.length), 1);
|
||||
const totalConversations = conversations.length;
|
||||
const hasChats = totalConversations > 0;
|
||||
const singleChat = totalConversations === 1;
|
||||
const softMaxReached = totalConversations >= 50;
|
||||
|
||||
|
||||
const handleButtonNew = React.useCallback(() => {
|
||||
onConversationNew();
|
||||
closeAppDrawer();
|
||||
}, [closeAppDrawer, onConversationNew]);
|
||||
|
||||
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
|
||||
onConversationActivate(conversationId);
|
||||
if (closeMenu)
|
||||
closeAppDrawer();
|
||||
}, [closeAppDrawer, onConversationActivate]);
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
|
||||
!singleChat && conversationId && onConversationDelete(conversationId, true);
|
||||
}, [onConversationDelete, singleChat]);
|
||||
|
||||
|
||||
// grouping
|
||||
/*let sortedIds = conversationIDs;
|
||||
if (grouping === 'persona') {
|
||||
const conversations = useChatStore.getState().conversations;
|
||||
|
||||
// group conversations by persona
|
||||
const groupedConversations: { [personaId: string]: string[] } = {};
|
||||
conversations.forEach(conversation => {
|
||||
const persona = conversation.systemPurposeId;
|
||||
if (persona) {
|
||||
if (!groupedConversations[persona])
|
||||
groupedConversations[persona] = [];
|
||||
groupedConversations[persona].push(conversation.id);
|
||||
}
|
||||
});
|
||||
|
||||
// flatten grouped conversations
|
||||
sortedIds = Object.values(groupedConversations).flat();
|
||||
}*/
|
||||
|
||||
return <>
|
||||
|
||||
{/*<ListItem>*/}
|
||||
{/* <Typography level='body-sm'>*/}
|
||||
{/* Active chats*/}
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
<MenuItem disabled={props.disableNewButton} onClick={handleButtonNew}>
|
||||
<ListItemDecorator><AddIcon /></ListItemDecorator>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
New
|
||||
{/*<KeyStroke combo='Ctrl + Alt + N' />*/}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider sx={{ mb: 0 }} />
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/*<ListItem sticky sx={{ justifyContent: 'space-between', boxShadow: 'sm' }}>*/}
|
||||
{/* <Typography level='body-sm'>*/}
|
||||
{/* Conversations*/}
|
||||
{/* </Typography>*/}
|
||||
{/* <ToggleButtonGroup variant='soft' size='sm' value={grouping} onChange={(_event, newValue) => newValue && setGrouping(newValue)}>*/}
|
||||
{/* <IconButton value='off'>*/}
|
||||
{/* <AccessTimeIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* <IconButton value='persona'>*/}
|
||||
{/* <PersonIcon />*/}
|
||||
{/* </IconButton>*/}
|
||||
{/* </ToggleButtonGroup>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
{conversations.map(conversation =>
|
||||
<ChatNavigationItemMemo
|
||||
key={'nav-' + conversation.id}
|
||||
conversation={conversation}
|
||||
isActive={conversation.id === props.activeConversationId}
|
||||
isLonely={singleChat}
|
||||
maxChatMessages={(labsEnhancedUI || softMaxReached) ? maxChatMessages : 0}
|
||||
showSymbols={showSymbols}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
/>)}
|
||||
</Box>
|
||||
|
||||
<ListDivider sx={{ mt: 0 }} />
|
||||
|
||||
<MenuItem onClick={props.onConversationImportDialog}>
|
||||
<ListItemDecorator>
|
||||
<FileUploadIcon />
|
||||
</ListItemDecorator>
|
||||
Import chats
|
||||
<OpenAIIcon sx={{ fontSize: 'xl', ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={!hasChats} onClick={props.onConversationsDeleteAll}>
|
||||
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
|
||||
<Typography>
|
||||
Delete {totalConversations >= 2 ? `all ${totalConversations} chats` : 'chat'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { useChatLLMDropdown } from './useLLMDropdown';
|
||||
import { usePersonaIdDropdown } from './usePersonaDropdown';
|
||||
import { useFolderDropdown } from './folder/useFolderDropdown';
|
||||
|
||||
|
||||
export function ChatDropdowns(props: {
|
||||
@@ -19,26 +20,29 @@ export function ChatDropdowns(props: {
|
||||
// state
|
||||
const { chatLLMDropdown } = useChatLLMDropdown();
|
||||
const { personaDropdown } = usePersonaIdDropdown(props.conversationId);
|
||||
const { folderDropdown } = useFolderDropdown(props.conversationId);
|
||||
|
||||
// external state
|
||||
const labsSplitBranching = useUXLabsStore(state => state.labsSplitBranching);
|
||||
|
||||
return <>
|
||||
|
||||
{/* Model selector */}
|
||||
{chatLLMDropdown}
|
||||
|
||||
{/* Persona selector */}
|
||||
{personaDropdown}
|
||||
|
||||
{/* Model selector */}
|
||||
{chatLLMDropdown}
|
||||
|
||||
{/* Folder selector */}
|
||||
{folderDropdown}
|
||||
|
||||
{/* Split Panes button */}
|
||||
{labsSplitBranching && <IconButton
|
||||
variant={props.isSplitPanes ? 'solid' : 'plain'}
|
||||
color='neutral'
|
||||
variant={props.isSplitPanes ? 'solid' : undefined}
|
||||
onClick={props.onToggleSplitPanes}
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
}}
|
||||
// sx={{
|
||||
// ml: 'auto',
|
||||
// }}
|
||||
>
|
||||
<VerticalSplitIcon />
|
||||
</IconButton>}
|
||||
|
||||
@@ -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 { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
|
||||
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 { closeAppMenu } = useOptimaLayout();
|
||||
const { touch: shareTouch } = useUICounter('export-share');
|
||||
const { closePageMenu } = useOptimaDrawers();
|
||||
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
|
||||
|
||||
// derived state
|
||||
@@ -40,7 +36,7 @@ export function ChatMenuItems(props: {
|
||||
|
||||
const closeMenu = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
closeAppMenu();
|
||||
closePageMenu();
|
||||
};
|
||||
|
||||
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -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,17 +97,10 @@ 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 }}>
|
||||
Reset
|
||||
Reset Chat
|
||||
{!disabled && <KeyStroke combo='Ctrl + Alt + X' />}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Avatar, Box, IconButton, ListItemDecorator, MenuItem, 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 { SystemPurposes } from '../../../../data';
|
||||
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
const DEBUG_CONVERSATION_IDs = false;
|
||||
|
||||
|
||||
export const ChatNavigationItemMemo = React.memo(ChatNavigationItem);
|
||||
|
||||
function ChatNavigationItem(props: {
|
||||
conversation: DConversation,
|
||||
isActive: boolean,
|
||||
isLonely: boolean,
|
||||
maxChatMessages: number,
|
||||
showSymbols: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
const { conversation, isActive } = props;
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const doubleClickToEdit = useUIPreferencesStore(state => state.doubleClickToEdit);
|
||||
|
||||
// derived state
|
||||
const { id: conversationId } = conversation;
|
||||
const isNew = conversation.messages.length === 0;
|
||||
const messageCount = conversation.messages.length;
|
||||
const assistantTyping = !!conversation.abortController;
|
||||
const systemPurposeId = conversation.systemPurposeId;
|
||||
const title = conversationTitle(conversation, 'new conversation');
|
||||
// const setUserTitle = state.setUserTitle;
|
||||
|
||||
// auto-close the arming menu when clicking away
|
||||
// NOTE: there currently is a bug (race condition) where the menu closes on a new item right after opening
|
||||
// because the isActive prop is not yet updated
|
||||
React.useEffect(() => {
|
||||
if (deleteArmed && !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);
|
||||
};
|
||||
|
||||
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 = { ml: 1, ...(isActive ? { color: 'white' } : {}) };
|
||||
|
||||
const progress = props.maxChatMessages ? 100 * messageCount / props.maxChatMessages : 0;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
variant={isActive ? 'solid' : 'plain'} color='neutral'
|
||||
selected={isActive}
|
||||
onClick={handleConversationActivate}
|
||||
sx={{
|
||||
// py: 0,
|
||||
position: 'relative',
|
||||
border: 'none', // note, there's a default border of 1px and invisible.. hmm
|
||||
'&:hover > button': { opacity: 1 },
|
||||
...(isActive ? { bgcolor: 'red' } : {}),
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Optional progress bar, underlay */}
|
||||
{progress > 0 && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'neutral.softActiveBg',
|
||||
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: 24,
|
||||
height: 24,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography sx={{ fontSize: '18px' }}>
|
||||
{isNew ? '' : textSymbol}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItemDecorator>}
|
||||
|
||||
{/* Text */}
|
||||
{!isEditingTitle ? (
|
||||
|
||||
<Box onDoubleClick={() => doubleClickToEdit ? handleTitleEdit() : null} sx={{ flexGrow: 1 }}>
|
||||
{DEBUG_CONVERSATION_IDs ? conversationId.slice(0, 10) : title}{assistantTyping && '...'}
|
||||
</Box>
|
||||
|
||||
) : (
|
||||
|
||||
<InlineTextarea initialText={title} onEdit={handleTitleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
|
||||
|
||||
)}
|
||||
|
||||
{/* // TODO: Commented code */}
|
||||
{/* Edit */}
|
||||
{/*<IconButton*/}
|
||||
{/* variant='plain' color='neutral'*/}
|
||||
{/* onClick={() => props.onEditTitle(props.conversationId)}*/}
|
||||
{/* sx={{*/}
|
||||
{/* opacity: 0, transition: 'opacity 0.3s', ml: 'auto',*/}
|
||||
{/* }}>*/}
|
||||
{/* <EditIcon />*/}
|
||||
{/*</IconButton>*/}
|
||||
|
||||
{/* Delete Arming */}
|
||||
{!props.isLonely && !deleteArmed && (
|
||||
<IconButton
|
||||
variant={isActive ? 'solid' : 'outlined'} color='neutral'
|
||||
size='sm' sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.3s', ...buttonSx }}
|
||||
onClick={handleDeleteButtonShow}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Delete / Cancel buttons */}
|
||||
{!props.isLonely && deleteArmed && <>
|
||||
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleConversationDelete}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteButtonHide}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>}
|
||||
</MenuItem>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, ListItem, ListItemDecorator } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { getRotatingFolderColor, useFolderStore } from '~/common/state/store-folders';
|
||||
|
||||
|
||||
export function AddFolderButton() {
|
||||
|
||||
// state
|
||||
const [isAddingFolder, setIsAddingFolder] = React.useState(false);
|
||||
const [newFolderColor, setNewFolderColor] = React.useState<string | null>(null);
|
||||
|
||||
|
||||
const handleAddFolder = () => {
|
||||
setNewFolderColor(getRotatingFolderColor());
|
||||
setIsAddingFolder(true);
|
||||
};
|
||||
|
||||
const handleCreateFolder = (name: string) => {
|
||||
if (name.trim())
|
||||
useFolderStore.getState().createFolder(name.trim(), newFolderColor || undefined);
|
||||
setIsAddingFolder(false);
|
||||
};
|
||||
|
||||
const handleCancelAddFolder = () => {
|
||||
setIsAddingFolder(false);
|
||||
};
|
||||
|
||||
return isAddingFolder ? (
|
||||
<ListItem sx={{
|
||||
'--ListItem-paddingLeft': '0.75rem',
|
||||
'--ListItem-minHeight': '3rem', // --Folder-ListItem-height
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
}}>
|
||||
<ListItemDecorator>
|
||||
<FolderIcon style={{ color: newFolderColor || 'inherit' }} />
|
||||
</ListItemDecorator>
|
||||
<InlineTextarea
|
||||
initialText='' placeholder='Folder Name'
|
||||
onEdit={handleCreateFolder}
|
||||
onCancel={handleCancelAddFolder}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
}} />
|
||||
{/*<IconButton color='danger' onClick={handleCancelAddFolder}>*/}
|
||||
{/* <CloseIcon />*/}
|
||||
{/*</IconButton>*/}
|
||||
</ListItem>
|
||||
) : (
|
||||
<Button
|
||||
color='neutral'
|
||||
variant='plain'
|
||||
startDecorator={<AddIcon />}
|
||||
onClick={handleAddFolder}
|
||||
sx={{
|
||||
// display: 'flex', alignItems: 'center', justifyContent: 'flex-start',
|
||||
// minHeight: '3rem', // --Folder-ListItem-height
|
||||
// match the forder elements
|
||||
paddingInline: '1.2rem',
|
||||
gap: '0.75rem',
|
||||
// fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from 'react';
|
||||
import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { List, ListItem, ListItemButton, ListItemContent, ListItemDecorator, Sheet, Typography } from '@mui/joy';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
import { DFolder, useFolderStore } from '~/common/state/store-folders';
|
||||
|
||||
import { AddFolderButton } from './AddFolderButton';
|
||||
import { FolderListItem } from './FolderListItem';
|
||||
import { StrictModeDroppable } from './StrictModeDroppable';
|
||||
|
||||
|
||||
export function ChatFolderList(props: {
|
||||
folders: DFolder[];
|
||||
activeFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
}) {
|
||||
|
||||
// derived props
|
||||
const { folders, onFolderSelect, activeFolderId } = props;
|
||||
|
||||
// handlers
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
useFolderStore.getState().moveFolder(result.source.index, result.destination.index);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Sheet variant='soft' sx={{ p: 2 }}>
|
||||
<List
|
||||
variant='plain'
|
||||
sx={(theme) => ({
|
||||
'& ul': {
|
||||
'--List-gap': '0px',
|
||||
bgcolor: 'background.surface',
|
||||
'& > li:first-of-type > [role="button"]': {
|
||||
borderTopRightRadius: 'var(--List-radius)',
|
||||
borderTopLeftRadius: 'var(--List-radius)',
|
||||
},
|
||||
'& > li:last-child > [role="button"]': {
|
||||
borderBottomRightRadius: 'var(--List-radius)',
|
||||
borderBottomLeftRadius: 'var(--List-radius)',
|
||||
},
|
||||
},
|
||||
// copied from the former PageDrawerList as this was contained
|
||||
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
|
||||
'--ListItemDecorator-size': '2.75rem',
|
||||
'--ListItem-minHeight': '3rem', // --Folder-ListItem-height
|
||||
|
||||
'--List-radius': '8px',
|
||||
'--List-gap': '1rem',
|
||||
'--ListDivider-gap': '0px',
|
||||
// '--ListItem-paddingY': '0.5rem',
|
||||
// override global variant tokens
|
||||
'--joy-palette-neutral-plainHoverBg': 'rgba(0 0 0 / 0.08)',
|
||||
'--joy-palette-neutral-plainActiveBg': 'rgba(0 0 0 / 0.12)',
|
||||
[theme.getColorSchemeSelector('light')]: {
|
||||
'--joy-palette-divider': 'rgba(0 0 0 / 0.08)',
|
||||
},
|
||||
[theme.getColorSchemeSelector('dark')]: {
|
||||
'--joy-palette-neutral-plainHoverBg': 'rgba(255 255 255 / 0.1)',
|
||||
'--joy-palette-neutral-plainActiveBg': 'rgba(255 255 255 / 0.16)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ListItem nested>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<StrictModeDroppable
|
||||
droppableId='folder'
|
||||
renderClone={(provided, snapshot, rubric) => (
|
||||
<FolderListItem
|
||||
activeFolderId={activeFolderId}
|
||||
folder={folders[rubric.source.index]}
|
||||
onFolderSelect={onFolderSelect}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{(provided) => (
|
||||
<List ref={provided.innerRef} {...provided.droppableProps}>
|
||||
|
||||
{/* First item is the 'All' button */}
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
// handle folder select
|
||||
onClick={(event) => {
|
||||
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
|
||||
onFolderSelect(null);
|
||||
}}
|
||||
selected={!activeFolderId}
|
||||
sx={{
|
||||
border: 0,
|
||||
justifyContent: 'space-between',
|
||||
'&:hover .menu-icon': {
|
||||
visibility: 'visible', // Hide delete icon for default folder
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<FolderIcon />
|
||||
</ListItemDecorator>
|
||||
|
||||
<ListItemContent>
|
||||
<Typography>All</Typography>
|
||||
</ListItemContent>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{folders.map((folder, index) => (
|
||||
<Draggable key={folder.id} draggableId={folder.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<FolderListItem
|
||||
activeFolderId={activeFolderId}
|
||||
folder={folder}
|
||||
onFolderSelect={onFolderSelect}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</List>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
</DragDropContext>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<AddFolderButton />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import React, { useState } from 'react';
|
||||
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';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import Done from '@mui/icons-material/Done';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DFolder, FOLDERS_COLOR_PALETTE, useFolderStore } from '~/common/state/store-folders';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
|
||||
|
||||
export function FolderListItem(props: {
|
||||
activeFolderId: string | null;
|
||||
folder: DFolder;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
}) {
|
||||
|
||||
// internal state
|
||||
const [deleteArmed, setDeleteArmed] = useState(false);
|
||||
const [editingFolderId, setEditingFolderId] = useState<string | null>(null);
|
||||
|
||||
// State to control the open state of the Menu
|
||||
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);
|
||||
setDeleteArmed(false); // Reset delete armed state
|
||||
};
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setMenuAnchorEl(null);
|
||||
};
|
||||
|
||||
|
||||
// Edit Title
|
||||
|
||||
const handleEditTitle = (event: React.MouseEvent<HTMLElement, MouseEvent>, folderId: string) => {
|
||||
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
|
||||
setEditingFolderId(folderId);
|
||||
};
|
||||
|
||||
const handleCancelEditTitle = () => {
|
||||
setEditingFolderId(null);
|
||||
};
|
||||
|
||||
const handleSetTitle = (newTitle: string, folderId: string) => {
|
||||
if (newTitle.trim())
|
||||
useFolderStore.getState().setFolderName(folderId, newTitle.trim());
|
||||
setEditingFolderId(null); // Exit edit mode
|
||||
// Blur the input element if it's currently focused
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Deletion
|
||||
|
||||
const handleDeleteButtonShow = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setDeleteArmed(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirmed = (event: React.MouseEvent) => {
|
||||
if (deleteArmed) {
|
||||
setDeleteArmed(false);
|
||||
event.stopPropagation();
|
||||
useFolderStore.getState().deleteFolder(folder.id);
|
||||
handleMenuClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCanceled = (event: React.MouseEvent) => {
|
||||
if (deleteArmed) {
|
||||
setDeleteArmed(false);
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Color
|
||||
|
||||
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
useFolderStore.getState().setFolderColor(folder.id, event.target.value);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
|
||||
const getItemStyle = (isDragging: boolean, draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({
|
||||
userSelect: 'none',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: isDragging ? 'rgba(0, 80, 80, 0.18)' : 'transparent',
|
||||
|
||||
...draggableStyle,
|
||||
|
||||
// Any additional styles you want to apply during dragging
|
||||
...(isDragging &&
|
||||
{
|
||||
// Apply any drag-specific styles here
|
||||
// marginLeft: '12px',
|
||||
}),
|
||||
});
|
||||
|
||||
const getListItemContentStyle = (isDragging: boolean, _draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({
|
||||
...(isDragging && {
|
||||
// Apply any drag-specific styles here
|
||||
marginLeft: '20px',
|
||||
}),
|
||||
});
|
||||
|
||||
const getListItemDecoratorStyle = (isDragging: boolean, _draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({
|
||||
...(isDragging && {
|
||||
// Apply any drag-specific styles here
|
||||
marginLeft: '12px',
|
||||
}),
|
||||
});
|
||||
|
||||
const handleFolderSelect = (folderId: string | null) => {
|
||||
onFolderSelect(folderId);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
...getItemStyle(snapshot.isDragging, provided.draggableProps.style),
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<ListItemButton
|
||||
// handle folder select
|
||||
onClick={(event) => {
|
||||
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
|
||||
handleFolderSelect(folder.id);
|
||||
}}
|
||||
selected={folder.id === activeFolderId}
|
||||
sx={{
|
||||
border: 0,
|
||||
justifyContent: 'space-between',
|
||||
'&:hover .menu-icon': {
|
||||
visibility: 'visible', // Hide delete icon for default folder
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
style={{
|
||||
...getListItemDecoratorStyle(snapshot.isDragging, provided.draggableProps.style),
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<FolderIcon style={{ color: folder.color || 'inherit' }} />
|
||||
</ListItemDecorator>
|
||||
|
||||
{editingFolderId === folder.id ? (
|
||||
<InlineTextarea
|
||||
initialText={folder.title}
|
||||
onEdit={newTitle => handleSetTitle(newTitle, folder.id)}
|
||||
onCancel={handleCancelEditTitle}
|
||||
sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<ListItemContent
|
||||
onDoubleClick={event => handleEditTitle(event, folder.id)}
|
||||
style={{
|
||||
...getListItemContentStyle(snapshot.isDragging, provided.draggableProps.style),
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography>{folder.title}</Typography>
|
||||
</ListItemContent>
|
||||
)}
|
||||
|
||||
{/* Icon to show the Popup menu */}
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
className='menu-icon'
|
||||
onClick={handleMenuOpen}
|
||||
sx={{
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
|
||||
{!!menuAnchorEl && (
|
||||
<CloseableMenu
|
||||
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
|
||||
placement='top'
|
||||
zIndex={1301 /* need to be on top of the Modal on Mobile */}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
|
||||
<MenuItem
|
||||
onClick={(event) => {
|
||||
handleEditTitle(event, folder.id);
|
||||
handleMenuClose();
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<EditIcon />
|
||||
</ListItemDecorator>
|
||||
Edit
|
||||
</MenuItem>
|
||||
|
||||
{!deleteArmed ? (
|
||||
<MenuItem onClick={handleDeleteButtonShow}>
|
||||
<ListItemDecorator>
|
||||
<DeleteOutlineIcon />
|
||||
</ListItemDecorator>
|
||||
Delete
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem onClick={handleDeleteCanceled}>
|
||||
<ListItemDecorator>
|
||||
<CloseIcon />
|
||||
</ListItemDecorator>
|
||||
Cancel
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleDeleteConfirmed} color='danger' sx={{ color: 'danger' }}>
|
||||
<ListItemDecorator>
|
||||
<DeleteOutlineIcon />
|
||||
</ListItemDecorator>
|
||||
Confirm Deletion
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
p: 2,
|
||||
minWidth: 200,
|
||||
}}
|
||||
>
|
||||
<FormLabel
|
||||
id='folder-color'
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
fontWeight: 'xl',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 'xs',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
Color
|
||||
</FormLabel>
|
||||
<RadioGroup
|
||||
aria-labelledby='product-color-attribute'
|
||||
defaultValue={folder.color || 'warning'}
|
||||
onChange={handleColorChange}
|
||||
sx={{ gap: 2, flexWrap: 'wrap', flexDirection: 'row', maxWidth: 240 }}
|
||||
>
|
||||
{FOLDERS_COLOR_PALETTE.map((color, index) => (
|
||||
<Sheet
|
||||
key={index}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: 20,
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
bgcolor: `${color}`,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Radio
|
||||
overlay
|
||||
variant='solid'
|
||||
checkedIcon={<Done />}
|
||||
value={color}
|
||||
color='neutral'
|
||||
slotProps={{
|
||||
input: { 'aria-label': color },
|
||||
radio: {
|
||||
sx: {
|
||||
display: 'contents',
|
||||
'--variant-borderWidth': '2px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'--joy-focus-outlineOffset': '4px',
|
||||
'--joy-palette-focusVisible': color,
|
||||
[`& .${radioClasses.action}.${radioClasses.focusVisible}`]: {
|
||||
outlineWidth: '2px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Sheet>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</MenuItem>
|
||||
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Droppable, DroppableProps } from "react-beautiful-dnd";
|
||||
|
||||
export const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const animation = requestAnimationFrame(() => setEnabled(true));
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animation);
|
||||
setEnabled(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Droppable {...props}>{children}</Droppable>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { DropdownItems, PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
|
||||
|
||||
export const ClearFolderText = 'Clear Folder';
|
||||
const SPECIAL_ID_CLEAR_FOLDER = '_REMOVE_';
|
||||
|
||||
|
||||
export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
|
||||
// external state
|
||||
const { folders, enableFolders } = useFolderStore();
|
||||
|
||||
|
||||
// Prepare items for the dropdown
|
||||
const folderItems: DropdownItems = React.useMemo(() => {
|
||||
// add one item per folder
|
||||
const items = folders.reduce((items, folder) => {
|
||||
items[folder.id] = {
|
||||
title: folder.title,
|
||||
icon: <FolderIcon sx={{ color: folder.color }} />,
|
||||
};
|
||||
return items;
|
||||
}, {} as DropdownItems);
|
||||
|
||||
// add one item representing no folder
|
||||
items[SPECIAL_ID_CLEAR_FOLDER] = {
|
||||
title: ClearFolderText,
|
||||
};
|
||||
|
||||
return items;
|
||||
}, [folders]);
|
||||
|
||||
|
||||
// Handle dropdown folder change
|
||||
const handleFolderChange = React.useCallback((_event: any, folderId: string | null) => {
|
||||
if (conversationId && folderId) {
|
||||
// Remove conversation from all folders
|
||||
folders.forEach(folder => {
|
||||
if (folder.conversationIds.includes(conversationId)) {
|
||||
useFolderStore.getState().removeConversationFromFolder(folder.id, conversationId);
|
||||
}
|
||||
});
|
||||
// Add conversation to the selected folder
|
||||
if (folderId !== SPECIAL_ID_CLEAR_FOLDER)
|
||||
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
|
||||
}
|
||||
}, [conversationId, folders]);
|
||||
|
||||
// find the folder ID for the active Conversation
|
||||
const currentFolderId = folders.find(folder => folder.conversationIds.includes(conversationId || ''))?.id || null;
|
||||
|
||||
// Create the dropdown component
|
||||
const folderDropdown = React.useMemo(() => {
|
||||
|
||||
// don't show the dropdown if folders are not enabled
|
||||
if (!enableFolders)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<PageBarDropdown
|
||||
items={folderItems}
|
||||
value={currentFolderId}
|
||||
onChange={handleFolderChange}
|
||||
placeholder='Select a folder'
|
||||
showSymbols
|
||||
/>
|
||||
);
|
||||
}, [currentFolderId, enableFolders, folderItems, handleFolderChange]);
|
||||
|
||||
return { folderDropdown };
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
||||
|
||||
import { DLLM, DLLMId, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { GoodDropdown, DropdownItems } from '~/common/components/GoodDropdown';
|
||||
import { PageBarDropdown, DropdownItems } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
@@ -54,7 +54,7 @@ function AppBarLLMDropdown(props: {
|
||||
|
||||
|
||||
return (
|
||||
<GoodDropdown
|
||||
<PageBarDropdown
|
||||
items={llmItems}
|
||||
value={props.chatLlmId} onChange={handleChatLLMChange}
|
||||
placeholder={props.placeholder || 'Models …'}
|
||||
|
||||
@@ -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 { GoodDropdown } from '~/common/components/GoodDropdown';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
import { PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
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 {!!props.systemPurposeId && SystemPurposes[props.systemPurposeId]?.symbol}
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
// let appendOption: React.JSX.Element | undefined = undefined;
|
||||
|
||||
return (
|
||||
<GoodDropdown
|
||||
<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 };
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
|
||||
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
|
||||
|
||||
import { CameraCaptureModal } from './CameraCaptureModal';
|
||||
|
||||
|
||||
const attachCameraLegend = (isMobile: boolean) =>
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
<b>Attach photo</b><br />
|
||||
{isMobile ? 'Auto-OCR to read text' : 'See the world, on the go'}
|
||||
</Box>;
|
||||
|
||||
|
||||
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
|
||||
|
||||
function ButtonAttachCamera(props: { isMobile?: boolean, onAttachImage: (file: File) => void }) {
|
||||
// state
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return <>
|
||||
|
||||
{/* The Button */}
|
||||
{props.isMobile ? (
|
||||
<IconButton variant='plain' color='neutral' onClick={() => setOpen(true)}>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Camera
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* The actual capture dialog, which will stream the video */}
|
||||
{open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={props.onAttachImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -170,7 +170,7 @@ export function CameraCaptureModal(props: {
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'space-between' }}>
|
||||
{/* Info */}
|
||||
<IconButton disabled={!info} variant='soft' color='neutral' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
|
||||
<IconButton size='lg' disabled={!info} variant='soft' onClick={() => setShowInfo(info => !info)} sx={{ zIndex: 30 }}>
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
{/*<Button disabled={ocrProgress !== null} fullWidth variant='solid' size='lg' onClick={handleVideoOCRClicked} sx={{ flex: 1, maxWidth: 260 }}>*/}
|
||||
@@ -189,7 +189,7 @@ export function CameraCaptureModal(props: {
|
||||
</Button>
|
||||
|
||||
{/* Download */}
|
||||
<IconButton variant='soft' color='neutral' onClick={handleVideoDownloadClicked}>
|
||||
<IconButton size='lg' variant='soft' onClick={handleVideoDownloadClicked}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -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,38 +24,43 @@ 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';
|
||||
import { lineHeightTextarea } from '~/common/app.theme';
|
||||
import { playSoundUrl } from '~/common/util/audioUtils';
|
||||
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';
|
||||
|
||||
import type { ActileItem, ActileProvider } from './actile/ActileProvider';
|
||||
import { providerCommands } from './actile/providerCommands';
|
||||
import { useActileManager } from './actile/useActileManager';
|
||||
|
||||
import type { AttachmentId } from './attachments/store-attachments';
|
||||
import { Attachments } from './attachments/Attachments';
|
||||
import { getTextBlockText, useLLMAttachments } from './attachments/useLLMAttachments';
|
||||
import { useAttachments } from './attachments/useAttachments';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './composer.types';
|
||||
import { ButtonAttachCameraMemo } from './ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './ButtonAttachClipboard';
|
||||
import { ButtonAttachFileMemo } from './ButtonAttachFile';
|
||||
import { ButtonCall } from './ButtonCall';
|
||||
import { ButtonMicContinuationMemo } from './ButtonMicContinuation';
|
||||
import { ButtonMicMemo } from './ButtonMic';
|
||||
import { ButtonOptionsDraw } from './ButtonOptionsDraw';
|
||||
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
|
||||
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
|
||||
import { ButtonCall } from './buttons/ButtonCall';
|
||||
import { ButtonMicContinuationMemo } from './buttons/ButtonMicContinuation';
|
||||
import { ButtonMicMemo } from './buttons/ButtonMic';
|
||||
import { ButtonOptionsDraw } from './buttons/ButtonOptionsDraw';
|
||||
import { ChatModeMenu } from './ChatModeMenu';
|
||||
import { TokenBadgeMemo } from './TokenBadge';
|
||||
import { TokenProgressbarMemo } from './TokenProgressbar';
|
||||
import { useComposerStartupText } from './store-composer';
|
||||
|
||||
|
||||
const animationStopEnter = keyframes`
|
||||
export const animationStopEnter = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px)
|
||||
@@ -89,19 +95,18 @@ export function Composer(props: {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const { openPreferences } = 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');
|
||||
const [startupText, setStartupText] = useComposerStartupText();
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
const chatMicTimeoutMs = useChatMicTimeoutMsValue();
|
||||
const { assistantTyping, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(state => {
|
||||
const { assistantAbortible, systemPurposeId, tokenCount: _historyTokenCount, stopTyping } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
|
||||
return {
|
||||
assistantTyping: conversation ? !!conversation.abortController : false,
|
||||
assistantAbortible: conversation ? !!conversation.abortController : false,
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
tokenCount: conversation ? conversation.tokenCount : 0,
|
||||
stopTyping: state.stopTyping,
|
||||
@@ -125,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)
|
||||
@@ -167,24 +172,6 @@ export function Composer(props: {
|
||||
return enqueued;
|
||||
}, [clearAttachments, conversationId, llmAttachments, onAction, setComposeText]);
|
||||
|
||||
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
// Alt: append the message instead
|
||||
if (e.altKey) {
|
||||
handleSendAction('append-user', composeText);
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Shift: toggles the 'enter is newline'
|
||||
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
|
||||
if (!assistantTyping)
|
||||
handleSendAction(chatModeId, composeText);
|
||||
return e.preventDefault();
|
||||
}
|
||||
}
|
||||
}, [assistantTyping, chatModeId, composeText, enterIsNewline, handleSendAction]);
|
||||
|
||||
const handleSendClicked = () => handleSendAction(chatModeId, composeText);
|
||||
|
||||
const handleStopClicked = () => props.conversationId && stopTyping(props.conversationId);
|
||||
@@ -194,7 +181,7 @@ export function Composer(props: {
|
||||
|
||||
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
|
||||
const handleDrawOptionsClicked = () => openPreferences(2);
|
||||
const handleDrawOptionsClicked = () => openPreferencesTab(PreferencesTab.Draw);
|
||||
|
||||
const handleTextImagineClicked = () => {
|
||||
if (!composeText || !props.conversationId)
|
||||
@@ -217,6 +204,75 @@ export function Composer(props: {
|
||||
};
|
||||
|
||||
|
||||
// Actiles
|
||||
|
||||
const onActileCommandSelect = React.useCallback((item: ActileItem) => {
|
||||
if (props.composerTextAreaRef.current) {
|
||||
const textArea = props.composerTextAreaRef.current;
|
||||
const currentText = textArea.value;
|
||||
const cursorPos = textArea.selectionStart;
|
||||
|
||||
// Find the position where the command starts
|
||||
const commandStart = currentText.lastIndexOf('/', cursorPos);
|
||||
|
||||
// Construct the new text with the autocompleted command
|
||||
const newText = currentText.substring(0, commandStart) + item.label + ' ' + currentText.substring(cursorPos);
|
||||
|
||||
// Update the text area with the new text
|
||||
setComposeText(newText);
|
||||
|
||||
// Move the cursor to the end of the autocompleted command
|
||||
const newCursorPos = commandStart + item.label.length + 1;
|
||||
textArea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}
|
||||
}, [props.composerTextAreaRef, setComposeText]);
|
||||
|
||||
const actileProviders: ActileProvider[] = React.useMemo(() => {
|
||||
return [providerCommands(onActileCommandSelect)];
|
||||
}, [onActileCommandSelect]);
|
||||
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
|
||||
|
||||
|
||||
// Text typing
|
||||
|
||||
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setComposeText(e.target.value);
|
||||
isMobile && actileInterceptTextChange(e.target.value);
|
||||
}, [actileInterceptTextChange, isMobile, setComposeText]);
|
||||
|
||||
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// disable keyboard handling if the actile is visible
|
||||
if (actileInterceptKeydown(e))
|
||||
return;
|
||||
|
||||
// Enter: primary action
|
||||
if (e.key === 'Enter') {
|
||||
|
||||
// Alt: append the message instead
|
||||
if (e.altKey) {
|
||||
handleSendAction('append-user', composeText);
|
||||
return e.preventDefault();
|
||||
}
|
||||
|
||||
// Shift: toggles the 'enter is newline'
|
||||
if (enterIsNewline ? e.shiftKey : !e.shiftKey) {
|
||||
if (!assistantAbortible)
|
||||
handleSendAction(chatModeId, composeText);
|
||||
return e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
}, [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) => {
|
||||
@@ -233,7 +289,7 @@ export function Composer(props: {
|
||||
nextText = nextText ? nextText + ' ' + transcript : transcript;
|
||||
|
||||
// auto-send (mic continuation mode) if requested
|
||||
const autoSend = micContinuation && nextText.length >= 1 && !!props.conversationId; //&& assistantTyping;
|
||||
const autoSend = micContinuation && nextText.length >= 1 && !!props.conversationId; //&& assistantAbortible;
|
||||
const notUserStop = result.doneReason !== 'manual';
|
||||
if (autoSend) {
|
||||
if (notUserStop)
|
||||
@@ -255,7 +311,7 @@ export function Composer(props: {
|
||||
useGlobalShortcut('m', true, false, false, toggleRecording);
|
||||
|
||||
const micIsRunning = !!speechInterimResult;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantTyping;
|
||||
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');
|
||||
|
||||
@@ -285,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 });
|
||||
@@ -367,7 +425,9 @@ export function Composer(props: {
|
||||
const isChat = isText || isAppend;
|
||||
const isReAct = chatModeId === 'generate-react';
|
||||
const isDraw = chatModeId === 'generate-image';
|
||||
const buttonColor: ColorPaletteProp = isReAct ? 'success' : isDraw ? 'warning' : 'primary';
|
||||
const buttonColor: ColorPaletteProp = assistantAbortible
|
||||
? 'warning'
|
||||
: isReAct ? 'success' : isDraw ? 'warning' : 'primary';
|
||||
|
||||
const textPlaceholder: string =
|
||||
isDraw
|
||||
@@ -377,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) */}
|
||||
@@ -390,23 +450,36 @@ export function Composer(props: {
|
||||
|
||||
{/* Vertical (insert) buttons */}
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { md: 2 } }}>
|
||||
<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>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { md: 2 } }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
|
||||
{/*<FormHelperText sx={{ mx: 'auto' }}>*/}
|
||||
{/* Attach*/}
|
||||
@@ -419,7 +492,7 @@ export function Composer(props: {
|
||||
{supportsClipboardRead && <ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onAttachImage={handleAttachCameraImage} />}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
@@ -428,7 +501,7 @@ export function Composer(props: {
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
minWidth: 250, // enable X-scrolling (resetting any possible minWidth due to the attachments)
|
||||
minWidth: 200, // enable X-scrolling (resetting any possible minWidth due to the attachments)
|
||||
}}>
|
||||
|
||||
{/* Edit box + Overlays + Mic buttons */}
|
||||
@@ -438,16 +511,20 @@ 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={5} maxRows={10}
|
||||
minRows={isMobile ? 4 : 5}
|
||||
maxRows={isMobile ? 8 : 10}
|
||||
placeholder={textPlaceholder}
|
||||
value={composeText}
|
||||
onChange={(event) => setComposeText(event.target.value)}
|
||||
onChange={handleTextareaTextChange}
|
||||
onDragEnter={handleTextareaDragEnter}
|
||||
onDragStart={handleTextareaDragStart}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
onPasteCapture={handleAttachCtrlV}
|
||||
// onFocusCapture={handleFocusModeOn}
|
||||
// onBlurCapture={handleFocusModeOff}
|
||||
slotProps={{
|
||||
textarea: {
|
||||
enterKeyHint: enterIsNewline ? 'enter' : 'send',
|
||||
@@ -460,10 +537,8 @@ export function Composer(props: {
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
'&:focus-within': {
|
||||
backgroundColor: 'background.popup',
|
||||
},
|
||||
lineHeight: 1.75,
|
||||
'&:focus-within': { backgroundColor: 'background.popup' },
|
||||
lineHeight: lineHeightTextarea,
|
||||
}} />
|
||||
|
||||
{tokenLimit > 0 && (tokensComposer > 0 || (tokensHistory + tokensReponseMax) > 0) && (
|
||||
@@ -564,10 +639,10 @@ 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 variant='plain' color='neutral' sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
)}
|
||||
|
||||
{/* Responsive Send/Stop buttons */}
|
||||
@@ -579,7 +654,7 @@ export function Composer(props: {
|
||||
boxShadow: isMobile ? 'none' : `0 8px 24px -4px rgb(var(--joy-palette-${buttonColor}-mainChannel) / 20%)`,
|
||||
}}
|
||||
>
|
||||
{!assistantTyping ? (
|
||||
{!assistantAbortible ? (
|
||||
<Button
|
||||
key='composer-act'
|
||||
fullWidth disabled={!props.conversationId || !chatLLMId || !llmAttachments.isOutputAttacheable}
|
||||
@@ -598,7 +673,7 @@ export function Composer(props: {
|
||||
) : (
|
||||
<Button
|
||||
key='composer-stop'
|
||||
fullWidth variant='soft' color={isReAct ? 'success' : 'primary'} disabled={!props.conversationId}
|
||||
fullWidth variant='soft' disabled={!props.conversationId}
|
||||
onClick={handleStopClicked}
|
||||
endDecorator={<StopOutlinedIcon sx={{ fontSize: 18 }} />}
|
||||
sx={{ animation: `${animationStopEnter} 0.1s ease-out` }}
|
||||
@@ -615,7 +690,11 @@ export function Composer(props: {
|
||||
</Tooltip>}
|
||||
|
||||
{/* Mode expander */}
|
||||
<IconButton variant={isDraw ? undefined : undefined} disabled={!props.conversationId || !chatLLMId || !!chatModeMenuAnchor} onClick={handleModeSelectorShow}>
|
||||
<IconButton
|
||||
variant={assistantAbortible ? 'soft' : isDraw ? undefined : undefined}
|
||||
disabled={!props.conversationId || !chatLLMId || !!chatModeMenuAnchor}
|
||||
onClick={handleModeSelectorShow}
|
||||
>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
@@ -627,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} />}
|
||||
@@ -647,6 +726,12 @@ export function Composer(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Camera */}
|
||||
{cameraCaptureComponent}
|
||||
|
||||
{/* Actile */}
|
||||
{actileComponent}
|
||||
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, ListItem, ListItemButton, ListItemDecorator, Sheet, Typography } from '@mui/joy';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
|
||||
import type { ActileItem } from './ActileProvider';
|
||||
|
||||
|
||||
export function ActilePopup(props: {
|
||||
anchorEl: HTMLElement | null,
|
||||
onClose: () => void,
|
||||
title?: string,
|
||||
items: ActileItem[],
|
||||
activeItemIndex: number | undefined,
|
||||
activePrefixLength: number,
|
||||
onItemClick: (item: ActileItem) => void,
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
|
||||
const hasAnyIcon = props.items.some(item => !!item.Icon);
|
||||
|
||||
return (
|
||||
<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' }}>
|
||||
<Typography level='title-sm'>
|
||||
{props.title}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{!props.items.length && (
|
||||
<ListItem variant='soft' color='warning'>
|
||||
<Typography level='body-md'>
|
||||
No matching command
|
||||
</Typography>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<ListItem
|
||||
key={item.id}
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
color={isActive ? 'primary' : undefined}
|
||||
onClick={() => props.onItemClick(item)}
|
||||
>
|
||||
<ListItemButton>
|
||||
{hasAnyIcon && (
|
||||
<ListItemDecorator>
|
||||
{item.Icon ? <item.Icon /> : null}
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
<Box>
|
||||
|
||||
<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>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{props.children}
|
||||
|
||||
</CloseableMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
export interface ActileItem {
|
||||
id: string;
|
||||
label: string;
|
||||
argument?: string;
|
||||
description?: string;
|
||||
Icon?: FunctionComponent;
|
||||
}
|
||||
|
||||
type ActileProviderIds = 'actile-commands' | 'actile-attach-reference';
|
||||
|
||||
export interface ActileProvider {
|
||||
id: ActileProviderIds;
|
||||
title: string;
|
||||
searchPrefix: string;
|
||||
|
||||
checkTriggerText: (trailingText: string) => boolean;
|
||||
|
||||
fetchItems: () => Promise<ActileItem[]>;
|
||||
onItemSelect: (item: ActileItem) => void;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
|
||||
|
||||
/*export const providerAttachReference: ActileProvider = {
|
||||
id: 'actile-attach-reference',
|
||||
title: 'Attach Reference',
|
||||
searchPrefix: '@',
|
||||
|
||||
checkTriggerText: (trailingText: string) =>
|
||||
trailingText.endsWith(' @'),
|
||||
|
||||
fetchItems: async () => {
|
||||
return [{
|
||||
id: 'test-1',
|
||||
label: 'Attach This',
|
||||
description: 'Attach this to the message',
|
||||
Icon: undefined,
|
||||
}];
|
||||
},
|
||||
|
||||
onItemSelect: (item: ActileItem) => {
|
||||
console.log('Selected item:', item);
|
||||
},
|
||||
};*/
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
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() === '/',
|
||||
|
||||
fetchItems: async () => {
|
||||
return findAllChatCommands().map((cmd) => ({
|
||||
id: cmd.primary,
|
||||
label: cmd.primary,
|
||||
argument: cmd.arguments?.join(' ') ?? undefined,
|
||||
description: cmd.description,
|
||||
Icon: cmd.Icon,
|
||||
}));
|
||||
},
|
||||
|
||||
onItemSelect,
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from 'react';
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
import { ActilePopup } from './ActilePopup';
|
||||
|
||||
|
||||
export const useActileManager = (providers: ActileProvider[], anchorRef: React.RefObject<HTMLElement>) => {
|
||||
|
||||
// state
|
||||
const [popupOpen, setPopupOpen] = React.useState(false);
|
||||
const [provider, setProvider] = React.useState<ActileProvider | null>(null);
|
||||
|
||||
const [items, setItems] = React.useState<ActileItem[]>([]);
|
||||
const [activeSearchString, setActiveSearchString] = React.useState<string>('');
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number>(0);
|
||||
|
||||
|
||||
// derived state
|
||||
const activeItems = React.useMemo(() => {
|
||||
const search = activeSearchString.trim().toLowerCase();
|
||||
return items.filter(item => item.label.toLowerCase().startsWith(search));
|
||||
}, [items, activeSearchString]);
|
||||
const activeItem = activeItemIndex >= 0 && activeItemIndex < activeItems.length ? activeItems[activeItemIndex] : null;
|
||||
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setPopupOpen(false);
|
||||
setProvider(null);
|
||||
setItems([]);
|
||||
setActiveSearchString('');
|
||||
setActiveItemIndex(0);
|
||||
}, []);
|
||||
|
||||
const handlePopupItemClicked = React.useCallback((item: ActileItem) => {
|
||||
provider?.onItemSelect(item);
|
||||
handleClose();
|
||||
}, [handleClose, provider]);
|
||||
|
||||
const handleEnterKey = React.useCallback(() => {
|
||||
activeItem && handlePopupItemClicked(activeItem);
|
||||
}, [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
|
||||
|
||||
const { key, currentTarget, ctrlKey, metaKey } = _event;
|
||||
|
||||
if (popupOpen) {
|
||||
if (key === 'Escape' || key === 'ArrowLeft') {
|
||||
_event.preventDefault();
|
||||
handleClose();
|
||||
} else if (key === 'ArrowUp') {
|
||||
_event.preventDefault();
|
||||
setActiveItemIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : activeItems.length - 1));
|
||||
} else if (key === 'ArrowDown') {
|
||||
_event.preventDefault();
|
||||
setActiveItemIndex((prevIndex) => (prevIndex < activeItems.length - 1 ? prevIndex + 1 : 0));
|
||||
} else if (key === 'Enter' || key === 'ArrowRight' || key === 'Tab' || (key === ' ' && activeItems.length === 1)) {
|
||||
_event.preventDefault();
|
||||
handleEnterKey();
|
||||
} else if (key === 'Backspace') {
|
||||
handleClose();
|
||||
} else if (key.length === 1 && !ctrlKey && !metaKey) {
|
||||
setActiveSearchString((prev) => prev + key);
|
||||
setActiveItemIndex(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Popup closed: Check for triggers
|
||||
const trailingText = (currentTarget.value || '') + key;
|
||||
return actileInterceptTextChange(trailingText);
|
||||
|
||||
}, [actileInterceptTextChange, activeItems.length, handleClose, handleEnterKey, popupOpen]);
|
||||
|
||||
|
||||
const actileComponent = React.useMemo(() => {
|
||||
return !popupOpen ? null : (
|
||||
<ActilePopup
|
||||
anchorEl={anchorRef.current}
|
||||
onClose={handleClose}
|
||||
title={provider?.title}
|
||||
items={activeItems}
|
||||
activeItemIndex={activeItemIndex}
|
||||
activePrefixLength={activeSearchString.length}
|
||||
onItemClick={handlePopupItemClicked}
|
||||
/>
|
||||
);
|
||||
}, [activeItemIndex, activeItems, activeSearchString.length, anchorRef, handleClose, handlePopupItemClicked, popupOpen, provider?.title]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export function Attachments(props: {
|
||||
|
||||
{/* Overall Menu button */}
|
||||
<IconButton
|
||||
variant='plain' onClick={handleOverallMenuToggle}
|
||||
onClick={handleOverallMenuToggle}
|
||||
sx={{
|
||||
// borderRadius: 'sm',
|
||||
borderRadius: 0,
|
||||
|
||||
@@ -11,6 +11,16 @@ import type { ComposerOutputMultiPart } from '../composer.types';
|
||||
// extensions to treat as plain text
|
||||
const PLAIN_TEXT_EXTENSIONS: string[] = ['.ts', '.tsx'];
|
||||
|
||||
// mimetypes to treat as plain text
|
||||
const PLAIN_TEXT_MIMETYPES: string[] = [
|
||||
'text/plain',
|
||||
'text/html',
|
||||
'text/markdown',
|
||||
'text/csv',
|
||||
'text/css',
|
||||
'application/json',
|
||||
];
|
||||
|
||||
/**
|
||||
* Creates a new Attachment object.
|
||||
*/
|
||||
@@ -141,7 +151,7 @@ export function attachmentDefineConverters(sourceType: AttachmentSource['media']
|
||||
switch (true) {
|
||||
|
||||
// plain text types
|
||||
case ['text/plain', 'text/html', 'text/markdown', 'text/csv', 'application/json'].includes(input.mimeType):
|
||||
case PLAIN_TEXT_MIMETYPES.includes(input.mimeType):
|
||||
// handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read
|
||||
const textOriginHtml = sourceType === 'text' && input.altMimeType === 'text/html' && !!input.altData;
|
||||
const isHtmlTable = !!input.altData?.startsWith('<table');
|
||||
@@ -245,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)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, Tooltip } from '@mui/joy';
|
||||
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
|
||||
|
||||
import { CameraCaptureModal } from '../CameraCaptureModal';
|
||||
|
||||
|
||||
const attachCameraLegend = (isMobile: boolean) =>
|
||||
<Box sx={{ px: 1, py: 0.75, lineHeight: '1.5rem' }}>
|
||||
<b>Attach photo</b><br />
|
||||
{isMobile ? 'Auto-OCR to read text' : 'See the world, on the go'}
|
||||
</Box>;
|
||||
|
||||
|
||||
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
|
||||
|
||||
function ButtonAttachCamera(props: { isMobile?: boolean, 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);
|
||||
|
||||
const openCamera = React.useCallback(() => setOpen(true), []);
|
||||
|
||||
const cameraCaptureComponent = React.useMemo(() => open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={onAttachImage}
|
||||
/>
|
||||
), [open, onAttachImage]);
|
||||
|
||||
return {
|
||||
openCamera,
|
||||
cameraCaptureComponent,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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>
|
||||
@@ -3,7 +3,7 @@ import TimeAgo from 'react-timeago';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { cleanupEfficiency, Diff as TextDiff, makeDiff } from '@sanity/diff-match-patch';
|
||||
|
||||
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Stack, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import { Avatar, Box, Button, CircularProgress, IconButton, ListDivider, ListItem, ListItemDecorator, MenuItem, Switch, Tooltip, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
@@ -29,7 +29,7 @@ import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
import { cssRainbowColorKeyframes, lineHeightChatText } from '~/common/app.theme';
|
||||
import { prettyBaseModel } from '~/common/util/modelUtils';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
@@ -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.
|
||||
@@ -68,17 +68,18 @@ export function messageBackground(messageRole: DMessage['role'] | string, wasEdi
|
||||
}
|
||||
}
|
||||
|
||||
const avatarIconSx = { width: 36, height: 36 };
|
||||
|
||||
export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['role'] | string, messageOriginLLM: string | undefined, messagePurposeId: SystemPurposeId | undefined, messageSender: string, messageTyping: boolean, size: 'sm' | undefined = undefined): React.JSX.Element {
|
||||
if (typeof messageAvatar === 'string' && messageAvatar)
|
||||
return <Avatar alt={messageSender} src={messageAvatar} />;
|
||||
const iconSx = { width: 40, height: 40 };
|
||||
const mascotSx = size === 'sm' ? { width: 40, height: 40 } : { width: 64, height: 64 };
|
||||
const mascotSx = size === 'sm' ? avatarIconSx : { width: 64, height: 64 };
|
||||
switch (messageRole) {
|
||||
case 'system':
|
||||
return <SettingsSuggestIcon sx={iconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
|
||||
return <SettingsSuggestIcon sx={avatarIconSx} />; // https://em-content.zobj.net/thumbs/120/apple/325/robot_1f916.png
|
||||
|
||||
case 'user':
|
||||
return <Face6Icon sx={iconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
|
||||
return <Face6Icon sx={avatarIconSx} />; // https://www.svgrepo.com/show/306500/openai.svg
|
||||
|
||||
case 'assistant':
|
||||
// typing gif (people seem to love this, so keeping it after april fools')
|
||||
@@ -97,7 +98,7 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
|
||||
// text-to-image: icon
|
||||
if (isTextToImage)
|
||||
return <FormatPaintIcon sx={{
|
||||
...iconSx,
|
||||
...avatarIconSx,
|
||||
animation: `${cssRainbowColorKeyframes} 1s linear 2.66`,
|
||||
}} />;
|
||||
|
||||
@@ -106,13 +107,15 @@ export function makeAvatar(messageAvatar: string | null, messageRole: DMessage['
|
||||
if (symbol) return <Box sx={{
|
||||
fontSize: '24px',
|
||||
textAlign: 'center',
|
||||
width: '100%', minWidth: `${iconSx.width}px`, lineHeight: `${iconSx.height}px`,
|
||||
width: '100%',
|
||||
minWidth: `${avatarIconSx.width}px`,
|
||||
lineHeight: `${avatarIconSx.height}px`,
|
||||
}}>
|
||||
{symbol}
|
||||
</Box>;
|
||||
|
||||
// default assistant avatar
|
||||
return <SmartToyOutlinedIcon sx={iconSx} />; // https://mui.com/static/images/avatar/2.jpg
|
||||
return <SmartToyOutlinedIcon sx={avatarIconSx} />; // https://mui.com/static/images/avatar/2.jpg
|
||||
}
|
||||
return <Avatar alt={messageSender} />;
|
||||
}
|
||||
@@ -147,13 +150,14 @@ function explainErrorInMessage(text: string, isAssistant: boolean, modelId?: str
|
||||
</>;
|
||||
} else if (text.includes('"context_length_exceeded"')) {
|
||||
// TODO: propose to summarize or split the input?
|
||||
const pattern = /maximum context length is (\d+) tokens.+you requested (\d+) tokens/;
|
||||
const pattern = /maximum context length is (\d+) tokens.+resulted in (\d+) tokens/;
|
||||
const match = pattern.exec(text);
|
||||
const usedText = match ? <b>{parseInt(match[2] || '0').toLocaleString()} tokens > {parseInt(match[1] || '0').toLocaleString()}</b> : '';
|
||||
errorMessage = <>
|
||||
This thread <b>surpasses the maximum size</b> allowed for {modelId || 'this model'}. {usedText}.
|
||||
Please consider removing some earlier messages from the conversation, start a new conversation,
|
||||
choose a model with larger context, or submit a shorter new message.
|
||||
{!usedText && ` -- ${text}`}
|
||||
</>;
|
||||
}
|
||||
// [OpenAI] {"error":{"message":"Incorrect API key provided: ...","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}
|
||||
@@ -210,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,
|
||||
@@ -298,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);
|
||||
@@ -409,15 +413,19 @@ export function ChatMessage(props: {
|
||||
// per-blocks css
|
||||
const blockSx: SxProps = {
|
||||
my: 'auto',
|
||||
lineHeight: lineHeightChatText,
|
||||
};
|
||||
const typographySx: SxProps = {
|
||||
lineHeight: lineHeightChatText,
|
||||
};
|
||||
const codeSx: SxProps = {
|
||||
// backgroundColor: fromAssistant ? 'background.level1' : 'background.level1',
|
||||
backgroundColor: props.codeBackground ? props.codeBackground : fromAssistant ? 'neutral.plainHoverBg' : 'primary.plainActiveBg',
|
||||
boxShadow: 'xs',
|
||||
fontFamily: 'code',
|
||||
fontSize: '14px',
|
||||
fontSize: '0.875rem',
|
||||
fontVariantLigatures: 'none',
|
||||
lineHeight: 1.75,
|
||||
lineHeight: lineHeightChatText,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
};
|
||||
|
||||
@@ -450,41 +458,55 @@ export function ChatMessage(props: {
|
||||
>
|
||||
|
||||
{/* Avatar */}
|
||||
{showAvatars && <Stack
|
||||
sx={{ alignItems: 'center', minWidth: { xs: 50, md: 64 }, maxWidth: 80, textAlign: 'center' }}
|
||||
onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)}
|
||||
onClick={event => setOpsMenuAnchor(event.currentTarget)}>
|
||||
{showAvatars && (
|
||||
<Box
|
||||
onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)}
|
||||
onClick={event => setOpsMenuAnchor(event.currentTarget)}
|
||||
sx={{
|
||||
// flexBasis: 0, // this won't let the item grow
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
minWidth: { xs: 50, md: 64 }, maxWidth: 80,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
|
||||
{isHovering ? (
|
||||
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
avatarEl
|
||||
)}
|
||||
{isHovering ? (
|
||||
<IconButton variant='soft' color={fromAssistant ? 'neutral' : 'primary'} sx={avatarIconSx}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
avatarEl
|
||||
)}
|
||||
|
||||
{/* Assistant model name */}
|
||||
{fromAssistant && (
|
||||
<Tooltip title={messageOriginLLM || 'unk-model'} variant='solid'>
|
||||
<Typography level='body-sm' sx={{
|
||||
fontSize: { xs: 'xs', sm: 'sm' }, fontWeight: 500,
|
||||
overflowWrap: 'anywhere',
|
||||
...(messageTyping ? { animation: `${cssRainbowColorKeyframes} 5s linear infinite` } : {}),
|
||||
}}>
|
||||
{prettyBaseModel(messageOriginLLM)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Assistant model name */}
|
||||
{fromAssistant && (
|
||||
<Tooltip title={messageOriginLLM || 'unk-model'} variant='solid'>
|
||||
<Typography level='body-xs' sx={{
|
||||
overflowWrap: 'anywhere',
|
||||
...(messageTyping ? { animation: `${cssRainbowColorKeyframes} 5s linear infinite` } : {}),
|
||||
}}>
|
||||
{prettyBaseModel(messageOriginLLM)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
</Stack>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
{/* Edit / Blocks */}
|
||||
{isEditing
|
||||
{isEditing ? (
|
||||
|
||||
? <InlineTextarea initialText={messageText} onEdit={handleTextEdited} sx={{ ...blockSx, lineHeight: 1.75, flexGrow: 1 }} />
|
||||
<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={{
|
||||
@@ -526,12 +548,12 @@ export function ChatMessage(props: {
|
||||
: block.type === 'image'
|
||||
? <RenderImage key={'image-' + index} imageBlock={block} isFirst={!index} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsConversationRestartFrom} />
|
||||
: block.type === 'latex'
|
||||
? <RenderLatex key={'latex-' + index} latexBlock={block} />
|
||||
? <RenderLatex key={'latex-' + index} latexBlock={block} sx={typographySx} />
|
||||
: block.type === 'diff'
|
||||
? <RenderTextDiff key={'latex-' + index} diffBlock={block} />
|
||||
? <RenderTextDiff key={'latex-' + index} diffBlock={block} sx={typographySx} />
|
||||
: (renderMarkdown && props.noMarkdown !== true && !fromSystem && !(fromUser && block.content.startsWith('/')))
|
||||
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
|
||||
: <RenderText key={'text-' + index} textBlock={block} />)}
|
||||
: <RenderText key={'text-' + index} textBlock={block} sx={typographySx} />)}
|
||||
|
||||
{isCollapsed && (
|
||||
<Button variant='plain' color='neutral' onClick={handleUncollapse}>... expand ...</Button>
|
||||
@@ -544,14 +566,14 @@ export function ChatMessage(props: {
|
||||
{/*</Chip>*/}
|
||||
|
||||
</Box>
|
||||
}
|
||||
)}
|
||||
|
||||
|
||||
{/* Overlay copy icon */}
|
||||
{ENABLE_COPY_MESSAGE_OVERLAY && !fromSystem && !isEditing && (
|
||||
<Tooltip title={fromAssistant ? 'Copy message' : 'Copy input'} variant='solid'>
|
||||
<IconButton
|
||||
variant='outlined' color='neutral' onClick={handleOpsCopy}
|
||||
variant='outlined' onClick={handleOpsCopy}
|
||||
sx={{
|
||||
position: 'absolute', ...(fromAssistant ? { right: { xs: 12, md: 28 } } : { left: { xs: 12, md: 28 } }), zIndex: 10,
|
||||
opacity: 0, transition: 'opacity 0.3s',
|
||||
@@ -615,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>
|
||||
@@ -653,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>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const MessagesSelectionHeader = (props: { hasSelected: boolean, sumTokens
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<IconButton variant='plain' onClick={props.onClose}>
|
||||
<IconButton onClick={props.onClose}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
</Sheet>;
|
||||
|
||||
@@ -173,21 +173,21 @@ function RenderCodeImpl(props: {
|
||||
)}
|
||||
{isMermaid && (
|
||||
<Tooltip title={renderMermaid ? 'Show Code' : 'Render Mermaid'} variant='solid'>
|
||||
<IconButton variant={renderMermaid ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowMermaid(!showMermaid)}>
|
||||
<IconButton variant={renderMermaid ? 'solid' : 'outlined'} onClick={() => setShowMermaid(!showMermaid)}>
|
||||
<SchemaIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isPlantUML && (
|
||||
<Tooltip title={renderPlantUML ? 'Show Code' : 'Render PlantUML'} variant='solid'>
|
||||
<IconButton variant={renderPlantUML ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowPlantUML(!showPlantUML)}>
|
||||
<IconButton variant={renderPlantUML ? 'solid' : 'outlined'} onClick={() => setShowPlantUML(!showPlantUML)}>
|
||||
<SchemaIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isSVG && (
|
||||
<Tooltip title={renderSVG ? 'Show Code' : 'Render SVG'} variant='solid'>
|
||||
<IconButton variant={renderSVG ? 'solid' : 'outlined'} color='neutral' onClick={() => setShowSVG(!showSVG)}>
|
||||
<IconButton variant={renderSVG ? 'solid' : 'outlined'} onClick={() => setShowSVG(!showSVG)}>
|
||||
<ShapeLineOutlinedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -195,7 +195,7 @@ function RenderCodeImpl(props: {
|
||||
{canCodepen && <OpenInCodepen codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
|
||||
{canReplit && <OpenInReplit codeBlock={{ code: blockCode, language: inferredCodeLanguage || undefined }} />}
|
||||
{props.noCopyButton !== true && <Tooltip title='Copy Code' variant='solid'>
|
||||
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
|
||||
<IconButton variant='outlined' onClick={handleCopyToClipboard}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>}
|
||||
|
||||
@@ -104,7 +104,7 @@ export function RenderHtml(props: { htmlBlock: HtmlBlock, sx?: SxProps }) {
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title='Copy Code' variant='solid'>
|
||||
<IconButton variant='outlined' color='neutral' onClick={handleCopyToClipboard}>
|
||||
<IconButton variant='outlined' onClick={handleCopyToClipboard}>
|
||||
<ContentCopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -125,13 +125,13 @@ export const RenderImage = (props: { imageBlock: ImageBlock, isFirst: boolean, a
|
||||
<Box className='overlay-buttons' sx={{ ...overlayButtonsSx, pt: 0.5, px: 0.5, gap: 0.5 }}>
|
||||
{props.allowRunAgain && !!props.onRunAgain && (
|
||||
<Tooltip title='Draw again' variant='solid'>
|
||||
<IconButton variant='solid' color='neutral' onClick={props.onRunAgain}>
|
||||
<IconButton variant='solid' onClick={props.onRunAgain}>
|
||||
<ReplayIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title='Open in new tab'>
|
||||
<IconButton component={Link} href={url} target='_blank' variant='solid' color='neutral'>
|
||||
<IconButton component={Link} href={url} download={alt || 'image'} target='_blank' variant='solid'>
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -17,7 +17,6 @@ const RenderLatexDynamic = React.lazy(async () => {
|
||||
export const RenderLatex = ({ latexBlock, sx }: { latexBlock: LatexBlock; sx?: SxProps; }) =>
|
||||
<Box
|
||||
sx={{
|
||||
lineHeight: 1.75,
|
||||
mx: 1.5,
|
||||
...(sx || {}),
|
||||
}}>
|
||||
|
||||
@@ -1,43 +1,53 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, useTheme } from '@mui/joy';
|
||||
import { Box, styled } from '@mui/joy';
|
||||
|
||||
import { TextBlock } from './blocks';
|
||||
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')
|
||||
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 = ({ textBlock }: { textBlock: TextBlock }) => {
|
||||
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: '12px !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
|
||||
fontFamily: `inherit !important`, // use the default font family
|
||||
lineHeight: '1.75 !important', // line-height: 1.75 like the text block
|
||||
}}>
|
||||
|
||||
{/* 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>{textBlock.content}</ReactMarkdown>
|
||||
<DynamicReactGFM>
|
||||
{props.textBlock.content}
|
||||
</DynamicReactGFM>
|
||||
</React.Suspense>
|
||||
</Box>
|
||||
</RenderMarkdownBox>
|
||||
);
|
||||
};
|
||||
@@ -3,28 +3,36 @@ import * as React from 'react';
|
||||
import { Chip, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { extractCommands } from '../../editors/commands';
|
||||
import { extractChatCommand } from '../../commands/commands.registry';
|
||||
|
||||
import { TextBlock } from './blocks';
|
||||
import type { TextBlock } from './blocks';
|
||||
|
||||
|
||||
export const RenderText = ({ textBlock, sx }: { textBlock: TextBlock; sx?: SxProps; }) => {
|
||||
const elements = extractCommands(textBlock.content);
|
||||
export const RenderText = (props: { textBlock: TextBlock; sx?: SxProps; }) => {
|
||||
const elements = extractChatCommand(props.textBlock.content);
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
lineHeight: 1.75,
|
||||
mx: 1.5,
|
||||
display: 'flex', alignItems: 'baseline',
|
||||
// display: 'flex', // Commented on 2023-12-29: the commands were drawn as columns
|
||||
alignItems: 'baseline',
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'break-spaces',
|
||||
...(sx || {}),
|
||||
...(props.sx || {}),
|
||||
}}
|
||||
>
|
||||
{elements.map((element, index) =>
|
||||
element.type === 'cmd'
|
||||
? <Chip key={index} component='span' size='md' variant='solid' color='neutral' sx={{ mr: 1 }}>{element.value}</Chip>
|
||||
: <span key={index}>{element.value}</span>,
|
||||
<React.Fragment key={index}>
|
||||
{element.type === 'cmd'
|
||||
? <>
|
||||
<Chip component='span' size='md' variant='solid' color={element.isError ? 'danger' : 'neutral'} sx={{ mr: 1 }}>
|
||||
{element.command}
|
||||
</Chip>
|
||||
<span>{element.params}</span>
|
||||
</>
|
||||
: <span>{element.value}</span>
|
||||
}
|
||||
</React.Fragment>,
|
||||
)}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
@@ -38,14 +38,13 @@ export const RenderTextDiff = ({ diffBlock, sx }: { diffBlock: DiffBlock; sx?: S
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
lineHeight: 1.75,
|
||||
mx: 1.5,
|
||||
// display: 'flex', alignItems: 'baseline',
|
||||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'break-spaces',
|
||||
...(sx || {}),
|
||||
display: 'block',
|
||||
zIndex: 200,
|
||||
...(sx || {}),
|
||||
}}
|
||||
>
|
||||
{textDiffs.map(([op, text], index) =>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { lineHeightTextarea } from '~/common/app.theme';
|
||||
import { navigateToPersonas } from '~/common/app.routes';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
@@ -129,7 +132,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
placeholder='Search for purpose…'
|
||||
startDecorator={<SearchIcon />}
|
||||
endDecorator={searchQuery && (
|
||||
<IconButton variant='plain' color='neutral' onClick={handleSearchClear}>
|
||||
<IconButton onClick={handleSearchClear}>
|
||||
<ClearIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -143,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' }}>
|
||||
@@ -247,7 +252,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
? <>
|
||||
Example: {selectedExample}
|
||||
<IconButton
|
||||
variant='plain' color='primary' size='md'
|
||||
color='primary'
|
||||
onClick={() => props.runExample(selectedExample)}
|
||||
sx={{ opacity: 0, transition: 'opacity 0.3s' }}
|
||||
>
|
||||
@@ -268,7 +273,7 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
'&:focus-within': {
|
||||
backgroundColor: 'background.popup',
|
||||
},
|
||||
lineHeight: 1.75,
|
||||
lineHeight: lineHeightTextarea,
|
||||
mt: 1,
|
||||
}} />
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,8 @@ import { ScrollToBottomState, UseScrollToBottomProvider } from './useScrollToBot
|
||||
const DEBUG_SCROLL_TO_BOTTOM = false;
|
||||
|
||||
// NOTE: in Chrome a wheel scroll event is 100px
|
||||
// If you make this too small, the button may show when jumping lines on mobile
|
||||
// if you make it too large, the user would need a very large flick to unlock the view
|
||||
const USER_STICKY_MARGIN = 60;
|
||||
|
||||
// during the 'booting' timeout, scrolls happen instantly instead of smoothly
|
||||
@@ -50,6 +52,7 @@ function DebugBorderBox(props: { heightPx: number, color: string }) {
|
||||
|
||||
export function ScrollToBottom(props: {
|
||||
bootToBottom?: boolean
|
||||
bootSmoothly?: boolean
|
||||
stickToBottom?: boolean
|
||||
sx?: SxProps
|
||||
children: React.ReactNode,
|
||||
@@ -73,7 +76,7 @@ export function ScrollToBottom(props: {
|
||||
// derived state
|
||||
|
||||
const bootToBottom = props.bootToBottom || false;
|
||||
const scrollBehavior: ScrollBehavior = state.booting ? 'auto' : 'smooth';
|
||||
const scrollBehavior: ScrollBehavior = (state.booting && !props.bootSmoothly) ? 'auto' : 'smooth';
|
||||
|
||||
|
||||
// [Debugging]
|
||||
@@ -132,11 +135,24 @@ export function ScrollToBottom(props: {
|
||||
if (DEBUG_SCROLL_TO_BOTTOM)
|
||||
console.log(' -> scrollable children resized', entries.length);
|
||||
|
||||
// Edge case: when the content is smaller, we need to reset the bottom state (#312)
|
||||
const atTop = scrollable.scrollTop == 0;
|
||||
const unScrollable = scrollable.scrollHeight <= scrollable.offsetHeight;
|
||||
if (unScrollable && atTop) {
|
||||
if (DEBUG_SCROLL_TO_BOTTOM)
|
||||
console.log(' -> large enough window', entries.length);
|
||||
|
||||
// udpate state only if this changed
|
||||
setState(state => (state.atBottom !== true)
|
||||
? ({ ...state, atBottom: true })
|
||||
: state,
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length > 0 && state.stickToBottom)
|
||||
doScrollToBottom();
|
||||
});
|
||||
|
||||
|
||||
// cancelable observer of resize of scrollable's children elements
|
||||
Array.from(scrollable.children).forEach(child => _containerResizeObserver.observe(child));
|
||||
return () => _containerResizeObserver.disconnect();
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ScrollToBottomButton() {
|
||||
// </Typography>
|
||||
// }>
|
||||
<IconButton
|
||||
variant='outlined' color='neutral' size='md'
|
||||
variant='outlined'
|
||||
onClick={handleStickToBottom}
|
||||
sx={{
|
||||
// place this on the bottom-right corner (FAB-like)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { CmdRunBrowse } from '~/modules/browse/browse.client';
|
||||
import { CmdRunReact } from '~/modules/aifn/react/react';
|
||||
import { CmdRunSearch } from '~/modules/google/search.client';
|
||||
import { CmdRunT2I } from '~/modules/t2i/t2i.client';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { createDMessage, DMessage } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
export const CmdAddRoleMessage: string[] = ['/assistant', '/a', '/system', '/s'];
|
||||
|
||||
export const CmdHelp: string[] = ['/help', '/h', '/?'];
|
||||
|
||||
export const commands = [...CmdRunBrowse, ...CmdRunT2I, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage, ...CmdHelp];
|
||||
|
||||
export interface SentencePiece {
|
||||
type: 'text' | 'cmd';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentence to pieces (must have a leading slash) from the provided text
|
||||
* Used by rendering functions, as well as input processing functions.
|
||||
*/
|
||||
export function extractCommands(input: string): SentencePiece[] {
|
||||
// 'help' commands are the only without a space and text after
|
||||
if (CmdHelp.includes(input))
|
||||
return [{ type: 'cmd', value: input }, { type: 'text', value: '' }];
|
||||
const regexFromTags = commands.map(tag => `^\\${tag} `).join('\\b|') + '\\b';
|
||||
const pattern = new RegExp(regexFromTags, 'g');
|
||||
const result: SentencePiece[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(input)) !== null) {
|
||||
if (match.index !== lastIndex)
|
||||
result.push({ type: 'text', value: input.substring(lastIndex, match.index) });
|
||||
result.push({ type: 'cmd', value: match[0].trim() });
|
||||
lastIndex = pattern.lastIndex;
|
||||
|
||||
// Remove the space after the matched tag
|
||||
if (input[lastIndex] === ' ')
|
||||
lastIndex++;
|
||||
}
|
||||
|
||||
if (lastIndex !== input.length)
|
||||
result.push({ type: 'text', value: input.substring(lastIndex) });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createCommandsHelpMessage(): DMessage {
|
||||
let text = 'Available Chat Commands:\n';
|
||||
text += commands.map(c => ` - ${c}`).join('\n');
|
||||
const helpMessage = createDMessage('assistant', text);
|
||||
helpMessage.originLLM = Brand.Title.Base;
|
||||
return helpMessage;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export async function runReActUpdatingState(conversationId: string, question: st
|
||||
const agent = new Agent();
|
||||
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
|
||||
|
||||
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 2 * 1000);
|
||||
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 4 * 1000);
|
||||
updateAssistantMessage({ text: reactResult, typing: false });
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -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.' />
|
||||
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
}}
|
||||
/>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,109 +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 { useHasChatLinkItems } from '~/modules/trade/store-module-trade';
|
||||
|
||||
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 { AppChatLinkDrawerItems } from './AppChatLinkDrawerItems';
|
||||
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 drawerItems = React.useMemo(() => <AppChatLinkDrawerItems />, []);
|
||||
const menuItems = React.useMemo(() => <AppChatLinkMenuItems />, []);
|
||||
usePluggableOptimaLayout(hasLinkItems ? drawerItems : null, 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,69 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, ListDivider, ListItem, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
import { useChatLinkItems } from '~/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 { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
|
||||
/**
|
||||
* Drawer Items are all the links already shared, for quick access.
|
||||
* This is stores in the Trade Store (local storage).
|
||||
*/
|
||||
export function AppChatLinkDrawerItems() {
|
||||
|
||||
// external state
|
||||
const { closeAppDrawer } = useOptimaLayout();
|
||||
const chatLinkItems = useChatLinkItems()
|
||||
.slice()
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const notEmpty = chatLinkItems.length > 0;
|
||||
|
||||
return <>
|
||||
|
||||
<MenuItem
|
||||
onClick={closeAppDrawer}
|
||||
component={Link} href={ROUTE_INDEX} noLinkStyle
|
||||
>
|
||||
<ListItemDecorator><ArrowBackIcon /></ListItemDecorator>
|
||||
{Brand.Title.Base}
|
||||
</MenuItem>
|
||||
|
||||
{notEmpty && <ListDivider />}
|
||||
|
||||
{notEmpty && <ListItem>
|
||||
<Typography level='body-sm'>
|
||||
Links shared by you
|
||||
</Typography>
|
||||
</ListItem>}
|
||||
|
||||
{notEmpty && <Box sx={{ overflowY: 'auto' }}>
|
||||
{chatLinkItems.map(item => (
|
||||
|
||||
<MenuItem
|
||||
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>
|
||||
</MenuItem>
|
||||
|
||||
))}
|
||||
</Box>}
|
||||
</>;
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user