mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-11 06:00:15 -07:00
Compare commits
225 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 | |||
| 7f84160a62 |
@@ -27,7 +27,7 @@ assignees: enricoros
|
||||
- [ ] Copy the highlights to the [docs/changelog.md](/docs/changelog.md)
|
||||
- Release:
|
||||
- [ ] merge onto main `git checkout main && git merge --no-ff release-1.2.3`
|
||||
- [ ] re-tag `git tag -f v1.2.3 && git push opensource --tags -f'
|
||||
- [ ] re-tag `git tag -f v1.2.3 && git push opensource --tags -f`
|
||||
- [ ] verify deployment on Vercel
|
||||
- [ ] verify container on GitHub Packages
|
||||
- [ ] update the GitHub release
|
||||
@@ -39,14 +39,13 @@ assignees: enricoros
|
||||
### Links
|
||||
|
||||
- Milestone: https://github.com/enricoros/big-AGI/milestone/X
|
||||
- GitHub release: https://github.com/enricoros/big-AGI/releases/tag/vX.Y.Z
|
||||
- Former release task: https://github.com/enricoros/big-AGI/issues/XXX
|
||||
- GitHub release: https://github.com/enricoros/big-AGI/releases/tag/v1.2.3
|
||||
- Former release task: #...
|
||||
|
||||
## Artifacts Generation
|
||||
|
||||
```markdown
|
||||
You help me generate the following collateral for the new release of my opensource application
|
||||
called big-AGI. The new release is 1.2.3.
|
||||
You help me generate the following collateral for the new release of my opensource application called big-AGI. The new release is 1.2.3.
|
||||
To familiarize yourself with the application, the following are the Website and the GitHub README.md.
|
||||
```
|
||||
|
||||
@@ -55,8 +54,7 @@ To familiarize yourself with the application, the following are the Website and
|
||||
|
||||
```markdown
|
||||
I am announcing a new version, 1.2.3.
|
||||
For reference, the following was the collateral for 1.1.0 (Discord announcement,
|
||||
GitHub Release, in-app-news file news.data.tsx, changelog.md).
|
||||
For reference, the following was the collateral for 1.1.0 (Discord announcement, GitHub Release, in-app-news file news.data.tsx).
|
||||
```
|
||||
|
||||
- paste the former: `discord announcement`,
|
||||
@@ -66,20 +64,24 @@ GitHub Release, in-app-news file news.data.tsx, changelog.md).
|
||||
|
||||
```markdown
|
||||
The following are the new developments for 1.2.3:
|
||||
|
||||
- ...
|
||||
- git log --pretty=format:"%h %an %B" v1.1.0..v1.2.3 | clip
|
||||
```
|
||||
|
||||
- paste the link to the milestone (closed) and each individual issue (content will be downloaded)
|
||||
- paste the git changelog `git log v1.1.0..v1.2.3 | clip`
|
||||
- paste the output of the git log command
|
||||
|
||||
### news.data.tsx
|
||||
|
||||
```markdown
|
||||
I need the following from you:
|
||||
|
||||
1. a table summarizing all the new features in 1.2.3 (description, significance, usefulness, do not link the commit, but have the issue number), which will be used for the artifacts later
|
||||
2. after the table score each feature from a user impact and magnitude point of view
|
||||
3. Improve the table, in decreasing order of importance for features, fixing any detail that's missing, in particular check if there are commits of significance from a user or developer point of view, which are not contained in the table
|
||||
4. I want you then to update the news.data.tsx for the new release
|
||||
1. a table summarizing all the new features in 1.2.3 with the following columns: 4 words description (exactly what it is), short description, usefulness (what it does for the user), significance, link to the issue number (not the commit)), which will be used for the artifacts later
|
||||
2. then double-check the git log to see if there are any features of significance that are not in the table
|
||||
3. then score each feature in terms of importance for users (1-10), relative impact of the feature (1-10, where 10 applies to the broadest user base), and novelty and uniqueness (1-10, where 10 is truly unique and novel from what exists already)
|
||||
4. then improve the table, in decreasing order of importance for features, fixing any detail that's missing, in particular check if there are commits of significance from a user or developer point of view, which are not contained in the table
|
||||
5. then I want you then to update the news.data.tsx for the new release
|
||||
```
|
||||
|
||||
### Readme (and Changelog)
|
||||
@@ -92,9 +94,9 @@ Attaching the in-app news, with my language for you to improve on, but keep the
|
||||
### GitHub release
|
||||
|
||||
```markdown
|
||||
Please create the 1.2.3 Release Notes for GitHub.
|
||||
Please create the 1.2.3 Release Notes for GitHub, following the format of the 1.1.0 GitHub release notes attached before.
|
||||
Use a truthful and honest tone, understanding that people's time and attention span is short.
|
||||
Today is 2024-1-1.
|
||||
Today is 2024-XXXX-YYYY.
|
||||
```
|
||||
|
||||
Now paste-attachment the former release notes (or 1.5.0 which was accurate and great), including the new contributors and
|
||||
|
||||
@@ -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,6 +21,30 @@ shows the current developments and future ideas.
|
||||
- Got a suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
|
||||
- Want to contribute? [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
|
||||
|
||||
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
|
||||
|
||||
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
|
||||
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
|
||||
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
|
||||
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
|
||||
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
|
||||
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
|
||||
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
|
||||
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
|
||||
|
||||
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
|
||||
|
||||
- **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)
|
||||
@@ -30,32 +54,7 @@ shows the current developments and future ideas.
|
||||
- Large performance optimizations
|
||||
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
|
||||
|
||||
### What's New in 1.9.0 · Dec 28, 2023 · Creative Horizons
|
||||
|
||||
- **DALL·E 3 integration** for enhanced image generation. [#212](https://github.com/enricoros/big-AGI/issues/212)
|
||||
- **Perfect scrolling mechanics** across devices. [#304](https://github.com/enricoros/big-AGI/issues/304)
|
||||
- Persona creation now supports **text input**. [#287](https://github.com/enricoros/big-AGI/pull/287)
|
||||
- Openrouter updates for better model management and rate limit handling
|
||||
- Image drawing UX improvements
|
||||
- Layout fix for Firefox users
|
||||
- Developer enhancements: Text2Image subsystem, Optima layout, ScrollToBottom library, Panes library, and Llms subsystem updates.
|
||||
|
||||
### What's New in 1.8.0 · Dec 20, 2023
|
||||
|
||||
- **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... ?
|
||||
|
||||
> [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)
|
||||
For full details and former releases, check out the [changelog](docs/changelog.md).
|
||||
|
||||
## ✨ Key Features 👊
|
||||
|
||||
@@ -114,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`.
|
||||
|
||||
+26
-2
@@ -5,11 +5,35 @@ by release.
|
||||
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
### 1.11.0 - Jan 2024
|
||||
### 1.13.0 - Feb 2024
|
||||
|
||||
- milestone: [1.11.0](https://github.com/enricoros/big-agi/milestone/11)
|
||||
- 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)
|
||||
|
||||
@@ -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
+788
-364
File diff suppressed because it is too large
Load Diff
+21
-21
@@ -1,42 +1,41 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.10.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"env:pull": "npx vercel env pull .env.development.local",
|
||||
"postinstall": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio"
|
||||
"db:studio": "prisma studio",
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dqbd/tiktoken": "^1.0.7",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.2",
|
||||
"@mui/joy": "^5.0.0-beta.20",
|
||||
"@next/bundle-analyzer": "^14.0.4",
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@mui/icons-material": "^5.15.6",
|
||||
"@mui/joy": "^5.0.0-beta.24",
|
||||
"@next/bundle-analyzer": "^14.1.0",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@t3-oss/env-nextjs": "^0.8.0",
|
||||
"@tanstack/react-query": "~4.36.1",
|
||||
"@trpc/client": "10.44.1",
|
||||
"@trpc/next": "10.44.1",
|
||||
"@trpc/react-query": "10.44.1",
|
||||
"@trpc/server": "10.44.1",
|
||||
"@vercel/analytics": "^1.1.1",
|
||||
"@vercel/speed-insights": "^1.0.2",
|
||||
"@vercel/analytics": "^1.1.2",
|
||||
"@vercel/speed-insights": "^1.0.8",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"eventsource-parser": "^1.1.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"next": "^14.0.4",
|
||||
"next": "^14.1.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "4.0.269",
|
||||
"pdfjs-dist": "4.0.379",
|
||||
"plantuml-encoder": "^1.4.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -44,31 +43,32 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-katex": "^3.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^1.0.5",
|
||||
"react-resizable-panels": "^1.0.9",
|
||||
"react-timeago": "^7.2.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"superjson": "^2.2.1",
|
||||
"tesseract.js": "^5.0.4",
|
||||
"tiktoken": "^1.0.11",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.7"
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/puppeteer": "^0.0.5",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/node": "^20.11.7",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-timeago": "^4.1.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"prettier": "^3.1.1",
|
||||
"prisma": "^5.7.1",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prisma": "^5.8.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
|
||||
</ProviderTheming>
|
||||
|
||||
<VercelAnalytics debug={false} />
|
||||
<VercelSpeedInsights debug={false} />
|
||||
<VercelSpeedInsights debug={false} sampleRate={1 / 10} />
|
||||
|
||||
</>;
|
||||
|
||||
|
||||
@@ -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 />);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
|
||||
import { themeBgApp } from '~/common/app.theme';
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
@@ -42,7 +41,6 @@ function CallbackOpenRouterPage(props: { openRouterCode: string | undefined }) {
|
||||
return (
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: themeBgApp,
|
||||
overflowY: 'auto',
|
||||
display: 'flex', justifyContent: 'center',
|
||||
p: { xs: 3, md: 6 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppChatLink } from '../../../src/apps/link/AppChatLink';
|
||||
import { AppLinkChat } from '../../../src/apps/link/AppLinkChat';
|
||||
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
@@ -11,5 +11,5 @@ export default function ChatLinkPage() {
|
||||
// external state
|
||||
const { chatLinkId } = useRouterQuery<{ chatLinkId: string | undefined }>();
|
||||
|
||||
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppChatLink linkId={chatLinkId || ''} />);
|
||||
return withLayout({ type: 'optima', suspendAutoModelsSetup: true }, <AppLinkChat chatLinkId={chatLinkId || null} />);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
import { LogoProgress } from '~/common/components/LogoProgress';
|
||||
import { asValidURL } from '~/common/util/urlUtils';
|
||||
import { navigateToIndex, useRouterQuery } from '~/common/app.routes';
|
||||
import { themeBgApp } from '~/common/app.theme';
|
||||
import { withLayout } from '~/common/layout/withLayout';
|
||||
|
||||
|
||||
@@ -92,7 +91,6 @@ function AppShareTarget() {
|
||||
return (
|
||||
|
||||
<Box sx={{
|
||||
backgroundColor: themeBgApp,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
}}>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,14 +3,13 @@ import * as React from 'react';
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { themeBgApp } from '~/common/app.theme';
|
||||
import { useRouterRoute } from '~/common/app.routes';
|
||||
|
||||
|
||||
/**
|
||||
* https://github.com/enricoros/big-AGI/issues/299
|
||||
*/
|
||||
export function AppPlaceholder() {
|
||||
export function AppPlaceholder(props: { text?: string }) {
|
||||
|
||||
// external state
|
||||
const route = useRouterRoute();
|
||||
@@ -21,7 +20,6 @@ export function AppPlaceholder() {
|
||||
return (
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: themeBgApp,
|
||||
overflowY: 'auto',
|
||||
p: { xs: 3, md: 6 },
|
||||
border: '1px solid blue',
|
||||
@@ -38,7 +36,7 @@ export function AppPlaceholder() {
|
||||
{placeholderAppName}
|
||||
</Typography>
|
||||
<Typography>
|
||||
Intelligent applications to help you learn, think, and do
|
||||
{props.text || 'Intelligent applications to help you learn, think, and do'}
|
||||
</Typography>
|
||||
|
||||
</Box>
|
||||
|
||||
+57
-22
@@ -2,41 +2,76 @@ import * as React from 'react';
|
||||
|
||||
import { Container, Sheet } from '@mui/joy';
|
||||
|
||||
import { AppCallQueryParams, useRouterQuery } from '~/common/app.routes';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { useRouterQuery } from '~/common/app.routes';
|
||||
|
||||
import { CallUI } from './CallUI';
|
||||
import { CallWizard } from './CallWizard';
|
||||
import { Contacts } from './Contacts';
|
||||
import { Telephone } from './Telephone';
|
||||
import { useAppCallStore } from './state/store-app-call';
|
||||
|
||||
|
||||
/**
|
||||
* Used to define the intent of the call from other apps (via query params) or
|
||||
* from the contacts list (via the 'call' button).
|
||||
*/
|
||||
export interface AppCallIntent {
|
||||
conversationId: DConversationId | null;
|
||||
personaId: string;
|
||||
backTo: 'app-chat' | 'app-call-contacts';
|
||||
}
|
||||
|
||||
|
||||
export function AppCall() {
|
||||
|
||||
// external state
|
||||
const { conversationId, personaId } = useRouterQuery<AppCallQueryParams>();
|
||||
// state
|
||||
const [callIntent, setCallIntent] = React.useState<AppCallIntent | null>(null);
|
||||
|
||||
// derived state
|
||||
const validInput = !!conversationId && !!personaId;
|
||||
// external state
|
||||
const grayUI = useAppCallStore(state => state.grayUI);
|
||||
const query = useRouterQuery<Partial<AppCallIntent>>();
|
||||
|
||||
|
||||
// [effect] set intent from the query parameters
|
||||
React.useEffect(() => {
|
||||
if (query.personaId) {
|
||||
setCallIntent({
|
||||
conversationId: query.conversationId ?? null,
|
||||
personaId: query.personaId,
|
||||
backTo: query.backTo || 'app-chat',
|
||||
});
|
||||
}
|
||||
}, [query.backTo, query.conversationId, query.personaId]);
|
||||
|
||||
|
||||
const hasIntent = !!callIntent && !!callIntent.personaId;
|
||||
|
||||
return (
|
||||
<Sheet variant='solid' color='neutral' invertedColors sx={{
|
||||
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
minHeight: 96,
|
||||
}}>
|
||||
<Sheet
|
||||
variant={grayUI ? 'solid' : 'soft'}
|
||||
invertedColors={grayUI ? true : undefined}
|
||||
sx={{
|
||||
// take the full V-area (we're inside PageWrapper) and scroll as needed
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
|
||||
<Container maxWidth='sm' sx={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
minHeight: '80dvh', justifyContent: 'space-evenly',
|
||||
gap: { xs: 2, md: 4 },
|
||||
// container will take the full v-area
|
||||
display: 'grid',
|
||||
}}>
|
||||
|
||||
{!validInput && <InlineError error={`Something went wrong. ${conversationId}:${personaId}`} />}
|
||||
<Container
|
||||
maxWidth={hasIntent ? 'sm' : 'md'}
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: hasIntent ? 'space-evenly' : undefined,
|
||||
gap: hasIntent ? 1 : undefined,
|
||||
}}>
|
||||
|
||||
{validInput && (
|
||||
<CallWizard conversationId={conversationId}>
|
||||
<CallUI conversationId={conversationId} personaId={personaId} />
|
||||
{!hasIntent ? (
|
||||
<Contacts setCallIntent={setCallIntent} />
|
||||
) : (
|
||||
<CallWizard conversationId={callIntent.conversationId}>
|
||||
<Telephone callIntent={callIntent} backToContacts={() => setCallIntent(null)} />
|
||||
</CallWizard>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
import { Box, Button, Card, CardContent, IconButton, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
@@ -10,14 +9,15 @@ import MicIcon from '@mui/icons-material/Mic';
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
import { navigateBack } from '~/common/app.routes';
|
||||
import { useCapabilityBrowserSpeechRecognition, useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
const cssRainbowBackgroundKeyframes = keyframes`
|
||||
/*export const cssRainbowBackgroundKeyframes = keyframes`
|
||||
100%, 0% {
|
||||
background-color: rgb(128, 0, 0);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ const cssRainbowBackgroundKeyframes = keyframes`
|
||||
}
|
||||
91% {
|
||||
background-color: rgb(102, 0, 51);
|
||||
}`;
|
||||
}`;*/
|
||||
|
||||
function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: string, button?: React.JSX.Element }) {
|
||||
return (
|
||||
@@ -75,7 +75,8 @@ function StatusCard(props: { icon: React.JSX.Element, hasIssue: boolean, text: s
|
||||
}
|
||||
|
||||
|
||||
export function CallWizard(props: { strict?: boolean, conversationId: string, children: React.ReactNode }) {
|
||||
export function CallWizard(props: { strict?: boolean, conversationId: string | null, children: React.ReactNode }) {
|
||||
|
||||
// state
|
||||
const [chatEmptyOverride, setChatEmptyOverride] = React.useState(false);
|
||||
const [recognitionOverride, setRecognitionOverride] = React.useState(false);
|
||||
@@ -85,12 +86,15 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
|
||||
const recognition = useCapabilityBrowserSpeechRecognition();
|
||||
const synthesis = useCapabilityElevenLabs();
|
||||
const chatIsEmpty = useChatStore(state => {
|
||||
if (!props.conversationId)
|
||||
return false;
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return !(conversation?.messages?.length);
|
||||
});
|
||||
const { novel, touch } = useUICounter('call-wizard');
|
||||
|
||||
// derived state
|
||||
const outOfTheBlue = !props.conversationId;
|
||||
const overriddenEmptyChat = chatEmptyOverride || !chatIsEmpty;
|
||||
const overriddenRecognition = recognitionOverride || recognition.mayWork;
|
||||
const allGood = overriddenEmptyChat && overriddenRecognition && synthesis.mayWork;
|
||||
@@ -104,7 +108,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
|
||||
const handleOverrideRecognition = () => setRecognitionOverride(true);
|
||||
|
||||
const handleConfigureElevenLabs = () => {
|
||||
openPreferencesTab(3);
|
||||
openPreferencesTab(PreferencesTab.Voice);
|
||||
};
|
||||
|
||||
const handleFinishButton = () => {
|
||||
@@ -118,16 +122,11 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
|
||||
|
||||
<Box sx={{ flexGrow: 0.5 }} />
|
||||
|
||||
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, lineHeight: '1.5em', textAlign: 'center' }}>
|
||||
<Typography level='title-lg' sx={{ fontSize: '3rem', fontWeight: 200, textAlign: 'center' }}>
|
||||
Welcome to<br />
|
||||
<Typography
|
||||
component='span'
|
||||
sx={{
|
||||
backgroundColor: 'primary.solidActiveBg', mx: -0.5, px: 0.5,
|
||||
animation: `${cssRainbowBackgroundKeyframes} 15s linear infinite`,
|
||||
}}>
|
||||
<Box component='span' sx={{ animation: `${cssRainbowColorKeyframes} 15s linear infinite` }}>
|
||||
your first call
|
||||
</Typography>
|
||||
</Box>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ flexGrow: 0.5 }} />
|
||||
@@ -138,7 +137,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
|
||||
</Typography>
|
||||
|
||||
{/* Chat Empty status */}
|
||||
<StatusCard
|
||||
{!outOfTheBlue && <StatusCard
|
||||
icon={<ChatIcon />}
|
||||
hasIssue={!overriddenEmptyChat}
|
||||
text={overriddenEmptyChat ? 'Great! Your chat has messages.' : 'The chat is empty. Calls are effective when the caller has context.'}
|
||||
@@ -147,7 +146,7 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
|
||||
Ignore
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
/>}
|
||||
|
||||
{/* Add the speech to text feature status */}
|
||||
<StatusCard
|
||||
@@ -199,14 +198,21 @@ export function CallWizard(props: { strict?: boolean, conversationId: string, ch
|
||||
</Typography>
|
||||
|
||||
<IconButton
|
||||
size='lg' variant={allGood ? 'soft' : 'solid'} color={allGood ? 'success' : 'danger'}
|
||||
onClick={handleFinishButton} sx={{ borderRadius: '50px', mr: 0.5 }}
|
||||
size='lg'
|
||||
variant='solid' color={allGood ? 'success' : 'danger'}
|
||||
onClick={handleFinishButton}
|
||||
sx={{
|
||||
borderRadius: '50px',
|
||||
mr: 0.5,
|
||||
// animation: `${cssRainbowBackgroundKeyframes} 15s linear infinite`,
|
||||
// boxShadow: allGood ? 'md' : 'none',
|
||||
}}
|
||||
>
|
||||
{allGood ? <ArrowForwardIcon sx={{ fontSize: '1.5em' }} /> : <CloseIcon sx={{ fontSize: '1.5em' }} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 0.5 }} />
|
||||
<Box sx={{ flexGrow: 2 }} />
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Avatar, Box, Card, CardContent, Chip, IconButton, Link as MuiLink, ListDivider, MenuItem, Sheet, Switch, Typography } from '@mui/joy';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
|
||||
import { GitHubProjectIssueCard } from '~/common/components/GitHubProjectIssueCard';
|
||||
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
import { MockPersona, useMockPersonas } from './state/useMockPersonas';
|
||||
import { useAppCallStore } from './state/store-app-call';
|
||||
|
||||
|
||||
// number of conversations to show before collapsing
|
||||
const COLLAPSED_COUNT = 2;
|
||||
|
||||
|
||||
export const niceShadowKeyframes = keyframes`
|
||||
100%, 0% {
|
||||
//background-color: rgb(102, 0, 51);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(183, 255, 0);
|
||||
}
|
||||
25% {
|
||||
//background-color: rgb(76, 0, 76);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 251, 0);
|
||||
//scale: 1.2;
|
||||
}
|
||||
50% {
|
||||
//background-color: rgb(63, 0, 128);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgba(0, 255, 81);
|
||||
//scale: 0.8;
|
||||
}
|
||||
75% {
|
||||
//background-color: rgb(0, 0, 128);
|
||||
box-shadow: 1px 1px 0 white, 2px 2px 12px rgb(255, 153, 0);
|
||||
}`;
|
||||
|
||||
|
||||
const ContactCardAvatar = (props: { size: string, symbol?: string, imageUrl?: string, onClick?: () => void, sx?: SxProps }) =>
|
||||
<Avatar
|
||||
// variant='outlined'
|
||||
onClick={props.onClick}
|
||||
src={props.imageUrl}
|
||||
sx={{
|
||||
'--Avatar-size': props.size,
|
||||
fontSize: props.size,
|
||||
backgroundColor: 'background.popup',
|
||||
boxShadow: !props.imageUrl ? 'sm' : null,
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
{/* As fallback, show the large Persona Symbol */}
|
||||
{!props.imageUrl && <Box>{props.symbol}</Box>}
|
||||
</Avatar>;
|
||||
|
||||
|
||||
const ContactCardConversationCall = (props: { conversation: DConversation, onConversationClicked: (conversationId: DConversationId) => void, }) =>
|
||||
<Chip
|
||||
variant='plain' color='primary' size='sm'
|
||||
endDecorator={<CallIcon />}
|
||||
onClick={() => props.onConversationClicked(props.conversation.id)}
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: {
|
||||
maxWidth: 'unset',
|
||||
mx: -1,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{conversationTitle(props.conversation, 'Chat')}
|
||||
</Chip>;
|
||||
|
||||
|
||||
function CallContactCard(props: {
|
||||
persona: MockPersona,
|
||||
callGrayUI: boolean,
|
||||
conversations: DConversation[],
|
||||
setCallIntent: (intent: AppCallIntent) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [conversationsExpanded, setConversationsExpanded] = React.useState(false);
|
||||
|
||||
// derived state
|
||||
const { persona, setCallIntent } = props;
|
||||
const conversations = props.conversations.slice(0, conversationsExpanded ? undefined : COLLAPSED_COUNT);
|
||||
const hasConversations = !!conversations.length;
|
||||
const showExpander = props.conversations.length > COLLAPSED_COUNT && !conversationsExpanded;
|
||||
|
||||
|
||||
const handleCallPersona = React.useCallback(() => setCallIntent({
|
||||
conversationId: null,
|
||||
personaId: persona.personaId,
|
||||
backTo: 'app-call-contacts',
|
||||
}), [persona.personaId, setCallIntent]);
|
||||
|
||||
const handleCallPersonaRe = React.useCallback((conversationId: DConversationId | null) => setCallIntent({
|
||||
conversationId: conversationId,
|
||||
personaId: persona.personaId,
|
||||
backTo: 'app-call-contacts',
|
||||
}), [persona.personaId, setCallIntent]);
|
||||
|
||||
return (
|
||||
|
||||
<Box sx={{ mt: 3.5 }}>
|
||||
|
||||
<Card sx={{
|
||||
// boxShadow: 'lg',
|
||||
height: '100%',
|
||||
gap: 0,
|
||||
}}>
|
||||
|
||||
{/* Persona Symbol - Overlapping */}
|
||||
<ContactCardAvatar
|
||||
size='6rem'
|
||||
symbol={persona.symbol}
|
||||
imageUrl={persona?.imageUri}
|
||||
sx={{
|
||||
mx: 'auto',
|
||||
mt: '-2.5rem',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardContent sx={{ my: 2, display: 'flex' }}>
|
||||
{/* Persona Description */}
|
||||
<Typography level='body-xs' sx={{ minHeight: '3em', mb: hasConversations ? 1.5 : undefined }}>
|
||||
{typeof persona.description === 'string' ? persona.description : 'Custom persona'}
|
||||
</Typography>
|
||||
|
||||
{/*{hasConversations && <Divider>*/}
|
||||
{/*<Typography level='body-xs'>call about</Typography>*/}
|
||||
{/*</Divider>}*/}
|
||||
|
||||
{/* Persona Recent Converstions */}
|
||||
{conversations.map(conversation =>
|
||||
<ContactCardConversationCall
|
||||
key={conversation.id}
|
||||
conversation={conversation}
|
||||
onConversationClicked={handleCallPersonaRe}
|
||||
/>,
|
||||
)}
|
||||
|
||||
{showExpander && <Chip
|
||||
variant='plain' color='primary' size='sm'
|
||||
onClick={() => setConversationsExpanded(true)}
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: {
|
||||
maxWidth: 'unset',
|
||||
mx: -1,
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{`+${props.conversations.length - COLLAPSED_COUNT} more`}
|
||||
</Chip>}
|
||||
|
||||
</CardContent>
|
||||
|
||||
{/*<Divider />*/}
|
||||
|
||||
{/* Bottom Name and "Call" Button */}
|
||||
<Sheet
|
||||
variant='soft' color='primary'
|
||||
invertedColors={props.callGrayUI ? undefined : true}
|
||||
sx={{
|
||||
// emulate CardOverflow, because CardOverflow doesn't work well with Sheet/Inverted
|
||||
// (there's also a potential top-level inversion)
|
||||
'--variant-borderWidth': '1px',
|
||||
'--CardOverflow-offset': 'calc(-1 * var(--Card-padding))',
|
||||
'--CardOverflow-radius': 'calc(var(--Card-radius) - var(--variant-borderWidth, 0px))',
|
||||
margin: '0 var(--CardOverflow-offset) var(--CardOverflow-offset)',
|
||||
borderRadius: '0 0 var(--CardOverflow-radius) var(--CardOverflow-radius)',
|
||||
padding: '0.5rem var(--Card-padding)',
|
||||
|
||||
// contents
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-md'>
|
||||
{persona.title}
|
||||
</Typography>
|
||||
<MuiLink overlay onClick={handleCallPersona}>
|
||||
<IconButton size='md' variant='soft' sx={{
|
||||
// borderRadius: '50%',
|
||||
ml: 'auto',
|
||||
mr: -1,
|
||||
}}>
|
||||
<CallIcon />
|
||||
</IconButton>
|
||||
</MuiLink>
|
||||
</Sheet>
|
||||
|
||||
</Card>
|
||||
|
||||
</Box>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function useConversationsByPersona() {
|
||||
const conversations = useChatStore(state => state.conversations, shallow);
|
||||
|
||||
return React.useMemo(() => {
|
||||
// group by personaId
|
||||
const groupedConversations: { [personaId: string]: DConversation[] } = conversations.reduce((acc, conversation) => {
|
||||
const personaId = conversation.systemPurposeId;
|
||||
acc[personaId] = [...acc[personaId] || [], conversation];
|
||||
return acc;
|
||||
}, {} as { [personaId: string]: DConversation[] });
|
||||
|
||||
// sort conversations by time and limit to 3
|
||||
Object.values(groupedConversations).forEach(conversations =>
|
||||
conversations.sort((a, b) => (b.updated || b.created) - (a.updated || a.created)),
|
||||
);
|
||||
|
||||
return groupedConversations;
|
||||
}, [conversations]);
|
||||
}
|
||||
|
||||
|
||||
export function Contacts(props: { setCallIntent: (intent: AppCallIntent) => void }) {
|
||||
|
||||
// external state
|
||||
const {
|
||||
grayUI, toggleGrayUI,
|
||||
showConversations, toggleShowConversations,
|
||||
showSupport, toggleShowSupport,
|
||||
} = useAppCallStore();
|
||||
const { personas } = useMockPersonas();
|
||||
const conversationsByPersona = useConversationsByPersona();
|
||||
|
||||
|
||||
// pluggable UI
|
||||
|
||||
const menuItems = React.useMemo(() => <>
|
||||
|
||||
<MenuItem onClick={toggleShowConversations}>
|
||||
Conversations
|
||||
<Switch checked={showConversations} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={toggleShowSupport}>
|
||||
Support
|
||||
<Switch checked={showSupport} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={toggleGrayUI}>
|
||||
Grayed UI
|
||||
<Switch checked={grayUI} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
</>, [grayUI, showConversations, showSupport, toggleGrayUI, toggleShowConversations, toggleShowSupport]);
|
||||
|
||||
usePluggableOptimaLayout(null, null, menuItems, 'CallUI');
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
{/* Header "Call AGI" */}
|
||||
<Box sx={{
|
||||
my: 6,
|
||||
display: 'flex', alignItems: 'center',
|
||||
gap: 3,
|
||||
}}>
|
||||
<IconButton
|
||||
variant='soft' color='success'
|
||||
sx={{
|
||||
'--IconButton-size': { xs: '4.2rem', md: '5rem' },
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: 'background.popup',
|
||||
animation: `${niceShadowKeyframes} 5s infinite`,
|
||||
}}>
|
||||
<CallIcon />
|
||||
</IconButton>
|
||||
|
||||
<Box>
|
||||
<Typography level='title-lg'>
|
||||
Call AGI
|
||||
</Typography>
|
||||
<Typography level='title-sm' sx={{ mt: 1 }}>
|
||||
Explore ideas and ignite creativity
|
||||
</Typography>
|
||||
<Chip variant='outlined' size='sm' sx={{ px: 1, py: 0.5, mt: 0.25, ml: -1, textWrap: 'wrap' }}>
|
||||
Out-of-the-blue, or within a conversation
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ListDivider>
|
||||
Personas
|
||||
</ListDivider>
|
||||
|
||||
{/* Personas Cards */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
my: 5,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: { xs: 1, md: 2 },
|
||||
}}
|
||||
>
|
||||
{personas.map((persona) =>
|
||||
<CallContactCard
|
||||
key={persona.personaId}
|
||||
persona={persona}
|
||||
callGrayUI={grayUI}
|
||||
conversations={!showConversations ? [] : conversationsByPersona[persona.personaId] || []}
|
||||
setCallIntent={props.setCallIntent}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showSupport && <ListDivider sx={{ my: 1 }} />}
|
||||
|
||||
{showSupport && <GitHubProjectIssueCard
|
||||
issue={354}
|
||||
text='Call App: Support thread and compatibility matrix'
|
||||
note={<>
|
||||
Voice input uses the HTML Web Speech API, and speech output requires an ElevenLabs API Key.
|
||||
</>}
|
||||
// note2='Please report any issues you encounter'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
mt: 5,
|
||||
}}
|
||||
/>}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Card, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
|
||||
import { Box, Card, ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CallEndIcon from '@mui/icons-material/CallEnd';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
import ChatOutlinedIcon from '@mui/icons-material/ChatOutlined';
|
||||
import MicIcon from '@mui/icons-material/Mic';
|
||||
import MicNoneIcon from '@mui/icons-material/MicNone';
|
||||
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
@@ -20,14 +19,16 @@ import { useElevenLabsVoiceDropdown } from '~/modules/elevenlabs/useElevenLabsVo
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { conversationTitle, createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { launchAppChat, navigateToIndex } from '~/common/app.routes';
|
||||
import { playSoundUrl, usePlaySoundUrl } from '~/common/util/audioUtils';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import type { AppCallIntent } from './AppCall';
|
||||
import { CallAvatar } from './components/CallAvatar';
|
||||
import { CallButton } from './components/CallButton';
|
||||
import { CallMessage } from './components/CallMessage';
|
||||
import { CallStatus } from './components/CallStatus';
|
||||
import { launchAppChat, ROUTE_APP_CHAT } from '~/common/app.routes';
|
||||
import { useAppCallStore } from './state/store-app-call';
|
||||
|
||||
|
||||
function CallMenuItems(props: {
|
||||
@@ -38,6 +39,7 @@ function CallMenuItems(props: {
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { grayUI, toggleGrayUI } = useAppCallStore();
|
||||
const { voicesDropdown } = useElevenLabsVoiceDropdown(false, !props.override);
|
||||
|
||||
const handlePushToTalkToggle = () => props.setPushToTalk(!props.pushToTalk);
|
||||
@@ -63,8 +65,14 @@ function CallMenuItems(props: {
|
||||
{voicesDropdown}
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider />
|
||||
|
||||
<MenuItem onClick={toggleGrayUI}>
|
||||
Grayed UI
|
||||
<Switch checked={grayUI} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem component={Link} href='https://github.com/enricoros/big-agi/issues/175' target='_blank'>
|
||||
<ListItemDecorator><ChatOutlinedIcon /></ListItemDecorator>
|
||||
Voice Calls Feedback
|
||||
</MenuItem>
|
||||
|
||||
@@ -72,9 +80,9 @@ function CallMenuItems(props: {
|
||||
}
|
||||
|
||||
|
||||
export function CallUI(props: {
|
||||
conversationId: string,
|
||||
personaId: string,
|
||||
export function Telephone(props: {
|
||||
callIntent: AppCallIntent,
|
||||
backToContacts: () => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -89,14 +97,16 @@ export function CallUI(props: {
|
||||
|
||||
// external state
|
||||
const { chatLLMId, chatLLMDropdown } = useChatLLMDropdown();
|
||||
const { chatTitle, messages } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
const { chatTitle, reMessages } = useChatStore(state => {
|
||||
const conversation = props.callIntent.conversationId
|
||||
? state.conversations.find(conversation => conversation.id === props.callIntent.conversationId) ?? null
|
||||
: null;
|
||||
return {
|
||||
chatTitle: conversation ? conversationTitle(conversation) : 'no conversation',
|
||||
messages: conversation ? conversation.messages : [],
|
||||
chatTitle: conversation ? conversationTitle(conversation) : null,
|
||||
reMessages: conversation ? conversation.messages : null,
|
||||
};
|
||||
}, shallow);
|
||||
const persona = SystemPurposes[props.personaId as SystemPurposeId] ?? undefined;
|
||||
const persona = SystemPurposes[props.callIntent.personaId as SystemPurposeId] ?? undefined;
|
||||
const personaCallStarters = persona?.call?.starters ?? undefined;
|
||||
const personaVoiceId = overridePersonaVoice ? undefined : (persona?.voices?.elevenLabs?.voiceId ?? undefined);
|
||||
const personaSystemMessage = persona?.systemMessage ?? undefined;
|
||||
@@ -193,8 +203,8 @@ export function CallUI(props: {
|
||||
if (!chatLLMId) return;
|
||||
|
||||
// temp fix: when the chat has no messages, only assume a single system message
|
||||
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = messages.length > 0
|
||||
? messages
|
||||
const chatMessages: { role: VChatMessageIn['role'], text: string }[] = (reMessages && reMessages.length > 0)
|
||||
? reMessages
|
||||
: personaSystemMessage
|
||||
? [{ role: 'system', text: personaSystemMessage }]
|
||||
: [];
|
||||
@@ -223,16 +233,18 @@ export function CallUI(props: {
|
||||
error = err;
|
||||
}).finally(() => {
|
||||
setPersonaTextInterim(null);
|
||||
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
|
||||
if (finalText || error)
|
||||
setCallMessages(messages => [...messages, createDMessage('assistant', finalText + (error ? ` (ERROR: ${error.message || error.toString()})` : ''))]);
|
||||
// fire/forget
|
||||
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
|
||||
if (finalText?.length >= 1)
|
||||
void EXPERIMENTAL_speakTextStream(finalText, personaVoiceId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
responseAbortController.current?.abort();
|
||||
responseAbortController.current = null;
|
||||
};
|
||||
}, [isConnected, callMessages, chatLLMId, messages, personaVoiceId, personaSystemMessage]);
|
||||
}, [isConnected, callMessages, chatLLMId, personaVoiceId, personaSystemMessage, reMessages]);
|
||||
|
||||
// [E] Message interrupter
|
||||
const abortTrigger = isConnected && isRecordingSpeech;
|
||||
@@ -294,7 +306,7 @@ export function CallUI(props: {
|
||||
|
||||
<CallStatus
|
||||
callerName={isConnected ? undefined : personaName}
|
||||
statusText={isRinging ? 'is calling you' : isDeclined ? 'call declined' : isEnded ? 'call ended' : callElapsedTime}
|
||||
statusText={isRinging ? '' /*'is calling you'*/ : isDeclined ? 'call declined' : isEnded ? 'call ended' : callElapsedTime}
|
||||
regardingText={chatTitle}
|
||||
micError={!isMicEnabled} speakError={!isTTSEnabled}
|
||||
/>
|
||||
@@ -303,11 +315,18 @@ export function CallUI(props: {
|
||||
{(isConnected || isEnded) && (
|
||||
<Card variant='soft' sx={{
|
||||
flexGrow: 1,
|
||||
minHeight: '15dvh', maxHeight: '24dvh',
|
||||
overflow: 'auto',
|
||||
maxHeight: '24%',
|
||||
minHeight: '15%',
|
||||
width: '100%',
|
||||
|
||||
// style
|
||||
backgroundColor: 'background.surface',
|
||||
borderRadius: 'lg',
|
||||
flexDirection: 'column-reverse',
|
||||
boxShadow: 'sm',
|
||||
|
||||
// children
|
||||
display: 'flex', flexDirection: 'column-reverse',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
|
||||
{/* Messages in reverse order, for auto-scroll from the bottom */}
|
||||
@@ -344,18 +363,18 @@ export function CallUI(props: {
|
||||
)}
|
||||
|
||||
{/* Call Buttons */}
|
||||
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-evenly' }}>
|
||||
<Box sx={{ width: '100%', display: 'flex', justifyContent: 'space-evenly', gap: 4 }}>
|
||||
|
||||
{/* [ringing] Decline / Accept */}
|
||||
{isRinging && <CallButton Icon={CallEndIcon} text='Decline' color='danger' onClick={() => setStage('declined')} />}
|
||||
{isRinging && isEnabled && <CallButton Icon={CallIcon} text='Accept' color='success' variant='soft' onClick={() => setStage('connected')} />}
|
||||
{isRinging && <CallButton Icon={CallEndIcon} text='Decline' color='danger' variant='solid' onClick={() => setStage('declined')} />}
|
||||
{isRinging && isEnabled && <CallButton Icon={CallIcon} text='Accept' color='success' variant='solid' onClick={() => setStage('connected')} />}
|
||||
|
||||
{/* [Calling] Hang / PTT (mute not enabled yet) */}
|
||||
{isConnected && <CallButton Icon={CallEndIcon} text='Hang up' color='danger' onClick={handleCallStop} />}
|
||||
{isConnected && <CallButton Icon={CallEndIcon} text='Hang up' color='danger' variant='soft' onClick={handleCallStop} />}
|
||||
{isConnected && (pushToTalk
|
||||
? <CallButton Icon={MicIcon} onClick={toggleRecording}
|
||||
text={isRecordingSpeech ? 'Listening...' : isRecording ? 'Listening' : 'Push To Talk'}
|
||||
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'} />
|
||||
variant={isRecordingSpeech ? 'solid' : isRecording ? 'soft' : 'outlined'} sx={!isRecording ? { backgroundColor: 'background.surface' } : undefined} />
|
||||
: null
|
||||
// <CallButton disabled={true} Icon={MicOffIcon} onClick={() => setMicMuted(muted => !muted)}
|
||||
// text={micMuted ? 'Muted' : 'Mute'}
|
||||
@@ -363,7 +382,7 @@ export function CallUI(props: {
|
||||
)}
|
||||
|
||||
{/* [ended] Back / Call Again */}
|
||||
{(isEnded || isDeclined) && <Link noLinkStyle href={ROUTE_APP_CHAT}><CallButton Icon={ArrowBackIcon} text='Back' variant='soft' /></Link>}
|
||||
{(isEnded || isDeclined) && <CallButton Icon={ArrowBackIcon} text='Back' variant='soft' onClick={() => props.callIntent.backTo === 'app-chat' ? navigateToIndex() : props.backToContacts()} />}
|
||||
{(isEnded || isDeclined) && <CallButton Icon={CallIcon} text='Call Again' color='success' variant='soft' onClick={() => setStage('connected')} />}
|
||||
|
||||
</Box>
|
||||
@@ -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]);
|
||||
}
|
||||
+37
-39
@@ -4,13 +4,13 @@ import { Panel, PanelGroup } from 'react-resizable-panels';
|
||||
|
||||
import { useTheme } from '@mui/joy';
|
||||
|
||||
import { 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 { 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';
|
||||
@@ -18,13 +18,13 @@ import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/commo
|
||||
import { GoodPanelResizeHandler } from '~/common/components/panes/GoodPanelResizeHandler';
|
||||
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
|
||||
import { themeBgApp, themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
import { useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
|
||||
import { ChatDrawerContentMemo } from './components/applayout/ChatDrawerItems';
|
||||
import { ChatDrawerMemo } from './components/applayout/ChatDrawer';
|
||||
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
|
||||
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
@@ -63,7 +63,7 @@ export function AppChat() {
|
||||
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
|
||||
const showNextTitle = React.useRef(false);
|
||||
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [_selectedFolderId, setSelectedFolderId] = React.useState<string | null>(null);
|
||||
const [_activeFolderId, setActiveFolderId] = React.useState<string | null>(null);
|
||||
|
||||
// external state
|
||||
const theme = useTheme();
|
||||
@@ -101,13 +101,12 @@ export function AppChat() {
|
||||
|
||||
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
|
||||
|
||||
const { folderConversationsCount, selectedFolderId } = useFolderStore(state => {
|
||||
const selectedFolderId = state.useFolders ? _selectedFolderId : null;
|
||||
const { activeFolderId, activeFolderConversationsCount } = useFolderStore(({ enableFolders, folders }) => {
|
||||
const activeFolderId = enableFolders ? _activeFolderId : null;
|
||||
const activeFolder = activeFolderId ? folders.find(folder => folder.id === activeFolderId) : null;
|
||||
return {
|
||||
folderConversationsCount: selectedFolderId
|
||||
? state.folders.find(folder => folder.id === selectedFolderId)?.conversationIds.length || 0
|
||||
: conversationsLength,
|
||||
selectedFolderId,
|
||||
activeFolderId: activeFolder?.id ?? null,
|
||||
activeFolderConversationsCount: activeFolder ? activeFolder.conversationIds.length : conversationsLength,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -147,8 +146,8 @@ export function AppChat() {
|
||||
|
||||
// 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
|
||||
@@ -248,8 +247,9 @@ export function AppChat() {
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
await _handleExecute('generate-text', conversationId, history);
|
||||
}, [_handleExecute]);
|
||||
|
||||
const handleMessageRegenerateLast = React.useCallback(async () => {
|
||||
const focusedConversation = getConversation(focusedConversationId);
|
||||
@@ -262,9 +262,9 @@ export function AppChat() {
|
||||
}
|
||||
}, [focusedConversationId, _handleExecute]);
|
||||
|
||||
const handleTextDiagram = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
|
||||
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
|
||||
|
||||
const handleTextImagine = async (conversationId: DConversationId, messageText: string) => {
|
||||
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string): Promise<void> => {
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation)
|
||||
return;
|
||||
@@ -273,11 +273,11 @@ export function AppChat() {
|
||||
...conversation.messages,
|
||||
createDMessage('user', imaginedPrompt),
|
||||
]);
|
||||
};
|
||||
}, [_handleExecute]);
|
||||
|
||||
const handleTextSpeak = async (text: string) => {
|
||||
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
|
||||
await speakText(text);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Chat actions
|
||||
|
||||
@@ -289,18 +289,18 @@ export function AppChat() {
|
||||
: prependNewConversation(focusedSystemPurposeId ?? undefined);
|
||||
setFocusedConversationId(conversationId);
|
||||
|
||||
// if a folder is selected, add the new conversation to the folder
|
||||
if (selectedFolderId && conversationId)
|
||||
useFolderStore.getState().addConversationToFolder(selectedFolderId, conversationId);
|
||||
// if a folder is active, add the new conversation to the folder
|
||||
if (activeFolderId && conversationId)
|
||||
useFolderStore.getState().addConversationToFolder(activeFolderId, conversationId);
|
||||
|
||||
// focus the composer
|
||||
composerTextAreaRef.current?.focus();
|
||||
|
||||
}, [focusedSystemPurposeId, newConversationId, prependNewConversation, selectedFolderId, setFocusedConversationId]);
|
||||
}, [activeFolderId, focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
|
||||
|
||||
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
|
||||
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
|
||||
|
||||
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
|
||||
const handleConversationExport = React.useCallback((conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId }), []);
|
||||
|
||||
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
|
||||
showNextTitle.current = true;
|
||||
@@ -331,13 +331,13 @@ export function AppChat() {
|
||||
}
|
||||
}, [clearConversationId, setMessages]);
|
||||
|
||||
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
|
||||
const handleConversationClear = React.useCallback((conversationId: DConversationId) => setClearConversationId(conversationId), []);
|
||||
|
||||
const handleConfirmedDeleteConversation = () => {
|
||||
if (deleteConversationId) {
|
||||
let nextConversationId: DConversationId | null;
|
||||
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
|
||||
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, selectedFolderId);
|
||||
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, activeFolderId);
|
||||
else
|
||||
nextConversationId = deleteConversation(deleteConversationId);
|
||||
setFocusedConversationId(nextConversationId);
|
||||
@@ -345,7 +345,7 @@ export function AppChat() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
|
||||
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
|
||||
|
||||
const handleConversationDelete = React.useCallback(
|
||||
(conversationId: DConversationId, bypassConfirmation: boolean) => {
|
||||
@@ -358,7 +358,7 @@ export function AppChat() {
|
||||
// Shortcuts
|
||||
|
||||
const handleOpenChatLlmOptions = React.useCallback(() => {
|
||||
const { chatLLMId } = useModelsStore.getState();
|
||||
const chatLLMId = getChatLLMId();
|
||||
if (!chatLLMId) return;
|
||||
openLlmOptions(chatLLMId);
|
||||
}, [openLlmOptions]);
|
||||
@@ -372,7 +372,7 @@ export function AppChat() {
|
||||
['d', true, false, true, () => focusedConversationId && handleConversationDelete(focusedConversationId, false)],
|
||||
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
|
||||
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
// Pluggable ApplicationBar components
|
||||
@@ -387,8 +387,9 @@ export function AppChat() {
|
||||
);
|
||||
|
||||
const drawerContent = React.useMemo(() =>
|
||||
<ChatDrawerContentMemo
|
||||
<ChatDrawerMemo
|
||||
activeConversationId={focusedConversationId}
|
||||
activeFolderId={activeFolderId}
|
||||
disableNewButton={isFocusedChatEmpty}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
@@ -396,10 +397,9 @@ export function AppChat() {
|
||||
onConversationImportDialog={handleConversationImportDialog}
|
||||
onConversationNew={handleConversationNew}
|
||||
onConversationsDeleteAll={handleConversationsDeleteAll}
|
||||
selectedFolderId={selectedFolderId}
|
||||
setSelectedFolderId={setSelectedFolderId}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
/>,
|
||||
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, selectedFolderId, setFocusedConversationId],
|
||||
[activeFolderId, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, setFocusedConversationId],
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() =>
|
||||
@@ -411,10 +411,9 @@ export function AppChat() {
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationClear={handleConversationClear}
|
||||
onConversationExport={handleConversationExport}
|
||||
onConversationFlatten={handleConversationFlatten}
|
||||
/>,
|
||||
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
|
||||
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, isFocusedChatEmpty, isMessageSelectionMode],
|
||||
);
|
||||
|
||||
usePluggableOptimaLayout(drawerContent, centerItems, menuItems, 'AppChat');
|
||||
@@ -474,7 +473,6 @@ export function AppChat() {
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={{
|
||||
backgroundColor: themeBgApp,
|
||||
minHeight: '100%', // ensures filling of the blank space on newer chats
|
||||
}}
|
||||
/>
|
||||
@@ -555,10 +553,10 @@ export function AppChat() {
|
||||
{!!deleteConversationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? `Are you absolutely sure you want to delete ${selectedFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
|
||||
? `Are you absolutely sure you want to delete ${activeFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
|
||||
: 'Are you sure you want to delete this conversation?'}
|
||||
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? `Yes, delete all ${folderConversationsCount} conversations`
|
||||
? `Yes, delete all ${activeFolderConversationsCount} conversations`
|
||||
: 'Delete conversation'}
|
||||
/>}
|
||||
</>;
|
||||
|
||||
@@ -7,10 +7,12 @@ export const CommandsAlter: ICommandsProvider = {
|
||||
getCommands: () => [{
|
||||
primary: '/assistant',
|
||||
alternatives: ['/a'],
|
||||
arguments: ['text'],
|
||||
description: 'Injects assistant response',
|
||||
}, {
|
||||
primary: '/system',
|
||||
alternatives: ['/s'],
|
||||
arguments: ['text'],
|
||||
description: 'Injects system message',
|
||||
}],
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const CommandsBrowse: ICommandsProvider = {
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/browse',
|
||||
arguments: ['URL'],
|
||||
description: 'Assistant will download the web page',
|
||||
Icon: LanguageIcon,
|
||||
}],
|
||||
|
||||
@@ -9,7 +9,8 @@ export const CommandsDraw: ICommandsProvider = {
|
||||
getCommands: () => [{
|
||||
primary: '/draw',
|
||||
alternatives: ['/imagine', '/img'],
|
||||
description: 'Generate an image from text',
|
||||
arguments: ['prompt'],
|
||||
description: 'Assistant will draw the text',
|
||||
Icon: FormatPaintIcon,
|
||||
}],
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ export const CommandsHelp: ICommandsProvider = {
|
||||
getCommands: () => [{
|
||||
primary: '/help',
|
||||
alternatives: ['/?'],
|
||||
noArgs: true,
|
||||
description: 'Display this list of commands',
|
||||
}],
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ export const CommandsReact: ICommandsProvider = {
|
||||
|
||||
getCommands: () => [{
|
||||
primary: '/react',
|
||||
description: 'Use the AI ReAct strategy to answer your query (as sidebar)',
|
||||
arguments: ['prompt'],
|
||||
description: 'Use the AI ReAct strategy to answer your query',
|
||||
Icon: PsychologyIcon,
|
||||
}],
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CommandsProviderId } from './commands.registry';
|
||||
export interface ChatCommand {
|
||||
primary: string; // The primary command
|
||||
alternatives?: string[]; // Alternative commands
|
||||
noArgs?: boolean; // Whether the command requires arguments
|
||||
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
|
||||
|
||||
@@ -46,7 +46,7 @@ export function extractChatCommand(input: string): TextCommandPiece[] {
|
||||
if (cmd.primary === potentialCommand || cmd.alternatives?.includes(potentialCommand)) {
|
||||
|
||||
// command needs arguments: take the rest of the input as parameters
|
||||
if (cmd.noArgs !== true) {
|
||||
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 }];
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import type { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
|
||||
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { useCapabilityElevenLabs } from '~/common/components/useCapabilities';
|
||||
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import { ChatMessageMemo } from './message/ChatMessage';
|
||||
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
|
||||
@@ -28,10 +28,10 @@ export function ChatMessageList(props: {
|
||||
chatLLMContextTokens: number | null,
|
||||
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string) => void,
|
||||
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => void,
|
||||
onTextDiagram: (diagramConfig: DiagramConfig | null) => Promise<any>,
|
||||
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<any>,
|
||||
onTextSpeak: (selectedText: string) => Promise<any>,
|
||||
onConversationExecuteHistory: (conversationId: DConversationId, history: DMessage[]) => Promise<void>,
|
||||
onTextDiagram: (diagramConfig: DiagramConfig | null) => void,
|
||||
onTextImagine: (conversationId: DConversationId, selectedText: string) => Promise<void>,
|
||||
onTextSpeak: (selectedText: string) => Promise<void>,
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
|
||||
@@ -62,8 +62,9 @@ export function ChatMessageList(props: {
|
||||
|
||||
// text actions
|
||||
|
||||
const handleRunExample = (text: string) =>
|
||||
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
|
||||
const handleRunExample = React.useCallback(async (text: string) => {
|
||||
conversationId && await onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
|
||||
}, [conversationId, conversationMessages, onConversationExecuteHistory]);
|
||||
|
||||
|
||||
// message menu methods proxy
|
||||
@@ -72,11 +73,11 @@ export function ChatMessageList(props: {
|
||||
conversationId && onConversationBranch(conversationId, messageId);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
const handleConversationRestartFrom = React.useCallback((messageId: string, offset: number) => {
|
||||
const handleConversationRestartFrom = React.useCallback(async (messageId: string, offset: number) => {
|
||||
const messages = getConversation(conversationId)?.messages;
|
||||
if (messages) {
|
||||
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
|
||||
conversationId && onConversationExecuteHistory(conversationId, truncatedHistory);
|
||||
conversationId && await onConversationExecuteHistory(conversationId, truncatedHistory);
|
||||
}
|
||||
}, [conversationId, onConversationExecuteHistory]);
|
||||
|
||||
@@ -97,12 +98,12 @@ export function ChatMessageList(props: {
|
||||
}, [conversationId, editMessage]);
|
||||
|
||||
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
|
||||
conversationId && await onTextDiagram({ conversationId: conversationId, messageId, text });
|
||||
conversationId && onTextDiagram({ conversationId: conversationId, messageId, text });
|
||||
}, [conversationId, onTextDiagram]);
|
||||
|
||||
const handleTextImagine = React.useCallback(async (text: string) => {
|
||||
if (!capabilityHasT2I)
|
||||
return openPreferencesTab(2);
|
||||
return openPreferencesTab(PreferencesTab.Draw);
|
||||
if (conversationId) {
|
||||
setIsImagining(true);
|
||||
await onTextImagine(conversationId, text);
|
||||
@@ -112,7 +113,7 @@ export function ChatMessageList(props: {
|
||||
|
||||
const handleTextSpeak = React.useCallback(async (text: string) => {
|
||||
if (!isSpeakable)
|
||||
return openPreferencesTab(3);
|
||||
return openPreferencesTab(PreferencesTab.Voice);
|
||||
setIsSpeaking(true);
|
||||
await onTextSpeak(text);
|
||||
setIsSpeaking(false);
|
||||
|
||||
@@ -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,244 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, IconButton, ListDivider, ListItemDecorator, MenuItem, 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 FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
|
||||
import { DFolder, useFoldersToggle, useFolderStore } from '~/common/state/store-folders';
|
||||
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
|
||||
import { PageDrawerList, PageDrawerTallItemSx } from '~/common/layout/optima/components/PageDrawerList';
|
||||
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ChatFolderList } from './folder/ChatFolderList';
|
||||
import { ChatDrawerItemMemo, ChatNavigationItemData } from './ChatNavigationItem';
|
||||
|
||||
// type ListGrouping = 'off' | 'persona';
|
||||
|
||||
|
||||
/*
|
||||
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
|
||||
* to avoid unnecessary re-renders on each new character typed by the assistant
|
||||
*/
|
||||
export const useChatNavigationItems = (activeConversationId: DConversationId | null, folderId: string | null): {
|
||||
chatNavItems: ChatNavigationItemData[],
|
||||
folders: DFolder[],
|
||||
} => {
|
||||
|
||||
// monitor folder changes
|
||||
// NOTE: we're not checking for state.useFolders, as we strongly assume folderId to be null when folders are disabled
|
||||
const { currentFolder, folders } = useFolderStore(state => {
|
||||
const currentFolder = folderId ? state.folders.find(_f => _f.id === folderId) ?? null : null;
|
||||
return {
|
||||
folders: state.folders,
|
||||
currentFolder,
|
||||
};
|
||||
}, shallow);
|
||||
|
||||
// transform (folder) selected conversation into optimized 'navigation item' data
|
||||
const chatNavItems: ChatNavigationItemData[] = useChatStore(state => {
|
||||
|
||||
const selectConversations = currentFolder
|
||||
? state.conversations.filter(_c => currentFolder.conversationIds.includes(_c.id))
|
||||
: state.conversations;
|
||||
|
||||
return selectConversations.map(_c => ({
|
||||
conversationId: _c.id,
|
||||
isActive: _c.id === activeConversationId,
|
||||
isEmpty: !_c.messages.length && !_c.userTitle,
|
||||
title: conversationTitle(_c, 'New Title'),
|
||||
messageCount: _c.messages.length,
|
||||
assistantTyping: !!_c.abortController,
|
||||
systemPurposeId: _c.systemPurposeId,
|
||||
}));
|
||||
|
||||
}, (a: ChatNavigationItemData[], b: ChatNavigationItemData[]) => {
|
||||
// custom equality function to avoid unnecessary re-renders
|
||||
return a.length === b.length && a.every((_a, i) => shallow(_a, b[i]));
|
||||
});
|
||||
|
||||
return { chatNavItems, folders };
|
||||
};
|
||||
|
||||
|
||||
export const ChatDrawerContentMemo = React.memo(ChatDrawerItems);
|
||||
|
||||
function ChatDrawerItems(props: {
|
||||
activeConversationId: DConversationId | null,
|
||||
disableNewButton: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId) => void,
|
||||
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
|
||||
onConversationExportDialog: (conversationId: DConversationId | null) => void,
|
||||
onConversationImportDialog: () => void,
|
||||
onConversationNew: () => void,
|
||||
onConversationsDeleteAll: () => void,
|
||||
selectedFolderId: string | null,
|
||||
setSelectedFolderId: (folderId: string | null) => void,
|
||||
}) {
|
||||
|
||||
// local state
|
||||
// const [grouping] = React.useState<ListGrouping>('off');
|
||||
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
|
||||
|
||||
// external state
|
||||
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const { useFolders, toggleUseFolders } = useFoldersToggle();
|
||||
const { chatNavItems, folders } = useChatNavigationItems(props.activeConversationId, props.selectedFolderId);
|
||||
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
||||
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
|
||||
|
||||
// derived state
|
||||
const maxChatMessages = chatNavItems.reduce((longest, _c) => Math.max(longest, _c.messageCount), 1);
|
||||
const selectConversationsCount = chatNavItems.length;
|
||||
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
|
||||
const singleChat = selectConversationsCount === 1;
|
||||
const softMaxReached = selectConversationsCount >= 50;
|
||||
|
||||
|
||||
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]);
|
||||
|
||||
|
||||
// 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}
|
||||
startButton={
|
||||
<Tooltip title={useFolders ? 'Hide Folders' : 'Use Folders'}>
|
||||
<IconButton onClick={toggleUseFolders}>
|
||||
{useFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Folders List */}
|
||||
{useFolders && (
|
||||
<ChatFolderList
|
||||
folders={folders}
|
||||
selectedFolderId={props.selectedFolderId}
|
||||
onFolderSelect={props.setSelectedFolderId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chats List */}
|
||||
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
|
||||
|
||||
{useFolders && <ListDivider sx={{ mb: 0 }} />}
|
||||
|
||||
<MenuItem 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>
|
||||
</MenuItem>
|
||||
|
||||
{/*<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>*/}
|
||||
|
||||
{chatNavItems.map(item =>
|
||||
<ChatDrawerItemMemo
|
||||
key={'nav-' + item.conversationId}
|
||||
item={item}
|
||||
isLonely={singleChat}
|
||||
maxChatMessages={(labsEnhancedUI || softMaxReached) ? maxChatMessages : 0}
|
||||
showSymbols={showSymbols}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
/>)}
|
||||
</Box>
|
||||
|
||||
<ListDivider sx={{ mt: 0 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
<MenuItem onClick={props.onConversationImportDialog} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator>
|
||||
<FileUploadIcon />
|
||||
</ListItemDecorator>
|
||||
Import
|
||||
{/*<OpenAIIcon sx={{ ml: 'auto' }} />*/}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={!nonEmptyChats} onClick={() => props.onConversationExportDialog(props.activeConversationId)} sx={{ flex: 1, display: 'flex', justifyContent: 'flex-end', gap: 2.5 }}>
|
||||
Export
|
||||
<FileDownloadIcon />
|
||||
</MenuItem>
|
||||
</Box>
|
||||
|
||||
<MenuItem disabled={!nonEmptyChats} onClick={props.onConversationsDeleteAll}>
|
||||
<ListItemDecorator>
|
||||
<DeleteOutlineIcon />
|
||||
</ListItemDecorator>
|
||||
Delete {selectConversationsCount >= 2 ? `all ${selectConversationsCount} chats` : 'chat'}
|
||||
</MenuItem>
|
||||
|
||||
</PageDrawerList>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -5,14 +5,12 @@ import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlin
|
||||
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
|
||||
import { useChatShowSystemMessages } from '../../store-app-chat';
|
||||
|
||||
@@ -25,13 +23,11 @@ export function ChatMenuItems(props: {
|
||||
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
|
||||
onConversationClear: (conversationId: DConversationId) => void,
|
||||
onConversationExport: (conversationId: DConversationId | null) => void,
|
||||
onConversationFlatten: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { closePageMenu } = useOptimaDrawers();
|
||||
const { touch: shareTouch } = useUICounter('export-share');
|
||||
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
|
||||
|
||||
// derived state
|
||||
@@ -53,12 +49,6 @@ export function ChatMenuItems(props: {
|
||||
props.conversationId && props.onConversationBranch(props.conversationId, null);
|
||||
};
|
||||
|
||||
const handleConversationExport = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.onConversationExport(!disabled ? props.conversationId : null);
|
||||
shareTouch();
|
||||
};
|
||||
|
||||
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationFlatten(props.conversationId);
|
||||
@@ -107,13 +97,6 @@ export function ChatMenuItems(props: {
|
||||
</span>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={!props.hasConversations} onClick={handleConversationExport}>
|
||||
<ListItemDecorator>
|
||||
<FileDownloadIcon />
|
||||
</ListItemDecorator>
|
||||
Share / Export ...
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={disabled} onClick={handleConversationClear}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Avatar, Box, IconButton, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
const DEBUG_CONVERSATION_IDs = false;
|
||||
|
||||
|
||||
export const ChatDrawerItemMemo = React.memo(ChatNavigationItem);
|
||||
|
||||
export interface ChatNavigationItemData {
|
||||
conversationId: DConversationId;
|
||||
isActive: boolean;
|
||||
isEmpty: boolean;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
assistantTyping: boolean;
|
||||
systemPurposeId: SystemPurposeId;
|
||||
}
|
||||
|
||||
function ChatNavigationItem(props: {
|
||||
item: ChatNavigationItemData,
|
||||
isLonely: boolean,
|
||||
maxChatMessages: number,
|
||||
showSymbols: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const doubleClickToEdit = useUIPreferencesStore(state => state.doubleClickToEdit);
|
||||
|
||||
// derived state
|
||||
const { conversationId, isActive, title, messageCount, assistantTyping, systemPurposeId } = props.item;
|
||||
const isNew = messageCount === 0;
|
||||
|
||||
// auto-close the arming menu when clicking away
|
||||
// NOTE: there currently is a bug (race condition) where the menu closes on a new item right after opening
|
||||
// because the isActive prop is not yet updated
|
||||
React.useEffect(() => {
|
||||
if (deleteArmed && !isActive)
|
||||
setDeleteArmed(false);
|
||||
}, [deleteArmed, isActive]);
|
||||
|
||||
|
||||
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
|
||||
|
||||
const handleTitleEdit = () => setIsEditingTitle(true);
|
||||
|
||||
const handleTitleEdited = (text: string) => {
|
||||
setIsEditingTitle(false);
|
||||
useChatStore.getState().setUserTitle(conversationId, text);
|
||||
};
|
||||
|
||||
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 (
|
||||
<ListItemButton
|
||||
variant={isActive ? 'soft' : 'plain'} color='neutral'
|
||||
onClick={handleConversationActivate}
|
||||
sx={{
|
||||
// py: 0,
|
||||
position: 'relative',
|
||||
border: 'none', // note, there's a default border of 1px and invisible.. hmm
|
||||
cursor: 'pointer',
|
||||
'&:hover > button': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Optional progress bar, underlay */}
|
||||
{progress > 0 && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'neutral.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*/}
|
||||
{/* 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'}
|
||||
size='sm'
|
||||
sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.2s', ...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>
|
||||
</>}
|
||||
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
import { List, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuList, Sheet, Typography } from '@mui/joy';
|
||||
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';
|
||||
@@ -13,12 +13,12 @@ import { StrictModeDroppable } from './StrictModeDroppable';
|
||||
|
||||
export function ChatFolderList(props: {
|
||||
folders: DFolder[];
|
||||
activeFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
selectedFolderId: string | null;
|
||||
}) {
|
||||
|
||||
// derived props
|
||||
const { folders, onFolderSelect, selectedFolderId } = props;
|
||||
const { folders, onFolderSelect, activeFolderId } = props;
|
||||
|
||||
// handlers
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ChatFolderList(props: {
|
||||
|
||||
return (
|
||||
<Sheet variant='soft' sx={{ p: 2 }}>
|
||||
<MenuList
|
||||
<List
|
||||
variant='plain'
|
||||
sx={(theme) => ({
|
||||
'& ul': {
|
||||
@@ -72,11 +72,11 @@ export function ChatFolderList(props: {
|
||||
droppableId='folder'
|
||||
renderClone={(provided, snapshot, rubric) => (
|
||||
<FolderListItem
|
||||
activeFolderId={activeFolderId}
|
||||
folder={folders[rubric.source.index]}
|
||||
onFolderSelect={onFolderSelect}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
onFolderSelect={onFolderSelect}
|
||||
selectedFolderId={selectedFolderId}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -91,7 +91,7 @@ export function ChatFolderList(props: {
|
||||
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
|
||||
onFolderSelect(null);
|
||||
}}
|
||||
selected={selectedFolderId === null}
|
||||
selected={!activeFolderId}
|
||||
sx={{
|
||||
border: 0,
|
||||
justifyContent: 'space-between',
|
||||
@@ -114,11 +114,11 @@ export function ChatFolderList(props: {
|
||||
<Draggable key={folder.id} draggableId={folder.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<FolderListItem
|
||||
activeFolderId={activeFolderId}
|
||||
folder={folder}
|
||||
onFolderSelect={onFolderSelect}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
onFolderSelect={onFolderSelect}
|
||||
selectedFolderId={selectedFolderId}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
@@ -129,7 +129,7 @@ export function ChatFolderList(props: {
|
||||
</StrictModeDroppable>
|
||||
</DragDropContext>
|
||||
</ListItem>
|
||||
</MenuList>
|
||||
</List>
|
||||
|
||||
<AddFolderButton />
|
||||
</Sheet>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
|
||||
import type { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
|
||||
|
||||
import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuItem, Radio, radioClasses, RadioGroup, Sheet, Typography } from '@mui/joy';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
@@ -14,17 +14,13 @@ import { DFolder, FOLDERS_COLOR_PALETTE, useFolderStore } from '~/common/state/s
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
|
||||
|
||||
// Define the type for your props if you're using TypeScript
|
||||
type RenderItemProps = {
|
||||
export function FolderListItem(props: {
|
||||
activeFolderId: string | null;
|
||||
folder: DFolder;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
selectedFolderId: string | null;
|
||||
// Include any other props that RenderItem needs
|
||||
};
|
||||
|
||||
export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, snapshot, onFolderSelect, selectedFolderId }) => {
|
||||
}) {
|
||||
|
||||
// internal state
|
||||
const [deleteArmed, setDeleteArmed] = useState(false);
|
||||
@@ -34,6 +30,10 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLAnchorElement>(null);
|
||||
|
||||
|
||||
// derived props
|
||||
const { activeFolderId, folder, onFolderSelect, provided, snapshot } = props;
|
||||
|
||||
|
||||
// Menu
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
@@ -148,7 +148,7 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
|
||||
handleFolderSelect(folder.id);
|
||||
}}
|
||||
selected={folder.id === selectedFolderId}
|
||||
selected={folder.id === activeFolderId}
|
||||
sx={{
|
||||
border: 0,
|
||||
justifyContent: 'space-between',
|
||||
@@ -200,7 +200,8 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
{!!menuAnchorEl && (
|
||||
<CloseableMenu
|
||||
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
|
||||
placement='top' zIndex={1301 /* need to be on top of the Modal on Mobile */}
|
||||
placement='top'
|
||||
zIndex={1301 /* need to be on top of the Modal on Mobile */}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
|
||||
@@ -316,4 +317,4 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,17 +3,18 @@ import * as React from 'react';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { DropdownItems, GoodDropdown } from '~/common/components/GoodDropdown';
|
||||
import { DropdownItems, PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
|
||||
|
||||
const SPECIAL_ID_REMOVE = '_REMOVE_';
|
||||
export const ClearFolderText = 'Clear Folder';
|
||||
const SPECIAL_ID_CLEAR_FOLDER = '_REMOVE_';
|
||||
|
||||
|
||||
export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
|
||||
// external state
|
||||
const { folders, useFolders } = useFolderStore();
|
||||
const { folders, enableFolders } = useFolderStore();
|
||||
|
||||
|
||||
// Prepare items for the dropdown
|
||||
@@ -28,8 +29,8 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
}, {} as DropdownItems);
|
||||
|
||||
// add one item representing no folder
|
||||
items[SPECIAL_ID_REMOVE] = {
|
||||
title: 'No Folder',
|
||||
items[SPECIAL_ID_CLEAR_FOLDER] = {
|
||||
title: ClearFolderText,
|
||||
};
|
||||
|
||||
return items;
|
||||
@@ -46,7 +47,8 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
}
|
||||
});
|
||||
// Add conversation to the selected folder
|
||||
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
|
||||
if (folderId !== SPECIAL_ID_CLEAR_FOLDER)
|
||||
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
|
||||
}
|
||||
}, [conversationId, folders]);
|
||||
|
||||
@@ -57,11 +59,11 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
const folderDropdown = React.useMemo(() => {
|
||||
|
||||
// don't show the dropdown if folders are not enabled
|
||||
if (!useFolders)
|
||||
if (!enableFolders)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<GoodDropdown
|
||||
<PageBarDropdown
|
||||
items={folderItems}
|
||||
value={currentFolderId}
|
||||
onChange={handleFolderChange}
|
||||
@@ -69,7 +71,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
showSymbols
|
||||
/>
|
||||
);
|
||||
}, [currentFolderId, folderItems, handleFolderChange, useFolders]);
|
||||
}, [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 };
|
||||
|
||||
@@ -3,8 +3,9 @@ import { shallow } from 'zustand/shallow';
|
||||
import { fileOpen, FileWithHandle } from 'browser-fs-access';
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
import { Box, Button, ButtonGroup, Card, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import AutoModeIcon from '@mui/icons-material/AutoMode';
|
||||
@@ -23,6 +24,7 @@ import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vend
|
||||
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
@@ -32,17 +34,20 @@ 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 './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
|
||||
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
|
||||
import { ButtonCall } from './buttons/ButtonCall';
|
||||
@@ -55,7 +60,7 @@ import { TokenProgressbarMemo } from './TokenProgressbar';
|
||||
import { useComposerStartupText } from './store-composer';
|
||||
|
||||
|
||||
const animationStopEnter = keyframes`
|
||||
export const animationStopEnter = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px)
|
||||
@@ -90,9 +95,8 @@ export function Composer(props: {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const { openPreferencesTab, setIsFocusedMode } = useOptimaLayout();
|
||||
const { labsCalling, labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
labsCalling: state.labsCalling,
|
||||
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
|
||||
const { labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
labsCameraDesktop: state.labsCameraDesktop,
|
||||
}), shallow);
|
||||
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
|
||||
@@ -126,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)
|
||||
@@ -177,7 +181,7 @@ export function Composer(props: {
|
||||
|
||||
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
|
||||
const handleDrawOptionsClicked = () => openPreferencesTab(2);
|
||||
const handleDrawOptionsClicked = () => openPreferencesTab(PreferencesTab.Draw);
|
||||
|
||||
const handleTextImagineClicked = () => {
|
||||
if (!composeText || !props.conversationId)
|
||||
@@ -187,13 +191,62 @@ export function Composer(props: {
|
||||
};
|
||||
|
||||
|
||||
// Text actions
|
||||
// Mode menu
|
||||
|
||||
const handleTextAreaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleModeSelectorHide = () => setChatModeMenuAnchor(null);
|
||||
|
||||
const handleModeSelectorShow = (event: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
|
||||
const handleModeChange = (_chatModeId: ChatModeId) => {
|
||||
handleModeSelectorHide();
|
||||
setChatModeId(_chatModeId);
|
||||
};
|
||||
|
||||
|
||||
// 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);
|
||||
}, [setComposeText]);
|
||||
isMobile && actileInterceptTextChange(e.target.value);
|
||||
}, [actileInterceptTextChange, isMobile, setComposeText]);
|
||||
|
||||
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent) => {
|
||||
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
|
||||
@@ -209,20 +262,15 @@ export function Composer(props: {
|
||||
return e.preventDefault();
|
||||
}
|
||||
}
|
||||
}, [assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);
|
||||
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);
|
||||
|
||||
|
||||
// Mode menu
|
||||
// Focus mode
|
||||
|
||||
const handleModeSelectorHide = () => setChatModeMenuAnchor(null);
|
||||
// const handleFocusModeOn = React.useCallback(() => setIsFocusedMode(true), [setIsFocusedMode]);
|
||||
|
||||
const handleModeSelectorShow = (event: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
|
||||
const handleModeChange = (_chatModeId: ChatModeId) => {
|
||||
handleModeSelectorHide();
|
||||
setChatModeId(_chatModeId);
|
||||
};
|
||||
// const handleFocusModeOff = React.useCallback(() => setIsFocusedMode(false), [setIsFocusedMode]);
|
||||
|
||||
|
||||
// Mic typing & continuation mode
|
||||
@@ -263,7 +311,7 @@ export function Composer(props: {
|
||||
useGlobalShortcut('m', true, false, false, toggleRecording);
|
||||
|
||||
const micIsRunning = !!speechInterimResult;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible && !isSpeechError;
|
||||
const micColor: ColorPaletteProp = isSpeechError ? 'danger' : isRecordingSpeech ? 'primary' : isRecordingAudio ? 'primary' : 'neutral';
|
||||
const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'soft' : 'soft'; //(isDesktop ? 'soft' : 'plain');
|
||||
|
||||
@@ -293,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 });
|
||||
@@ -387,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) */}
|
||||
@@ -400,19 +450,32 @@ export function Composer(props: {
|
||||
|
||||
{/* Vertical (insert) buttons */}
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
|
||||
{/* [mobile] Mic button */}
|
||||
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
<ButtonAttachCameraMemo isMobile onAttachImage={handleAttachCameraImage} />
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddCircleOutlineIcon />
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{/* Responsive Camera OCR button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Open Files button */}
|
||||
<ButtonAttachFileMemo isMobile onAttachFilePicker={handleAttachFilePicker} />
|
||||
{/* Responsive Open Files button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <ButtonAttachClipboardMemo isMobile onClick={attachAppendClipboardItems} />}
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <MenuItem>
|
||||
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
|
||||
</MenuItem>}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
|
||||
</Box>
|
||||
) : (
|
||||
@@ -429,7 +492,7 @@ export function Composer(props: {
|
||||
{supportsClipboardRead && <ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onAttachImage={handleAttachCameraImage} />}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
@@ -448,18 +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={isMobile ? 5 : 5} maxRows={10}
|
||||
minRows={isMobile ? 4 : 5}
|
||||
maxRows={isMobile ? 8 : 10}
|
||||
placeholder={textPlaceholder}
|
||||
value={composeText}
|
||||
onChange={handleTextAreaTextChange}
|
||||
onChange={handleTextareaTextChange}
|
||||
onDragEnter={handleTextareaDragEnter}
|
||||
onDragStart={handleTextareaDragStart}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
onPasteCapture={handleAttachCtrlV}
|
||||
onFocusCapture={() => setIsFocusedMode(true)}
|
||||
onBlurCapture={() => setIsFocusedMode(false)}
|
||||
// onFocusCapture={handleFocusModeOn}
|
||||
// onBlurCapture={handleFocusModeOff}
|
||||
slotProps={{
|
||||
textarea: {
|
||||
enterKeyHint: enterIsNewline ? 'enter' : 'send',
|
||||
@@ -472,9 +537,7 @@ export function Composer(props: {
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
'&:focus-within': {
|
||||
backgroundColor: 'background.popup',
|
||||
},
|
||||
'&:focus-within': { backgroundColor: 'background.popup' },
|
||||
lineHeight: lineHeightTextarea,
|
||||
}} />
|
||||
|
||||
@@ -576,7 +639,7 @@ export function Composer(props: {
|
||||
|
||||
{/* [mobile] bottom-corner secondary button */}
|
||||
{isMobile && (isChat
|
||||
? <ButtonCall isMobile disabled={!labsCalling || !props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
? <ButtonCall isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: isDraw
|
||||
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
@@ -643,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} />}
|
||||
@@ -663,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ export async function attachmentPerformConversion(attachment: Readonly<Attachmen
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: input.altData!,
|
||||
title: ref,
|
||||
title: ref || '\n<!DOCTYPE html>',
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
|
||||
|
||||
// see how we fare on budget
|
||||
if (chatLLMId) {
|
||||
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger');
|
||||
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger') ?? 0;
|
||||
|
||||
// simple trigger for the reduction dialog
|
||||
if (newTextTokens > remainingTokens) {
|
||||
|
||||
@@ -10,6 +10,10 @@ import { getClipboardItems } from '~/common/util/clipboardUtils';
|
||||
import { AttachmentSourceOriginDTO, AttachmentSourceOriginFile, useAttachmentsStore } from './store-attachments';
|
||||
|
||||
|
||||
// enable to debug attachment operations
|
||||
const ATTACHMENTS_DEBUG_INTAKE = false;
|
||||
|
||||
|
||||
export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
|
||||
// state
|
||||
@@ -24,17 +28,30 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
|
||||
// Creation helpers
|
||||
|
||||
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) =>
|
||||
createAttachment({
|
||||
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
|
||||
})
|
||||
, [createAttachment]);
|
||||
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) => {
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log('attachAppendFile', origin, fileWithHandle, overrideFileName);
|
||||
|
||||
return createAttachment({
|
||||
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
|
||||
});
|
||||
}, [createAttachment]);
|
||||
|
||||
|
||||
const attachAppendDataTransfer = React.useCallback((dt: DataTransfer, method: AttachmentSourceOriginDTO, attachText: boolean): 'as_files' | 'as_url' | 'as_text' | false => {
|
||||
|
||||
// https://github.com/enricoros/big-AGI/issues/286
|
||||
const textHtml = dt.getData('text/html') || '';
|
||||
const heuristicIsExcel = textHtml.includes('"urn:schemas-microsoft-com:office:excel"');
|
||||
// noinspection HttpUrlsUsage
|
||||
const heuristicIsPowerPoint = textHtml.includes('xmlns:m="http://schemas.microsoft.com/office/20') && textHtml.includes('<meta name=Generator content="Microsoft PowerPoint');
|
||||
const heuristicBypassImage = heuristicIsExcel || heuristicIsPowerPoint;
|
||||
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log('attachAppendDataTransfer', dt.types, dt.items, dt.files, textHtml);
|
||||
|
||||
// attach File(s)
|
||||
if (dt.files.length >= 1) {
|
||||
if (dt.files.length >= 1 && !heuristicBypassImage /* special case: ignore images from Microsoft Office pastes (prioritize the HTML paste) */) {
|
||||
// rename files from a common prefix, to better relate them (if the transfer contains a list of paths)
|
||||
let overrideFileNames: string[] = [];
|
||||
if (dt.types.includes('text/plain')) {
|
||||
@@ -68,7 +85,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
}
|
||||
|
||||
// attach as Text/Html (further conversion, e.g. to markdown is done later)
|
||||
const textHtml = dt.getData('text/html') || '';
|
||||
if (attachText && (textHtml || textPlain)) {
|
||||
void createAttachment({
|
||||
media: 'text', method, textPlain, textHtml,
|
||||
@@ -100,13 +116,20 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// loop on all the possible attachments
|
||||
// loop on all the clipboard items
|
||||
for (const clipboardItem of clipboardItems) {
|
||||
|
||||
// https://github.com/enricoros/big-AGI/issues/286
|
||||
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
|
||||
const heuristicBypassImage = textHtml.startsWith('<table ');
|
||||
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log(' - attachAppendClipboardItems.item:', clipboardItem, textHtml, heuristicBypassImage);
|
||||
|
||||
// attach as image
|
||||
let imageAttached = false;
|
||||
for (const mimeType of clipboardItem.types) {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
if (mimeType.startsWith('image/') && !heuristicBypassImage) {
|
||||
try {
|
||||
const imageBlob = await clipboardItem.getType(mimeType);
|
||||
const imageName = mimeType.replace('image/', 'clipboard.').replaceAll('/', '.') || 'clipboard.png';
|
||||
@@ -136,7 +159,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
}
|
||||
|
||||
// attach as Text
|
||||
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
|
||||
if (textHtml || textPlain) {
|
||||
void createAttachment({
|
||||
media: 'text', method: 'clipboard-read', textPlain, textHtml,
|
||||
|
||||
@@ -78,7 +78,7 @@ function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: Compo
|
||||
const tokenCountApprox = llmForTokenCount
|
||||
? attachmentOutputs.reduce((acc, output) => {
|
||||
if (output.type === 'text-block')
|
||||
return acc + countModelTokens(output.text, llmForTokenCount, 'attachments tokens count');
|
||||
return acc + (countModelTokens(output.text, llmForTokenCount, 'attachments tokens count') ?? 0);
|
||||
console.warn('Unhandled token preview for output type:', output.type);
|
||||
return acc;
|
||||
}, 0)
|
||||
|
||||
@@ -15,33 +15,37 @@ const attachCameraLegend = (isMobile: boolean) =>
|
||||
|
||||
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
|
||||
|
||||
function ButtonAttachCamera(props: { isMobile?: boolean, onAttachImage: (file: File) => void }) {
|
||||
function ButtonAttachCamera(props: { isMobile?: boolean, onOpenCamera: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton onClick={props.onOpenCamera}>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={props.onOpenCamera} startDecorator={<AddAPhotoIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Camera
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCameraCaptureModal(onAttachImage: (file: File) => void) {
|
||||
|
||||
// state
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return <>
|
||||
const openCamera = React.useCallback(() => setOpen(true), []);
|
||||
|
||||
{/* The Button */}
|
||||
{props.isMobile ? (
|
||||
<IconButton onClick={() => setOpen(true)}>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Camera
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
const cameraCaptureComponent = React.useMemo(() => open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={onAttachImage}
|
||||
/>
|
||||
), [open, onAttachImage]);
|
||||
|
||||
{/* The actual capture dialog, which will stream the video */}
|
||||
{open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={props.onAttachImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
return {
|
||||
openCamera,
|
||||
cameraCaptureComponent,
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function ButtonAttachClipboard(props: { isMobile?: boolean, onClick: () => void
|
||||
<ContentPasteGoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={pasteClipboardLegend}>
|
||||
<Tooltip disableInteractive variant='solid' placement='top-start' title={pasteClipboardLegend}>
|
||||
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onClick}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Paste
|
||||
|
||||
@@ -19,7 +19,7 @@ function ButtonAttachFile(props: { isMobile?: boolean, onAttachFilePicker: () =>
|
||||
<AttachFileOutlinedIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
|
||||
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachFileLegend}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={props.onAttachFilePicker} startDecorator={<AttachFileOutlinedIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
File
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onCl
|
||||
<CallIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' arrow placement='right' title={callConversationLegend}>
|
||||
<Tooltip disableInteractive variant='solid' arrow placement='right' title={callConversationLegend}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={props.sx}>
|
||||
Call
|
||||
</Button>
|
||||
|
||||
@@ -48,7 +48,7 @@ import { parseBlocks } from './blocks';
|
||||
// How long is the user collapsed message
|
||||
const USER_COLLAPSED_LINES: number = 8;
|
||||
|
||||
// Enable the automatic menu on text selection
|
||||
// Enable the menu on text selection
|
||||
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
|
||||
|
||||
// Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu.
|
||||
@@ -214,7 +214,7 @@ export function ChatMessage(props: {
|
||||
isBottom?: boolean, noBottomBorder?: boolean,
|
||||
isImagining?: boolean, isSpeaking?: boolean,
|
||||
onConversationBranch?: (messageId: string) => void,
|
||||
onConversationRestartFrom?: (messageId: string, offset: number) => void,
|
||||
onConversationRestartFrom?: (messageId: string, offset: number) => Promise<void>,
|
||||
onConversationTruncate?: (messageId: string) => void,
|
||||
onMessageDelete?: (messageId: string) => void,
|
||||
onMessageEdit?: (messageId: string, text: string) => void,
|
||||
@@ -302,10 +302,10 @@ export function ChatMessage(props: {
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
|
||||
const handleOpsConversationRestartFrom = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
props.onConversationRestartFrom && props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
|
||||
closeOperationsMenu();
|
||||
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
|
||||
};
|
||||
|
||||
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
|
||||
@@ -495,16 +495,18 @@ export function ChatMessage(props: {
|
||||
|
||||
|
||||
{/* Edit / Blocks */}
|
||||
{isEditing
|
||||
{isEditing ? (
|
||||
|
||||
? <InlineTextarea
|
||||
<InlineTextarea
|
||||
initialText={messageText} onEdit={handleTextEdited}
|
||||
sx={{
|
||||
...blockSx,
|
||||
flexGrow: 1,
|
||||
}} />
|
||||
|
||||
: <Box
|
||||
) : (
|
||||
|
||||
<Box
|
||||
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
|
||||
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
|
||||
sx={{
|
||||
@@ -550,7 +552,7 @@ export function ChatMessage(props: {
|
||||
: block.type === 'diff'
|
||||
? <RenderTextDiff key={'latex-' + index} diffBlock={block} sx={typographySx} />
|
||||
: (renderMarkdown && props.noMarkdown !== true && !fromSystem && !(fromUser && block.content.startsWith('/')))
|
||||
? <RenderMarkdown key={'text-md-' + index} textBlock={block} sx={typographySx} />
|
||||
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
|
||||
: <RenderText key={'text-' + index} textBlock={block} sx={typographySx} />)}
|
||||
|
||||
{isCollapsed && (
|
||||
@@ -564,7 +566,7 @@ export function ChatMessage(props: {
|
||||
{/*</Chip>*/}
|
||||
|
||||
</Box>
|
||||
}
|
||||
)}
|
||||
|
||||
|
||||
{/* Overlay copy icon */}
|
||||
@@ -635,7 +637,7 @@ export function ChatMessage(props: {
|
||||
{!!props.onConversationBranch && <ListDivider />}
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Visualize ...
|
||||
Diagram ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
@@ -673,7 +675,7 @@ export function ChatMessage(props: {
|
||||
</MenuItem>
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Visualize ...
|
||||
Diagram ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
|
||||
@@ -1,48 +1,53 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, useTheme } from '@mui/joy';
|
||||
import { Box, styled } from '@mui/joy';
|
||||
|
||||
import { lineHeightChatText } from '~/common/app.theme';
|
||||
|
||||
import type { TextBlock } from './blocks';
|
||||
|
||||
|
||||
/*
|
||||
* For performance reasons, we style this component here and copy the equivalent of 'props.sx' (the lineHeight) locally.
|
||||
*/
|
||||
const RenderMarkdownBox = styled(Box)({
|
||||
// same look as the other RenderComponents
|
||||
marginInline: '0.75rem !important', // margin: 1.5 like other blocks
|
||||
lineHeight: lineHeightChatText,
|
||||
|
||||
// patch the CSS
|
||||
// fontFamily: `inherit !important`, // (not needed anymore, as CSS is under our control) use the default font family
|
||||
// '--color-canvas-default': 'transparent !important', // (not needed anymore) remove the default background color
|
||||
'& table': { width: 'inherit !important' }, // un-break auto-width (tables have 'max-content', which overflows)
|
||||
});
|
||||
|
||||
|
||||
// Dynamically import ReactMarkdown using React.lazy
|
||||
const ReactMarkdown = React.lazy(async () => {
|
||||
const DynamicReactGFM = React.lazy(async () => {
|
||||
const [markdownModule, remarkGfmModule] = await Promise.all([
|
||||
import('react-markdown'),
|
||||
import('remark-gfm'),
|
||||
]);
|
||||
|
||||
// NOTE: extracted here instead of inline as a large performance optimization
|
||||
const remarkPlugins = [remarkGfmModule.default];
|
||||
|
||||
// Pass the dynamically imported remarkGfm as children
|
||||
const ReactMarkdownWithRemarkGfm = (props: any) => (
|
||||
<markdownModule.default remarkPlugins={[remarkGfmModule.default]} {...props} />
|
||||
);
|
||||
const ReactMarkdownWithRemarkGfm = (props: any) =>
|
||||
<markdownModule.default remarkPlugins={remarkPlugins} {...props} />;
|
||||
|
||||
return { default: ReactMarkdownWithRemarkGfm };
|
||||
});
|
||||
|
||||
|
||||
export const RenderMarkdown = (props: { textBlock: TextBlock, sx?: SxProps }) => {
|
||||
const theme = useTheme();
|
||||
export const RenderMarkdown = (props: { textBlock: TextBlock }) => {
|
||||
return (
|
||||
<Box
|
||||
className={`markdown-body ${theme.palette.mode === 'dark' ? 'markdown-body-dark' : 'markdown-body-light'}`}
|
||||
sx={{
|
||||
mx: '0.75rem !important', // margin: 1.5 like other blocks
|
||||
'& table': {
|
||||
width: 'inherit !important', // un-break auto-width (tables have 'max-content', which overflows)
|
||||
},
|
||||
'--color-canvas-default': 'transparent !important', // remove the default background color
|
||||
// NOTE: the following are not needed because the CSS is under our control, and we
|
||||
// disabled the redefintions there
|
||||
// fontFamily: `inherit !important`, // use the default font family
|
||||
...(props.sx || {}),
|
||||
}}>
|
||||
|
||||
{/* Using React.Suspense / React.Lazy loading this */}
|
||||
<RenderMarkdownBox className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */ >
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<ReactMarkdown>{props.textBlock.content}</ReactMarkdown>
|
||||
<DynamicReactGFM>
|
||||
{props.textBlock.content}
|
||||
</DynamicReactGFM>
|
||||
</React.Suspense>
|
||||
</Box>
|
||||
</RenderMarkdownBox>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Typography } from '@mui/joy';
|
||||
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
@@ -144,13 +146,15 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
|
||||
<Box sx={{ maxWidth: bpMaxWidth }}>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 2, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 1 }}>
|
||||
<Typography level='title-sm'>
|
||||
AI Persona
|
||||
</Typography>
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={toggleEditMode}>
|
||||
{editMode ? 'Done' : 'Edit'}
|
||||
</Button>
|
||||
<Tooltip disableInteractive title={editMode ? 'Done Editing' : 'Edit Tiles'}>
|
||||
<IconButton size='sm' onClick={toggleEditMode}>
|
||||
{editMode ? <DoneIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={tileSpacing} sx={{ justifyContent: 'flex-start' }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 { AppChatLinkDrawerContent } from './AppChatLinkDrawerContent';
|
||||
import { AppChatLinkMenuItems } from './AppChatLinkMenuItems';
|
||||
import { ViewChatLink } from './ViewChatLink';
|
||||
|
||||
|
||||
const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) =>
|
||||
<Box sx={{
|
||||
backgroundColor: props.backgroundColor,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
}}>
|
||||
{props.children}
|
||||
</Box>;
|
||||
|
||||
const ShowLoading = () =>
|
||||
<Centerer backgroundColor={themeBgAppDarker}>
|
||||
<LogoProgress showProgress={true} />
|
||||
<Typography level='title-sm' sx={{ mt: 2 }}>
|
||||
Loading Chat...
|
||||
</Typography>
|
||||
</Centerer>;
|
||||
|
||||
const ShowError = (props: { error: any }) =>
|
||||
<Centerer backgroundColor={themeBgAppDarker}>
|
||||
<InlineError error={props.error} severity='warning' />
|
||||
</Centerer>;
|
||||
|
||||
|
||||
/**
|
||||
* Fetches the object using tRPC
|
||||
* Note: we don't have react-query for the Node functions, so we use the immediate API here,
|
||||
* and wrap it in a react-query hook
|
||||
*/
|
||||
async function fetchStoredChatV1(objectId: string) {
|
||||
// fetch
|
||||
const result = await apiAsyncNode.trade.storageGet.query({ objectId });
|
||||
if (result.type === 'error')
|
||||
throw result.error;
|
||||
|
||||
// validate a CHAT_V1
|
||||
const { dataType, dataObject, storedAt, expiresAt } = result;
|
||||
if (dataType !== 'CHAT_V1')
|
||||
throw new Error('Unsupported data type: ' + dataType);
|
||||
|
||||
// convert to DConversation
|
||||
const restored = createConversationFromJsonV1(dataObject as any);
|
||||
if (!restored)
|
||||
throw new Error('Could not restore conversation');
|
||||
|
||||
return { conversation: restored, storedAt, expiresAt };
|
||||
}
|
||||
|
||||
|
||||
export function AppChatLink(props: { linkId: string }) {
|
||||
|
||||
// external state
|
||||
const { data, isError, error, isLoading } = useQuery({
|
||||
enabled: !!props.linkId,
|
||||
queryKey: ['chat-link', props.linkId],
|
||||
queryFn: () => fetchStoredChatV1(props.linkId),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
const hasLinkItems = useHasChatLinkItems();
|
||||
|
||||
|
||||
// pluggable UI
|
||||
|
||||
const drawerContent = React.useMemo(() => <AppChatLinkDrawerContent />, []);
|
||||
const menuItems = React.useMemo(() => <AppChatLinkMenuItems />, []);
|
||||
usePluggableOptimaLayout(hasLinkItems ? drawerContent : 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,70 +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 { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
|
||||
|
||||
|
||||
/**
|
||||
* Drawer Items are all the links already shared, for quick access.
|
||||
* This is stores in the Trade Store (local storage).
|
||||
*/
|
||||
export function AppChatLinkDrawerContent() {
|
||||
|
||||
// external state
|
||||
const { closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const chatLinkItems = useChatLinkItems()
|
||||
.slice()
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const notEmpty = chatLinkItems.length > 0;
|
||||
|
||||
return <PageDrawerList>
|
||||
|
||||
<MenuItem
|
||||
onClick={closeDrawerOnMobile}
|
||||
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>}
|
||||
</PageDrawerList>;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import * as React from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Box, Button, Card, CardContent, Divider, Input, Typography } from '@mui/joy';
|
||||
import WarningRoundedIcon from '@mui/icons-material/WarningRounded';
|
||||
|
||||
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
|
||||
import { forgetChatLinkItem, useSharedChatLinkItems } from '~/modules/trade/link/store-link';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { LogoProgress } from '~/common/components/LogoProgress';
|
||||
import { apiAsyncNode } from '~/common/util/trpc.client';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { conversationTitle } from '~/common/state/store-chats';
|
||||
import { themeBgAppDarker } from '~/common/app.theme';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import { LinkChat } from './LinkChat';
|
||||
import { LinkChatDrawer } from './LinkChatDrawer';
|
||||
import { LinkChatMenuItems } from './LinkChatMenuItems';
|
||||
import { addSnackbar } from '~/common/components/useSnackbarsStore';
|
||||
import { navigateToChatLinkList } from '~/common/app.routes';
|
||||
|
||||
|
||||
const SPECIAL_LIST_PAGE_ID = 'list';
|
||||
|
||||
|
||||
const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) =>
|
||||
<Box sx={{
|
||||
backgroundColor: props.backgroundColor,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
}}>
|
||||
{props.children}
|
||||
</Box>;
|
||||
|
||||
const ListPlaceholder = (props: { hasLinks: boolean }) =>
|
||||
<Box sx={{ p: { xs: 3, md: 6 } }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography level='title-md'>
|
||||
Shared Conversations
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{props.hasLinks
|
||||
? 'Here you can see formely exported shared conversations. Please select a conversation from the drawer.'
|
||||
: 'No shared conversations found. Please export a conversation from this browser first.'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>;
|
||||
|
||||
|
||||
const ShowLoading = () =>
|
||||
<Centerer backgroundColor={themeBgAppDarker}>
|
||||
<LogoProgress showProgress={true} />
|
||||
<Typography level='title-sm' sx={{ mt: 2 }}>
|
||||
Loading Chat...
|
||||
</Typography>
|
||||
</Centerer>;
|
||||
|
||||
const ShowError = (props: { error: any }) =>
|
||||
<Centerer backgroundColor={themeBgAppDarker}>
|
||||
<InlineError error={props.error} severity='warning' />
|
||||
</Centerer>;
|
||||
|
||||
|
||||
/**
|
||||
* Fetches the object using tRPC
|
||||
* Note: we don't have react-query for the Node functions, so we use the immediate API here,
|
||||
* and wrap it in a react-query hook
|
||||
*/
|
||||
async function fetchStoredChatV1(objectId: string | null) {
|
||||
if (!objectId)
|
||||
throw new Error('No Stored Chat');
|
||||
|
||||
// fetch
|
||||
const result = await apiAsyncNode.trade.storageGet.query({ objectId });
|
||||
if (result.type === 'error')
|
||||
throw result.error;
|
||||
|
||||
// validate a CHAT_V1
|
||||
const { dataType, dataObject, storedAt, expiresAt } = result;
|
||||
if (dataType !== 'CHAT_V1')
|
||||
throw new Error('Unsupported data type: ' + dataType);
|
||||
|
||||
// convert to DConversation
|
||||
const restored = createConversationFromJsonV1(dataObject as any);
|
||||
if (!restored)
|
||||
throw new Error('Could not restore conversation');
|
||||
|
||||
return { conversation: restored, storedAt, expiresAt };
|
||||
}
|
||||
|
||||
|
||||
export function AppLinkChat(props: { chatLinkId: string | null }) {
|
||||
|
||||
// state
|
||||
const [deleteConfirmId, setDeleteConfirmId] = React.useState<string | null>(null);
|
||||
const [deleteConfirmKey, setDeleteConfirmKey] = React.useState<string | null>(null);
|
||||
const [showDeletionKeys, setShowDeletionKeys] = React.useState<boolean>(false);
|
||||
|
||||
// derived state 1
|
||||
const isListPage = props.chatLinkId === SPECIAL_LIST_PAGE_ID;
|
||||
const linkId = isListPage ? null : props.chatLinkId;
|
||||
|
||||
// external state
|
||||
const sharedChatLinkItems = useSharedChatLinkItems();
|
||||
const { data, isError, error, isLoading } = useQuery({
|
||||
enabled: !!linkId,
|
||||
queryKey: ['chat-link', linkId],
|
||||
queryFn: () => fetchStoredChatV1(linkId),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
|
||||
// derived state 2
|
||||
const hasLinks = sharedChatLinkItems.length > 0;
|
||||
const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Shared Chat'; // also the (nav) App title
|
||||
|
||||
|
||||
const handleDelete = React.useCallback(async (objectId: string, deletionKey: string) => {
|
||||
setDeleteConfirmId(null);
|
||||
setDeleteConfirmKey(null);
|
||||
|
||||
// delete from storage
|
||||
let err: string | null = null;
|
||||
try {
|
||||
const response = await apiAsyncNode.trade.storageDelete.mutate({ objectId, deletionKey });
|
||||
if (response.type === 'error')
|
||||
err = response.error || 'unknown error';
|
||||
} catch (error: any) {
|
||||
err = error?.message ?? error?.toString() ?? 'unknown error';
|
||||
}
|
||||
|
||||
// delete from local store
|
||||
if (!err)
|
||||
forgetChatLinkItem(objectId);
|
||||
|
||||
// UI feedback
|
||||
addSnackbar({
|
||||
key: err ? 'chatlink-deletion-issue' : 'chatlink-deletion-success',
|
||||
type: err ? 'issue' : 'success',
|
||||
message: err ? 'Could not delete link: ' + err : 'Link deleted successfully',
|
||||
});
|
||||
|
||||
// move to the list page
|
||||
if (!err)
|
||||
void navigateToChatLinkList();
|
||||
}, []);
|
||||
|
||||
|
||||
// Delete: ID confirmation
|
||||
|
||||
const handleConfirmDeletion = React.useCallback((linkId: string) => linkId && setDeleteConfirmId(linkId), []);
|
||||
|
||||
const handleCancelDeletion = React.useCallback(() => setDeleteConfirmId(null), []);
|
||||
|
||||
// Delete: Key confirmation
|
||||
|
||||
const handleConfirmDeletionKey = React.useCallback(() => {
|
||||
if (!deleteConfirmId) return;
|
||||
|
||||
// if we already have the key, we can delete right away
|
||||
const item = sharedChatLinkItems.find(i => i.objectId === deleteConfirmId);
|
||||
let deletionKey = (item && item.deletionKey) ? item.deletionKey : null;
|
||||
if (deletionKey)
|
||||
return handleDelete(deleteConfirmId, deletionKey);
|
||||
|
||||
// otherwise ask for the key
|
||||
setDeleteConfirmKey('');
|
||||
}, [deleteConfirmId, handleDelete, sharedChatLinkItems]);
|
||||
|
||||
const handleCancelDeletionKey = React.useCallback(() => {
|
||||
setDeleteConfirmId(null);
|
||||
setDeleteConfirmKey(null);
|
||||
}, []);
|
||||
|
||||
const handleDeletionKeyConfirmed = React.useCallback(() => {
|
||||
deleteConfirmId && deleteConfirmKey && handleDelete(deleteConfirmId, deleteConfirmKey);
|
||||
}, [deleteConfirmId, deleteConfirmKey, handleDelete]);
|
||||
|
||||
|
||||
// pluggable UI
|
||||
|
||||
const drawerContent = React.useMemo(() => <LinkChatDrawer
|
||||
activeLinkId={linkId}
|
||||
sharedChatLinkItems={sharedChatLinkItems}
|
||||
showDeletionKeys={showDeletionKeys}
|
||||
onDeleteLink={handleConfirmDeletion}
|
||||
/>, [handleConfirmDeletion, linkId, sharedChatLinkItems, showDeletionKeys]);
|
||||
|
||||
const menuItems = React.useMemo(() => <LinkChatMenuItems
|
||||
activeLinkId={linkId}
|
||||
showDeletionKeys={showDeletionKeys}
|
||||
onDeleteLink={handleConfirmDeletion}
|
||||
onToggleDeletionKeys={() => setShowDeletionKeys(on => !on)}
|
||||
/>, [handleConfirmDeletion, linkId, showDeletionKeys]);
|
||||
|
||||
usePluggableOptimaLayout(drawerContent, null, menuItems, 'AppChatLink');
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
<Head>
|
||||
<title>{capitalizeFirstLetter(pageTitle)} · {Brand.Title.Base} 🚀</title>
|
||||
</Head>
|
||||
|
||||
{isListPage
|
||||
? <ListPlaceholder hasLinks={hasLinks} />
|
||||
: isLoading
|
||||
? <ShowLoading />
|
||||
: isError
|
||||
? <ShowError error={error} />
|
||||
: !!data?.conversation
|
||||
? <LinkChat conversation={data.conversation} storedAt={data.storedAt} expiresAt={data.expiresAt} />
|
||||
: <Centerer backgroundColor={themeBgAppDarker} />}
|
||||
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{!!deleteConfirmId && (deleteConfirmKey === null) && (
|
||||
<ConfirmationModal
|
||||
onClose={handleCancelDeletion} onPositive={handleConfirmDeletionKey}
|
||||
confirmationText='Are you sure you want to delete this link?'
|
||||
positiveActionText={'Yes, Delete'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Deletion Key Input */}
|
||||
{!!deleteConfirmId && (deleteConfirmKey !== null) && (
|
||||
<GoodModal
|
||||
open title='Enter Deletion Key'
|
||||
titleStartDecorator={<WarningRoundedIcon sx={{ color: 'danger.solidBg' }} />}
|
||||
onClose={handleCancelDeletionKey}
|
||||
hideBottomClose
|
||||
>
|
||||
<Divider />
|
||||
<Typography level='body-md'>
|
||||
You need to enter the original deletion key to delete this conversation.
|
||||
</Typography>
|
||||
<Input
|
||||
value={deleteConfirmKey}
|
||||
onChange={event => setDeleteConfirmKey(event.target.value)}
|
||||
sx={{ flexGrow: 1 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end', mt: 2 }}>
|
||||
<Button autoFocus variant='plain' color='neutral' onClick={handleCancelDeletionKey}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid' color='danger'
|
||||
disabled={!deleteConfirmKey.trim()}
|
||||
onClick={handleDeletionKeyConfirmed}
|
||||
sx={{ lineHeight: '1.5em' }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</GoodModal>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, Button, Card, List, ListItem, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Button, Card, CardContent, List, ListItem, Tooltip, Typography } from '@mui/joy';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { ChatMessage } from '../chat/components/message/ChatMessage';
|
||||
@@ -9,6 +9,7 @@ import { ScrollToBottom } from '../chat/components/scroll-to-bottom/ScrollToBott
|
||||
import { useChatShowSystemMessages } from '../chat/store-app-chat';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { conversationTitle, DConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { launchAppChat } from '~/common/app.routes';
|
||||
import { themeBgAppDarker } from '~/common/app.theme';
|
||||
@@ -18,7 +19,7 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
/**
|
||||
* Renders a chat link view with conversation details and messages.
|
||||
*/
|
||||
export function ViewChatLink(props: { conversation: DConversation, storedAt: Date, expiresAt: Date | null }) {
|
||||
export function LinkChat(props: { conversation: DConversation, storedAt: Date, expiresAt: Date | null }) {
|
||||
|
||||
// state
|
||||
const [cloning, setCloning] = React.useState<boolean>(false);
|
||||
@@ -76,23 +77,26 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
|
||||
py: { xs: 4, md: 5, xl: 6 },
|
||||
}}>
|
||||
|
||||
{/* Heading */}
|
||||
<Box sx={{
|
||||
{/* Title Card */}
|
||||
<Card sx={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
backgroundColor: 'background.level1', borderRadius: 'xl', boxShadow: 'xs',
|
||||
gap: 1,
|
||||
px: { xs: 2.5, md: 3.5 },
|
||||
py: { xs: 2, md: 3 },
|
||||
// backgroundColor: 'background.level1',
|
||||
// borderRadius: 'xl',
|
||||
// boxShadow: 'xs',
|
||||
px: 2.5,
|
||||
maxWidth: '100%',
|
||||
// animation: `${cssMagicSwapKeyframes} 0.4s cubic-bezier(0.22, 1, 0.36, 1)`,
|
||||
}}>
|
||||
<Typography level='h3' startDecorator={<TelegramIcon sx={{ fontSize: 'xl3', mr: 0.5 }} />}>
|
||||
{conversationTitle(props.conversation, 'Chat')}
|
||||
</Typography>
|
||||
<Typography level='title-sm'>
|
||||
Uploaded <TimeAgo date={props.storedAt} />
|
||||
{!!props.expiresAt && <>, expires <TimeAgo date={props.expiresAt} /></>}.
|
||||
</Typography>
|
||||
</Box>
|
||||
<CardContent sx={{ gap: 1 }}>
|
||||
<Typography level='h4' startDecorator={<TelegramIcon sx={{ fontSize: 'xl2' }} />}>
|
||||
{capitalizeFirstLetter(conversationTitle(props.conversation, 'Chat'))}
|
||||
</Typography>
|
||||
<Typography level='body-xs'>
|
||||
Uploaded <TimeAgo date={props.storedAt} />
|
||||
{!!props.expiresAt && <>, expires <TimeAgo date={props.expiresAt} /></>}.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Messages */}
|
||||
<Card sx={{
|
||||
@@ -143,7 +147,7 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
|
||||
px: { xs: 2.5, md: 3.5 }, py: 2,
|
||||
}}>
|
||||
<Typography level='body-sm' ref={listBottomRef}>
|
||||
Like the chat? Clone it and keep the talk going! 🚀
|
||||
Like the chat? Import it and keep the talk going! 🚀
|
||||
</Typography>
|
||||
</ListItem>
|
||||
|
||||
@@ -164,7 +168,9 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
Clone on {Brand.Title.Base}
|
||||
{hasExistingChat
|
||||
? `Import as New`
|
||||
: `Import on ${Brand.Title.Base}`}
|
||||
</Button>
|
||||
|
||||
{hasExistingChat && (
|
||||
@@ -175,7 +181,7 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
|
||||
endDecorator={<TelegramIcon />}
|
||||
onClick={() => handleClone(true)}
|
||||
>
|
||||
Replace Existing
|
||||
Import Over
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, ListDivider, ListItem, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import type { SharedChatLinkItem } from '~/modules/trade/link/store-link';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
|
||||
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
|
||||
import { getChatLinkRelativePath } from '~/common/app.routes';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
|
||||
|
||||
/**
|
||||
* Drawer Items are all the links already shared, for quick access.
|
||||
* This is stores in the Trade Store (local storage).
|
||||
*/
|
||||
export function LinkChatDrawer(props: {
|
||||
activeLinkId: string | null,
|
||||
sharedChatLinkItems: SharedChatLinkItem[]
|
||||
showDeletionKeys: boolean,
|
||||
onDeleteLink: (linkId: string) => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { closeDrawer } = useOptimaDrawers();
|
||||
|
||||
// derived state
|
||||
const { activeLinkId, onDeleteLink } = props;
|
||||
const chatLinkItems = props.sharedChatLinkItems.toSorted((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const hasLinks = chatLinkItems.length > 0;
|
||||
|
||||
|
||||
const handleDeleteLink = React.useCallback(() => {
|
||||
activeLinkId && onDeleteLink(activeLinkId);
|
||||
}, [activeLinkId, onDeleteLink]);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
<PageDrawerHeader
|
||||
title='Your Shared Links'
|
||||
onClose={closeDrawer}
|
||||
/>
|
||||
|
||||
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
|
||||
|
||||
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>
|
||||
{hasLinks ? 'Links shared by you' : 'No prior shared links'}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
|
||||
{hasLinks && <Box sx={{ overflowY: 'auto' }}>
|
||||
{chatLinkItems.map(item => (
|
||||
<ListItemButton
|
||||
key={'chat-link-' + item.objectId}
|
||||
variant={activeLinkId === item.objectId ? 'soft' : undefined}
|
||||
component={Link} href={getChatLinkRelativePath(item.objectId)} noLinkStyle
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography level='title-sm'>
|
||||
{item.chatTitle || 'Untitled Chat'}
|
||||
</Typography>
|
||||
{props.showDeletionKeys && <Typography level='body-xs'>
|
||||
Deletion Key: {item.deletionKey}
|
||||
</Typography>}
|
||||
<Typography level='body-xs'>
|
||||
<TimeAgo date={item.createdAt} />
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</Box>}
|
||||
|
||||
</Box>
|
||||
|
||||
<ListDivider sx={{ mt: 0 }} />
|
||||
|
||||
<ListItemButton disabled={!hasLinks || !activeLinkId} onClick={handleDeleteLink}>
|
||||
<ListItemDecorator>
|
||||
<DeleteOutlineIcon />
|
||||
</ListItemDecorator>
|
||||
Delete
|
||||
</ListItemButton>
|
||||
|
||||
</PageDrawerList>
|
||||
|
||||
</>;
|
||||
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { MenuItem, Switch, Typography } from '@mui/joy';
|
||||
import { ListDivider, ListItemDecorator, MenuItem, Switch, Typography } from '@mui/joy';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
@@ -11,7 +12,12 @@ import { useChatShowSystemMessages } from '../chat/store-app-chat';
|
||||
/**
|
||||
* Menu Items are the settings for the chat.
|
||||
*/
|
||||
export function AppChatLinkMenuItems() {
|
||||
export function LinkChatMenuItems(props: {
|
||||
activeLinkId: string | null,
|
||||
showDeletionKeys: boolean,
|
||||
onDeleteLink: (linkId: string) => void,
|
||||
onToggleDeletionKeys: () => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
|
||||
@@ -25,9 +31,18 @@ export function AppChatLinkMenuItems() {
|
||||
|
||||
|
||||
const handleRenderSystemMessageChange = (event: React.ChangeEvent<HTMLInputElement>) => setShowSystemMessages(event.target.checked);
|
||||
|
||||
const handleRenderMarkdownChange = (event: React.ChangeEvent<HTMLInputElement>) => setRenderMarkdown(event.target.checked);
|
||||
|
||||
const handleZenModeChange = (event: React.ChangeEvent<HTMLInputElement>) => setZenMode(event.target.checked ? 'cleaner' : 'clean');
|
||||
|
||||
const { activeLinkId, onDeleteLink } = props;
|
||||
|
||||
const handleDeleteLink = React.useCallback(() => {
|
||||
activeLinkId && onDeleteLink(activeLinkId);
|
||||
}, [activeLinkId, onDeleteLink]);
|
||||
|
||||
|
||||
const zenOn = zenMode === 'cleaner';
|
||||
|
||||
|
||||
@@ -66,5 +81,24 @@ export function AppChatLinkMenuItems() {
|
||||
/>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={props.onToggleDeletionKeys} sx={{ justifyContent: 'space-between' }}>
|
||||
<Typography>
|
||||
Show Keys
|
||||
</Typography>
|
||||
<Switch
|
||||
checked={props.showDeletionKeys}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }}
|
||||
/>
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider />
|
||||
|
||||
<MenuItem onClick={handleDeleteLink} sx={{ justifyContent: 'space-between' }}>
|
||||
Delete
|
||||
<ListItemDecorator>
|
||||
<DeleteOutlineIcon />
|
||||
</ListItemDecorator>
|
||||
</MenuItem>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { ROUTE_INDEX } from '~/common/app.routes';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { cssRainbowColorKeyframes, themeBgApp } from '~/common/app.theme';
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
|
||||
import { newsCallout, NewsItems } from './news.data';
|
||||
|
||||
@@ -44,7 +44,6 @@ export function AppNews() {
|
||||
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: themeBgApp,
|
||||
overflowY: 'auto',
|
||||
display: 'flex', justifyContent: 'center',
|
||||
p: { xs: 3, md: 6 },
|
||||
|
||||
@@ -10,14 +10,29 @@ import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
|
||||
|
||||
// update this variable every time you want to broadcast a new version to clients
|
||||
export const incrementalVersion: number = 11;
|
||||
export const incrementalVersion: number = 12.1;
|
||||
|
||||
|
||||
function B(props: {
|
||||
href?: string,
|
||||
issue?: number,
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const href = props.issue ? RIssues + '/' + props.issue : props.href;
|
||||
const boldText = (
|
||||
<Typography component='span' color={!!href ? 'primary' : 'neutral'} sx={{ fontWeight: 600 }}>
|
||||
{props.children}
|
||||
</Typography>
|
||||
);
|
||||
if (!href)
|
||||
return boldText;
|
||||
return (
|
||||
<Link href={href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>
|
||||
{boldText} <LaunchIcon sx={{ mx: 0.5, fontSize: 16 }} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const B = (props: { href?: string, children: React.ReactNode }) => {
|
||||
const boldText = <Typography color={!!props.href ? 'primary' : 'neutral'} sx={{ fontWeight: 600 }}>{props.children}</Typography>;
|
||||
return props.href ?
|
||||
<Link href={props.href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>{boldText} <LaunchIcon sx={{ ml: 1 }} /></Link> :
|
||||
boldText;
|
||||
};
|
||||
|
||||
const { OpenRepo, OpenProject } = Brand.URIs;
|
||||
const RCode = `${OpenRepo}/blob/main`;
|
||||
@@ -59,10 +74,42 @@ export const newsCallout =
|
||||
// news and feature surfaces
|
||||
export const NewsItems: NewsItem[] = [
|
||||
// still unannounced: phone calls, split windows, ...
|
||||
{// 🆕
|
||||
versionCode: '1.12.0',
|
||||
versionName: 'AGI Hotline',
|
||||
versionMoji: '✨🗣️',
|
||||
versionDate: new Date('2024-01-26T12:30:00Z'),
|
||||
items: [
|
||||
{ text: <><B issue={354}>Voice Call Personas</B>: save time, recap conversations</>, issue: 354 },
|
||||
{ text: <>Updated <B issue={364}>OpenAI Models</B> to the 0125 release</>, issue: 364 },
|
||||
{ text: <>Chats: Auto-<B issue={222}>Rename</B> and <B issue={360}>assign folders</B></>, issue: 222 },
|
||||
{ text: <><B issue={356}>Link Sharing</B> makeover and control</>, issue: 356 },
|
||||
{ text: <><B issue={358}>Accessibility</B> for screen readers</>, issue: 358 },
|
||||
{ text: <>Export chats to <B>Markdown</B></>, issue: 337 },
|
||||
{ text: <>Paste <B>tables from Excel</B></>, issue: 286 },
|
||||
{ text: <>Large optimizations</> },
|
||||
{ text: <>Ollama updates</>, issue: 309 },
|
||||
{ text: <>Over <B>150 commits</B> and <B>7,000+ lines changed</B> for development enhancements</>, dev: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '1.11.0',
|
||||
versionName: 'Singularity',
|
||||
versionMoji: '🌌🌠',
|
||||
versionDate: new Date('2024-01-16T06:30:00Z'),
|
||||
items: [
|
||||
{ text: <><B href={RIssues + '/329'}>Search</B> past conversations (@joriskalz) 🔍</>, issue: 329 },
|
||||
{ text: <>Quick <B href={RIssues + '/327'}>commands pane</B> (open with '/')</>, issue: 327 },
|
||||
{ text: <><B>Together AI</B> Inference platform support</>, issue: 346 },
|
||||
{ text: <>Persona creation: <B href={RIssues + '/301'}>history</B></>, issue: 301 },
|
||||
{ text: <>Persona creation: fix <B href={RIssues + '/328'}>API timeouts</B></>, issue: 328 },
|
||||
{ text: <>Support up to five <B href={RIssues + '/323'}>OpenAI-compatible</B> endpoints</>, issue: 323 },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '1.10.0',
|
||||
versionName: 'The Year of AGI',
|
||||
versionMoji: '🎊✨',
|
||||
// versionMoji: '🎊✨',
|
||||
versionDate: new Date('2024-01-06T08:00:00Z'),
|
||||
items: [
|
||||
{ text: <><B href={RIssues + '/201'}>New UI</B> for desktop and mobile, enabling future expansions</>, issue: 201 },
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Container, ListDivider, Sheet, Typography } from '@mui/joy';
|
||||
import { Box, Container, ListDivider, Typography } from '@mui/joy';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import { themeBgApp } from '~/common/app.theme';
|
||||
|
||||
import { PersonaCreator } from './PersonaCreator';
|
||||
import { Creator } from './creator/Creator';
|
||||
import { CreatorDrawer } from './creator/CreatorDrawer';
|
||||
import { Viewer } from './creator/Viewer';
|
||||
|
||||
|
||||
export function AppPersonas() {
|
||||
|
||||
// state
|
||||
const [selectedSimplePersonaId, setSelectedSimplePersonaId] = React.useState<string | null>(null);
|
||||
|
||||
|
||||
// pluggable UI
|
||||
|
||||
const drawerContent = React.useMemo(() => {
|
||||
return (
|
||||
<CreatorDrawer
|
||||
selectedSimplePersonaId={selectedSimplePersonaId}
|
||||
setSelectedSimplePersonaId={setSelectedSimplePersonaId}
|
||||
/>
|
||||
);
|
||||
}, [selectedSimplePersonaId]);
|
||||
|
||||
usePluggableOptimaLayout(drawerContent, null, null, 'AppPersonas');
|
||||
|
||||
|
||||
return (
|
||||
<Sheet sx={{
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
backgroundColor: themeBgApp,
|
||||
p: { xs: 3, md: 6 },
|
||||
}}>
|
||||
|
||||
@@ -24,10 +43,12 @@ export function AppPersonas() {
|
||||
|
||||
<ListDivider sx={{ my: 2 }} />
|
||||
|
||||
<PersonaCreator />
|
||||
{!!selectedSimplePersonaId && <Viewer selectedSimplePersonaId={selectedSimplePersonaId} />}
|
||||
|
||||
<Creator display={!selectedSimplePersonaId} />
|
||||
|
||||
</Container>
|
||||
|
||||
</Sheet>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, Box, Button, Card, CardContent, CircularProgress, Grid, Input, LinearProgress, Tab, TabList, TabPanel, Tabs, Textarea, Typography } from '@mui/joy';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import SettingsAccessibilityIcon from '@mui/icons-material/SettingsAccessibility';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||
|
||||
import { RenderMarkdown } from '../chat/components/message/RenderMarkdown';
|
||||
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { lineHeightTextarea } from '~/common/app.theme';
|
||||
import { useFormRadioLlmType } from '~/common/components/forms/useFormRadioLlmType';
|
||||
|
||||
import { LLMChainStep, useLLMChain } from './useLLMChain';
|
||||
|
||||
|
||||
function extractVideoID(videoURL: string): string | null {
|
||||
const regExp = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^#&?]*).*/;
|
||||
const match = videoURL.match(regExp);
|
||||
return (match && match[1]?.length == 11) ? match[1] : null;
|
||||
}
|
||||
|
||||
|
||||
function useTranscriptFromVideo(videoID: string | null) {
|
||||
const { data, isFetching, isError, error } =
|
||||
apiQuery.ytpersona.getTranscript.useQuery({ videoId: videoID || '' }, {
|
||||
enabled: !!videoID,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
return {
|
||||
title: data?.videoTitle ?? null,
|
||||
thumbnailUrl: data?.thumbnailUrl ?? null,
|
||||
transcript: data?.transcript?.trim() ?? null,
|
||||
isFetching,
|
||||
isError, error,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const PersonaCreationSteps: LLMChainStep[] = [
|
||||
{
|
||||
name: 'Analyzing the transcript / text',
|
||||
setSystem: 'You are skilled in analyzing and embodying diverse characters. You meticulously study transcripts to capture key attributes, draft comprehensive character sheets, and refine them for authenticity. Feel free to make assumptions without hedging, be concise and be creative.',
|
||||
addUserInput: true,
|
||||
addUser: 'Conduct comprehensive research on the provided transcript. Identify key characteristics of the speaker, including age, professional field, distinct personality traits, style of communication, narrative context, and self-awareness. Additionally, consider any unique aspects such as their use of humor, their cultural background, core values, passions, fears, personal history, and social interactions. Your output for this stage is an in-depth written analysis that exhibits an understanding of both the superficial and more profound aspects of the speaker\'s persona.',
|
||||
},
|
||||
{
|
||||
name: 'Defining the character',
|
||||
addPrevAssistant: true,
|
||||
addUser: 'Craft your documented analysis into a draft of the \'You are a...\' character sheet. It should encapsulate all crucial personality dimensions, along with the motivations and aspirations of the persona. Keep in mind to balance succinctness and depth of detail for each dimension. The deliverable here is a comprehensive draft of the character sheet that captures the speaker\'s unique essence.',
|
||||
},
|
||||
{
|
||||
name: 'Crossing the t\'s',
|
||||
addPrevAssistant: true,
|
||||
addUser: 'Compare the draft character sheet with the original transcript, validating its content and ensuring it captures both the speaker’s overt characteristics and the subtler undertones. Omit unknown information, fine-tune any areas that require clarity, have been overlooked, or require more authenticity. Use clear and illustrative examples from the transcript to refine your sheet and offer meaningful, tangible reference points. Your output is a coherent, comprehensive, and nuanced instruction that begins with \'You are a...\' and serves as a go-to guide for an actor recreating the persona.',
|
||||
},
|
||||
// {
|
||||
// name: 'Shrink',
|
||||
// addPrevAssistant: true,
|
||||
// addUser: 'Now remove all the uncertain information, omit unknown information, Your output is a coherent, comprehensive, and nuanced instruction that begins with \'You are a...\' and serves as a go-to guide for a recreating the persona.',
|
||||
// },
|
||||
];
|
||||
|
||||
|
||||
export function PersonaCreator() {
|
||||
// state
|
||||
const [selectedTab, setSelectedTab] = React.useState(0);
|
||||
const [inputText, setInputText] = React.useState<string | null>(null);
|
||||
const [videoURL, setVideoURL] = React.useState('');
|
||||
const [videoID, setVideoID] = React.useState('');
|
||||
const [personaText, setPersonaText] = React.useState('');
|
||||
|
||||
// external state
|
||||
const [personaLlm, llmComponent] = useFormRadioLlmType('Persona Creation Model');
|
||||
|
||||
|
||||
// chain to convert a text input string (e.g. youtube transcript) into a persona prompt
|
||||
const savePersona = React.useCallback((personaPrompt: string) => {
|
||||
// TODO.. save the persona prompt here
|
||||
}, []);
|
||||
|
||||
const { isFinished, isTransforming, chainProgress, chainIntermediates, chainStepName, chainOutput, chainError, abortChain } =
|
||||
useLLMChain(PersonaCreationSteps, personaLlm?.id, inputText ?? undefined, savePersona);
|
||||
|
||||
|
||||
// fetch transcript when the Video ID is ready, then store it
|
||||
const { transcript, thumbnailUrl, title, isFetching, isError, error: transcriptError } =
|
||||
useTranscriptFromVideo(videoID);
|
||||
React.useEffect(() => setInputText(transcript), [transcript]);
|
||||
|
||||
|
||||
// Reset the relevant state when the selected tab changes
|
||||
React.useEffect(() => {
|
||||
// reset state
|
||||
setVideoURL('');
|
||||
setVideoID('');
|
||||
setInputText(null);
|
||||
setPersonaText('');
|
||||
}, [selectedTab]);
|
||||
|
||||
|
||||
// [Tab: 0] Video download
|
||||
|
||||
const handleVideoIdChange = (e: React.ChangeEvent<HTMLInputElement>) => setVideoURL(e.target.value);
|
||||
|
||||
const handleFetchTranscript = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault(); // stop the form submit
|
||||
const videoId = extractVideoID(videoURL);
|
||||
if (!videoId) {
|
||||
setVideoURL('Invalid');
|
||||
} else {
|
||||
setInputText(null);
|
||||
setVideoID(videoId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// [Tab: 1] Text input
|
||||
|
||||
const handlePersonaTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => setPersonaText(e.target.value);
|
||||
|
||||
return <>
|
||||
|
||||
<Typography level='title-sm' mb={3}>
|
||||
Create the <em>System Prompt</em> of an AI Persona from YouTube or Text.
|
||||
</Typography>
|
||||
|
||||
<Tabs defaultValue={0} variant='outlined'
|
||||
value={selectedTab}
|
||||
onChange={(_event, newValue) => setSelectedTab(newValue as number)}>
|
||||
<TabList sx={{ minHeight: 48 }}>
|
||||
<Tab>From YouTube Video</Tab>
|
||||
<Tab>From Text</Tab>
|
||||
</TabList>
|
||||
|
||||
{/* YouTube URL inputs */}
|
||||
<TabPanel value={0} sx={{ p: 3 }}>
|
||||
|
||||
<Typography level='title-md' startDecorator={<YouTubeIcon sx={{ color: '#f00' }} />} sx={{ mb: 3 }}>
|
||||
YouTube -> Persona
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleFetchTranscript}>
|
||||
<Input
|
||||
required
|
||||
type='url'
|
||||
fullWidth
|
||||
variant='outlined'
|
||||
placeholder='YouTube Video URL'
|
||||
value={videoURL}
|
||||
onChange={handleVideoIdChange}
|
||||
sx={{ mb: 1.5 }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button type='submit' variant='solid' disabled={isFetching || isTransforming || !videoURL} loading={isFetching} sx={{ minWidth: 140 }}>
|
||||
Create
|
||||
</Button>
|
||||
<GoodTooltip title='This example comes from the popular Fireship YouTube channel, which presents technical topics with irreverent humor.'>
|
||||
<Button variant='outlined' color='neutral' onClick={() => setVideoURL('https://www.youtube.com/watch?v=M_wZpSEvOkc')}>
|
||||
Example
|
||||
</Button>
|
||||
</GoodTooltip>
|
||||
</Box>
|
||||
</form>
|
||||
</TabPanel>
|
||||
|
||||
{/* Text area for users to paste copied text */}
|
||||
<TabPanel value={1} sx={{ p: 3 }}>
|
||||
|
||||
<Typography level='title-md' startDecorator={<TextFieldsIcon />} sx={{ mb: 3 }}>
|
||||
<b>Text</b> -> Persona
|
||||
</Typography>
|
||||
|
||||
<Textarea
|
||||
variant='outlined'
|
||||
minRows={4} maxRows={8}
|
||||
placeholder='Paste your text here...'
|
||||
value={personaText}
|
||||
onChange={handlePersonaTextChange}
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
'&:focus-within': {
|
||||
backgroundColor: 'background.popup',
|
||||
},
|
||||
lineHeight: lineHeightTextarea,
|
||||
mb: 1.5,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button variant='solid' disabled={isFetching || isTransforming || !personaText} onClick={() => setInputText(personaText)} sx={{ minWidth: 140 }}>
|
||||
Create
|
||||
</Button>
|
||||
{!!personaText?.length && <Typography level='body-sm'>{personaText.length.toLocaleString()}</Typography>}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
{/* LLM selector (chat vs fast) */}
|
||||
{!isTransforming && !isFinished && <Box sx={{ mt: 3 }}>{llmComponent}</Box>}
|
||||
|
||||
{/* Errors */}
|
||||
{isError && (
|
||||
<Alert color='warning' sx={{ mt: 1 }}>
|
||||
<Typography component='div'>{transcriptError?.message || 'Unknown error'}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{!!chainError && (
|
||||
<Alert color='warning' sx={{ mt: 1 }}>
|
||||
<Typography component='div'>{chainError}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Persona! */}
|
||||
{chainOutput && <>
|
||||
<Card sx={{ boxShadow: 'md', mt: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography level='title-lg' color='success' startDecorator={<SettingsAccessibilityIcon color='success' />}>
|
||||
Persona Prompt
|
||||
</Typography>
|
||||
<GoodTooltip title='Copy system prompt'>
|
||||
<Button color='success' onClick={() => copyToClipboard(chainOutput, 'Persona prompt')} endDecorator={<ContentCopyIcon />} sx={{ minWidth: 120 }}>
|
||||
Copy
|
||||
</Button>
|
||||
</GoodTooltip>
|
||||
</Box>
|
||||
<CardContent>
|
||||
<Alert variant='soft' color='success' sx={{ mb: 1 }}>
|
||||
You may now copy the text below and use it as Custom prompt!
|
||||
</Alert>
|
||||
<RenderMarkdown textBlock={{ type: 'text', content: chainOutput }} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Input: Transcript/Text */}
|
||||
{inputText && <>
|
||||
<Typography level='title-lg' sx={{ mt: 3, mb: 0.5 }}>
|
||||
Input Data
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
{title || 'Transcript / Text'}
|
||||
</Typography>
|
||||
<Box>
|
||||
{!!thumbnailUrl && <picture><img src={thumbnailUrl} alt='YouTube Video Thumbnail' height={80} style={{ float: 'left', marginRight: 8 }} /></picture>}
|
||||
<Typography level='body-sm'>
|
||||
{inputText.slice(0, 280)}...
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Intermediate outputs rendered as cards in a grid */}
|
||||
{chainIntermediates && chainIntermediates.length > 0 && <>
|
||||
<Typography level='title-lg' sx={{ mt: 3, mb: 0.5 }}>
|
||||
{isTransforming ? 'Working...' : 'Intermediate Work'}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
{chainIntermediates.map((intermediate, i) =>
|
||||
<Grid xs={12} sm={6} md={4} key={i}>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
{i + 1}. {PersonaCreationSteps[i].name}
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{intermediate?.slice(0, 140)}...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>,
|
||||
)}
|
||||
</Grid>
|
||||
</>}
|
||||
|
||||
|
||||
{/* Dialog: Embodiment Progress */}
|
||||
{isTransforming && <GoodModal open>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 2 }}>
|
||||
<CircularProgress color='primary' value={Math.max(10, 100 * chainProgress)} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography color='success' level='title-lg'>
|
||||
Embodying Persona ...
|
||||
</Typography>
|
||||
<Typography level='title-sm' sx={{ mt: 1 }}>
|
||||
Using: {personaLlm?.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography color='success' level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{chainStepName}
|
||||
</Typography>
|
||||
<LinearProgress color='success' determinate value={Math.max(10, 100 * chainProgress)} sx={{ mt: 1.5 }} />
|
||||
</Box>
|
||||
<Typography level='title-sm'>
|
||||
This may take 1-2 minutes. Do not close this window or the progress will be lost.
|
||||
While larger models will produce higher quality prompts,
|
||||
if you experience any errors (e.g. LLM timeouts, or context overflows for larger videos)
|
||||
please try again with faster/smaller models.
|
||||
</Typography>
|
||||
<Button variant='soft' color='neutral' onClick={abortChain} sx={{ ml: 'auto', minWidth: 100, mt: 3 }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</GoodModal>}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, Box, Button, Card, CardContent, CircularProgress, Divider, FormLabel, Grid, IconButton, LinearProgress, Tab, TabList, TabPanel, Tabs, Typography } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import SettingsAccessibilityIcon from '@mui/icons-material/SettingsAccessibility';
|
||||
|
||||
import { RenderMarkdown } from '../../chat/components/message/RenderMarkdown';
|
||||
|
||||
import { LLMChainStep, useLLMChain } from '~/modules/aifn/useLLMChain';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { copyToClipboard } from '~/common/util/clipboardUtils';
|
||||
import { useFormEditTextArray } from '~/common/components/forms/useFormEditTextArray';
|
||||
import { useLLMSelect } from '~/common/components/forms/useLLMSelect';
|
||||
import { useToggleableBoolean } from '~/common/util/useToggleableBoolean';
|
||||
|
||||
import { FromText } from './FromText';
|
||||
import { FromYouTube } from './FromYouTube';
|
||||
import { prependSimplePersona, SimplePersonaProvenance } from '../store-app-personas';
|
||||
|
||||
|
||||
// delay to start a new chain after the previous one finishes
|
||||
const CONTINUE_DELAY: number | false = false;
|
||||
|
||||
|
||||
const Prompts: string[] = [
|
||||
'You are skilled in analyzing and embodying diverse characters. You meticulously study transcripts to capture key attributes, draft comprehensive character sheets, and refine them for authenticity. Feel free to make assumptions without hedging, be concise and be creative.',
|
||||
'Conduct comprehensive research on the provided transcript. Identify key characteristics of the speaker, including age, professional field, distinct personality traits, style of communication, narrative context, and self-awareness. Additionally, consider any unique aspects such as their use of humor, their cultural background, core values, passions, fears, personal history, and social interactions. Your output for this stage is an in-depth written analysis that exhibits an understanding of both the superficial and more profound aspects of the speaker\'s persona.',
|
||||
'Craft your documented analysis into a draft of the \'You are a...\' character sheet. It should encapsulate all crucial personality dimensions, along with the motivations and aspirations of the persona. Keep in mind to balance succinctness and depth of detail for each dimension. The deliverable here is a comprehensive draft of the character sheet that captures the speaker\'s unique essence.',
|
||||
'Compare the draft character sheet with the original transcript, validating its content and ensuring it captures both the speaker’s overt characteristics and the subtler undertones. Omit unknown information, fine-tune any areas that require clarity, have been overlooked, or require more authenticity. Use clear and illustrative examples from the transcript to refine your sheet and offer meaningful, tangible reference points. Your output is a coherent, comprehensive, and nuanced instruction that begins with \'You are a...\' and serves as a go-to guide for an actor recreating the persona.',
|
||||
];
|
||||
|
||||
const PromptTitles: string[] = [
|
||||
'Common: Creator System Prompt',
|
||||
'Analyze the transcript',
|
||||
'Define the character',
|
||||
'Cross the t\'s',
|
||||
];
|
||||
|
||||
// chain to convert a text input string (e.g. youtube transcript) into a persona prompt
|
||||
function createChain(instructions: string[], titles: string[]): LLMChainStep[] {
|
||||
return [
|
||||
{
|
||||
name: titles[1],
|
||||
setSystem: instructions[0],
|
||||
addUserInput: true,
|
||||
addUser: instructions[1],
|
||||
},
|
||||
{
|
||||
name: titles[2],
|
||||
addPrevAssistant: true,
|
||||
addUser: instructions[2],
|
||||
},
|
||||
{
|
||||
name: titles[3],
|
||||
addPrevAssistant: true,
|
||||
addUser: instructions[3],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
export const PersonaPromptCard = (props: { content: string }) =>
|
||||
<Card sx={{ boxShadow: 'md', mt: 3 }}>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography level='title-lg' color='success' startDecorator={<SettingsAccessibilityIcon color='success' />}>
|
||||
Persona Prompt
|
||||
</Typography>
|
||||
<GoodTooltip title='Copy system prompt'>
|
||||
<Button color='success' onClick={() => copyToClipboard(props.content, 'Persona prompt')} endDecorator={<ContentCopyIcon />} sx={{ minWidth: 120 }}>
|
||||
Copy
|
||||
</Button>
|
||||
</GoodTooltip>
|
||||
</Box>
|
||||
|
||||
<CardContent>
|
||||
<Alert variant='soft' color='success' sx={{ mb: 1 }}>
|
||||
You may now copy the text below and use it as Custom prompt!
|
||||
</Alert>
|
||||
<RenderMarkdown textBlock={{ type: 'text', content: props.content }} />
|
||||
</CardContent>
|
||||
</Card>;
|
||||
|
||||
|
||||
export function Creator(props: { display: boolean }) {
|
||||
|
||||
// state
|
||||
const advanced = useToggleableBoolean();
|
||||
const [selectedTab, setSelectedTab] = React.useState(0);
|
||||
const [chainInputText, setChainInputText] = React.useState<string | null>(null);
|
||||
const [inputProvenance, setInputProvenance] = React.useState<SimplePersonaProvenance | null>(null);
|
||||
const [showIntermediates, setShowIntermediates] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const [personaLlm, llmComponent] = useLLMSelect(true, 'Persona Creation Model');
|
||||
|
||||
|
||||
// editable prompts
|
||||
const {
|
||||
strings: editedInstructions, stringEditors: instructionEditors,
|
||||
} = useFormEditTextArray(Prompts, PromptTitles);
|
||||
|
||||
const creationChainSteps = React.useMemo(() => {
|
||||
return createChain(editedInstructions, PromptTitles);
|
||||
}, [editedInstructions]);
|
||||
|
||||
const llmLabel = personaLlm?.label || undefined;
|
||||
const savePersona = React.useCallback((personaPrompt: string, inputText: string) => {
|
||||
prependSimplePersona(personaPrompt, inputText, inputProvenance ?? undefined, llmLabel);
|
||||
}, [inputProvenance, llmLabel]);
|
||||
|
||||
const {
|
||||
// isFinished,
|
||||
isTransforming,
|
||||
chainProgress,
|
||||
chainIntermediates,
|
||||
chainStepName,
|
||||
chainStepInterimChars,
|
||||
chainOutput,
|
||||
chainError,
|
||||
userCancelChain,
|
||||
restartChain,
|
||||
} = useLLMChain(creationChainSteps, personaLlm?.id, chainInputText ?? undefined, savePersona);
|
||||
|
||||
|
||||
// Reset the relevant state when the selected tab changes
|
||||
React.useEffect(() => {
|
||||
setChainInputText(null);
|
||||
}, [selectedTab]);
|
||||
|
||||
|
||||
// [debug] Restart the chain when complete after a delay
|
||||
const debugRestart = !!CONTINUE_DELAY && !isTransforming && (chainProgress === 1 || !!chainError);
|
||||
React.useEffect(() => {
|
||||
if (debugRestart) {
|
||||
const timeout = setTimeout(restartChain, CONTINUE_DELAY);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [debugRestart, restartChain]);
|
||||
|
||||
|
||||
const handleCreate = React.useCallback((text: string, provenance: SimplePersonaProvenance) => {
|
||||
setChainInputText(text);
|
||||
setInputProvenance(provenance);
|
||||
}, []);
|
||||
|
||||
const handleCancel = React.useCallback(() => {
|
||||
setChainInputText(null);
|
||||
setInputProvenance(null);
|
||||
userCancelChain();
|
||||
}, [userCancelChain]);
|
||||
|
||||
|
||||
// Hide the GFX, but not the logic (hooks)
|
||||
if (!props.display)
|
||||
return null;
|
||||
|
||||
return <>
|
||||
|
||||
<Typography level='title-sm' mb={3}>
|
||||
Create the <em>System Prompt</em> of an AI Persona from YouTube or Text.
|
||||
</Typography>
|
||||
|
||||
|
||||
{/* Inputs */}
|
||||
<Tabs
|
||||
variant='outlined'
|
||||
defaultValue={0}
|
||||
value={selectedTab}
|
||||
onChange={(_event, newValue) => setSelectedTab(newValue as number)}
|
||||
sx={{
|
||||
// boxShadow: 'sm',
|
||||
borderRadius: 'md',
|
||||
// overflow: 'hidden',
|
||||
display: isTransforming ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<TabList sx={{ minHeight: '3rem' }}>
|
||||
<Tab>From YouTube Video</Tab>
|
||||
<Tab>From Text</Tab>
|
||||
</TabList>
|
||||
<TabPanel keepMounted value={0} sx={{ p: 3 }}>
|
||||
<FromYouTube isTransforming={isTransforming} onCreate={handleCreate} />
|
||||
</TabPanel>
|
||||
<TabPanel keepMounted value={1} sx={{ p: 3 }}>
|
||||
<FromText isCreating={isTransforming} onCreate={handleCreate} />
|
||||
</TabPanel>
|
||||
|
||||
<Divider orientation='horizontal' />
|
||||
|
||||
<Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{llmComponent}
|
||||
|
||||
{advanced.on && (
|
||||
<Box sx={{ my: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{instructionEditors}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<FormLabel onClick={advanced.toggle} sx={{ textDecoration: 'underline', cursor: 'pointer' }}>
|
||||
{advanced.on ? 'Hide Advanced' : 'Advanced: Prompts'}
|
||||
</FormLabel>
|
||||
</Box>
|
||||
</Tabs>
|
||||
|
||||
|
||||
{/* Embodiment Progress */}
|
||||
{/* <GoodModal open> */}
|
||||
{isTransforming && <Card><CardContent sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', my: 2 }}>
|
||||
<CircularProgress color='primary' value={Math.max(10, 100 * chainProgress)} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography color='success' level='title-lg'>
|
||||
Embodying Persona ...
|
||||
</Typography>
|
||||
<Typography level='title-sm' sx={{ mt: 1 }}>
|
||||
Using: {personaLlm?.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography color='success' level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{chainStepName}
|
||||
</Typography>
|
||||
<LinearProgress color='success' determinate value={Math.max(10, 100 * chainProgress)} sx={{ mt: 1.5 }} />
|
||||
<Typography level='body-sm' sx={{ mt: 1 }}>
|
||||
{chainStepInterimChars === null ? 'Loading ...' : `Generating (${chainStepInterimChars.toLocaleString()} bytes) ...`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography level='title-sm'>
|
||||
This may take 1-2 minutes.
|
||||
While larger models will produce higher quality prompts,
|
||||
if you experience any errors (e.g. LLM timeouts, or context overflows for larger videos)
|
||||
please try again with faster/smaller models.
|
||||
</Typography>
|
||||
<Button variant='soft' color='neutral' onClick={handleCancel} sx={{ ml: 'auto', minWidth: 100, mt: 3 }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</CardContent></Card>}
|
||||
|
||||
|
||||
{/* Errors */}
|
||||
{!!chainError && (
|
||||
<Alert color='warning' sx={{ mt: 1 }}>
|
||||
<Typography component='div'>{chainError}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* The Persona (Output) */}
|
||||
{chainOutput && <>
|
||||
<PersonaPromptCard content={chainOutput} />
|
||||
</>}
|
||||
|
||||
|
||||
{/* Input + Intermediate outputs (with expander) */}
|
||||
{(isTransforming || chainIntermediates?.length > 0) && <>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', mt: 3, mb: 0.5, mx: 1 }}>
|
||||
<Typography level='title-lg'>
|
||||
{isTransforming ? 'Working ...' : 'Intermediate Work'}
|
||||
</Typography>
|
||||
<IconButton size='sm' variant={showIntermediates ? 'solid' : 'outlined'} onClick={() => setShowIntermediates(s => !s)}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid xs={12} md={showIntermediates ? 12 : 6}>
|
||||
<Card sx={{ height: '100%', overflow: 'hidden' }}>
|
||||
<CardContent>
|
||||
<Typography color='success' level='title-sm' sx={{ mb: 1 }}>
|
||||
Input Text
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{showIntermediates ? chainInputText : (chainInputText?.slice(0, 280) + '...')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
{chainIntermediates.map((intermediate, i) =>
|
||||
<Grid xs={12} md={showIntermediates ? 12 : 6} key={i}>
|
||||
<Card sx={{ height: '100%', overflow: 'hidden' }}>
|
||||
<CardContent>
|
||||
<Typography color='success' level='title-sm' sx={{ mb: 1 }}>
|
||||
{i + 1}. {intermediate.name}
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
{showIntermediates ? intermediate.output : (intermediate.output?.slice(0, 280) + '...')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>,
|
||||
)}
|
||||
</Grid>
|
||||
</>}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, IconButton, ListItemButton, ListItemDecorator, Sheet, Tooltip, Typography } from '@mui/joy';
|
||||
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import Diversity2Icon from '@mui/icons-material/Diversity2';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
|
||||
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
|
||||
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
|
||||
import { CreatorDrawerItem } from './CreatorDrawerItem';
|
||||
import { deleteSimplePersona, deleteSimplePersonas, useSimplePersonas } from '../store-app-personas';
|
||||
|
||||
|
||||
export function CreatorDrawer(props: {
|
||||
selectedSimplePersonaId: string | null,
|
||||
setSelectedSimplePersonaId: (simplePersonaId: string | null) => void,
|
||||
}) {
|
||||
|
||||
// selection mode
|
||||
const [selectMode, setSelectMode] = React.useState(false);
|
||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
|
||||
|
||||
// external state
|
||||
const { closeDrawer } = useOptimaDrawers();
|
||||
const { simplePersonas } = useSimplePersonas();
|
||||
|
||||
|
||||
// derived state
|
||||
const hasPersonas = simplePersonas.length > 0;
|
||||
|
||||
|
||||
// Simple Persona Operations
|
||||
|
||||
const { setSelectedSimplePersonaId } = props;
|
||||
|
||||
const handleSimplePersonaUnselect = React.useCallback(() => {
|
||||
setSelectedSimplePersonaId(null);
|
||||
}, [setSelectedSimplePersonaId]);
|
||||
|
||||
const handleSimplePersonaDelete = React.useCallback((simplePersonaId: string) => {
|
||||
deleteSimplePersona(simplePersonaId);
|
||||
handleSimplePersonaUnselect();
|
||||
}, [handleSimplePersonaUnselect]);
|
||||
|
||||
|
||||
// Selection
|
||||
|
||||
const handleSelectionClose = React.useCallback(() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}, []);
|
||||
|
||||
const handleSelectionToggleId = React.useCallback((simplePersonaId: string) => {
|
||||
setSelectedIds(prevSelectedIds => {
|
||||
const newSelectedItems = new Set(prevSelectedIds);
|
||||
if (newSelectedItems.has(simplePersonaId))
|
||||
newSelectedItems.delete(simplePersonaId);
|
||||
else
|
||||
newSelectedItems.add(simplePersonaId);
|
||||
return newSelectedItems;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectionInvert = React.useCallback(() => {
|
||||
setSelectedIds(prevSelectedIds => {
|
||||
const newSelectedIds = new Set(prevSelectedIds);
|
||||
simplePersonas.forEach(persona => {
|
||||
if (newSelectedIds.has(persona.id))
|
||||
newSelectedIds.delete(persona.id);
|
||||
else
|
||||
newSelectedIds.add(persona.id);
|
||||
});
|
||||
return newSelectedIds;
|
||||
});
|
||||
}, [simplePersonas]);
|
||||
|
||||
const handleSelectionDelete = React.useCallback(() => {
|
||||
deleteSimplePersonas(selectedIds);
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedIds]);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
{/* Drawer Header */}
|
||||
<PageDrawerHeader
|
||||
title={selectMode ? 'Selection Mode' : 'Recent'}
|
||||
onClose={selectMode ? handleSelectionClose : closeDrawer}
|
||||
>
|
||||
{hasPersonas && !selectMode && (
|
||||
<Tooltip title={selectMode ? 'Done' : 'Select'}>
|
||||
<IconButton onClick={selectMode ? handleSelectionClose : () => setSelectMode(true)}>
|
||||
{selectMode ? <DoneIcon /> : <CheckBoxOutlineBlankIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</PageDrawerHeader>
|
||||
|
||||
<PageDrawerList
|
||||
variant='plain'
|
||||
noTopPadding noBottomPadding tallRows
|
||||
onClick={handleSimplePersonaUnselect}
|
||||
>
|
||||
|
||||
{selectMode ? (
|
||||
// Selection Header
|
||||
<Sheet variant='soft' color='warning' invertedColors>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 1, minHeight: '3rem' }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='warning'
|
||||
startDecorator={selectedIds.size === simplePersonas.length ? <CheckBoxOutlineBlankIcon /> : <CheckBoxIcon />}
|
||||
onClick={handleSelectionInvert}
|
||||
>
|
||||
{selectedIds.size === simplePersonas.length
|
||||
? 'Select None'
|
||||
: selectedIds.size === 0
|
||||
? `Select ${simplePersonas.length.toLocaleString() || 'All'}`
|
||||
: 'Invert'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='warning'
|
||||
startDecorator={<DeleteOutlineIcon />}
|
||||
onClick={handleSelectionDelete}
|
||||
disabled={selectedIds.size === 0}
|
||||
sx={{ ml: 'auto' }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</Sheet>
|
||||
) : (
|
||||
// Create Button
|
||||
<ListItemButton
|
||||
variant={props.selectedSimplePersonaId ? 'plain' : 'soft'}
|
||||
onClick={handleSimplePersonaUnselect}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<Diversity2Icon />
|
||||
</ListItemDecorator>
|
||||
<Typography level='title-sm' sx={!props.selectedSimplePersonaId ? { fontWeight: 600 } : undefined}>
|
||||
Create
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
)}
|
||||
|
||||
{/* Personas [] */}
|
||||
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||
{simplePersonas.map(item =>
|
||||
<CreatorDrawerItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={item.id === props.selectedSimplePersonaId}
|
||||
isSelected={selectedIds.has(item.id)}
|
||||
isSelection={selectMode}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (selectMode)
|
||||
handleSelectionToggleId(item.id);
|
||||
else
|
||||
props.setSelectedSimplePersonaId(item.id);
|
||||
}}
|
||||
onDelete={handleSimplePersonaDelete}
|
||||
/>,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
</PageDrawerList>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, Checkbox, IconButton, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||
|
||||
import type { SimplePersona } from '../store-app-personas';
|
||||
|
||||
|
||||
export function CreatorDrawerItem(props: {
|
||||
item: SimplePersona,
|
||||
isActive: boolean,
|
||||
isSelected: boolean,
|
||||
isSelection: boolean,
|
||||
onClick: (event: React.MouseEvent) => void,
|
||||
onDelete: (simplePersonaId: string) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
|
||||
// derived
|
||||
|
||||
const { item, isActive } = props;
|
||||
|
||||
const thumbnailUrl = item.pictureUrl || ((item.inputProvenance?.type === 'youtube' && item.inputProvenance.thumbnailUrl) ? item.inputProvenance.thumbnailUrl : undefined);
|
||||
|
||||
const icon = thumbnailUrl
|
||||
? <picture style={{ lineHeight: 0 }}><img src={thumbnailUrl} alt='Simple Persona Thumbnail' width={20} height={20} /></picture>
|
||||
: item.inputProvenance?.type === 'text'
|
||||
? <TextFieldsIcon />
|
||||
: item.inputProvenance?.type === 'youtube'
|
||||
? <YouTubeIcon />
|
||||
: undefined;
|
||||
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
onClick={props.onClick}
|
||||
sx={{
|
||||
'&:hover > button': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
{/* Symbol or Thumbnail picture */}
|
||||
<ListItemDecorator>
|
||||
{props.isSelection ? (
|
||||
<Checkbox checked={props.isSelected} />
|
||||
) : icon}
|
||||
</ListItemDecorator>
|
||||
|
||||
<Box sx={{ overflow: 'hidden' }}>
|
||||
|
||||
{/* Title or System prompt (ellipsized) */}
|
||||
<Typography level='title-sm' sx={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
|
||||
{item.name || (item.systemPrompt?.slice(0, 40) + '...')}
|
||||
</Typography>
|
||||
|
||||
{/* creation Model */}
|
||||
{/*{!!item.llmLabel && <Typography level='body-xs' sx={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>*/}
|
||||
{/* {item.llmLabel}*/}
|
||||
{/*</Typography>}*/}
|
||||
|
||||
{/* creation Date */}
|
||||
<Typography level='body-xs'>
|
||||
{!!item.creationDate && <TimeAgo date={item.creationDate} />}
|
||||
</Typography>
|
||||
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Delete Arming */}
|
||||
{!props.isSelection && !deleteArmed && (
|
||||
<IconButton
|
||||
variant={isActive ? 'solid' : 'outlined'}
|
||||
size='sm'
|
||||
sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.2s' }}
|
||||
onClick={() => setDeleteArmed(on => !on)}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Delete / Cancel buttons */}
|
||||
{!props.isSelection && deleteArmed && <>
|
||||
<IconButton size='sm' variant='solid' color='danger' onClick={() => props.onDelete(item.id)}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
<IconButton size='sm' variant='solid' color='neutral' onClick={() => setDeleteArmed(false)}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>}
|
||||
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, Textarea, Typography } from '@mui/joy';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
|
||||
import { lineHeightTextarea } from '~/common/app.theme';
|
||||
|
||||
import type { SimplePersonaProvenance } from '../store-app-personas';
|
||||
|
||||
|
||||
// minimum number of characters required to create from text
|
||||
const MIN_CHARS = 100;
|
||||
|
||||
|
||||
export function FromText(props: {
|
||||
isCreating: boolean;
|
||||
onCreate: (text: string, provenance: SimplePersonaProvenance) => void;
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [text, setText] = React.useState('');
|
||||
|
||||
const handleCreateFromText = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault(); // stop the form submit
|
||||
props.onCreate(text, { type: 'text' });
|
||||
};
|
||||
|
||||
return <>
|
||||
|
||||
<Typography level='title-md' startDecorator={<TextFieldsIcon />} sx={{ mb: 3 }}>
|
||||
<b>Text</b> -> Persona
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleCreateFromText}>
|
||||
<Textarea
|
||||
required
|
||||
variant='outlined'
|
||||
minRows={4} maxRows={8}
|
||||
placeholder='Paste your text (e.g. tweets, social media, etc.) here...'
|
||||
value={text}
|
||||
onChange={event => setText(event.target.value)}
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
'&:focus-within': {
|
||||
backgroundColor: 'background.popup',
|
||||
},
|
||||
lineHeight: lineHeightTextarea,
|
||||
mb: 1.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button
|
||||
type='submit' variant='solid'
|
||||
disabled={props.isCreating || text?.length < MIN_CHARS}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
<Typography level='body-sm'>
|
||||
{text.length < MIN_CHARS ? `(${MIN_CHARS - text.length})` : text.length.toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Button, Card, IconButton, Input, Typography } from '@mui/joy';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||
|
||||
import { useYouTubeTranscript, YTVideoTranscript } from '~/modules/youtube/useYouTubeTranscript';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
|
||||
import type { SimplePersonaProvenance } from '../store-app-personas';
|
||||
|
||||
|
||||
function extractVideoID(videoURL: string): string | null {
|
||||
const regExp = /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^#&?]*).*/;
|
||||
const match = videoURL.match(regExp);
|
||||
return (match && match[1]?.length == 11) ? match[1] : null;
|
||||
}
|
||||
|
||||
|
||||
function YouTubeVideoTranscriptCard(props: { transcript: YTVideoTranscript, onClose: () => void, sx?: SxProps }) {
|
||||
const { transcript } = props;
|
||||
return (
|
||||
<Card
|
||||
variant='soft'
|
||||
sx={{
|
||||
border: '1px dashed',
|
||||
borderColor: 'neutral.solidBg',
|
||||
p: 1,
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{!!transcript.thumbnailUrl && (
|
||||
<picture style={{ lineHeight: 0 }}>
|
||||
<img
|
||||
src={transcript.thumbnailUrl}
|
||||
alt='YouTube Video Thumbnail'
|
||||
height={80}
|
||||
style={{ float: 'left', marginRight: 8 }}
|
||||
/>
|
||||
</picture>
|
||||
)}
|
||||
|
||||
{/*<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>*/}
|
||||
<Typography level='title-sm'>
|
||||
{transcript?.title}
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ mt: 0.75 }}>
|
||||
{transcript?.transcript.slice(0, 280)}...
|
||||
</Typography>
|
||||
{/*</Box>*/}
|
||||
|
||||
<IconButton
|
||||
size='sm'
|
||||
onClick={props.onClose}
|
||||
sx={{
|
||||
position: 'absolute', top: -8, right: -8,
|
||||
borderRadius: 'md',
|
||||
}}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function FromYouTube(props: {
|
||||
isTransforming: boolean;
|
||||
onCreate: (text: string, provenance: SimplePersonaProvenance) => void;
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [videoURL, setVideoURL] = React.useState('');
|
||||
const [videoID, setVideoID] = React.useState<string | null>(null);
|
||||
|
||||
// external state
|
||||
|
||||
const { onCreate } = props;
|
||||
const onNewTranscript = React.useCallback((transcript: YTVideoTranscript) => {
|
||||
// setVideoID(null); // reset the video ID, to cycle the refetch
|
||||
onCreate(
|
||||
transcript.transcript,
|
||||
{
|
||||
type: 'youtube',
|
||||
url: videoURL,
|
||||
title: transcript.title,
|
||||
thumbnailUrl: transcript.thumbnailUrl,
|
||||
},
|
||||
);
|
||||
}, [onCreate, videoURL]);
|
||||
|
||||
const {
|
||||
transcript,
|
||||
isFetching, isError, error,
|
||||
} = useYouTubeTranscript(videoID, onNewTranscript);
|
||||
|
||||
|
||||
const handleVideoURLChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setVideoID(null);
|
||||
setVideoURL(e.target.value);
|
||||
};
|
||||
|
||||
const handleCreateFromTranscript = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault(); // stop the form submit
|
||||
|
||||
const videoId = extractVideoID(videoURL) || null;
|
||||
if (!videoId)
|
||||
setVideoURL('Invalid');
|
||||
|
||||
// kick-start the transcript fetch
|
||||
setVideoID(videoId);
|
||||
};
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
<Typography level='title-md' startDecorator={<YouTubeIcon sx={{ color: '#f00' }} />} sx={{ mb: 3 }}>
|
||||
YouTube -> Persona
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleCreateFromTranscript}>
|
||||
<Input
|
||||
required
|
||||
type='url'
|
||||
fullWidth
|
||||
disabled={isFetching || props.isTransforming}
|
||||
variant='outlined'
|
||||
placeholder='YouTube Video URL'
|
||||
value={videoURL}
|
||||
onChange={handleVideoURLChange}
|
||||
sx={{ mb: 1.5 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Button
|
||||
type='submit' variant='solid'
|
||||
disabled={isFetching || props.isTransforming || !videoURL}
|
||||
loading={isFetching}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
<GoodTooltip title='This example comes from the popular Fireship YouTube channel, which presents technical topics with irreverent humor.'>
|
||||
<Button variant='outlined' color='neutral' onClick={() => setVideoURL('https://www.youtube.com/watch?v=M_wZpSEvOkc')}>
|
||||
Example
|
||||
</Button>
|
||||
</GoodTooltip>
|
||||
</Box>
|
||||
</form>
|
||||
|
||||
{isError && (
|
||||
<InlineError error={error} sx={{ mt: 3 }} />
|
||||
)}
|
||||
|
||||
{!!transcript && !!videoID && (
|
||||
<YouTubeVideoTranscriptCard transcript={transcript} onClose={() => setVideoID(null)} sx={{ mt: 3 }} />
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Typography } from '@mui/joy';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
import { PersonaPromptCard } from './Creator';
|
||||
import { useSimplePersona } from '../store-app-personas';
|
||||
|
||||
|
||||
export function Viewer(props: { selectedSimplePersonaId: string }) {
|
||||
|
||||
// external state
|
||||
const { simplePersona } = useSimplePersona(props.selectedSimplePersonaId);
|
||||
|
||||
if (!simplePersona)
|
||||
return <Typography level='body-sm'>Loading Persona...</Typography>;
|
||||
|
||||
return <>
|
||||
|
||||
<Typography level='title-sm'>
|
||||
This <em>System Prompt</em> was created <TimeAgo date={simplePersona.creationDate} />
|
||||
using the <strong>{simplePersona.llmLabel}</strong> model.
|
||||
</Typography>
|
||||
|
||||
<PersonaPromptCard content={simplePersona.systemPrompt || ''} />
|
||||
|
||||
{/* tell about the Provenances */}
|
||||
<Typography level='body-sm' sx={{ mt: 3 }}>
|
||||
{simplePersona.inputProvenance?.type === 'youtube' && <>The source was this YouTube video: <Link href={simplePersona.inputProvenance.url} target='_blank'>{simplePersona.inputProvenance.title}</Link>.</>}
|
||||
{simplePersona.inputProvenance?.type === 'text' && <>The source was a text snippet of {simplePersona.inputText?.length.toLocaleString()} characters.</>}
|
||||
</Typography>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { createBase36Uid } from '~/common/util/textUtils';
|
||||
|
||||
// constraint the max number of saved prompts, to stay below localStorage quota
|
||||
const MAX_SAVED_PROMPTS = 100;
|
||||
|
||||
|
||||
/**
|
||||
* Very simple personas store for the "Persona Creator" - note that we shall
|
||||
* switch to a more complex personas store in the future, as for now we mainly
|
||||
* save system prompts so that we don't lose what was created.
|
||||
*/
|
||||
export interface SimplePersona {
|
||||
id: string;
|
||||
name?: string;
|
||||
systemPrompt: string; // The system prompt is very important and required
|
||||
creationDate: string; // ISO string format
|
||||
pictureUrl?: string; // Optional picture URL
|
||||
// source material
|
||||
inputProvenance?: SimplePersonaProvenance;
|
||||
inputText: string;
|
||||
// llm used
|
||||
llmLabel?: string;
|
||||
}
|
||||
|
||||
export type SimplePersonaProvenance = {
|
||||
type: 'youtube';
|
||||
url: string;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
} | {
|
||||
type: 'text';
|
||||
};
|
||||
|
||||
|
||||
interface AppPersonasStore {
|
||||
|
||||
// state
|
||||
simplePersonas: SimplePersona[];
|
||||
|
||||
// actions
|
||||
prependSimplePersona: (systemPrompt: string, inputText: string, inputProvenance?: SimplePersonaProvenance, llmLabel?: string) => void;
|
||||
deleteSimplePersona: (id: string) => void;
|
||||
deleteSimplePersonas: (ids: Set<string>) => void;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* DO NOT USE outside of this application - this is a very simple store for Personas so that
|
||||
* they're not immediately lost.
|
||||
*/
|
||||
const useAppPersonasStore = create<AppPersonasStore>()(persist(
|
||||
(_set, _get) => ({
|
||||
|
||||
simplePersonas: [],
|
||||
|
||||
prependSimplePersona: (systemPrompt: string, inputText: string, inputProvenance?: SimplePersonaProvenance, llmLabel?: string) =>
|
||||
_set(state => {
|
||||
const newPersona: SimplePersona = {
|
||||
id: createBase36Uid(state.simplePersonas.map(persona => persona.id)),
|
||||
systemPrompt,
|
||||
creationDate: new Date().toISOString(),
|
||||
inputProvenance,
|
||||
// to save bytes, do not save input text when from YouTube
|
||||
inputText: inputProvenance?.type === 'youtube' ? '' : inputText,
|
||||
llmLabel,
|
||||
};
|
||||
return {
|
||||
simplePersonas: [
|
||||
newPersona,
|
||||
...state.simplePersonas.slice(0, MAX_SAVED_PROMPTS - 1),
|
||||
],
|
||||
};
|
||||
}),
|
||||
|
||||
deleteSimplePersona: (simplePersonaId: string) =>
|
||||
_set(state => ({
|
||||
simplePersonas: state.simplePersonas.filter(persona => persona.id !== simplePersonaId),
|
||||
})),
|
||||
|
||||
deleteSimplePersonas: (simplePersonaIds: Set<string>) =>
|
||||
_set(state => ({
|
||||
simplePersonas: state.simplePersonas.filter(persona => !simplePersonaIds.has(persona.id)),
|
||||
})),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-app-personas',
|
||||
version: 1,
|
||||
},
|
||||
));
|
||||
|
||||
export function useSimplePersonas() {
|
||||
const simplePersonas = useAppPersonasStore(state => state.simplePersonas, shallow);
|
||||
return { simplePersonas };
|
||||
}
|
||||
|
||||
export function useSimplePersona(simplePersonaId: string) {
|
||||
const simplePersona = useAppPersonasStore(state => {
|
||||
return state.simplePersonas.find(persona => persona.id === simplePersonaId) ?? null;
|
||||
}, shallow);
|
||||
return { simplePersona };
|
||||
}
|
||||
|
||||
export function prependSimplePersona(systemPrompt: string, inputText: string, inputProvenance?: SimplePersonaProvenance, llmLabel?: string) {
|
||||
useAppPersonasStore.getState().prependSimplePersona(systemPrompt, inputText, inputProvenance, llmLabel);
|
||||
}
|
||||
|
||||
export function deleteSimplePersona(simplePersonaId: string) {
|
||||
useAppPersonasStore.getState().deleteSimplePersona(simplePersonaId);
|
||||
}
|
||||
|
||||
export function deleteSimplePersonas(simplePersonaIds: Set<string>) {
|
||||
useAppPersonasStore.getState().deleteSimplePersonas(simplePersonaIds);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { ProdiaSettings } from '~/modules/t2i/prodia/ProdiaSettings';
|
||||
import { T2ISettings } from '~/modules/t2i/T2ISettings';
|
||||
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { settingsGap } from '~/common/app.theme';
|
||||
import { PreferencesTab } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { AppChatSettingsAI } from './AppChatSettingsAI';
|
||||
@@ -87,7 +87,7 @@ function Topic(props: { title?: string, icon?: string | React.ReactNode, startCo
|
||||
)}
|
||||
|
||||
<AccordionDetails>
|
||||
<Stack sx={{ gap: settingsGap, border: 'none' }}>
|
||||
<Stack sx={{ gap: 'calc(var(--Card-padding) / 2)', border: 'none' }}>
|
||||
{props.children}
|
||||
</Stack>
|
||||
</AccordionDetails>
|
||||
@@ -122,9 +122,6 @@ export function SettingsModal(props: {
|
||||
👉 See Shortcuts
|
||||
</Button>
|
||||
)}
|
||||
sx={{
|
||||
'--Card-padding': { xs: '8px', sm: '16px', lg: '24px' },
|
||||
}}
|
||||
>
|
||||
|
||||
<Divider />
|
||||
@@ -150,13 +147,13 @@ export function SettingsModal(props: {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab disableIndicator value={1} sx={tabFixSx}>Chat</Tab>
|
||||
<Tab disableIndicator value={3} sx={tabFixSx}>Voice</Tab>
|
||||
<Tab disableIndicator value={2} sx={tabFixSx}>Draw</Tab>
|
||||
<Tab disableIndicator value={4} sx={tabFixSx}>Tools</Tab>
|
||||
<Tab disableIndicator value={PreferencesTab.Chat} sx={tabFixSx}>Chat</Tab>
|
||||
<Tab disableIndicator value={PreferencesTab.Voice} sx={tabFixSx}>Voice</Tab>
|
||||
<Tab disableIndicator value={PreferencesTab.Draw} sx={tabFixSx}>Draw</Tab>
|
||||
<Tab disableIndicator value={PreferencesTab.Tools} sx={tabFixSx}>Tools</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value={1} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<TabPanel value={PreferencesTab.Chat} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<Topics>
|
||||
<Topic>
|
||||
<AppChatSettingsUI />
|
||||
@@ -170,7 +167,7 @@ export function SettingsModal(props: {
|
||||
</Topics>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={3} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<TabPanel value={PreferencesTab.Voice} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<Topics>
|
||||
<Topic icon='🎙️' title='Voice settings'>
|
||||
<VoiceSettings />
|
||||
@@ -181,7 +178,7 @@ export function SettingsModal(props: {
|
||||
</Topics>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={2} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<TabPanel value={PreferencesTab.Draw} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<Topics>
|
||||
<Topic>
|
||||
<T2ISettings />
|
||||
@@ -190,12 +187,12 @@ export function SettingsModal(props: {
|
||||
<DallESettings />
|
||||
</Topic>
|
||||
<Topic icon='🖍️️' title='Prodia API' startCollapsed>
|
||||
<ProdiaSettings />
|
||||
<ProdiaSettings noSkipKey />
|
||||
</Topic>
|
||||
</Topics>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={4} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<TabPanel value={PreferencesTab.Tools} variant='outlined' sx={{ p: 'var(--Tabs-gap)', borderRadius: 'md' }}>
|
||||
<Topics>
|
||||
<Topic icon={<SearchIcon />} title='Browsing' startCollapsed>
|
||||
<BrowseSettings />
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from 'react';
|
||||
|
||||
import { FormControl, Typography } from '@mui/joy';
|
||||
import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
|
||||
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
@@ -17,17 +16,12 @@ export function UxLabsSettings() {
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const {
|
||||
labsCalling, labsCameraDesktop, /*labsEnhancedUI,*/ labsSplitBranching,
|
||||
setLabsCalling, setLabsCameraDesktop, /*setLabsEnhancedUI,*/ setLabsSplitBranching,
|
||||
labsCameraDesktop, labsSplitBranching, //labsDrawing,
|
||||
setLabsCameraDesktop, setLabsSplitBranching, //setLabsDrawing,
|
||||
} = useUXLabsStore();
|
||||
|
||||
return <>
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><CallIcon color={labsCalling ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Voice Calls</>} description={labsCalling ? 'Call AGI' : 'Disabled'}
|
||||
checked={labsCalling} onChange={setLabsCalling}
|
||||
/>
|
||||
|
||||
{!isMobile && <FormSwitchControl
|
||||
title={<><AddAPhotoIcon color={labsCameraDesktop ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Webcam</>} description={labsCameraDesktop ? 'Enabled' : 'Disabled'}
|
||||
checked={labsCameraDesktop} onChange={setLabsCameraDesktop}
|
||||
@@ -39,14 +33,14 @@ export function UxLabsSettings() {
|
||||
/>
|
||||
|
||||
{/*<FormSwitchControl*/}
|
||||
{/* title='Enhanced UI' description={labsEnhancedUI ? 'Enabled' : 'Disabled'}*/}
|
||||
{/* checked={labsEnhancedUI} onChange={setLabsEnhancedUI}*/}
|
||||
{/* title={<><AddAPhotoIcon color={labsDrawing ? 'primary' : undefined} sx={{ mr: 0.25 }} /> Drawing</>} description={labsDrawing ? 'Enabled' : 'Disabled'}*/}
|
||||
{/* checked={labsDrawing} onChange={setLabsDrawing}*/}
|
||||
{/*/>*/}
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title='Graduated' />
|
||||
<Typography level='body-xs'>
|
||||
<Link href='https://github.com/enricoros/big-AGI/issues/282' target='_blank'>Persona Creator</Link> · <Link href='https://github.com/enricoros/big-agi/issues/192' target='_blank'>Auto Diagrams</Link> · Imagine · Relative chat size · Text Tools · LLM Overheat
|
||||
<Link href='https://github.com/enricoros/big-AGI/issues/354' target='_blank'>Call AGI</Link> · <Link href='https://github.com/enricoros/big-AGI/issues/282' target='_blank'>Persona Creator</Link> · <Link href='https://github.com/enricoros/big-agi/issues/192' target='_blank'>Auto Diagrams</Link> · Imagine · Relative chat size · Text Tools · LLM Overheat
|
||||
</Typography>
|
||||
</FormControl>
|
||||
|
||||
|
||||
+101
-39
@@ -1,43 +1,68 @@
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
// App icons
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
||||
import Diversity2Icon from '@mui/icons-material/Diversity2';
|
||||
import Diversity2OutlinedIcon from '@mui/icons-material/Diversity2Outlined';
|
||||
import EventNoteIcon from '@mui/icons-material/EventNote';
|
||||
import EventNoteOutlinedIcon from '@mui/icons-material/EventNoteOutlined';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import IosShareIcon from '@mui/icons-material/IosShare';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import IosShareOutlinedIcon from '@mui/icons-material/IosShareOutlined';
|
||||
import TextsmsIcon from '@mui/icons-material/Textsms';
|
||||
import TextsmsOutlinedIcon from '@mui/icons-material/TextsmsOutlined';
|
||||
import WorkspacesIcon from '@mui/icons-material/Workspaces';
|
||||
import WorkspacesOutlinedIcon from '@mui/icons-material/WorkspacesOutlined';
|
||||
// Link icons
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import { DiscordIcon } from '~/common/components/icons/DiscordIcon';
|
||||
// Modal icons
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { DiscordIcon } from '~/common/components/icons/DiscordIcon';
|
||||
import { hasNoChatLinkItems } from '~/modules/trade/link/store-link';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
// enable to show all items, for layout development
|
||||
const SHOW_ALL_APPS = false;
|
||||
|
||||
const SPECIAL_DIVIDER = '__DIVIDER__';
|
||||
|
||||
|
||||
// Nav items
|
||||
|
||||
interface ItemBase {
|
||||
name: string,
|
||||
icon: FunctionComponent,
|
||||
iconActive?: FunctionComponent,
|
||||
tooltip?: string,
|
||||
}
|
||||
|
||||
export interface NavItemApp extends ItemBase {
|
||||
type: 'app',
|
||||
route: string,
|
||||
drawer?: string | true, // true: can make use of the drawer, string: also set the title
|
||||
landingRoute?: string, // specify a different route than the nextjs page router route, to land to
|
||||
barTitle?: string, // set to override the name as the bar title (unless custom bar content is used)
|
||||
hideOnMobile?: boolean, // set to true to hide the icon on mobile, unless this is the active app
|
||||
hideIcon?: boolean
|
||||
| (() => boolean), // set to true to hide the icon, unless this is the active app
|
||||
hideBar?: boolean, // set to true to hide the page bar
|
||||
hideNav?: boolean, // set to hide the Nav bar (note: must have a way to navigate back)
|
||||
automatic?: boolean, // only accessible by the machine
|
||||
hideDrawer?: boolean, // set to true to hide the drawer
|
||||
hideNav?: boolean
|
||||
| (() => boolean), // set to hide the Nav bar (note: must have a way to navigate back)
|
||||
fullWidth?: boolean, // set to true to override the user preference
|
||||
hide?: boolean, // delete from the UI
|
||||
_delete?: boolean, // delete from the UI
|
||||
}
|
||||
|
||||
export interface NavItemModal extends ItemBase {
|
||||
@@ -57,8 +82,8 @@ export interface NavItemExtLink extends ItemBase {
|
||||
|
||||
|
||||
export const navItems: {
|
||||
apps: NavItemApp[]
|
||||
modals: NavItemModal[]
|
||||
apps: NavItemApp[],
|
||||
modals: NavItemModal[],
|
||||
links: NavItemExtLink[],
|
||||
} = {
|
||||
|
||||
@@ -66,73 +91,98 @@ export const navItems: {
|
||||
apps: [
|
||||
{
|
||||
name: 'Chat',
|
||||
icon: TelegramIcon,
|
||||
icon: TextsmsOutlinedIcon,
|
||||
iconActive: TextsmsIcon,
|
||||
type: 'app',
|
||||
route: '/',
|
||||
drawer: true,
|
||||
},
|
||||
{
|
||||
name: 'Call',
|
||||
icon: CallIcon,
|
||||
barTitle: 'Voice Calls',
|
||||
icon: CallOutlinedIcon,
|
||||
iconActive: CallIcon,
|
||||
type: 'app',
|
||||
route: '/call',
|
||||
drawer: 'Recent Calls',
|
||||
automatic: true,
|
||||
hideDrawer: true,
|
||||
fullWidth: true,
|
||||
},
|
||||
{
|
||||
name: 'Draw',
|
||||
icon: FormatPaintIcon,
|
||||
barTitle: 'Generate Images',
|
||||
icon: FormatPaintOutlinedIcon,
|
||||
iconActive: FormatPaintIcon,
|
||||
type: 'app',
|
||||
route: '/draw',
|
||||
hide: true,
|
||||
// hideOnMobile: true,
|
||||
hideDrawer: true,
|
||||
hideIcon: () => !useUXLabsStore.getState().labsDrawing,
|
||||
},
|
||||
{
|
||||
name: 'Cortex',
|
||||
icon: AutoAwesomeIcon,
|
||||
icon: AutoAwesomeOutlinedIcon,
|
||||
iconActive: AutoAwesomeIcon,
|
||||
type: 'app',
|
||||
route: '/cortex',
|
||||
automatic: true,
|
||||
hide: true,
|
||||
_delete: true,
|
||||
},
|
||||
{
|
||||
name: 'Patterns',
|
||||
icon: AccountTreeIcon,
|
||||
icon: AccountTreeOutlinedIcon,
|
||||
iconActive: AccountTreeIcon,
|
||||
type: 'app',
|
||||
route: '/patterns',
|
||||
hide: true,
|
||||
_delete: true,
|
||||
},
|
||||
{
|
||||
name: 'Workspace',
|
||||
icon: WorkspacesIcon,
|
||||
icon: WorkspacesOutlinedIcon,
|
||||
iconActive: WorkspacesIcon,
|
||||
type: 'app',
|
||||
route: '/workspace',
|
||||
hide: true,
|
||||
_delete: true,
|
||||
},
|
||||
// <-- divider here -->
|
||||
{
|
||||
name: SPECIAL_DIVIDER,
|
||||
type: 'app',
|
||||
route: SPECIAL_DIVIDER,
|
||||
icon: () => null,
|
||||
},
|
||||
{
|
||||
name: 'Personas',
|
||||
icon: Diversity2Icon,
|
||||
icon: Diversity2OutlinedIcon,
|
||||
iconActive: Diversity2Icon,
|
||||
type: 'app',
|
||||
route: '/personas',
|
||||
hideBar: true,
|
||||
},
|
||||
{
|
||||
name: 'Media Library',
|
||||
icon: ImageOutlinedIcon,
|
||||
iconActive: ImageIcon,
|
||||
type: 'app',
|
||||
route: '/media',
|
||||
_delete: true,
|
||||
},
|
||||
{
|
||||
name: 'Shared Chat',
|
||||
icon: IosShareOutlinedIcon,
|
||||
iconActive: IosShareIcon,
|
||||
type: 'app',
|
||||
route: '/link/chat/[chatLinkId]',
|
||||
landingRoute: '/link/chat/list',
|
||||
hideOnMobile: true,
|
||||
hideIcon: hasNoChatLinkItems,
|
||||
hideNav: hasNoChatLinkItems,
|
||||
},
|
||||
{
|
||||
name: 'News',
|
||||
icon: EventNoteIcon,
|
||||
icon: EventNoteOutlinedIcon,
|
||||
iconActive: EventNoteIcon,
|
||||
type: 'app',
|
||||
route: '/news',
|
||||
hideBar: true,
|
||||
},
|
||||
|
||||
// non-user-selectable ('automatic') Apps
|
||||
{
|
||||
name: 'Shared Chat',
|
||||
icon: IosShareIcon,
|
||||
type: 'app',
|
||||
route: '/link/chat/[chatLinkId]',
|
||||
drawer: 'Shared Chats',
|
||||
automatic: true,
|
||||
hideNav: true,
|
||||
hideDrawer: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -177,4 +227,16 @@ export const navItems: {
|
||||
};
|
||||
|
||||
// apply UI filtering right away - do it here, once, and for all
|
||||
navItems.apps = navItems.apps.filter(app => !app.hide || SHOW_ALL_APPS);
|
||||
navItems.apps = navItems.apps.filter(app => !app._delete || SHOW_ALL_APPS);
|
||||
|
||||
export function checkDivider(app?: NavItemApp) {
|
||||
return app?.name === SPECIAL_DIVIDER;
|
||||
}
|
||||
|
||||
export function checkVisibileIcon(app: NavItemApp, isMobile: boolean, currentApp?: NavItemApp) {
|
||||
return app.hideOnMobile && isMobile ? false : app === currentApp ? true : typeof app.hideIcon === 'function' ? !app.hideIcon() : !app.hideIcon;
|
||||
}
|
||||
|
||||
export function checkVisibleNav(app?: NavItemApp) {
|
||||
return !app ? false : typeof app.hideNav === 'function' ? !app.hideNav() : !app.hideNav;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import Router, { useRouter } from 'next/router';
|
||||
|
||||
import type { AppCallIntent } from '../apps/call/AppCall';
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { isBrowser } from './util/pwaUtils';
|
||||
|
||||
@@ -13,7 +14,7 @@ import { isBrowser } from './util/pwaUtils';
|
||||
export const ROUTE_INDEX = '/';
|
||||
export const ROUTE_APP_CHAT = '/';
|
||||
export const ROUTE_APP_CALL = '/call';
|
||||
export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId';
|
||||
export const ROUTE_APP_LINK_CHAT = '/link/chat/[chatLinkId]';
|
||||
export const ROUTE_APP_NEWS = '/news';
|
||||
export const ROUTE_APP_PERSONAS = '/personas';
|
||||
const ROUTE_CALLBACK_OPENROUTER = '/link/callback_openrouter';
|
||||
@@ -33,7 +34,8 @@ export const getCallbackUrl = (source: 'openrouter') => {
|
||||
return callbackUrl.toString();
|
||||
};
|
||||
|
||||
export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT.replace(':linkId', chatLinkId);
|
||||
export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT
|
||||
.replace('[chatLinkId]', chatLinkId);
|
||||
|
||||
export function useRouterQuery<TQuery>(): TQuery {
|
||||
const { query } = useRouter();
|
||||
@@ -54,6 +56,8 @@ export const navigateToNews = navigateFn(ROUTE_APP_NEWS);
|
||||
|
||||
export const navigateToPersonas = navigateFn(ROUTE_APP_PERSONAS);
|
||||
|
||||
export const navigateToChatLinkList = navigateFn(ROUTE_APP_LINK_CHAT.replace('[chatLinkId]', 'list'));
|
||||
|
||||
export const navigateBack = Router.back;
|
||||
|
||||
export const reloadPage = () => isBrowser && window.location.reload();
|
||||
@@ -83,10 +87,6 @@ export const launchAppChat = async (conversationId?: DConversationId) => {
|
||||
);
|
||||
};
|
||||
|
||||
export interface AppCallQueryParams {
|
||||
conversationId: string;
|
||||
personaId: string;
|
||||
}
|
||||
|
||||
export function launchAppCall(conversationId: string, personaId: string) {
|
||||
void Router.push(
|
||||
@@ -95,7 +95,8 @@ export function launchAppCall(conversationId: string, personaId: string) {
|
||||
query: {
|
||||
conversationId,
|
||||
personaId,
|
||||
} satisfies AppCallQueryParams,
|
||||
backTo: 'app-chat',
|
||||
} satisfies AppCallIntent,
|
||||
},
|
||||
// ROUTE_APP_CALL,
|
||||
).then();
|
||||
|
||||
@@ -9,8 +9,7 @@ export const hideOnMobile = { display: { xs: 'none', md: 'flex' } };
|
||||
// export const hideOnDesktop = { display: { xs: 'flex', md: 'none' } };
|
||||
|
||||
// Dimensions
|
||||
export const settingsGap = 2;
|
||||
export const settingsCol1Width = 150;
|
||||
export const formLabelStartWidth = 150;
|
||||
|
||||
|
||||
// Theme & Fonts
|
||||
|
||||
@@ -33,6 +33,7 @@ export function CloseableMenu(props: {
|
||||
noBottomPadding?: boolean,
|
||||
sx?: SxProps,
|
||||
zIndex?: number,
|
||||
listRef?: React.Ref<HTMLUListElement>,
|
||||
children?: React.ReactNode,
|
||||
}) {
|
||||
|
||||
@@ -71,6 +72,7 @@ export function CloseableMenu(props: {
|
||||
>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList
|
||||
ref={props.listRef}
|
||||
// variant={props.variant} color={props.color}
|
||||
onKeyDown={handleListKeyDown}
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import Input, { InputProps } from '@mui/joy/Input';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
|
||||
type DebounceInputProps = Omit<InputProps, 'onChange'> & {
|
||||
minChars?: number;
|
||||
onDebounce: (value: string) => void;
|
||||
debounceTimeout: number;
|
||||
};
|
||||
|
||||
const DebounceInput: React.FC<DebounceInputProps> = ({
|
||||
minChars,
|
||||
onDebounce,
|
||||
debounceTimeout,
|
||||
...rest
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = React.useState('');
|
||||
const timerRef = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
setInputValue(newValue); // Update internal state immediately for a responsive UI
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Don't call onDebounce if the input value is too short
|
||||
if (newValue && minChars && newValue?.length < minChars)
|
||||
return;
|
||||
onDebounce(newValue); // Call onDebounce after the debounce timeout
|
||||
}, debounceTimeout);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClear = () => {
|
||||
setInputValue(''); // Clear internal state
|
||||
onDebounce(''); // Call onDebounce with empty string
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...rest}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
aria-label={rest['aria-label'] || 'Search'}
|
||||
startDecorator={<SearchIcon />}
|
||||
endDecorator={
|
||||
inputValue && (
|
||||
<ClearIcon
|
||||
onClick={handleClear}
|
||||
tabIndex={0}
|
||||
onKeyPress={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
handleClear();
|
||||
}
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(DebounceInput);
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Card, Link as MuiLink, Typography } from '@mui/joy';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
|
||||
|
||||
export const GitHubProjectIssueCard = (props: {
|
||||
issue: number,
|
||||
text: string,
|
||||
note?: string | React.ReactNode,
|
||||
note2?: string | React.ReactNode,
|
||||
sx?: SxProps
|
||||
}) =>
|
||||
<Card variant='outlined' color='primary' sx={props.sx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<GitHubIcon />
|
||||
<Typography level='body-sm'>
|
||||
<MuiLink overlay href={`https://github.com/enricoros/big-AGI/issues/${props.issue}`} target='_blank'>
|
||||
big-AGI #{props.issue}
|
||||
</MuiLink>
|
||||
{' · '}{props.text}.
|
||||
</Typography>
|
||||
</Box>
|
||||
{!!props.note && (
|
||||
<Typography level='body-sm' sx={{ mt: 1 }}>
|
||||
{props.note}
|
||||
</Typography>
|
||||
)}
|
||||
{!!props.note2 && (
|
||||
<Typography level='body-sm' sx={{ mt: 1 }}>
|
||||
{props.note2}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>;
|
||||
@@ -23,20 +23,20 @@ export function GoodModal(props: {
|
||||
const showBottomClose = !!props.onClose && props.hideBottomClose !== true;
|
||||
return (
|
||||
<Modal open={props.open} onClose={props.onClose}>
|
||||
<ModalOverflow sx={{p:1}}>
|
||||
<ModalOverflow sx={{ p: 1 }}>
|
||||
<ModalDialog
|
||||
sx={{
|
||||
minWidth: { xs: 360, sm: 500, md: 600, lg: 700 },
|
||||
maxWidth: 700,
|
||||
display: 'flex', flexDirection: 'column', gap: 3,
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--Card-padding)',
|
||||
...props.sx,
|
||||
}}>
|
||||
|
||||
{!props.noTitleBar && <Box sx={{ mb: -1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography level={props.strongerTitle !== true ? 'title-md' : 'title-lg'} startDecorator={props.titleStartDecorator}>
|
||||
{!props.noTitleBar && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography component='h1' level={props.strongerTitle !== true ? 'title-md' : 'title-lg'} startDecorator={props.titleStartDecorator}>
|
||||
{props.title || ''}
|
||||
</Typography>
|
||||
{!!props.onClose && <ModalClose sx={{ position: 'static', mr: -1 }} />}
|
||||
{!!props.onClose && <ModalClose aria-label='Close Dialog' sx={{ position: 'static', my: -1, mr: -0.5 }} />}
|
||||
</Box>}
|
||||
|
||||
{props.dividers === true && <Divider />}
|
||||
@@ -47,7 +47,7 @@ export function GoodModal(props: {
|
||||
|
||||
{(!!props.startButton || showBottomClose) && <Box sx={{ mt: 'auto', display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'space-between' }}>
|
||||
{props.startButton}
|
||||
{showBottomClose && <Button variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
|
||||
{showBottomClose && <Button aria-label='Close Dialog' variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
|
||||
Close
|
||||
</Button>}
|
||||
</Box>}
|
||||
|
||||
@@ -17,9 +17,14 @@ export const GoodTooltip = (props: {
|
||||
<Tooltip
|
||||
title={props.title}
|
||||
placement={props.placement}
|
||||
disableInteractive
|
||||
variant={(props.isError || props.isWarning) ? 'soft' : undefined}
|
||||
color={props.isError ? 'danger' : props.isWarning ? 'warning' : undefined}
|
||||
sx={{ maxWidth: { sm: '50vw', md: '25vw' }, ...props.sx }}
|
||||
sx={{
|
||||
maxWidth: { sm: '50vw', md: '25vw' },
|
||||
whiteSpace: 'break-spaces',
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Tooltip>;
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
export function InlineTextarea(props: {
|
||||
initialText: string, placeholder?: string,
|
||||
initialText: string,
|
||||
placeholder?: string,
|
||||
invertedColors?: boolean,
|
||||
onEdit: (text: string) => void,
|
||||
onCancel?: () => void,
|
||||
sx?: SxProps,
|
||||
@@ -35,7 +37,8 @@ export function InlineTextarea(props: {
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
variant='soft' color='warning'
|
||||
variant={props.invertedColors ? 'plain' : 'soft'}
|
||||
color={props.invertedColors ? 'primary' : 'warning'}
|
||||
autoFocus
|
||||
minRows={1}
|
||||
placeholder={props.placeholder}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SxProps } from '@mui/joy/styles/types';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { settingsCol1Width } from '~/common/app.theme';
|
||||
import { formLabelStartWidth } from '~/common/app.theme';
|
||||
|
||||
|
||||
/**
|
||||
@@ -23,7 +23,7 @@ export const FormLabelStart = (props: {
|
||||
<FormLabel
|
||||
onClick={props.onClick}
|
||||
sx={{
|
||||
minWidth: settingsCol1Width,
|
||||
minWidth: formLabelStartWidth,
|
||||
...(!!props.onClick && { cursor: 'pointer', textDecoration: 'underline' }),
|
||||
...props.sx,
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user