mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-11 06:00:15 -07:00
Compare commits
164 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 |
@@ -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.
|
||||
- [ ] ...
|
||||
|
||||
@@ -21,6 +21,19 @@ shows the current developments and future ideas.
|
||||
- Got a suggestion? [_Add your roadmap ideas_](https://github.com/enricoros/big-agi/issues/new?&template=roadmap-request.md)
|
||||
- Want to contribute? [_Pick up a task!_](https://github.com/users/enricoros/projects/4/views/4) - _easy_ to _pro_
|
||||
|
||||
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
|
||||
|
||||
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
|
||||
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
|
||||
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
|
||||
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
|
||||
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
|
||||
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
|
||||
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
|
||||
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
|
||||
|
||||
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
|
||||
@@ -34,8 +47,6 @@ https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cf
|
||||
|
||||
### What's New in 1.10.0 · Jan 6, 2024 · The Year of AGI
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/fbb1be49-5c38-49c8-86fa-3705700f6c39
|
||||
|
||||
- **New UI**: for both desktop and mobile, sets the stage for future scale. [#201](https://github.com/enricoros/big-AGI/issues/201)
|
||||
- **Conversation Folders**: enhanced conversation organization. [#321](https://github.com/enricoros/big-AGI/issues/321)
|
||||
- **[LM Studio](https://lmstudio.ai/)** support and improved token management
|
||||
@@ -43,16 +54,6 @@ https://github.com/enricoros/big-AGI/assets/32999/fbb1be49-5c38-49c8-86fa-370570
|
||||
- Large performance optimizations
|
||||
- Developer enhancements: new UI framework, updated documentation for proxy settings on browserless/docker
|
||||
|
||||
### What's New in 1.9.0 · Dec 28, 2023 · Creative Horizons
|
||||
|
||||
- **DALL·E 3 integration** for enhanced image generation. [#212](https://github.com/enricoros/big-AGI/issues/212)
|
||||
- **Perfect scrolling mechanics** across devices. [#304](https://github.com/enricoros/big-AGI/issues/304)
|
||||
- Persona creation now supports **text input**. [#287](https://github.com/enricoros/big-AGI/pull/287)
|
||||
- Openrouter updates for better model management and rate limit handling
|
||||
- Image drawing UX improvements
|
||||
- Layout fix for Firefox users
|
||||
- Developer enhancements: Text2Image subsystem, Optima layout, ScrollToBottom library, Panes library, and Llms subsystem updates.
|
||||
|
||||
For full details and former releases, check out the [changelog](docs/changelog.md).
|
||||
|
||||
## ✨ Key Features 👊
|
||||
@@ -112,7 +113,7 @@ after installing the required dependencies.
|
||||
```bash
|
||||
# .. repeat the steps above up to `npm install`, then:
|
||||
npm run build
|
||||
npm run start --port 3000
|
||||
next start --port 3000
|
||||
```
|
||||
|
||||
The app will be running on the specified port, e.g. `http://localhost:3000`.
|
||||
|
||||
+18
-3
@@ -5,16 +5,31 @@ by release.
|
||||
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
### 1.12.0 - Jan 2024
|
||||
### 1.13.0 - Feb 2024
|
||||
|
||||
- milestone: [1.12.0](https://github.com/enricoros/big-agi/milestone/12)
|
||||
- milestone: [1.13.0](https://github.com/enricoros/big-agi/milestone/13)
|
||||
- work in progress: [big-AGI open roadmap](https://github.com/users/enricoros/projects/4/views/2), [help here](https://github.com/users/enricoros/projects/4/views/4)
|
||||
|
||||
## What's New in 1.12.0 · Jan 26, 2024 · AGI Hotline
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/32999/95ceb03c-945d-4fdd-9a9f-3317beb54f3f
|
||||
|
||||
- **Voice Calls**: real-time voice call your personas out of the blue or in relation to a chat [#354](https://github.com/enricoros/big-AGI/issues/354)
|
||||
- Support **OpenAI 0125** Models. [#364](https://github.com/enricoros/big-AGI/issues/364)
|
||||
- Rename or Auto-Rename chats. [#222](https://github.com/enricoros/big-AGI/issues/222), [#360](https://github.com/enricoros/big-AGI/issues/360)
|
||||
- More control over **Link Sharing** [#356](https://github.com/enricoros/big-AGI/issues/356)
|
||||
- **Accessibility** to screen readers [#358](https://github.com/enricoros/big-AGI/issues/358)
|
||||
- Export chats to Markdown [#337](https://github.com/enricoros/big-AGI/issues/337)
|
||||
- Paste tables from Excel [#286](https://github.com/enricoros/big-AGI/issues/286)
|
||||
- Ollama model updates and context window detection fixes [#309](https://github.com/enricoros/big-AGI/issues/309)
|
||||
|
||||
### What's New in 1.11.0 · Jan 16, 2024 · Singularity
|
||||
|
||||
https://github.com/enricoros/big-AGI/assets/1590910/a6b8e172-0726-4b03-a5e5-10cfcb110c68
|
||||
|
||||
- **Find chats**: search in titles and content, with frequency ranking. [#329](https://github.com/enricoros/big-AGI/issues/329)
|
||||
- **Commands**: command auto-completion (type '/'). [#327](https://github.com/enricoros/big-AGI/issues/327)
|
||||
- **[Together AI](https://www.together.ai/products#inference)** inference platform support. [#346](https://github.com/enricoros/big-AGI/issues/346)
|
||||
- **[Together AI](https://www.together.ai/products#inference)** inference platform support (good speed and newer models). [#346](https://github.com/enricoros/big-AGI/issues/346)
|
||||
- Persona Creator history, deletion, custom creation, fix llm API timeouts
|
||||
- Enable adding up to five custom OpenAI-compatible endpoints
|
||||
- Developer enhancements: new 'Actiles' framework
|
||||
|
||||
@@ -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.11.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"env:pull": "npx vercel env pull .env.development.local",
|
||||
"postinstall": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio"
|
||||
"db:studio": "prisma studio",
|
||||
"vercel:env:pull": "npx vercel env pull .env.development.local"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dqbd/tiktoken": "^1.0.7",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.2",
|
||||
"@mui/joy": "^5.0.0-beta.20",
|
||||
"@next/bundle-analyzer": "^14.0.4",
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@mui/icons-material": "^5.15.6",
|
||||
"@mui/joy": "^5.0.0-beta.24",
|
||||
"@next/bundle-analyzer": "^14.1.0",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@t3-oss/env-nextjs": "^0.8.0",
|
||||
"@tanstack/react-query": "~4.36.1",
|
||||
"@trpc/client": "10.44.1",
|
||||
"@trpc/next": "10.44.1",
|
||||
"@trpc/react-query": "10.44.1",
|
||||
"@trpc/server": "10.44.1",
|
||||
"@vercel/analytics": "^1.1.1",
|
||||
"@vercel/speed-insights": "^1.0.2",
|
||||
"@vercel/analytics": "^1.1.2",
|
||||
"@vercel/speed-insights": "^1.0.8",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"eventsource-parser": "^1.1.1",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"next": "^14.0.4",
|
||||
"next": "^14.1.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pdfjs-dist": "4.0.269",
|
||||
"pdfjs-dist": "4.0.379",
|
||||
"plantuml-encoder": "^1.4.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -44,31 +43,32 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-katex": "^3.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^1.0.5",
|
||||
"react-resizable-panels": "^1.0.9",
|
||||
"react-timeago": "^7.2.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"superjson": "^2.2.1",
|
||||
"tesseract.js": "^5.0.4",
|
||||
"tiktoken": "^1.0.11",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.7"
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/puppeteer": "^0.0.5",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/node": "^20.11.7",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.46",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-timeago": "^4.1.7",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"prettier": "^3.1.1",
|
||||
"prisma": "^5.7.1",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prisma": "^5.8.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -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]);
|
||||
}
|
||||
+33
-35
@@ -18,13 +18,13 @@ import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/commo
|
||||
import { GoodPanelResizeHandler } from '~/common/components/panes/GoodPanelResizeHandler';
|
||||
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
|
||||
import { themeBgApp, themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { themeBgAppChatComposer } from '~/common/app.theme';
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
import { useOptimaLayout, usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './components/composer/composer.types';
|
||||
import { ChatDrawerContentMemo } from './components/applayout/ChatDrawerItems';
|
||||
import { ChatDrawerMemo } from './components/applayout/ChatDrawer';
|
||||
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
|
||||
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
@@ -63,7 +63,7 @@ export function AppChat() {
|
||||
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
|
||||
const showNextTitle = React.useRef(false);
|
||||
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const [_selectedFolderId, setSelectedFolderId] = React.useState<string | null>(null);
|
||||
const [_activeFolderId, setActiveFolderId] = React.useState<string | null>(null);
|
||||
|
||||
// external state
|
||||
const theme = useTheme();
|
||||
@@ -101,13 +101,12 @@ export function AppChat() {
|
||||
|
||||
const { mayWork: capabilityHasT2I } = useCapabilityTextToImage();
|
||||
|
||||
const { folderConversationsCount, selectedFolderId } = useFolderStore(state => {
|
||||
const selectedFolderId = state.useFolders ? _selectedFolderId : null;
|
||||
const { activeFolderId, activeFolderConversationsCount } = useFolderStore(({ enableFolders, folders }) => {
|
||||
const activeFolderId = enableFolders ? _activeFolderId : null;
|
||||
const activeFolder = activeFolderId ? folders.find(folder => folder.id === activeFolderId) : null;
|
||||
return {
|
||||
folderConversationsCount: selectedFolderId
|
||||
? state.folders.find(folder => folder.id === selectedFolderId)?.conversationIds.length || 0
|
||||
: conversationsLength,
|
||||
selectedFolderId,
|
||||
activeFolderId: activeFolder?.id ?? null,
|
||||
activeFolderConversationsCount: activeFolder ? activeFolder.conversationIds.length : conversationsLength,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -147,7 +146,7 @@ export function AppChat() {
|
||||
|
||||
// Execution
|
||||
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
const chatLLMId = getChatLLMId();
|
||||
if (!chatModeId || !conversationId || !chatLLMId) return;
|
||||
|
||||
@@ -248,8 +247,9 @@ export function AppChat() {
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
|
||||
const handleConversationExecuteHistory = React.useCallback(async (conversationId: DConversationId, history: DMessage[]): Promise<void> => {
|
||||
await _handleExecute('generate-text', conversationId, history);
|
||||
}, [_handleExecute]);
|
||||
|
||||
const handleMessageRegenerateLast = React.useCallback(async () => {
|
||||
const focusedConversation = getConversation(focusedConversationId);
|
||||
@@ -262,9 +262,9 @@ export function AppChat() {
|
||||
}
|
||||
}, [focusedConversationId, _handleExecute]);
|
||||
|
||||
const handleTextDiagram = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
|
||||
const handleTextDiagram = React.useCallback((diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig), []);
|
||||
|
||||
const handleTextImagine = async (conversationId: DConversationId, messageText: string) => {
|
||||
const handleTextImagine = React.useCallback(async (conversationId: DConversationId, messageText: string): Promise<void> => {
|
||||
const conversation = getConversation(conversationId);
|
||||
if (!conversation)
|
||||
return;
|
||||
@@ -273,11 +273,11 @@ export function AppChat() {
|
||||
...conversation.messages,
|
||||
createDMessage('user', imaginedPrompt),
|
||||
]);
|
||||
};
|
||||
}, [_handleExecute]);
|
||||
|
||||
const handleTextSpeak = async (text: string) => {
|
||||
const handleTextSpeak = React.useCallback(async (text: string): Promise<void> => {
|
||||
await speakText(text);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Chat actions
|
||||
|
||||
@@ -289,18 +289,18 @@ export function AppChat() {
|
||||
: prependNewConversation(focusedSystemPurposeId ?? undefined);
|
||||
setFocusedConversationId(conversationId);
|
||||
|
||||
// if a folder is selected, add the new conversation to the folder
|
||||
if (selectedFolderId && conversationId)
|
||||
useFolderStore.getState().addConversationToFolder(selectedFolderId, conversationId);
|
||||
// if a folder is active, add the new conversation to the folder
|
||||
if (activeFolderId && conversationId)
|
||||
useFolderStore.getState().addConversationToFolder(activeFolderId, conversationId);
|
||||
|
||||
// focus the composer
|
||||
composerTextAreaRef.current?.focus();
|
||||
|
||||
}, [focusedSystemPurposeId, newConversationId, prependNewConversation, selectedFolderId, setFocusedConversationId]);
|
||||
}, [activeFolderId, focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
|
||||
|
||||
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
|
||||
const handleConversationImportDialog = React.useCallback(() => setTradeConfig({ dir: 'import' }), []);
|
||||
|
||||
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
|
||||
const handleConversationExport = React.useCallback((conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId }), []);
|
||||
|
||||
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
|
||||
showNextTitle.current = true;
|
||||
@@ -331,13 +331,13 @@ export function AppChat() {
|
||||
}
|
||||
}, [clearConversationId, setMessages]);
|
||||
|
||||
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
|
||||
const handleConversationClear = React.useCallback((conversationId: DConversationId) => setClearConversationId(conversationId), []);
|
||||
|
||||
const handleConfirmedDeleteConversation = () => {
|
||||
if (deleteConversationId) {
|
||||
let nextConversationId: DConversationId | null;
|
||||
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
|
||||
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, selectedFolderId);
|
||||
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined, activeFolderId);
|
||||
else
|
||||
nextConversationId = deleteConversation(deleteConversationId);
|
||||
setFocusedConversationId(nextConversationId);
|
||||
@@ -345,7 +345,7 @@ export function AppChat() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
|
||||
const handleConversationsDeleteAll = React.useCallback(() => setDeleteConversationId(SPECIAL_ID_WIPE_ALL), []);
|
||||
|
||||
const handleConversationDelete = React.useCallback(
|
||||
(conversationId: DConversationId, bypassConfirmation: boolean) => {
|
||||
@@ -372,7 +372,7 @@ export function AppChat() {
|
||||
['d', true, false, true, () => focusedConversationId && handleConversationDelete(focusedConversationId, false)],
|
||||
[ShortcutKeyName.Left, true, false, true, () => handleNavigateHistory('back')],
|
||||
[ShortcutKeyName.Right, true, false, true, () => handleNavigateHistory('forward')],
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
], [focusedConversationId, handleConversationBranch, handleConversationClear, handleConversationDelete, handleConversationNew, handleMessageRegenerateLast, handleNavigateHistory, handleOpenChatLlmOptions, isFocusedChatEmpty]);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
// Pluggable ApplicationBar components
|
||||
@@ -387,8 +387,9 @@ export function AppChat() {
|
||||
);
|
||||
|
||||
const drawerContent = React.useMemo(() =>
|
||||
<ChatDrawerContentMemo
|
||||
<ChatDrawerMemo
|
||||
activeConversationId={focusedConversationId}
|
||||
activeFolderId={activeFolderId}
|
||||
disableNewButton={isFocusedChatEmpty}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
@@ -396,10 +397,9 @@ export function AppChat() {
|
||||
onConversationImportDialog={handleConversationImportDialog}
|
||||
onConversationNew={handleConversationNew}
|
||||
onConversationsDeleteAll={handleConversationsDeleteAll}
|
||||
selectedFolderId={selectedFolderId}
|
||||
setSelectedFolderId={setSelectedFolderId}
|
||||
setActiveFolderId={setActiveFolderId}
|
||||
/>,
|
||||
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, selectedFolderId, setFocusedConversationId],
|
||||
[activeFolderId, focusedConversationId, handleConversationDelete, handleConversationExport, handleConversationImportDialog, handleConversationNew, handleConversationsDeleteAll, isFocusedChatEmpty, setFocusedConversationId],
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() =>
|
||||
@@ -411,10 +411,9 @@ export function AppChat() {
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationClear={handleConversationClear}
|
||||
onConversationExport={handleConversationExport}
|
||||
onConversationFlatten={handleConversationFlatten}
|
||||
/>,
|
||||
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
|
||||
[areChatsEmpty, focusedConversationId, handleConversationBranch, handleConversationClear, isFocusedChatEmpty, isMessageSelectionMode],
|
||||
);
|
||||
|
||||
usePluggableOptimaLayout(drawerContent, centerItems, menuItems, 'AppChat');
|
||||
@@ -474,7 +473,6 @@ export function AppChat() {
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={{
|
||||
backgroundColor: themeBgApp,
|
||||
minHeight: '100%', // ensures filling of the blank space on newer chats
|
||||
}}
|
||||
/>
|
||||
@@ -555,10 +553,10 @@ export function AppChat() {
|
||||
{!!deleteConversationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? `Are you absolutely sure you want to delete ${selectedFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
|
||||
? `Are you absolutely sure you want to delete ${activeFolderId ? 'ALL conversations in this folder' : 'ALL conversations'}? This action cannot be undone.`
|
||||
: 'Are you sure you want to delete this conversation?'}
|
||||
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? `Yes, delete all ${folderConversationsCount} conversations`
|
||||
? `Yes, delete all ${activeFolderConversationsCount} conversations`
|
||||
: 'Delete conversation'}
|
||||
/>}
|
||||
</>;
|
||||
|
||||
@@ -9,7 +9,7 @@ export const CommandsReact: ICommandsProvider = {
|
||||
getCommands: () => [{
|
||||
primary: '/react',
|
||||
arguments: ['prompt'],
|
||||
description: 'Use the AI ReAct strategy to answer your query (as sidebar)',
|
||||
description: 'Use the AI ReAct strategy to answer your query',
|
||||
Icon: PsychologyIcon,
|
||||
}],
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+148
-78
@@ -1,78 +1,88 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, IconButton, ListDivider, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
|
||||
import { Box, IconButton, ListDivider, ListItem, ListItemButton, ListItemDecorator, Tooltip } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import FolderOpenOutlinedIcon from '@mui/icons-material/FolderOpenOutlined';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
|
||||
import { DFolder, useFoldersToggle, useFolderStore } from '~/common/state/store-folders';
|
||||
import DebounceInput from '~/common/components/DebounceInput';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DFolder, useFolderStore } from '~/common/state/store-folders';
|
||||
import { PageDrawerHeader } from '~/common/layout/optima/components/PageDrawerHeader';
|
||||
import { PageDrawerList, PageDrawerTallItemSx } from '~/common/layout/optima/components/PageDrawerList';
|
||||
import { conversationTitle, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
import DebounceInput from '~/common/components/DebounceInput';
|
||||
|
||||
import { ChatDrawerItemMemo, ChatNavigationItemData, FolderChangeRequest } from './ChatDrawerItem';
|
||||
import { ChatFolderList } from './folder/ChatFolderList';
|
||||
import { ChatDrawerItemMemo, ChatNavigationItemData } from './ChatNavigationItem';
|
||||
import { ClearFolderText } from './folder/useFolderDropdown';
|
||||
|
||||
|
||||
// type ListGrouping = 'off' | 'persona';
|
||||
// this is here to make shallow comparisons work on the next hook
|
||||
const noFolders: DFolder[] = [];
|
||||
|
||||
/*
|
||||
* Lists folders and returns the active folder
|
||||
*/
|
||||
export const useFolders = (activeFolderId: string | null) => useFolderStore(({ enableFolders, folders, toggleEnableFolders }) => {
|
||||
|
||||
// finds the active folder if any
|
||||
const activeFolder = (enableFolders && activeFolderId)
|
||||
? folders.find(folder => folder.id === activeFolderId) ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
activeFolder,
|
||||
allFolders: enableFolders ? folders : noFolders,
|
||||
enableFolders,
|
||||
toggleEnableFolders,
|
||||
};
|
||||
}, shallow);
|
||||
|
||||
|
||||
/*
|
||||
* Optimization: return a reduced version of the DConversation object for 'Drawer Items' purposes,
|
||||
* to avoid unnecessary re-renders on each new character typed by the assistant
|
||||
*/
|
||||
export const useChatNavigationItems = (activeConversationId: DConversationId | null, folderId: string | null): {
|
||||
chatNavItems: ChatNavigationItemData[],
|
||||
folders: DFolder[],
|
||||
} => {
|
||||
export const useChatNavigationItemsData = (activeFolder: DFolder | null, allFolders: DFolder[], activeConversationId: DConversationId | null): ChatNavigationItemData[] =>
|
||||
useChatStore(({ conversations }) => {
|
||||
|
||||
// monitor folder changes
|
||||
// NOTE: we're not checking for state.useFolders, as we strongly assume folderId to be null when folders are disabled
|
||||
const { currentFolder, folders } = useFolderStore(state => {
|
||||
const currentFolder = folderId ? state.folders.find(_f => _f.id === folderId) ?? null : null;
|
||||
return {
|
||||
folders: state.folders,
|
||||
currentFolder,
|
||||
};
|
||||
}, shallow);
|
||||
const activeConversations = activeFolder
|
||||
? conversations.filter(_c => activeFolder.conversationIds.includes(_c.id))
|
||||
: conversations;
|
||||
|
||||
// transform (folder) selected conversation into optimized 'navigation item' data
|
||||
const chatNavItems: ChatNavigationItemData[] = useChatStore(state => {
|
||||
|
||||
const selectConversations = currentFolder
|
||||
? state.conversations.filter(_c => currentFolder.conversationIds.includes(_c.id))
|
||||
: state.conversations;
|
||||
|
||||
return selectConversations.map(_c => ({
|
||||
return activeConversations.map((_c): ChatNavigationItemData => ({
|
||||
conversationId: _c.id,
|
||||
isActive: _c.id === activeConversationId,
|
||||
isEmpty: !_c.messages.length && !_c.userTitle,
|
||||
title: conversationTitle(_c),
|
||||
folder: !allFolders.length
|
||||
? undefined // don't show folder select if folders are disabled
|
||||
: _c.id === activeConversationId // only show the folder for active conversation(s)
|
||||
? allFolders.find(folder => folder.conversationIds.includes(_c.id)) ?? null
|
||||
: null,
|
||||
messageCount: _c.messages.length,
|
||||
assistantTyping: !!_c.abortController,
|
||||
systemPurposeId: _c.systemPurposeId,
|
||||
}));
|
||||
|
||||
}, (a: ChatNavigationItemData[], b: ChatNavigationItemData[]) => {
|
||||
}, (a, b) => {
|
||||
// custom equality function to avoid unnecessary re-renders
|
||||
return a.length === b.length && a.every((_a, i) => shallow(_a, b[i]));
|
||||
});
|
||||
|
||||
return { chatNavItems, folders };
|
||||
};
|
||||
|
||||
export const ChatDrawerMemo = React.memo(ChatDrawer);
|
||||
|
||||
export const ChatDrawerContentMemo = React.memo(ChatDrawerItems);
|
||||
|
||||
function ChatDrawerItems(props: {
|
||||
function ChatDrawer(props: {
|
||||
activeConversationId: DConversationId | null,
|
||||
activeFolderId: string | null,
|
||||
disableNewButton: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId) => void,
|
||||
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
|
||||
@@ -80,28 +90,26 @@ function ChatDrawerItems(props: {
|
||||
onConversationImportDialog: () => void,
|
||||
onConversationNew: () => void,
|
||||
onConversationsDeleteAll: () => void,
|
||||
selectedFolderId: string | null,
|
||||
setSelectedFolderId: (folderId: string | null) => void,
|
||||
setActiveFolderId: (folderId: string | null) => void,
|
||||
}) {
|
||||
|
||||
const { onConversationActivate, onConversationDelete, onConversationExportDialog, onConversationNew } = props;
|
||||
|
||||
// local state
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = React.useState('');
|
||||
|
||||
// const [grouping] = React.useState<ListGrouping>('off');
|
||||
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
|
||||
const [folderChangeRequest, setFolderChangeRequest] = React.useState<FolderChangeRequest | null>(null);
|
||||
|
||||
// external state
|
||||
const { closeDrawer, closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const { useFolders, toggleUseFolders } = useFoldersToggle();
|
||||
const { chatNavItems, folders } = useChatNavigationItems(props.activeConversationId, props.selectedFolderId);
|
||||
const { activeFolder, allFolders, enableFolders, toggleEnableFolders } = useFolders(props.activeFolderId);
|
||||
const chatNavItems = useChatNavigationItemsData(activeFolder, allFolders, props.activeConversationId);
|
||||
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
||||
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
|
||||
|
||||
// derived state
|
||||
const selectConversationsCount = chatNavItems.length;
|
||||
const nonEmptyChats = selectConversationsCount > 1 || (selectConversationsCount === 1 && !chatNavItems[0].isEmpty);
|
||||
const singleChat = selectConversationsCount === 1;
|
||||
const softMaxReached = selectConversationsCount >= 50;
|
||||
const softMaxReached = selectConversationsCount >= 10;
|
||||
|
||||
|
||||
const handleButtonNew = React.useCallback(() => {
|
||||
@@ -109,17 +117,38 @@ function ChatDrawerItems(props: {
|
||||
closeDrawerOnMobile();
|
||||
}, [closeDrawerOnMobile, onConversationNew]);
|
||||
|
||||
|
||||
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
|
||||
onConversationActivate(conversationId);
|
||||
if (closeMenu)
|
||||
closeDrawerOnMobile();
|
||||
}, [closeDrawerOnMobile, onConversationActivate]);
|
||||
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
|
||||
!singleChat && conversationId && onConversationDelete(conversationId, true);
|
||||
}, [onConversationDelete, singleChat]);
|
||||
|
||||
|
||||
// Folder change request
|
||||
|
||||
const handleConversationFolderChange = React.useCallback((folderChangeRequest: FolderChangeRequest) => setFolderChangeRequest(folderChangeRequest), []);
|
||||
|
||||
const handleConversationFolderCancel = React.useCallback(() => setFolderChangeRequest(null), []);
|
||||
|
||||
const handleConversationFolderSet = React.useCallback((conversationId: DConversationId, nextFolderId: string | null) => {
|
||||
// Remove conversation from existing folders
|
||||
const { addConversationToFolder, folders, removeConversationFromFolder } = useFolderStore.getState();
|
||||
folders.forEach(folder => folder.conversationIds.includes(conversationId) && removeConversationFromFolder(folder.id, conversationId));
|
||||
|
||||
// Add conversation to the selected folder
|
||||
nextFolderId && addConversationToFolder(nextFolderId, conversationId);
|
||||
|
||||
// Close the menu
|
||||
setFolderChangeRequest(null);
|
||||
}, []);
|
||||
|
||||
|
||||
// Filter chatNavItems based on the search query and rank them by search frequency
|
||||
const filteredChatNavItems = React.useMemo(() => {
|
||||
if (!debouncedSearchQuery) return chatNavItems;
|
||||
@@ -172,34 +201,30 @@ function ChatDrawerItems(props: {
|
||||
return <>
|
||||
|
||||
{/* Drawer Header */}
|
||||
<PageDrawerHeader
|
||||
title='Chats'
|
||||
onClose={closeDrawer}
|
||||
startButton={
|
||||
<Tooltip title={useFolders ? 'Hide Folders' : 'Use Folders'}>
|
||||
<IconButton onClick={toggleUseFolders}>
|
||||
{useFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<PageDrawerHeader title='Chats' onClose={closeDrawer}>
|
||||
<Tooltip title={enableFolders ? 'Hide Folders' : 'Use Folders'}>
|
||||
<IconButton onClick={toggleEnableFolders}>
|
||||
{enableFolders ? <FolderOpenOutlinedIcon /> : <FolderOutlinedIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</PageDrawerHeader>
|
||||
|
||||
{/* Folders List */}
|
||||
{/*<Box sx={{*/}
|
||||
{/* display: 'grid',*/}
|
||||
{/* gridTemplateRows: !useFolders ? '0fr' : '1fr',*/}
|
||||
{/* gridTemplateRows: !enableFolders ? '0fr' : '1fr',*/}
|
||||
{/* transition: 'grid-template-rows 0.42s cubic-bezier(.17,.84,.44,1)',*/}
|
||||
{/* '& > div': {*/}
|
||||
{/* padding: useFolders ? 2 : 0,*/}
|
||||
{/* padding: enableFolders ? 2 : 0,*/}
|
||||
{/* transition: 'padding 0.42s cubic-bezier(.17,.84,.44,1)',*/}
|
||||
{/* overflow: 'hidden',*/}
|
||||
{/* },*/}
|
||||
{/*}}>*/}
|
||||
{useFolders && (
|
||||
{enableFolders && (
|
||||
<ChatFolderList
|
||||
folders={folders}
|
||||
selectedFolderId={props.selectedFolderId}
|
||||
onFolderSelect={props.setSelectedFolderId}
|
||||
folders={allFolders}
|
||||
activeFolderId={props.activeFolderId}
|
||||
onFolderSelect={props.setActiveFolderId}
|
||||
/>
|
||||
)}
|
||||
{/*</Box>*/}
|
||||
@@ -207,10 +232,11 @@ function ChatDrawerItems(props: {
|
||||
{/* Chats List */}
|
||||
<PageDrawerList variant='plain' noTopPadding noBottomPadding tallRows>
|
||||
|
||||
{useFolders && <ListDivider sx={{ mb: 0 }} />}
|
||||
{enableFolders && <ListDivider sx={{ mb: 0 }} />}
|
||||
|
||||
{/* Search Input Field */}
|
||||
<DebounceInput
|
||||
minChars={2}
|
||||
onDebounce={setDebouncedSearchQuery}
|
||||
debounceTimeout={300}
|
||||
placeholder='Search...'
|
||||
@@ -218,22 +244,24 @@ function ChatDrawerItems(props: {
|
||||
sx={{ m: 2 }}
|
||||
/>
|
||||
|
||||
<ListItemButton disabled={props.disableNewButton} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
|
||||
<ListItemDecorator><AddIcon /></ListItemDecorator>
|
||||
<Box sx={{
|
||||
// style
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'lg',
|
||||
// content
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}>
|
||||
New chat
|
||||
{/*<KeyStroke combo='Ctrl + Alt + N' sx={props.disableNewButton ? { opacity: 0.5 } : undefined} />*/}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
|
||||
<ListItemButton disabled={props.disableNewButton} onClick={handleButtonNew} sx={PageDrawerTallItemSx}>
|
||||
<ListItemDecorator><AddIcon /></ListItemDecorator>
|
||||
<Box sx={{
|
||||
// style
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'lg',
|
||||
// content
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1,
|
||||
}}>
|
||||
New chat
|
||||
{/*<KeyStroke combo='Ctrl + Alt + N' sx={props.disableNewButton ? { opacity: 0.5 } : undefined} />*/}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
||||
{/*<ListDivider sx={{ mt: 0 }} />*/}
|
||||
|
||||
@@ -258,16 +286,17 @@ function ChatDrawerItems(props: {
|
||||
item={item}
|
||||
isLonely={singleChat}
|
||||
showSymbols={showSymbols}
|
||||
bottomBarBasis={(labsEnhancedUI || softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
|
||||
bottomBarBasis={(softMaxReached || debouncedSearchQuery) ? bottomBarBasis : 0}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
onConversationExport={onConversationExportDialog}
|
||||
onConversationFolderChange={handleConversationFolderChange}
|
||||
/>)}
|
||||
</Box>
|
||||
|
||||
<ListDivider sx={{ mt: 0 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
|
||||
<ListItemButton onClick={props.onConversationImportDialog} sx={{ flex: 1 }}>
|
||||
<ListItemDecorator>
|
||||
<FileUploadIcon />
|
||||
@@ -293,5 +322,46 @@ function ChatDrawerItems(props: {
|
||||
|
||||
</PageDrawerList>
|
||||
|
||||
|
||||
{/* [Menu] Chat Item Folder Change */}
|
||||
{!!folderChangeRequest?.anchorEl && (
|
||||
<CloseableMenu
|
||||
open anchorEl={folderChangeRequest.anchorEl} onClose={handleConversationFolderCancel}
|
||||
placement='bottom-start'
|
||||
zIndex={1301 /* need to be on top of the Modal on Mobile */}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
|
||||
{/* Folder Assignment Buttons */}
|
||||
{allFolders.map(folder => {
|
||||
const isRequestFolder = folder === folderChangeRequest.currentFolder;
|
||||
return (
|
||||
<ListItem
|
||||
key={folder.id}
|
||||
variant={isRequestFolder ? 'soft' : 'plain'}
|
||||
onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, folder.id)}
|
||||
>
|
||||
<ListItemButton>
|
||||
<ListItemDecorator>
|
||||
<FolderIcon sx={{ color: folder.color }} />
|
||||
</ListItemDecorator>
|
||||
{folder.title}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Remove Folder Assignment */}
|
||||
{!!folderChangeRequest.currentFolder && (
|
||||
<ListItem onClick={() => handleConversationFolderSet(folderChangeRequest.conversationId, null)}>
|
||||
<ListItemButton>
|
||||
{ClearFolderText}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
</CloseableMenu>
|
||||
)}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Avatar, Box, Divider, IconButton, ListItem, ListItemButton, ListItemDecorator, Sheet, styled, Tooltip, Typography } from '@mui/joy';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { conversationAutoTitle } from '~/modules/aifn/autotitle/autoTitle';
|
||||
|
||||
import type { DFolder } from '~/common/state/store-folders';
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
|
||||
|
||||
// set to true to display the conversation IDs
|
||||
// const DEBUG_CONVERSATION_IDS = false;
|
||||
|
||||
|
||||
export const FadeInButton = styled(IconButton)({
|
||||
opacity: 0.5,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': { opacity: 1 },
|
||||
});
|
||||
|
||||
|
||||
export const ChatDrawerItemMemo = React.memo(ChatDrawerItem);
|
||||
|
||||
export interface ChatNavigationItemData {
|
||||
conversationId: DConversationId;
|
||||
isActive: boolean;
|
||||
isEmpty: boolean;
|
||||
title: string;
|
||||
folder: DFolder | null | undefined; // null: 'All', undefined: do not show folder select
|
||||
messageCount: number;
|
||||
assistantTyping: boolean;
|
||||
systemPurposeId: SystemPurposeId;
|
||||
searchFrequency?: number;
|
||||
}
|
||||
|
||||
export interface FolderChangeRequest {
|
||||
conversationId: DConversationId;
|
||||
anchorEl: HTMLButtonElement;
|
||||
currentFolder: DFolder | null;
|
||||
}
|
||||
|
||||
function ChatDrawerItem(props: {
|
||||
item: ChatNavigationItemData,
|
||||
isLonely: boolean,
|
||||
showSymbols: boolean,
|
||||
bottomBarBasis: number,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
onConversationExport: (conversationId: DConversationId) => void,
|
||||
onConversationFolderChange: (folderChangeRequest: FolderChangeRequest) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
// derived state
|
||||
const { onConversationExport, onConversationFolderChange } = props;
|
||||
const { conversationId, isActive, title, folder, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
|
||||
const isNew = messageCount === 0;
|
||||
|
||||
|
||||
// [effect] auto-disarm when inactive
|
||||
const shallClose = deleteArmed && !isActive;
|
||||
React.useEffect(() => {
|
||||
if (shallClose)
|
||||
setDeleteArmed(false);
|
||||
}, [shallClose]);
|
||||
|
||||
|
||||
// Activate
|
||||
|
||||
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
|
||||
|
||||
|
||||
// export
|
||||
|
||||
const handleConversationExport = React.useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
conversationId && onConversationExport(conversationId);
|
||||
}, [conversationId, onConversationExport]);
|
||||
|
||||
|
||||
// Folder change
|
||||
|
||||
const handleFolderChangeBegin = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onConversationFolderChange({
|
||||
conversationId,
|
||||
anchorEl: event.currentTarget,
|
||||
currentFolder: folder ?? null,
|
||||
});
|
||||
}, [conversationId, folder, onConversationFolderChange]);
|
||||
|
||||
|
||||
// Title Edit
|
||||
|
||||
const handleTitleEditBegin = React.useCallback(() => setIsEditingTitle(true), []);
|
||||
|
||||
const handleTitleEditCancel = React.useCallback(() => {
|
||||
setIsEditingTitle(false);
|
||||
}, []);
|
||||
|
||||
const handleTitleEditChange = React.useCallback((text: string) => {
|
||||
setIsEditingTitle(false);
|
||||
useChatStore.getState().setUserTitle(conversationId, text.trim());
|
||||
}, [conversationId]);
|
||||
|
||||
const handleTitleEditAuto = React.useCallback(() => {
|
||||
conversationAutoTitle(conversationId, true);
|
||||
}, [conversationId]);
|
||||
|
||||
|
||||
// Delete
|
||||
|
||||
const handleDeleteButtonShow = React.useCallback(() => setDeleteArmed(true), []);
|
||||
|
||||
const handleDeleteButtonHide = React.useCallback(() => setDeleteArmed(false), []);
|
||||
|
||||
const handleConversationDelete = React.useCallback((event: React.MouseEvent) => {
|
||||
if (deleteArmed) {
|
||||
setDeleteArmed(false);
|
||||
event.stopPropagation();
|
||||
props.onConversationDelete(conversationId);
|
||||
}
|
||||
}, [conversationId, deleteArmed, props]);
|
||||
|
||||
|
||||
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
|
||||
const progress = props.bottomBarBasis ? 100 * (searchFrequency ?? messageCount) / props.bottomBarBasis : 0;
|
||||
|
||||
|
||||
const titleRowComponent = React.useMemo(() => <>
|
||||
|
||||
{/* Symbol, if globally enabled */}
|
||||
{props.showSymbols && <ListItemDecorator>
|
||||
{assistantTyping
|
||||
? (
|
||||
<Avatar
|
||||
alt='typing' variant='plain'
|
||||
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
|
||||
sx={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography>
|
||||
{isNew ? '' : textSymbol}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItemDecorator>}
|
||||
|
||||
{/* Title */}
|
||||
{!isEditingTitle ? (
|
||||
<Typography
|
||||
// level={isActive ? 'title-md' : 'body-md'}
|
||||
onDoubleClick={handleTitleEditBegin}
|
||||
sx={{
|
||||
color: isActive ? 'text.primary' : 'text.secondary',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/*{DEBUG_CONVERSATION_IDS && `${conversationId} - `}*/}
|
||||
{title.trim() ? title : 'Chat'}{assistantTyping && '...'}
|
||||
</Typography>
|
||||
) : (
|
||||
<InlineTextarea
|
||||
invertedColors
|
||||
initialText={title}
|
||||
onEdit={handleTitleEditChange}
|
||||
onCancel={handleTitleEditCancel}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
ml: -1.5, mr: -0.5,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Display search frequency if it exists and is greater than 0 */}
|
||||
{searchFrequency && searchFrequency > 0 && (
|
||||
<Box sx={{ ml: 1 }}>
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
</>, [assistantTyping, handleTitleEditBegin, handleTitleEditCancel, handleTitleEditChange, isActive, isEditingTitle, isNew, props.showSymbols, searchFrequency, textSymbol, title]);
|
||||
|
||||
const progressBarFixedComponent = React.useMemo(() =>
|
||||
progress > 0 && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'neutral.softBg',
|
||||
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
|
||||
}} />
|
||||
), [progress]);
|
||||
|
||||
|
||||
return isActive ? (
|
||||
|
||||
// Active Conversation
|
||||
<Sheet
|
||||
variant={isActive ? 'solid' : 'plain'}
|
||||
invertedColors={isActive}
|
||||
sx={{
|
||||
// common
|
||||
'--ListItem-minHeight': '2.75rem',
|
||||
position: 'relative', // for the progress bar
|
||||
// '--variant-borderWidth': '0.125rem',
|
||||
border: 'none', // there's a default border of 1px and invisible.. hmm
|
||||
// style
|
||||
borderRadius: 'md',
|
||||
mx: '0.25rem',
|
||||
'&:hover > button': {
|
||||
opacity: 1, // fade in buttons when hovering, but by default wash them out a bit
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
<ListItem sx={{ border: 'none', display: 'grid', gap: 0, px: 'calc(var(--ListItem-paddingX) - 0.25rem)' }}>
|
||||
|
||||
{/* Title row */}
|
||||
<Box sx={{ display: 'flex', gap: 'var(--ListItem-gap)', minHeight: '2.25rem', alignItems: 'center' }}>
|
||||
|
||||
{titleRowComponent}
|
||||
|
||||
</Box>
|
||||
|
||||
{/* buttons row */}
|
||||
<Box sx={{ display: 'flex', gap: 1, minHeight: '2.25rem', alignItems: 'center' }}>
|
||||
|
||||
<ListItemDecorator />
|
||||
|
||||
{/* Current Folder color, and change initiator */}
|
||||
{(folder !== undefined) && <>
|
||||
<Tooltip disableInteractive title={folder ? `Change Folder (${folder.title})` : 'Add to Folder'}>
|
||||
{folder ? (
|
||||
<IconButton size='sm' onClick={handleFolderChangeBegin}>
|
||||
<FolderIcon style={{ color: folder.color || 'inherit' }} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<FadeInButton size='sm' onClick={handleFolderChangeBegin}>
|
||||
<FolderOutlinedIcon />
|
||||
</FadeInButton>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />
|
||||
</>}
|
||||
|
||||
<Tooltip disableInteractive title='Rename'>
|
||||
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditBegin}>
|
||||
<EditIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
{!isNew && <>
|
||||
<Tooltip disableInteractive title='Auto-Title'>
|
||||
<FadeInButton size='sm' disabled={isEditingTitle} onClick={handleTitleEditAuto}>
|
||||
<AutoFixHighIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
|
||||
<Divider orientation='vertical' sx={{ my: 1, opacity: 0.5 }} />
|
||||
|
||||
<Tooltip disableInteractive title='Export'>
|
||||
<FadeInButton size='sm' onClick={handleConversationExport}>
|
||||
<FileDownloadOutlinedIcon />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
</>}
|
||||
|
||||
|
||||
{/* --> */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
{/* Delete [armed, arming] buttons */}
|
||||
{!props.isLonely && !searchFrequency && <>
|
||||
{deleteArmed && (
|
||||
<Tooltip disableInteractive title='Confirm Deletion'>
|
||||
<FadeInButton key='btn-del' variant='solid' color='success' size='sm' onClick={handleConversationDelete} sx={{ opacity: 1 }}>
|
||||
<DeleteForeverIcon sx={{ color: 'danger.solidBg' }} />
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip disableInteractive title={deleteArmed ? 'Cancel Delete' : 'Delete'}>
|
||||
<FadeInButton key='btn-arm' size='sm' onClick={deleteArmed ? handleDeleteButtonHide : handleDeleteButtonShow} sx={deleteArmed ? { opacity: 1 } : {}}>
|
||||
{deleteArmed ? <CloseIcon /> : <DeleteOutlineIcon />}
|
||||
</FadeInButton>
|
||||
</Tooltip>
|
||||
</>}
|
||||
|
||||
</Box>
|
||||
|
||||
</ListItem>
|
||||
|
||||
{/* Optional progress bar, underlay */}
|
||||
{progressBarFixedComponent}
|
||||
|
||||
</Sheet>
|
||||
|
||||
) : (
|
||||
|
||||
// Inactive Conversation - click to activate
|
||||
<ListItem sx={{ '--ListItem-minHeight': '2.75rem' }}>
|
||||
|
||||
<ListItemButton
|
||||
onClick={handleConversationActivate}
|
||||
sx={{
|
||||
border: 'none', // there's a default border of 1px and invisible.. hmm
|
||||
position: 'relative', // for the progress bar
|
||||
}}
|
||||
>
|
||||
|
||||
{titleRowComponent}
|
||||
|
||||
{/* Optional progress bar, underlay */}
|
||||
{progressBarFixedComponent}
|
||||
|
||||
</ListItemButton>
|
||||
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,12 @@ import CheckBoxOutlineBlankOutlinedIcon from '@mui/icons-material/CheckBoxOutlin
|
||||
import CheckBoxOutlinedIcon from '@mui/icons-material/CheckBoxOutlined';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
|
||||
import { useChatShowSystemMessages } from '../../store-app-chat';
|
||||
|
||||
@@ -25,13 +23,11 @@ export function ChatMenuItems(props: {
|
||||
setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => void,
|
||||
onConversationClear: (conversationId: DConversationId) => void,
|
||||
onConversationExport: (conversationId: DConversationId | null) => void,
|
||||
onConversationFlatten: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { closePageMenu } = useOptimaDrawers();
|
||||
const { touch: shareTouch } = useUICounter('export-share');
|
||||
const [showSystemMessages, setShowSystemMessages] = useChatShowSystemMessages();
|
||||
|
||||
// derived state
|
||||
@@ -53,12 +49,6 @@ export function ChatMenuItems(props: {
|
||||
props.conversationId && props.onConversationBranch(props.conversationId, null);
|
||||
};
|
||||
|
||||
const handleConversationExport = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.onConversationExport(!disabled ? props.conversationId : null);
|
||||
shareTouch();
|
||||
};
|
||||
|
||||
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationFlatten(props.conversationId);
|
||||
@@ -107,13 +97,6 @@ export function ChatMenuItems(props: {
|
||||
</span>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={!props.hasConversations} onClick={handleConversationExport}>
|
||||
<ListItemDecorator>
|
||||
<FileDownloadIcon />
|
||||
</ListItemDecorator>
|
||||
Share / Export ...
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={disabled} onClick={handleConversationClear}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Avatar, Box, IconButton, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
|
||||
|
||||
const DEBUG_CONVERSATION_IDs = false;
|
||||
|
||||
|
||||
export const ChatDrawerItemMemo = React.memo(ChatNavigationItem);
|
||||
|
||||
export interface ChatNavigationItemData {
|
||||
conversationId: DConversationId;
|
||||
isActive: boolean;
|
||||
isEmpty: boolean;
|
||||
title: string;
|
||||
messageCount: number;
|
||||
assistantTyping: boolean;
|
||||
systemPurposeId: SystemPurposeId;
|
||||
searchFrequency?: number;
|
||||
}
|
||||
|
||||
function ChatNavigationItem(props: {
|
||||
item: ChatNavigationItemData,
|
||||
isLonely: boolean,
|
||||
showSymbols: boolean,
|
||||
bottomBarBasis: number,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
// derived state
|
||||
const { conversationId, isActive, title, messageCount, assistantTyping, systemPurposeId, searchFrequency } = props.item;
|
||||
const isNew = messageCount === 0;
|
||||
|
||||
// auto-close the arming menu when clicking away
|
||||
// NOTE: there currently is a bug (race condition) where the menu closes on a new item right after opening
|
||||
// because the isActive prop is not yet updated
|
||||
React.useEffect(() => {
|
||||
if (deleteArmed && !isActive)
|
||||
setDeleteArmed(false);
|
||||
}, [deleteArmed, isActive]);
|
||||
|
||||
|
||||
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
|
||||
|
||||
const handleTitleEdit = () => setIsEditingTitle(true);
|
||||
|
||||
const handleTitleEdited = (text: string) => {
|
||||
setIsEditingTitle(false);
|
||||
useChatStore.getState().setUserTitle(conversationId, text.trim());
|
||||
};
|
||||
|
||||
const handleTitleEditCancel = () => {
|
||||
setIsEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleDeleteButtonShow = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!isActive)
|
||||
props.onConversationActivate(conversationId, false);
|
||||
else
|
||||
setDeleteArmed(true);
|
||||
};
|
||||
|
||||
const handleDeleteButtonHide = () => setDeleteArmed(false);
|
||||
|
||||
const handleConversationDelete = (event: React.MouseEvent) => {
|
||||
if (deleteArmed) {
|
||||
setDeleteArmed(false);
|
||||
event.stopPropagation();
|
||||
props.onConversationDelete(conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
const buttonSx: SxProps = isActive ? { color: 'white' } : {};
|
||||
|
||||
const progress = props.bottomBarBasis ? 100 * (searchFrequency ?? messageCount) / props.bottomBarBasis : 0;
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
variant={isActive ? 'soft' : 'plain'} color='neutral'
|
||||
onClick={!isActive ? handleConversationActivate : event => event.preventDefault()}
|
||||
sx={{
|
||||
// py: 0,
|
||||
position: 'relative',
|
||||
border: 'none', // note, there's a default border of 1px and invisible.. hmm
|
||||
cursor: 'pointer',
|
||||
'&:hover > button': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Optional progress bar, underlay */}
|
||||
{progress > 0 && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'neutral.softBg',
|
||||
position: 'absolute', left: 0, bottom: 0, width: progress + '%', height: 4,
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{props.showSymbols && <ListItemDecorator>
|
||||
{assistantTyping
|
||||
? (
|
||||
<Avatar
|
||||
alt='typing' variant='plain'
|
||||
src='https://i.giphy.com/media/jJxaUysjzO9ri/giphy.webp'
|
||||
sx={{
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Typography>
|
||||
{isNew ? '' : textSymbol}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItemDecorator>}
|
||||
|
||||
|
||||
{/* Text */}
|
||||
{!isEditingTitle ? (
|
||||
|
||||
<Typography
|
||||
level={isActive ? 'title-md' : 'body-md'}
|
||||
onDoubleClick={handleTitleEdit}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
{DEBUG_CONVERSATION_IDs ? conversationId.slice(0, 10) : (title.trim() ? title : 'Chat')}{assistantTyping && '...'}
|
||||
</Typography>
|
||||
|
||||
) : (
|
||||
|
||||
<InlineTextarea initialText={title} onEdit={handleTitleEdited} onCancel={handleTitleEditCancel} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
|
||||
|
||||
)}
|
||||
|
||||
{/* // TODO: Commented code */}
|
||||
{/* Edit */}
|
||||
{/*<IconButton*/}
|
||||
{/* onClick={() => props.onEditTitle(props.conversationId)}*/}
|
||||
{/* sx={{*/}
|
||||
{/* opacity: 0, transition: 'opacity 0.3s', ml: 'auto',*/}
|
||||
{/* }}>*/}
|
||||
{/* <EditIcon />*/}
|
||||
{/*</IconButton>*/}
|
||||
|
||||
{/* Display search frequency if it exists and is greater than 0 */}
|
||||
{searchFrequency && searchFrequency > 0 && (
|
||||
<Box sx={{ ml: 1 }}>
|
||||
<Typography level='body-sm'>
|
||||
{searchFrequency}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete Arming */}
|
||||
{!props.isLonely && !deleteArmed && !searchFrequency && (
|
||||
<IconButton
|
||||
variant={isActive ? 'solid' : 'outlined'}
|
||||
size='sm'
|
||||
sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.2s', ...buttonSx }}
|
||||
onClick={handleDeleteButtonShow}
|
||||
>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Delete / Cancel buttons */}
|
||||
{!props.isLonely && deleteArmed && !searchFrequency && <>
|
||||
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleConversationDelete}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteButtonHide}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>}
|
||||
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,12 @@ import { StrictModeDroppable } from './StrictModeDroppable';
|
||||
|
||||
export function ChatFolderList(props: {
|
||||
folders: DFolder[];
|
||||
activeFolderId: string | null;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
selectedFolderId: string | null;
|
||||
}) {
|
||||
|
||||
// derived props
|
||||
const { folders, onFolderSelect, selectedFolderId } = props;
|
||||
const { folders, onFolderSelect, activeFolderId } = props;
|
||||
|
||||
// handlers
|
||||
|
||||
@@ -72,11 +72,11 @@ export function ChatFolderList(props: {
|
||||
droppableId='folder'
|
||||
renderClone={(provided, snapshot, rubric) => (
|
||||
<FolderListItem
|
||||
activeFolderId={activeFolderId}
|
||||
folder={folders[rubric.source.index]}
|
||||
onFolderSelect={onFolderSelect}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
onFolderSelect={onFolderSelect}
|
||||
selectedFolderId={selectedFolderId}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -91,7 +91,7 @@ export function ChatFolderList(props: {
|
||||
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
|
||||
onFolderSelect(null);
|
||||
}}
|
||||
selected={selectedFolderId === null}
|
||||
selected={!activeFolderId}
|
||||
sx={{
|
||||
border: 0,
|
||||
justifyContent: 'space-between',
|
||||
@@ -114,11 +114,11 @@ export function ChatFolderList(props: {
|
||||
<Draggable key={folder.id} draggableId={folder.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<FolderListItem
|
||||
activeFolderId={activeFolderId}
|
||||
folder={folder}
|
||||
onFolderSelect={onFolderSelect}
|
||||
provided={provided}
|
||||
snapshot={snapshot}
|
||||
onFolderSelect={onFolderSelect}
|
||||
selectedFolderId={selectedFolderId}
|
||||
/>
|
||||
)}
|
||||
</Draggable>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
|
||||
import type { DraggableProvided, DraggableStateSnapshot, DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd';
|
||||
|
||||
import { FormLabel, IconButton, ListItem, ListItemButton, ListItemContent, ListItemDecorator, MenuItem, Radio, radioClasses, RadioGroup, Sheet, Typography } from '@mui/joy';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
@@ -14,17 +14,13 @@ import { DFolder, FOLDERS_COLOR_PALETTE, useFolderStore } from '~/common/state/s
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
|
||||
|
||||
// Define the type for your props if you're using TypeScript
|
||||
type RenderItemProps = {
|
||||
export function FolderListItem(props: {
|
||||
activeFolderId: string | null;
|
||||
folder: DFolder;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
provided: DraggableProvided;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
onFolderSelect: (folderId: string | null) => void;
|
||||
selectedFolderId: string | null;
|
||||
// Include any other props that RenderItem needs
|
||||
};
|
||||
|
||||
export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, snapshot, onFolderSelect, selectedFolderId }) => {
|
||||
}) {
|
||||
|
||||
// internal state
|
||||
const [deleteArmed, setDeleteArmed] = useState(false);
|
||||
@@ -34,6 +30,10 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<null | HTMLAnchorElement>(null);
|
||||
|
||||
|
||||
// derived props
|
||||
const { activeFolderId, folder, onFolderSelect, provided, snapshot } = props;
|
||||
|
||||
|
||||
// Menu
|
||||
const handleMenuOpen = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
setMenuAnchorEl(event.currentTarget);
|
||||
@@ -148,7 +148,7 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
event.stopPropagation(); // Prevent the ListItemButton's onClick from firing
|
||||
handleFolderSelect(folder.id);
|
||||
}}
|
||||
selected={folder.id === selectedFolderId}
|
||||
selected={folder.id === activeFolderId}
|
||||
sx={{
|
||||
border: 0,
|
||||
justifyContent: 'space-between',
|
||||
@@ -200,7 +200,8 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
{!!menuAnchorEl && (
|
||||
<CloseableMenu
|
||||
open anchorEl={menuAnchorEl} onClose={handleMenuClose}
|
||||
placement='top' zIndex={1301 /* need to be on top of the Modal on Mobile */}
|
||||
placement='top'
|
||||
zIndex={1301 /* need to be on top of the Modal on Mobile */}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
|
||||
@@ -316,4 +317,4 @@ export const FolderListItem: React.FC<RenderItemProps> = ({ folder, provided, sn
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import { DropdownItems, PageBarDropdown } from '~/common/layout/optima/component
|
||||
import { useFolderStore } from '~/common/state/store-folders';
|
||||
|
||||
|
||||
const SPECIAL_ID_REMOVE = '_REMOVE_';
|
||||
export const ClearFolderText = 'Clear Folder';
|
||||
const SPECIAL_ID_CLEAR_FOLDER = '_REMOVE_';
|
||||
|
||||
|
||||
export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
|
||||
// external state
|
||||
const { folders, useFolders } = useFolderStore();
|
||||
const { folders, enableFolders } = useFolderStore();
|
||||
|
||||
|
||||
// Prepare items for the dropdown
|
||||
@@ -28,8 +29,8 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
}, {} as DropdownItems);
|
||||
|
||||
// add one item representing no folder
|
||||
items[SPECIAL_ID_REMOVE] = {
|
||||
title: 'No Folder',
|
||||
items[SPECIAL_ID_CLEAR_FOLDER] = {
|
||||
title: ClearFolderText,
|
||||
};
|
||||
|
||||
return items;
|
||||
@@ -46,7 +47,8 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
}
|
||||
});
|
||||
// Add conversation to the selected folder
|
||||
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
|
||||
if (folderId !== SPECIAL_ID_CLEAR_FOLDER)
|
||||
useFolderStore.getState().addConversationToFolder(folderId, conversationId);
|
||||
}
|
||||
}, [conversationId, folders]);
|
||||
|
||||
@@ -57,7 +59,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
const folderDropdown = React.useMemo(() => {
|
||||
|
||||
// don't show the dropdown if folders are not enabled
|
||||
if (!useFolders)
|
||||
if (!enableFolders)
|
||||
return null;
|
||||
|
||||
return (
|
||||
@@ -69,7 +71,7 @@ export function useFolderDropdown(conversationId: DConversationId | null) {
|
||||
showSymbols
|
||||
/>
|
||||
);
|
||||
}, [currentFolderId, folderItems, handleFolderChange, useFolders]);
|
||||
}, [currentFolderId, enableFolders, folderItems, handleFolderChange]);
|
||||
|
||||
return { folderDropdown };
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { ListItemButton, ListItemDecorator } from '@mui/joy';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { PageBarDropdown } from '~/common/layout/optima/components/PageBarDropdown';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
function AppBarPersonaDropdown(props: {
|
||||
systemPurposeId: SystemPurposeId | null,
|
||||
setSystemPurposeId: (systemPurposeId: SystemPurposeId | null) => void,
|
||||
onCall?: () => void,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
@@ -29,23 +23,13 @@ function AppBarPersonaDropdown(props: {
|
||||
|
||||
// options
|
||||
|
||||
let appendOption: React.JSX.Element | undefined = undefined;
|
||||
|
||||
if (props.onCall) {
|
||||
const enableCallOption = !!props.systemPurposeId;
|
||||
appendOption = (
|
||||
<ListItemButton color='primary' disabled={!enableCallOption} key='menu-call-persona' onClick={props.onCall} sx={{ minWidth: 160 }}>
|
||||
<ListItemDecorator><CallIcon color={enableCallOption ? 'primary' : 'warning'} /></ListItemDecorator>
|
||||
Call {!!props.systemPurposeId && SystemPurposes[props.systemPurposeId]?.symbol}
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
// let appendOption: React.JSX.Element | undefined = undefined;
|
||||
|
||||
return (
|
||||
<PageBarDropdown
|
||||
items={SystemPurposes} showSymbols={zenMode !== 'cleaner'}
|
||||
value={props.systemPurposeId} onChange={handleSystemPurposeChange}
|
||||
appendOption={appendOption}
|
||||
// appendOption={appendOption}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -54,7 +38,6 @@ function AppBarPersonaDropdown(props: {
|
||||
export function usePersonaIdDropdown(conversationId: DConversationId | null) {
|
||||
|
||||
// external state
|
||||
const labsCalling = useUXLabsStore(state => state.labsCalling);
|
||||
const { systemPurposeId } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === conversationId);
|
||||
return {
|
||||
@@ -69,12 +52,8 @@ export function usePersonaIdDropdown(conversationId: DConversationId | null) {
|
||||
if (conversationId && systemPurposeId)
|
||||
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
|
||||
}}
|
||||
onCall={labsCalling ? () => {
|
||||
if (conversationId && systemPurposeId)
|
||||
launchAppCall(conversationId, systemPurposeId);
|
||||
} : undefined}
|
||||
/> : null,
|
||||
[conversationId, labsCalling, systemPurposeId],
|
||||
[conversationId, systemPurposeId],
|
||||
);
|
||||
|
||||
return { personaDropdown };
|
||||
|
||||
@@ -3,8 +3,9 @@ import { shallow } from 'zustand/shallow';
|
||||
import { fileOpen, FileWithHandle } from 'browser-fs-access';
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
import { Box, Button, ButtonGroup, Card, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Button, ButtonGroup, Card, Dropdown, Grid, IconButton, Menu, MenuButton, MenuItem, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import AutoModeIcon from '@mui/icons-material/AutoMode';
|
||||
@@ -23,6 +24,7 @@ import type { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vend
|
||||
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { PreferencesTab, useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
@@ -32,7 +34,6 @@ import { supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
import { useDebouncer } from '~/common/components/useDebouncer';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
@@ -46,7 +47,7 @@ import { getTextBlockText, useLLMAttachments } from './attachments/useLLMAttachm
|
||||
import { useAttachments } from './attachments/useAttachments';
|
||||
|
||||
import type { ComposerOutputMultiPart } from './composer.types';
|
||||
import { ButtonAttachCameraMemo } from './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachCameraMemo, useCameraCaptureModal } from './buttons/ButtonAttachCamera';
|
||||
import { ButtonAttachClipboardMemo } from './buttons/ButtonAttachClipboard';
|
||||
import { ButtonAttachFileMemo } from './buttons/ButtonAttachFile';
|
||||
import { ButtonCall } from './buttons/ButtonCall';
|
||||
@@ -59,7 +60,7 @@ import { TokenProgressbarMemo } from './TokenProgressbar';
|
||||
import { useComposerStartupText } from './store-composer';
|
||||
|
||||
|
||||
const animationStopEnter = keyframes`
|
||||
export const animationStopEnter = keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px)
|
||||
@@ -94,9 +95,8 @@ export function Composer(props: {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const { openPreferencesTab, setIsFocusedMode } = useOptimaLayout();
|
||||
const { labsCalling, labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
labsCalling: state.labsCalling,
|
||||
const { openPreferencesTab /*, setIsFocusedMode*/ } = useOptimaLayout();
|
||||
const { labsCameraDesktop } = useUXLabsStore(state => ({
|
||||
labsCameraDesktop: state.labsCameraDesktop,
|
||||
}), shallow);
|
||||
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('generate-text');
|
||||
@@ -130,7 +130,7 @@ export function Composer(props: {
|
||||
const tokensComposerText = React.useMemo(() => {
|
||||
if (!debouncedText || !chatLLMId)
|
||||
return 0;
|
||||
return countModelTokens(debouncedText, chatLLMId, 'composer text');
|
||||
return countModelTokens(debouncedText, chatLLMId, 'composer text') ?? 0;
|
||||
}, [chatLLMId, debouncedText]);
|
||||
let tokensComposer = tokensComposerText + llmAttachments.tokenCountApprox;
|
||||
if (tokensComposer > 0)
|
||||
@@ -181,7 +181,7 @@ export function Composer(props: {
|
||||
|
||||
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
|
||||
const handleDrawOptionsClicked = () => openPreferencesTab(2);
|
||||
const handleDrawOptionsClicked = () => openPreferencesTab(PreferencesTab.Draw);
|
||||
|
||||
const handleTextImagineClicked = () => {
|
||||
if (!composeText || !props.conversationId)
|
||||
@@ -231,14 +231,15 @@ export function Composer(props: {
|
||||
return [providerCommands(onActileCommandSelect)];
|
||||
}, [onActileCommandSelect]);
|
||||
|
||||
const { actileComponent, actileInterceptKeydown } = useActileManager(actileProviders, props.composerTextAreaRef);
|
||||
const { actileComponent, actileInterceptKeydown, actileInterceptTextChange } = useActileManager(actileProviders, props.composerTextAreaRef);
|
||||
|
||||
|
||||
// Text typing
|
||||
|
||||
const handleTextareaTextChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setComposeText(e.target.value);
|
||||
}, [setComposeText]);
|
||||
isMobile && actileInterceptTextChange(e.target.value);
|
||||
}, [actileInterceptTextChange, isMobile, setComposeText]);
|
||||
|
||||
const handleTextareaKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// disable keyboard handling if the actile is visible
|
||||
@@ -265,6 +266,13 @@ export function Composer(props: {
|
||||
}, [actileInterceptKeydown, assistantAbortible, chatModeId, composeText, enterIsNewline, handleSendAction]);
|
||||
|
||||
|
||||
// Focus mode
|
||||
|
||||
// const handleFocusModeOn = React.useCallback(() => setIsFocusedMode(true), [setIsFocusedMode]);
|
||||
|
||||
// const handleFocusModeOff = React.useCallback(() => setIsFocusedMode(false), [setIsFocusedMode]);
|
||||
|
||||
|
||||
// Mic typing & continuation mode
|
||||
|
||||
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
|
||||
@@ -303,7 +311,7 @@ export function Composer(props: {
|
||||
useGlobalShortcut('m', true, false, false, toggleRecording);
|
||||
|
||||
const micIsRunning = !!speechInterimResult;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantAbortible && !isSpeechError;
|
||||
const micColor: ColorPaletteProp = isSpeechError ? 'danger' : isRecordingSpeech ? 'primary' : isRecordingAudio ? 'primary' : 'neutral';
|
||||
const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'soft' : 'soft'; //(isDesktop ? 'soft' : 'plain');
|
||||
|
||||
@@ -333,6 +341,8 @@ export function Composer(props: {
|
||||
void attachAppendFile('camera', file);
|
||||
}, [attachAppendFile]);
|
||||
|
||||
const { openCamera, cameraCaptureComponent } = useCameraCaptureModal(handleAttachCameraImage);
|
||||
|
||||
const handleAttachFilePicker = React.useCallback(async () => {
|
||||
try {
|
||||
const selectedFiles: FileWithHandle[] = await fileOpen({ multiple: true });
|
||||
@@ -427,12 +437,12 @@ export function Composer(props: {
|
||||
: props.isDeveloperMode
|
||||
? 'Chat with me · drop source files · attach code...'
|
||||
: props.capabilityHasT2I
|
||||
? 'Chat · /react · /draw · drop text files...'
|
||||
: 'Chat · /react · drop text files...';
|
||||
? 'Chat · /react · /draw · drop files...'
|
||||
: 'Chat · /react · drop files...';
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={props.sx}>
|
||||
<Box aria-label='User Message' component='section' sx={props.sx}>
|
||||
<Grid container spacing={{ xs: 1, md: 2 }}>
|
||||
|
||||
{/* Button column and composer Text (mobile: top, desktop: left and center) */}
|
||||
@@ -440,19 +450,32 @@ export function Composer(props: {
|
||||
|
||||
{/* Vertical (insert) buttons */}
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
|
||||
{/* [mobile] Mic button */}
|
||||
{isSpeechEnabled && <ButtonMicMemo variant={micVariant} color={micColor} onClick={handleToggleMic} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
<ButtonAttachCameraMemo isMobile onAttachImage={handleAttachCameraImage} />
|
||||
<Dropdown>
|
||||
<MenuButton slots={{ root: IconButton }}>
|
||||
<AddCircleOutlineIcon />
|
||||
</MenuButton>
|
||||
<Menu>
|
||||
{/* Responsive Camera OCR button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachCameraMemo onOpenCamera={openCamera} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Open Files button */}
|
||||
<ButtonAttachFileMemo isMobile onAttachFilePicker={handleAttachFilePicker} />
|
||||
{/* Responsive Open Files button */}
|
||||
<MenuItem>
|
||||
<ButtonAttachFileMemo onAttachFilePicker={handleAttachFilePicker} />
|
||||
</MenuItem>
|
||||
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <ButtonAttachClipboardMemo isMobile onClick={attachAppendClipboardItems} />}
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <MenuItem>
|
||||
<ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />
|
||||
</MenuItem>}
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
|
||||
</Box>
|
||||
) : (
|
||||
@@ -469,7 +492,7 @@ export function Composer(props: {
|
||||
{supportsClipboardRead && <ButtonAttachClipboardMemo onClick={attachAppendClipboardItems} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onAttachImage={handleAttachCameraImage} />}
|
||||
{labsCameraDesktop && <ButtonAttachCameraMemo onOpenCamera={openCamera} />}
|
||||
|
||||
</Box>
|
||||
)}
|
||||
@@ -488,9 +511,11 @@ export function Composer(props: {
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
|
||||
<Textarea
|
||||
variant='outlined' color={isDraw ? 'warning' : isReAct ? 'success' : 'neutral'}
|
||||
variant='outlined'
|
||||
color={isDraw ? 'warning' : isReAct ? 'success' : undefined}
|
||||
autoFocus
|
||||
minRows={isMobile ? 5 : 5} maxRows={10}
|
||||
minRows={isMobile ? 4 : 5}
|
||||
maxRows={isMobile ? 8 : 10}
|
||||
placeholder={textPlaceholder}
|
||||
value={composeText}
|
||||
onChange={handleTextareaTextChange}
|
||||
@@ -498,8 +523,8 @@ export function Composer(props: {
|
||||
onDragStart={handleTextareaDragStart}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
onPasteCapture={handleAttachCtrlV}
|
||||
onFocusCapture={() => setIsFocusedMode(true)}
|
||||
onBlurCapture={() => setIsFocusedMode(false)}
|
||||
// onFocusCapture={handleFocusModeOn}
|
||||
// onBlurCapture={handleFocusModeOff}
|
||||
slotProps={{
|
||||
textarea: {
|
||||
enterKeyHint: enterIsNewline ? 'enter' : 'send',
|
||||
@@ -512,9 +537,7 @@ export function Composer(props: {
|
||||
}}
|
||||
sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
'&:focus-within': {
|
||||
backgroundColor: 'background.popup',
|
||||
},
|
||||
'&:focus-within': { backgroundColor: 'background.popup' },
|
||||
lineHeight: lineHeightTextarea,
|
||||
}} />
|
||||
|
||||
@@ -616,7 +639,7 @@ export function Composer(props: {
|
||||
|
||||
{/* [mobile] bottom-corner secondary button */}
|
||||
{isMobile && (isChat
|
||||
? <ButtonCall isMobile disabled={!labsCalling || !props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
? <ButtonCall isMobile disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: isDraw
|
||||
? <ButtonOptionsDraw isMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: <IconButton disabled sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
@@ -683,7 +706,7 @@ export function Composer(props: {
|
||||
{isDesktop && <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1, justifyContent: 'flex-end' }}>
|
||||
|
||||
{/* [desktop] Call secondary button */}
|
||||
{isChat && <ButtonCall disabled={!labsCalling || !props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
|
||||
{isChat && <ButtonCall disabled={!props.conversationId || !chatLLMId} onClick={handleCallClicked} />}
|
||||
|
||||
{/* [desktop] Draw Options secondary button */}
|
||||
{isDraw && <ButtonOptionsDraw onClick={handleDrawOptionsClicked} />}
|
||||
@@ -703,6 +726,9 @@ export function Composer(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Camera */}
|
||||
{cameraCaptureComponent}
|
||||
|
||||
{/* Actile */}
|
||||
{actileComponent}
|
||||
|
||||
|
||||
@@ -21,19 +21,18 @@ export function ActilePopup(props: {
|
||||
const hasAnyIcon = props.items.some(item => !!item.Icon);
|
||||
|
||||
return (
|
||||
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding>
|
||||
<CloseableMenu open anchorEl={props.anchorEl} onClose={props.onClose} noTopPadding noBottomPadding sx={{ minWidth: 320 }}>
|
||||
|
||||
{!!props.title && (
|
||||
<Sheet variant='soft' sx={{ p: 1, borderBottom: '1px solid', borderBottomColor: 'neutral.softActiveBg' }}>
|
||||
{/*<ListItemDecorator/>*/}
|
||||
<Typography level='title-md'>
|
||||
<Typography level='title-sm'>
|
||||
{props.title}
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{!props.items.length && (
|
||||
<ListItem variant='soft' color='primary'>
|
||||
<ListItem variant='soft' color='warning'>
|
||||
<Typography level='body-md'>
|
||||
No matching command
|
||||
</Typography>
|
||||
@@ -41,35 +40,39 @@ export function ActilePopup(props: {
|
||||
)}
|
||||
|
||||
{props.items.map((item, idx) => {
|
||||
const isActive = idx === props.activeItemIndex;
|
||||
const labelBold = item.label.slice(0, props.activePrefixLength);
|
||||
const labelNormal = item.label.slice(props.activePrefixLength);
|
||||
return (
|
||||
<ListItemButton
|
||||
<ListItem
|
||||
key={item.id}
|
||||
variant={idx === props.activeItemIndex ? 'soft' : undefined}
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
color={isActive ? 'primary' : undefined}
|
||||
onClick={() => props.onItemClick(item)}
|
||||
>
|
||||
{hasAnyIcon && (
|
||||
<ListItemDecorator>
|
||||
{item.Icon ? <item.Icon /> : null}
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
<Box>
|
||||
<ListItemButton>
|
||||
{hasAnyIcon && (
|
||||
<ListItemDecorator>
|
||||
{item.Icon ? <item.Icon /> : null}
|
||||
</ListItemDecorator>
|
||||
)}
|
||||
<Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography level='title-md' color='primary'>
|
||||
<span style={{ fontWeight: 600, textDecoration: 'underline' }}>{labelBold}</span>{labelNormal}
|
||||
</Typography>
|
||||
{item.argument && <Typography level='body-sm'>
|
||||
{item.argument}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography level='title-sm' color={isActive ? 'primary' : undefined}>
|
||||
<span style={{ fontWeight: 600, textDecoration: 'underline' }}>{labelBold}</span>{labelNormal}
|
||||
</Typography>
|
||||
{item.argument && <Typography level='body-sm'>
|
||||
{item.argument}
|
||||
</Typography>}
|
||||
</Box>
|
||||
|
||||
{!!item.description && <Typography level='body-xs'>
|
||||
{item.description}
|
||||
</Typography>}
|
||||
</Box>
|
||||
|
||||
{!!item.description && <Typography level='body-xs'>
|
||||
{item.description}
|
||||
</Typography>}
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ type ActileProviderIds = 'actile-commands' | 'actile-attach-reference';
|
||||
export interface ActileProvider {
|
||||
id: ActileProviderIds;
|
||||
title: string;
|
||||
searchPrefix: string;
|
||||
|
||||
checkTriggerText: (trailingText: string) => boolean;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
//import { ActileItem, ActileProvider } from './ActileProvider';
|
||||
|
||||
|
||||
export const providerAttachReference: ActileProvider = {
|
||||
/*export const providerAttachReference: ActileProvider = {
|
||||
id: 'actile-attach-reference',
|
||||
title: 'Attach Reference',
|
||||
searchPrefix: '@',
|
||||
|
||||
checkTriggerText: (trailingText: string) =>
|
||||
trailingText.endsWith(' @'),
|
||||
@@ -20,4 +21,4 @@ export const providerAttachReference: ActileProvider = {
|
||||
onItemSelect: (item: ActileItem) => {
|
||||
console.log('Selected item:', item);
|
||||
},
|
||||
};
|
||||
};*/
|
||||
@@ -5,6 +5,7 @@ import { findAllChatCommands } from '../../../commands/commands.registry';
|
||||
export const providerCommands = (onItemSelect: (item: ActileItem) => void): ActileProvider => ({
|
||||
id: 'actile-commands',
|
||||
title: 'Chat Commands',
|
||||
searchPrefix: '/',
|
||||
|
||||
checkTriggerText: (trailingText: string) =>
|
||||
trailingText.trim() === '/',
|
||||
|
||||
@@ -40,6 +40,26 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
}, [activeItem, handlePopupItemClicked]);
|
||||
|
||||
|
||||
const actileInterceptTextChange = React.useCallback((trailingText: string) => {
|
||||
for (const provider of providers) {
|
||||
if (provider.checkTriggerText(trailingText)) {
|
||||
setProvider(provider);
|
||||
setPopupOpen(true);
|
||||
setActiveSearchString(provider.searchPrefix);
|
||||
provider
|
||||
.fetchItems()
|
||||
.then(items => setItems(items))
|
||||
.catch(error => {
|
||||
handleClose();
|
||||
console.error('Failed to fetch popup items:', error);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [handleClose, providers]);
|
||||
|
||||
|
||||
const actileInterceptKeydown = React.useCallback((_event: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
|
||||
// Popup open: Intercept
|
||||
@@ -69,32 +89,10 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
}
|
||||
|
||||
// Popup closed: Check for triggers
|
||||
|
||||
// optimization
|
||||
if (key !== '/' && key !== '@')
|
||||
return false;
|
||||
|
||||
const trailingText = (currentTarget.value || '') + key;
|
||||
return actileInterceptTextChange(trailingText);
|
||||
|
||||
// check all rules to find one that triggers
|
||||
for (const provider of providers) {
|
||||
if (provider.checkTriggerText(trailingText)) {
|
||||
setProvider(provider);
|
||||
setPopupOpen(true);
|
||||
setActiveSearchString(key);
|
||||
provider
|
||||
.fetchItems()
|
||||
.then(items => setItems(items))
|
||||
.catch(error => {
|
||||
handleClose();
|
||||
console.error('Failed to fetch popup items:', error);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [activeItems.length, handleClose, handleEnterKey, popupOpen, providers]);
|
||||
}, [actileInterceptTextChange, activeItems.length, handleClose, handleEnterKey, popupOpen]);
|
||||
|
||||
|
||||
const actileComponent = React.useMemo(() => {
|
||||
@@ -114,5 +112,6 @@ export const useActileManager = (providers: ActileProvider[], anchorRef: React.R
|
||||
return {
|
||||
actileComponent,
|
||||
actileInterceptKeydown,
|
||||
actileInterceptTextChange,
|
||||
};
|
||||
};
|
||||
@@ -87,6 +87,13 @@ function attachmentConverterIcon(attachment: Attachment) {
|
||||
}
|
||||
|
||||
function attachmentLabelText(attachment: Attachment): string {
|
||||
const converter = attachment.converterIdx !== null ? attachment.converters[attachment.converterIdx] ?? null : null;
|
||||
if (converter && attachment.label === 'Rich Text') {
|
||||
if (converter.id === 'rich-text-table')
|
||||
return 'Rich Table';
|
||||
if (converter.id === 'rich-text')
|
||||
return 'Rich HTML';
|
||||
}
|
||||
return ellipsizeFront(attachment.label, 24);
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,7 @@ export async function attachmentPerformConversion(attachment: Readonly<Attachmen
|
||||
outputs.push({
|
||||
type: 'text-block',
|
||||
text: input.altData!,
|
||||
title: ref,
|
||||
title: ref || '\n<!DOCTYPE html>',
|
||||
collapsible: true,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
|
||||
|
||||
// see how we fare on budget
|
||||
if (chatLLMId) {
|
||||
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger');
|
||||
const newTextTokens = countModelTokens(newText, chatLLMId, 'reducer trigger') ?? 0;
|
||||
|
||||
// simple trigger for the reduction dialog
|
||||
if (newTextTokens > remainingTokens) {
|
||||
|
||||
@@ -10,6 +10,10 @@ import { getClipboardItems } from '~/common/util/clipboardUtils';
|
||||
import { AttachmentSourceOriginDTO, AttachmentSourceOriginFile, useAttachmentsStore } from './store-attachments';
|
||||
|
||||
|
||||
// enable to debug attachment operations
|
||||
const ATTACHMENTS_DEBUG_INTAKE = false;
|
||||
|
||||
|
||||
export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
|
||||
// state
|
||||
@@ -24,17 +28,30 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
|
||||
// Creation helpers
|
||||
|
||||
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) =>
|
||||
createAttachment({
|
||||
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
|
||||
})
|
||||
, [createAttachment]);
|
||||
const attachAppendFile = React.useCallback((origin: AttachmentSourceOriginFile, fileWithHandle: FileWithHandle, overrideFileName?: string) => {
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log('attachAppendFile', origin, fileWithHandle, overrideFileName);
|
||||
|
||||
return createAttachment({
|
||||
media: 'file', origin, fileWithHandle, refPath: overrideFileName || fileWithHandle.name,
|
||||
});
|
||||
}, [createAttachment]);
|
||||
|
||||
|
||||
const attachAppendDataTransfer = React.useCallback((dt: DataTransfer, method: AttachmentSourceOriginDTO, attachText: boolean): 'as_files' | 'as_url' | 'as_text' | false => {
|
||||
|
||||
// https://github.com/enricoros/big-AGI/issues/286
|
||||
const textHtml = dt.getData('text/html') || '';
|
||||
const heuristicIsExcel = textHtml.includes('"urn:schemas-microsoft-com:office:excel"');
|
||||
// noinspection HttpUrlsUsage
|
||||
const heuristicIsPowerPoint = textHtml.includes('xmlns:m="http://schemas.microsoft.com/office/20') && textHtml.includes('<meta name=Generator content="Microsoft PowerPoint');
|
||||
const heuristicBypassImage = heuristicIsExcel || heuristicIsPowerPoint;
|
||||
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log('attachAppendDataTransfer', dt.types, dt.items, dt.files, textHtml);
|
||||
|
||||
// attach File(s)
|
||||
if (dt.files.length >= 1) {
|
||||
if (dt.files.length >= 1 && !heuristicBypassImage /* special case: ignore images from Microsoft Office pastes (prioritize the HTML paste) */) {
|
||||
// rename files from a common prefix, to better relate them (if the transfer contains a list of paths)
|
||||
let overrideFileNames: string[] = [];
|
||||
if (dt.types.includes('text/plain')) {
|
||||
@@ -68,7 +85,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
}
|
||||
|
||||
// attach as Text/Html (further conversion, e.g. to markdown is done later)
|
||||
const textHtml = dt.getData('text/html') || '';
|
||||
if (attachText && (textHtml || textPlain)) {
|
||||
void createAttachment({
|
||||
media: 'text', method, textPlain, textHtml,
|
||||
@@ -100,13 +116,20 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// loop on all the possible attachments
|
||||
// loop on all the clipboard items
|
||||
for (const clipboardItem of clipboardItems) {
|
||||
|
||||
// https://github.com/enricoros/big-AGI/issues/286
|
||||
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
|
||||
const heuristicBypassImage = textHtml.startsWith('<table ');
|
||||
|
||||
if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
console.log(' - attachAppendClipboardItems.item:', clipboardItem, textHtml, heuristicBypassImage);
|
||||
|
||||
// attach as image
|
||||
let imageAttached = false;
|
||||
for (const mimeType of clipboardItem.types) {
|
||||
if (mimeType.startsWith('image/')) {
|
||||
if (mimeType.startsWith('image/') && !heuristicBypassImage) {
|
||||
try {
|
||||
const imageBlob = await clipboardItem.getType(mimeType);
|
||||
const imageName = mimeType.replace('image/', 'clipboard.').replaceAll('/', '.') || 'clipboard.png';
|
||||
@@ -136,7 +159,6 @@ export const useAttachments = (enableLoadURLs: boolean) => {
|
||||
}
|
||||
|
||||
// attach as Text
|
||||
const textHtml = clipboardItem.types.includes('text/html') ? await clipboardItem.getType('text/html').then(blob => blob.text()) : '';
|
||||
if (textHtml || textPlain) {
|
||||
void createAttachment({
|
||||
media: 'text', method: 'clipboard-read', textPlain, textHtml,
|
||||
|
||||
@@ -78,7 +78,7 @@ function toLLMAttachment(attachment: Attachment, supportedOutputPartTypes: Compo
|
||||
const tokenCountApprox = llmForTokenCount
|
||||
? attachmentOutputs.reduce((acc, output) => {
|
||||
if (output.type === 'text-block')
|
||||
return acc + countModelTokens(output.text, llmForTokenCount, 'attachments tokens count');
|
||||
return acc + (countModelTokens(output.text, llmForTokenCount, 'attachments tokens count') ?? 0);
|
||||
console.warn('Unhandled token preview for output type:', output.type);
|
||||
return acc;
|
||||
}, 0)
|
||||
|
||||
@@ -15,33 +15,37 @@ const attachCameraLegend = (isMobile: boolean) =>
|
||||
|
||||
export const ButtonAttachCameraMemo = React.memo(ButtonAttachCamera);
|
||||
|
||||
function ButtonAttachCamera(props: { isMobile?: boolean, onAttachImage: (file: File) => void }) {
|
||||
function ButtonAttachCamera(props: { isMobile?: boolean, onOpenCamera: () => void }) {
|
||||
return props.isMobile ? (
|
||||
<IconButton onClick={props.onOpenCamera}>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={props.onOpenCamera} startDecorator={<AddAPhotoIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Camera
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCameraCaptureModal(onAttachImage: (file: File) => void) {
|
||||
|
||||
// state
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return <>
|
||||
const openCamera = React.useCallback(() => setOpen(true), []);
|
||||
|
||||
{/* The Button */}
|
||||
{props.isMobile ? (
|
||||
<IconButton onClick={() => setOpen(true)}>
|
||||
<AddAPhotoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={attachCameraLegend(!!props.isMobile)}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={() => setOpen(true)} startDecorator={<AddAPhotoIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Camera
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
const cameraCaptureComponent = React.useMemo(() => open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={onAttachImage}
|
||||
/>
|
||||
), [open, onAttachImage]);
|
||||
|
||||
{/* The actual capture dialog, which will stream the video */}
|
||||
{open && (
|
||||
<CameraCaptureModal
|
||||
onCloseModal={() => setOpen(false)}
|
||||
onAttachImage={props.onAttachImage}
|
||||
/>
|
||||
)}
|
||||
|
||||
</>;
|
||||
return {
|
||||
openCamera,
|
||||
cameraCaptureComponent,
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function ButtonAttachClipboard(props: { isMobile?: boolean, onClick: () => void
|
||||
<ContentPasteGoIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={pasteClipboardLegend}>
|
||||
<Tooltip disableInteractive variant='solid' placement='top-start' title={pasteClipboardLegend}>
|
||||
<Button fullWidth variant='plain' color='neutral' startDecorator={<ContentPasteGoIcon />} onClick={props.onClick}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
Paste
|
||||
|
||||
@@ -19,7 +19,7 @@ function ButtonAttachFile(props: { isMobile?: boolean, onAttachFilePicker: () =>
|
||||
<AttachFileOutlinedIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' placement='top-start' title={attachFileLegend}>
|
||||
<Tooltip disableInteractive variant='solid' placement='top-start' title={attachFileLegend}>
|
||||
<Button fullWidth variant='plain' color='neutral' onClick={props.onAttachFilePicker} startDecorator={<AttachFileOutlinedIcon />}
|
||||
sx={{ justifyContent: 'flex-start' }}>
|
||||
File
|
||||
|
||||
@@ -16,7 +16,7 @@ export function ButtonCall(props: { isMobile?: boolean, disabled?: boolean, onCl
|
||||
<CallIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip variant='solid' arrow placement='right' title={callConversationLegend}>
|
||||
<Tooltip disableInteractive variant='solid' arrow placement='right' title={callConversationLegend}>
|
||||
<Button variant='soft' color='primary' disabled={props.disabled} onClick={props.onClick} endDecorator={<CallIcon />} sx={props.sx}>
|
||||
Call
|
||||
</Button>
|
||||
|
||||
@@ -48,7 +48,7 @@ import { parseBlocks } from './blocks';
|
||||
// How long is the user collapsed message
|
||||
const USER_COLLAPSED_LINES: number = 8;
|
||||
|
||||
// Enable the automatic menu on text selection
|
||||
// Enable the menu on text selection
|
||||
const ENABLE_SELECTION_RIGHT_CLICK_MENU: boolean = true;
|
||||
|
||||
// Enable the hover button to copy the whole message. The Copy button is also available in Blocks, or in the Avatar Menu.
|
||||
@@ -214,7 +214,7 @@ export function ChatMessage(props: {
|
||||
isBottom?: boolean, noBottomBorder?: boolean,
|
||||
isImagining?: boolean, isSpeaking?: boolean,
|
||||
onConversationBranch?: (messageId: string) => void,
|
||||
onConversationRestartFrom?: (messageId: string, offset: number) => void,
|
||||
onConversationRestartFrom?: (messageId: string, offset: number) => Promise<void>,
|
||||
onConversationTruncate?: (messageId: string) => void,
|
||||
onMessageDelete?: (messageId: string) => void,
|
||||
onMessageEdit?: (messageId: string, text: string) => void,
|
||||
@@ -302,10 +302,10 @@ export function ChatMessage(props: {
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
|
||||
const handleOpsConversationRestartFrom = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
props.onConversationRestartFrom && props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
|
||||
closeOperationsMenu();
|
||||
props.onConversationRestartFrom && await props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
|
||||
};
|
||||
|
||||
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
|
||||
@@ -495,16 +495,18 @@ export function ChatMessage(props: {
|
||||
|
||||
|
||||
{/* Edit / Blocks */}
|
||||
{isEditing
|
||||
{isEditing ? (
|
||||
|
||||
? <InlineTextarea
|
||||
<InlineTextarea
|
||||
initialText={messageText} onEdit={handleTextEdited}
|
||||
sx={{
|
||||
...blockSx,
|
||||
flexGrow: 1,
|
||||
}} />
|
||||
|
||||
: <Box
|
||||
) : (
|
||||
|
||||
<Box
|
||||
onContextMenu={(ENABLE_SELECTION_RIGHT_CLICK_MENU && props.onMessageEdit) ? event => handleMouseUp(event.nativeEvent) : undefined}
|
||||
onDoubleClick={event => (doubleClickToEdit && props.onMessageEdit) ? handleOpsEdit(event) : null}
|
||||
sx={{
|
||||
@@ -550,7 +552,7 @@ export function ChatMessage(props: {
|
||||
: block.type === 'diff'
|
||||
? <RenderTextDiff key={'latex-' + index} diffBlock={block} sx={typographySx} />
|
||||
: (renderMarkdown && props.noMarkdown !== true && !fromSystem && !(fromUser && block.content.startsWith('/')))
|
||||
? <RenderMarkdown key={'text-md-' + index} textBlock={block} sx={typographySx} />
|
||||
? <RenderMarkdown key={'text-md-' + index} textBlock={block} />
|
||||
: <RenderText key={'text-' + index} textBlock={block} sx={typographySx} />)}
|
||||
|
||||
{isCollapsed && (
|
||||
@@ -564,7 +566,7 @@ export function ChatMessage(props: {
|
||||
{/*</Chip>*/}
|
||||
|
||||
</Box>
|
||||
}
|
||||
)}
|
||||
|
||||
|
||||
{/* Overlay copy icon */}
|
||||
@@ -635,7 +637,7 @@ export function ChatMessage(props: {
|
||||
{!!props.onConversationBranch && <ListDivider />}
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Visualize ...
|
||||
Diagram ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
@@ -673,7 +675,7 @@ export function ChatMessage(props: {
|
||||
</MenuItem>
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Visualize ...
|
||||
Diagram ...
|
||||
</MenuItem>}
|
||||
{!!props.onTextImagine && <MenuItem onClick={handleOpsImagine} disabled={!couldImagine || props.isImagining}>
|
||||
<ListItemDecorator>{props.isImagining ? <CircularProgress size='sm' /> : <FormatPaintIcon color='success' />}</ListItemDecorator>
|
||||
|
||||
@@ -1,48 +1,53 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, useTheme } from '@mui/joy';
|
||||
import { Box, styled } from '@mui/joy';
|
||||
|
||||
import { lineHeightChatText } from '~/common/app.theme';
|
||||
|
||||
import type { TextBlock } from './blocks';
|
||||
|
||||
|
||||
/*
|
||||
* For performance reasons, we style this component here and copy the equivalent of 'props.sx' (the lineHeight) locally.
|
||||
*/
|
||||
const RenderMarkdownBox = styled(Box)({
|
||||
// same look as the other RenderComponents
|
||||
marginInline: '0.75rem !important', // margin: 1.5 like other blocks
|
||||
lineHeight: lineHeightChatText,
|
||||
|
||||
// patch the CSS
|
||||
// fontFamily: `inherit !important`, // (not needed anymore, as CSS is under our control) use the default font family
|
||||
// '--color-canvas-default': 'transparent !important', // (not needed anymore) remove the default background color
|
||||
'& table': { width: 'inherit !important' }, // un-break auto-width (tables have 'max-content', which overflows)
|
||||
});
|
||||
|
||||
|
||||
// Dynamically import ReactMarkdown using React.lazy
|
||||
const ReactMarkdown = React.lazy(async () => {
|
||||
const DynamicReactGFM = React.lazy(async () => {
|
||||
const [markdownModule, remarkGfmModule] = await Promise.all([
|
||||
import('react-markdown'),
|
||||
import('remark-gfm'),
|
||||
]);
|
||||
|
||||
// NOTE: extracted here instead of inline as a large performance optimization
|
||||
const remarkPlugins = [remarkGfmModule.default];
|
||||
|
||||
// Pass the dynamically imported remarkGfm as children
|
||||
const ReactMarkdownWithRemarkGfm = (props: any) => (
|
||||
<markdownModule.default remarkPlugins={[remarkGfmModule.default]} {...props} />
|
||||
);
|
||||
const ReactMarkdownWithRemarkGfm = (props: any) =>
|
||||
<markdownModule.default remarkPlugins={remarkPlugins} {...props} />;
|
||||
|
||||
return { default: ReactMarkdownWithRemarkGfm };
|
||||
});
|
||||
|
||||
|
||||
export const RenderMarkdown = (props: { textBlock: TextBlock, sx?: SxProps }) => {
|
||||
const theme = useTheme();
|
||||
export const RenderMarkdown = (props: { textBlock: TextBlock }) => {
|
||||
return (
|
||||
<Box
|
||||
className={`markdown-body ${theme.palette.mode === 'dark' ? 'markdown-body-dark' : 'markdown-body-light'}`}
|
||||
sx={{
|
||||
mx: '0.75rem !important', // margin: 1.5 like other blocks
|
||||
'& table': {
|
||||
width: 'inherit !important', // un-break auto-width (tables have 'max-content', which overflows)
|
||||
},
|
||||
'--color-canvas-default': 'transparent !important', // remove the default background color
|
||||
// NOTE: the following are not needed because the CSS is under our control, and we
|
||||
// disabled the redefintions there
|
||||
// fontFamily: `inherit !important`, // use the default font family
|
||||
...(props.sx || {}),
|
||||
}}>
|
||||
|
||||
{/* Using React.Suspense / React.Lazy loading this */}
|
||||
<RenderMarkdownBox className='markdown-body' /* NODE: see GithubMarkdown.css for the dark/light switch, synced with Joy's */ >
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<ReactMarkdown>{props.textBlock.content}</ReactMarkdown>
|
||||
<DynamicReactGFM>
|
||||
{props.textBlock.content}
|
||||
</DynamicReactGFM>
|
||||
</React.Suspense>
|
||||
</Box>
|
||||
</RenderMarkdownBox>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Typography } from '@mui/joy';
|
||||
import { Box, Button, Checkbox, Grid, IconButton, Input, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import DoneIcon from '@mui/icons-material/Done';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
@@ -144,13 +146,15 @@ export function PersonaSelector(props: { conversationId: DConversationId, runExa
|
||||
|
||||
<Box sx={{ maxWidth: bpMaxWidth }}>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 2, mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 2, mb: 1 }}>
|
||||
<Typography level='title-sm'>
|
||||
AI Persona
|
||||
</Typography>
|
||||
<Button variant='plain' color='neutral' size='sm' onClick={toggleEditMode}>
|
||||
{editMode ? 'Done' : 'Edit'}
|
||||
</Button>
|
||||
<Tooltip disableInteractive title={editMode ? 'Done Editing' : 'Edit Tiles'}>
|
||||
<IconButton size='sm' onClick={toggleEditMode}>
|
||||
{editMode ? <DoneIcon /> : <EditIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={tileSpacing} sx={{ justifyContent: 'flex-start' }}>
|
||||
|
||||
@@ -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,108 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import Head from 'next/head';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
|
||||
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { LogoProgress } from '~/common/components/LogoProgress';
|
||||
import { apiAsyncNode } from '~/common/util/trpc.client';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
import { conversationTitle } from '~/common/state/store-chats';
|
||||
import { themeBgAppDarker } from '~/common/app.theme';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import { AppChatLinkDrawerContent } from './AppChatLinkDrawerContent';
|
||||
import { AppChatLinkMenuItems } from './AppChatLinkMenuItems';
|
||||
import { ViewChatLink } from './ViewChatLink';
|
||||
|
||||
|
||||
const Centerer = (props: { backgroundColor: string, children?: React.ReactNode }) =>
|
||||
<Box sx={{
|
||||
backgroundColor: props.backgroundColor,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
}}>
|
||||
{props.children}
|
||||
</Box>;
|
||||
|
||||
const ShowLoading = () =>
|
||||
<Centerer backgroundColor={themeBgAppDarker}>
|
||||
<LogoProgress showProgress={true} />
|
||||
<Typography level='title-sm' sx={{ mt: 2 }}>
|
||||
Loading Chat...
|
||||
</Typography>
|
||||
</Centerer>;
|
||||
|
||||
const ShowError = (props: { error: any }) =>
|
||||
<Centerer backgroundColor={themeBgAppDarker}>
|
||||
<InlineError error={props.error} severity='warning' />
|
||||
</Centerer>;
|
||||
|
||||
|
||||
/**
|
||||
* Fetches the object using tRPC
|
||||
* Note: we don't have react-query for the Node functions, so we use the immediate API here,
|
||||
* and wrap it in a react-query hook
|
||||
*/
|
||||
async function fetchStoredChatV1(objectId: string) {
|
||||
// fetch
|
||||
const result = await apiAsyncNode.trade.storageGet.query({ objectId });
|
||||
if (result.type === 'error')
|
||||
throw result.error;
|
||||
|
||||
// validate a CHAT_V1
|
||||
const { dataType, dataObject, storedAt, expiresAt } = result;
|
||||
if (dataType !== 'CHAT_V1')
|
||||
throw new Error('Unsupported data type: ' + dataType);
|
||||
|
||||
// convert to DConversation
|
||||
const restored = createConversationFromJsonV1(dataObject as any);
|
||||
if (!restored)
|
||||
throw new Error('Could not restore conversation');
|
||||
|
||||
return { conversation: restored, storedAt, expiresAt };
|
||||
}
|
||||
|
||||
|
||||
export function AppChatLink(props: { linkId: string }) {
|
||||
|
||||
// external state
|
||||
const { data, isError, error, isLoading } = useQuery({
|
||||
enabled: !!props.linkId,
|
||||
queryKey: ['chat-link', props.linkId],
|
||||
queryFn: () => fetchStoredChatV1(props.linkId),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||
});
|
||||
// const hasLinkItems = useHasChatLinkItems();
|
||||
|
||||
|
||||
// pluggable UI
|
||||
|
||||
const drawerContent = React.useMemo(() => <AppChatLinkDrawerContent />, []);
|
||||
const menuItems = React.useMemo(() => <AppChatLinkMenuItems />, []);
|
||||
usePluggableOptimaLayout(drawerContent, null, menuItems, 'AppChatLink');
|
||||
|
||||
|
||||
const pageTitle = (data?.conversation && conversationTitle(data.conversation)) || 'Chat Link';
|
||||
|
||||
return <>
|
||||
|
||||
<Head>
|
||||
<title>{capitalizeFirstLetter(pageTitle)} · {Brand.Title.Base} 🚀</title>
|
||||
</Head>
|
||||
|
||||
{isLoading
|
||||
? <ShowLoading />
|
||||
: isError
|
||||
? <ShowError error={error} />
|
||||
: !!data?.conversation
|
||||
? <ViewChatLink conversation={data.conversation} storedAt={data.storedAt} expiresAt={data.expiresAt} />
|
||||
: <Centerer backgroundColor={themeBgAppDarker} />}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
|
||||
import { Box, ListDivider, ListItem, ListItemButton, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
import { useChatLinkItems } from '~/modules/trade/store-module-trade';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes';
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList';
|
||||
|
||||
|
||||
/**
|
||||
* Drawer Items are all the links already shared, for quick access.
|
||||
* This is stores in the Trade Store (local storage).
|
||||
*/
|
||||
export function AppChatLinkDrawerContent() {
|
||||
|
||||
// external state
|
||||
const { closeDrawerOnMobile } = useOptimaDrawers();
|
||||
const chatLinkItems = useChatLinkItems()
|
||||
.slice()
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
const notEmpty = chatLinkItems.length > 0;
|
||||
|
||||
return <PageDrawerList>
|
||||
|
||||
{notEmpty && (
|
||||
<ListItemButton
|
||||
onClick={closeDrawerOnMobile}
|
||||
component={Link} href={ROUTE_INDEX} noLinkStyle
|
||||
>
|
||||
<ListItemDecorator><ArrowBackIcon /></ListItemDecorator>
|
||||
{Brand.Title.Base}
|
||||
</ListItemButton>
|
||||
)}
|
||||
|
||||
{notEmpty && <ListDivider />}
|
||||
|
||||
<ListItem>
|
||||
<Typography level='body-sm'>
|
||||
{notEmpty ? 'Links shared by you' : 'No prior shared links'}
|
||||
</Typography>
|
||||
</ListItem>
|
||||
|
||||
{notEmpty && <Box sx={{ overflowY: 'auto' }}>
|
||||
{chatLinkItems.map(item => (
|
||||
|
||||
<ListItemButton
|
||||
key={'chat-link-' + item.objectId}
|
||||
component={Link} href={getChatLinkRelativePath(item.objectId)} noLinkStyle
|
||||
sx={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Typography level='title-sm'>
|
||||
{item.chatTitle || 'Untitled Chat'}
|
||||
</Typography>
|
||||
<Typography level='body-xs'>
|
||||
<TimeAgo date={item.createdAt} />
|
||||
</Typography>
|
||||
</ListItemButton>
|
||||
|
||||
))}
|
||||
</Box>}
|
||||
</PageDrawerList>;
|
||||
|
||||
}
|
||||
@@ -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 = 12;
|
||||
export const incrementalVersion: number = 12.1;
|
||||
|
||||
|
||||
function B(props: {
|
||||
href?: string,
|
||||
issue?: number,
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const href = props.issue ? RIssues + '/' + props.issue : props.href;
|
||||
const boldText = (
|
||||
<Typography component='span' color={!!href ? 'primary' : 'neutral'} sx={{ fontWeight: 600 }}>
|
||||
{props.children}
|
||||
</Typography>
|
||||
);
|
||||
if (!href)
|
||||
return boldText;
|
||||
return (
|
||||
<Link href={href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>
|
||||
{boldText} <LaunchIcon sx={{ mx: 0.5, fontSize: 16 }} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const B = (props: { href?: string, children: React.ReactNode }) => {
|
||||
const boldText = <Typography color={!!props.href ? 'primary' : 'neutral'} sx={{ fontWeight: 600 }}>{props.children}</Typography>;
|
||||
return props.href ?
|
||||
<Link href={props.href + clientUtmSource()} target='_blank' sx={{ /*textDecoration: 'underline'*/ }}>{boldText} <LaunchIcon sx={{ ml: 1 }} /></Link> :
|
||||
boldText;
|
||||
};
|
||||
|
||||
const { OpenRepo, OpenProject } = Brand.URIs;
|
||||
const RCode = `${OpenRepo}/blob/main`;
|
||||
@@ -59,6 +74,24 @@ export const newsCallout =
|
||||
// news and feature surfaces
|
||||
export const NewsItems: NewsItem[] = [
|
||||
// still unannounced: phone calls, split windows, ...
|
||||
{// 🆕
|
||||
versionCode: '1.12.0',
|
||||
versionName: 'AGI Hotline',
|
||||
versionMoji: '✨🗣️',
|
||||
versionDate: new Date('2024-01-26T12:30:00Z'),
|
||||
items: [
|
||||
{ text: <><B issue={354}>Voice Call Personas</B>: save time, recap conversations</>, issue: 354 },
|
||||
{ text: <>Updated <B issue={364}>OpenAI Models</B> to the 0125 release</>, issue: 364 },
|
||||
{ text: <>Chats: Auto-<B issue={222}>Rename</B> and <B issue={360}>assign folders</B></>, issue: 222 },
|
||||
{ text: <><B issue={356}>Link Sharing</B> makeover and control</>, issue: 356 },
|
||||
{ text: <><B issue={358}>Accessibility</B> for screen readers</>, issue: 358 },
|
||||
{ text: <>Export chats to <B>Markdown</B></>, issue: 337 },
|
||||
{ text: <>Paste <B>tables from Excel</B></>, issue: 286 },
|
||||
{ text: <>Large optimizations</> },
|
||||
{ text: <>Ollama updates</>, issue: 309 },
|
||||
{ text: <>Over <B>150 commits</B> and <B>7,000+ lines changed</B> for development enhancements</>, dev: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionCode: '1.11.0',
|
||||
versionName: 'Singularity',
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Container, ListDivider, Sheet, Typography } from '@mui/joy';
|
||||
|
||||
import { themeBgApp } from '~/common/app.theme';
|
||||
import { Box, Container, ListDivider, Typography } from '@mui/joy';
|
||||
import { usePluggableOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
|
||||
import { Creator } from './creator/Creator';
|
||||
@@ -31,10 +29,9 @@ export function AppPersonas() {
|
||||
|
||||
|
||||
return (
|
||||
<Sheet sx={{
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
backgroundColor: themeBgApp,
|
||||
p: { xs: 3, md: 6 },
|
||||
}}>
|
||||
|
||||
@@ -52,6 +49,6 @@ export function AppPersonas() {
|
||||
|
||||
</Container>
|
||||
|
||||
</Sheet>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { PageDrawerList } from '~/common/layout/optima/components/PageDrawerList
|
||||
import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers';
|
||||
|
||||
import { CreatorDrawerItem } from './CreatorDrawerItem';
|
||||
import { deleteSimplePersona, useSimplePersonas } from '../store-app-personas';
|
||||
import { deleteSimplePersona, deleteSimplePersonas, useSimplePersonas } from '../store-app-personas';
|
||||
|
||||
|
||||
export function CreatorDrawer(props: {
|
||||
@@ -79,10 +79,7 @@ export function CreatorDrawer(props: {
|
||||
}, [simplePersonas]);
|
||||
|
||||
const handleSelectionDelete = React.useCallback(() => {
|
||||
selectedIds.forEach(simplePersonaId => {
|
||||
deleteSimplePersona(simplePersonaId);
|
||||
});
|
||||
// clear the selection after deletion
|
||||
deleteSimplePersonas(selectedIds);
|
||||
setSelectedIds(new Set());
|
||||
}, [selectedIds]);
|
||||
|
||||
@@ -93,14 +90,15 @@ export function CreatorDrawer(props: {
|
||||
<PageDrawerHeader
|
||||
title={selectMode ? 'Selection Mode' : 'Recent'}
|
||||
onClose={selectMode ? handleSelectionClose : closeDrawer}
|
||||
startButton={(!hasPersonas || selectMode) ? undefined :
|
||||
>
|
||||
{hasPersonas && !selectMode && (
|
||||
<Tooltip title={selectMode ? 'Done' : 'Select'}>
|
||||
<IconButton onClick={selectMode ? handleSelectionClose : () => setSelectMode(true)}>
|
||||
{selectMode ? <DoneIcon /> : <CheckBoxOutlineBlankIcon />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PageDrawerHeader>
|
||||
|
||||
<PageDrawerList
|
||||
variant='plain'
|
||||
@@ -118,7 +116,11 @@ export function CreatorDrawer(props: {
|
||||
startDecorator={selectedIds.size === simplePersonas.length ? <CheckBoxOutlineBlankIcon /> : <CheckBoxIcon />}
|
||||
onClick={handleSelectionInvert}
|
||||
>
|
||||
{selectedIds.size === simplePersonas.length ? 'Select None' : selectedIds.size !== 0 ? 'Invert' : 'Select All'}
|
||||
{selectedIds.size === simplePersonas.length
|
||||
? 'Select None'
|
||||
: selectedIds.size === 0
|
||||
? `Select ${simplePersonas.length.toLocaleString() || 'All'}`
|
||||
: 'Invert'}
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
|
||||
@@ -36,7 +36,7 @@ export function FromText(props: {
|
||||
required
|
||||
variant='outlined'
|
||||
minRows={4} maxRows={8}
|
||||
placeholder='Paste your text here...'
|
||||
placeholder='Paste your text (e.g. tweets, social media, etc.) here...'
|
||||
value={text}
|
||||
onChange={event => setText(event.target.value)}
|
||||
sx={{
|
||||
|
||||
@@ -4,6 +4,9 @@ import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { createBase36Uid } from '~/common/util/textUtils';
|
||||
|
||||
// constraint the max number of saved prompts, to stay below localStorage quota
|
||||
const MAX_SAVED_PROMPTS = 100;
|
||||
|
||||
|
||||
/**
|
||||
* Very simple personas store for the "Persona Creator" - note that we shall
|
||||
@@ -41,6 +44,7 @@ interface AppPersonasStore {
|
||||
// actions
|
||||
prependSimplePersona: (systemPrompt: string, inputText: string, inputProvenance?: SimplePersonaProvenance, llmLabel?: string) => void;
|
||||
deleteSimplePersona: (id: string) => void;
|
||||
deleteSimplePersonas: (ids: Set<string>) => void;
|
||||
|
||||
}
|
||||
|
||||
@@ -54,25 +58,34 @@ const useAppPersonasStore = create<AppPersonasStore>()(persist(
|
||||
simplePersonas: [],
|
||||
|
||||
prependSimplePersona: (systemPrompt: string, inputText: string, inputProvenance?: SimplePersonaProvenance, llmLabel?: string) =>
|
||||
_set(state => ({
|
||||
simplePersonas: [
|
||||
{
|
||||
id: createBase36Uid(state.simplePersonas.map(persona => persona.id)),
|
||||
systemPrompt,
|
||||
creationDate: new Date().toISOString(),
|
||||
inputProvenance,
|
||||
inputText,
|
||||
llmLabel,
|
||||
},
|
||||
...state.simplePersonas,
|
||||
],
|
||||
})),
|
||||
_set(state => {
|
||||
const newPersona: SimplePersona = {
|
||||
id: createBase36Uid(state.simplePersonas.map(persona => persona.id)),
|
||||
systemPrompt,
|
||||
creationDate: new Date().toISOString(),
|
||||
inputProvenance,
|
||||
// to save bytes, do not save input text when from YouTube
|
||||
inputText: inputProvenance?.type === 'youtube' ? '' : inputText,
|
||||
llmLabel,
|
||||
};
|
||||
return {
|
||||
simplePersonas: [
|
||||
newPersona,
|
||||
...state.simplePersonas.slice(0, MAX_SAVED_PROMPTS - 1),
|
||||
],
|
||||
};
|
||||
}),
|
||||
|
||||
deleteSimplePersona: (simplePersonaId: string) =>
|
||||
_set(state => ({
|
||||
simplePersonas: state.simplePersonas.filter(persona => persona.id !== simplePersonaId),
|
||||
})),
|
||||
|
||||
deleteSimplePersonas: (simplePersonaIds: Set<string>) =>
|
||||
_set(state => ({
|
||||
simplePersonas: state.simplePersonas.filter(persona => !simplePersonaIds.has(persona.id)),
|
||||
})),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-app-personas',
|
||||
@@ -98,4 +111,8 @@ export function prependSimplePersona(systemPrompt: string, inputText: string, in
|
||||
|
||||
export function deleteSimplePersona(simplePersonaId: string) {
|
||||
useAppPersonasStore.getState().deleteSimplePersona(simplePersonaId);
|
||||
}
|
||||
|
||||
export function deleteSimplePersonas(simplePersonaIds: Set<string>) {
|
||||
useAppPersonasStore.getState().deleteSimplePersonas(simplePersonaIds);
|
||||
}
|
||||
@@ -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
-40
@@ -1,43 +1,68 @@
|
||||
import type { FunctionComponent } from 'react';
|
||||
|
||||
// App icons
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import AccountTreeOutlinedIcon from '@mui/icons-material/AccountTreeOutlined';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
import AutoAwesomeOutlinedIcon from '@mui/icons-material/AutoAwesomeOutlined';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
import CallOutlinedIcon from '@mui/icons-material/CallOutlined';
|
||||
import Diversity2Icon from '@mui/icons-material/Diversity2';
|
||||
import Diversity2OutlinedIcon from '@mui/icons-material/Diversity2Outlined';
|
||||
import EventNoteIcon from '@mui/icons-material/EventNote';
|
||||
import EventNoteOutlinedIcon from '@mui/icons-material/EventNoteOutlined';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import FormatPaintOutlinedIcon from '@mui/icons-material/FormatPaintOutlined';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
|
||||
import IosShareIcon from '@mui/icons-material/IosShare';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
import IosShareOutlinedIcon from '@mui/icons-material/IosShareOutlined';
|
||||
import TextsmsIcon from '@mui/icons-material/Textsms';
|
||||
import TextsmsOutlinedIcon from '@mui/icons-material/TextsmsOutlined';
|
||||
import WorkspacesIcon from '@mui/icons-material/Workspaces';
|
||||
import WorkspacesOutlinedIcon from '@mui/icons-material/WorkspacesOutlined';
|
||||
// Link icons
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
import { DiscordIcon } from '~/common/components/icons/DiscordIcon';
|
||||
// Modal icons
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { DiscordIcon } from '~/common/components/icons/DiscordIcon';
|
||||
import { hasNoChatLinkItems } from '~/modules/trade/link/store-link';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
// enable to show all items, for layout development
|
||||
const SHOW_ALL_APPS = false;
|
||||
|
||||
const SPECIAL_DIVIDER = '__DIVIDER__';
|
||||
|
||||
|
||||
// Nav items
|
||||
|
||||
interface ItemBase {
|
||||
name: string,
|
||||
icon: FunctionComponent,
|
||||
iconActive?: FunctionComponent,
|
||||
tooltip?: string,
|
||||
}
|
||||
|
||||
export interface NavItemApp extends ItemBase {
|
||||
type: 'app',
|
||||
route: string,
|
||||
drawer?: string | true, // true: can make use of the drawer, string: also set the title
|
||||
landingRoute?: string, // specify a different route than the nextjs page router route, to land to
|
||||
barTitle?: string, // set to override the name as the bar title (unless custom bar content is used)
|
||||
hideOnMobile?: boolean, // set to true to hide the icon on mobile, unless this is the active app
|
||||
hideIcon?: boolean
|
||||
| (() => boolean), // set to true to hide the icon, unless this is the active app
|
||||
hideBar?: boolean, // set to true to hide the page bar
|
||||
hideNav?: boolean, // set to hide the Nav bar (note: must have a way to navigate back)
|
||||
automatic?: boolean, // only accessible by the machine
|
||||
hideDrawer?: boolean, // set to true to hide the drawer
|
||||
hideNav?: boolean
|
||||
| (() => boolean), // set to hide the Nav bar (note: must have a way to navigate back)
|
||||
fullWidth?: boolean, // set to true to override the user preference
|
||||
hide?: boolean, // delete from the UI
|
||||
_delete?: boolean, // delete from the UI
|
||||
}
|
||||
|
||||
export interface NavItemModal extends ItemBase {
|
||||
@@ -57,8 +82,8 @@ export interface NavItemExtLink extends ItemBase {
|
||||
|
||||
|
||||
export const navItems: {
|
||||
apps: NavItemApp[]
|
||||
modals: NavItemModal[]
|
||||
apps: NavItemApp[],
|
||||
modals: NavItemModal[],
|
||||
links: NavItemExtLink[],
|
||||
} = {
|
||||
|
||||
@@ -66,74 +91,98 @@ export const navItems: {
|
||||
apps: [
|
||||
{
|
||||
name: 'Chat',
|
||||
icon: TelegramIcon,
|
||||
icon: TextsmsOutlinedIcon,
|
||||
iconActive: TextsmsIcon,
|
||||
type: 'app',
|
||||
route: '/',
|
||||
drawer: true,
|
||||
},
|
||||
{
|
||||
name: 'Call',
|
||||
icon: CallIcon,
|
||||
barTitle: 'Voice Calls',
|
||||
icon: CallOutlinedIcon,
|
||||
iconActive: CallIcon,
|
||||
type: 'app',
|
||||
route: '/call',
|
||||
drawer: 'Recent Calls',
|
||||
automatic: true,
|
||||
hideDrawer: true,
|
||||
fullWidth: true,
|
||||
},
|
||||
{
|
||||
name: 'Draw',
|
||||
icon: FormatPaintIcon,
|
||||
barTitle: 'Generate Images',
|
||||
icon: FormatPaintOutlinedIcon,
|
||||
iconActive: FormatPaintIcon,
|
||||
type: 'app',
|
||||
route: '/draw',
|
||||
hide: true,
|
||||
// hideOnMobile: true,
|
||||
hideDrawer: true,
|
||||
hideIcon: () => !useUXLabsStore.getState().labsDrawing,
|
||||
},
|
||||
{
|
||||
name: 'Cortex',
|
||||
icon: AutoAwesomeIcon,
|
||||
icon: AutoAwesomeOutlinedIcon,
|
||||
iconActive: AutoAwesomeIcon,
|
||||
type: 'app',
|
||||
route: '/cortex',
|
||||
automatic: true,
|
||||
hide: true,
|
||||
_delete: true,
|
||||
},
|
||||
{
|
||||
name: 'Patterns',
|
||||
icon: AccountTreeIcon,
|
||||
icon: AccountTreeOutlinedIcon,
|
||||
iconActive: AccountTreeIcon,
|
||||
type: 'app',
|
||||
route: '/patterns',
|
||||
hide: true,
|
||||
_delete: true,
|
||||
},
|
||||
{
|
||||
name: 'Workspace',
|
||||
icon: WorkspacesIcon,
|
||||
icon: WorkspacesOutlinedIcon,
|
||||
iconActive: WorkspacesIcon,
|
||||
type: 'app',
|
||||
route: '/workspace',
|
||||
hide: true,
|
||||
_delete: true,
|
||||
},
|
||||
// <-- divider here -->
|
||||
{
|
||||
name: SPECIAL_DIVIDER,
|
||||
type: 'app',
|
||||
route: SPECIAL_DIVIDER,
|
||||
icon: () => null,
|
||||
},
|
||||
{
|
||||
name: 'Personas',
|
||||
icon: Diversity2Icon,
|
||||
icon: Diversity2OutlinedIcon,
|
||||
iconActive: Diversity2Icon,
|
||||
type: 'app',
|
||||
route: '/personas',
|
||||
drawer: true,
|
||||
hideBar: true,
|
||||
},
|
||||
{
|
||||
name: 'Media Library',
|
||||
icon: ImageOutlinedIcon,
|
||||
iconActive: ImageIcon,
|
||||
type: 'app',
|
||||
route: '/media',
|
||||
_delete: true,
|
||||
},
|
||||
{
|
||||
name: 'Shared Chat',
|
||||
icon: IosShareOutlinedIcon,
|
||||
iconActive: IosShareIcon,
|
||||
type: 'app',
|
||||
route: '/link/chat/[chatLinkId]',
|
||||
landingRoute: '/link/chat/list',
|
||||
hideOnMobile: true,
|
||||
hideIcon: hasNoChatLinkItems,
|
||||
hideNav: hasNoChatLinkItems,
|
||||
},
|
||||
{
|
||||
name: 'News',
|
||||
icon: EventNoteIcon,
|
||||
icon: EventNoteOutlinedIcon,
|
||||
iconActive: EventNoteIcon,
|
||||
type: 'app',
|
||||
route: '/news',
|
||||
hideBar: true,
|
||||
},
|
||||
|
||||
// non-user-selectable ('automatic') Apps
|
||||
{
|
||||
name: 'Shared Chat',
|
||||
icon: IosShareIcon,
|
||||
type: 'app',
|
||||
route: '/link/chat/[chatLinkId]',
|
||||
drawer: 'Shared Chats',
|
||||
automatic: true,
|
||||
hideNav: true,
|
||||
hideDrawer: true,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -178,4 +227,16 @@ export const navItems: {
|
||||
};
|
||||
|
||||
// apply UI filtering right away - do it here, once, and for all
|
||||
navItems.apps = navItems.apps.filter(app => !app.hide || SHOW_ALL_APPS);
|
||||
navItems.apps = navItems.apps.filter(app => !app._delete || SHOW_ALL_APPS);
|
||||
|
||||
export function checkDivider(app?: NavItemApp) {
|
||||
return app?.name === SPECIAL_DIVIDER;
|
||||
}
|
||||
|
||||
export function checkVisibileIcon(app: NavItemApp, isMobile: boolean, currentApp?: NavItemApp) {
|
||||
return app.hideOnMobile && isMobile ? false : app === currentApp ? true : typeof app.hideIcon === 'function' ? !app.hideIcon() : !app.hideIcon;
|
||||
}
|
||||
|
||||
export function checkVisibleNav(app?: NavItemApp) {
|
||||
return !app ? false : typeof app.hideNav === 'function' ? !app.hideNav() : !app.hideNav;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,11 +4,13 @@ import ClearIcon from '@mui/icons-material/Clear';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
|
||||
type DebounceInputProps = Omit<InputProps, 'onChange'> & {
|
||||
minChars?: number;
|
||||
onDebounce: (value: string) => void;
|
||||
debounceTimeout: number;
|
||||
};
|
||||
|
||||
const DebounceInput: React.FC<DebounceInputProps> = ({
|
||||
minChars,
|
||||
onDebounce,
|
||||
debounceTimeout,
|
||||
...rest
|
||||
@@ -25,6 +27,9 @@ const DebounceInput: React.FC<DebounceInputProps> = ({
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
// Don't call onDebounce if the input value is too short
|
||||
if (newValue && minChars && newValue?.length < minChars)
|
||||
return;
|
||||
onDebounce(newValue); // Call onDebounce after the debounce timeout
|
||||
}, debounceTimeout);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Card, Link as MuiLink, Typography } from '@mui/joy';
|
||||
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||
|
||||
|
||||
export const GitHubProjectIssueCard = (props: {
|
||||
issue: number,
|
||||
text: string,
|
||||
note?: string | React.ReactNode,
|
||||
note2?: string | React.ReactNode,
|
||||
sx?: SxProps
|
||||
}) =>
|
||||
<Card variant='outlined' color='primary' sx={props.sx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<GitHubIcon />
|
||||
<Typography level='body-sm'>
|
||||
<MuiLink overlay href={`https://github.com/enricoros/big-AGI/issues/${props.issue}`} target='_blank'>
|
||||
big-AGI #{props.issue}
|
||||
</MuiLink>
|
||||
{' · '}{props.text}.
|
||||
</Typography>
|
||||
</Box>
|
||||
{!!props.note && (
|
||||
<Typography level='body-sm' sx={{ mt: 1 }}>
|
||||
{props.note}
|
||||
</Typography>
|
||||
)}
|
||||
{!!props.note2 && (
|
||||
<Typography level='body-sm' sx={{ mt: 1 }}>
|
||||
{props.note2}
|
||||
</Typography>
|
||||
)}
|
||||
</Card>;
|
||||
@@ -28,15 +28,15 @@ export function GoodModal(props: {
|
||||
sx={{
|
||||
minWidth: { xs: 360, sm: 500, md: 600, lg: 700 },
|
||||
maxWidth: 700,
|
||||
display: 'flex', flexDirection: 'column', gap: 3,
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--Card-padding)',
|
||||
...props.sx,
|
||||
}}>
|
||||
|
||||
{!props.noTitleBar && <Box sx={{ mb: -1, display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography level={props.strongerTitle !== true ? 'title-md' : 'title-lg'} startDecorator={props.titleStartDecorator}>
|
||||
{!props.noTitleBar && <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography component='h1' level={props.strongerTitle !== true ? 'title-md' : 'title-lg'} startDecorator={props.titleStartDecorator}>
|
||||
{props.title || ''}
|
||||
</Typography>
|
||||
{!!props.onClose && <ModalClose sx={{ position: 'static', mr: -1 }} />}
|
||||
{!!props.onClose && <ModalClose aria-label='Close Dialog' sx={{ position: 'static', my: -1, mr: -0.5 }} />}
|
||||
</Box>}
|
||||
|
||||
{props.dividers === true && <Divider />}
|
||||
@@ -47,7 +47,7 @@ export function GoodModal(props: {
|
||||
|
||||
{(!!props.startButton || showBottomClose) && <Box sx={{ mt: 'auto', display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'space-between' }}>
|
||||
{props.startButton}
|
||||
{showBottomClose && <Button variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
|
||||
{showBottomClose && <Button aria-label='Close Dialog' variant='solid' color='neutral' onClick={props.onClose} sx={{ ml: 'auto', minWidth: 100 }}>
|
||||
Close
|
||||
</Button>}
|
||||
</Box>}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const GoodTooltip = (props: {
|
||||
<Tooltip
|
||||
title={props.title}
|
||||
placement={props.placement}
|
||||
disableInteractive
|
||||
variant={(props.isError || props.isWarning) ? 'soft' : undefined}
|
||||
color={props.isError ? 'danger' : props.isWarning ? 'warning' : undefined}
|
||||
sx={{
|
||||
|
||||
@@ -7,7 +7,9 @@ import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
export function InlineTextarea(props: {
|
||||
initialText: string, placeholder?: string,
|
||||
initialText: string,
|
||||
placeholder?: string,
|
||||
invertedColors?: boolean,
|
||||
onEdit: (text: string) => void,
|
||||
onCancel?: () => void,
|
||||
sx?: SxProps,
|
||||
@@ -35,7 +37,8 @@ export function InlineTextarea(props: {
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
variant='soft' color='warning'
|
||||
variant={props.invertedColors ? 'plain' : 'soft'}
|
||||
color={props.invertedColors ? 'primary' : 'warning'}
|
||||
autoFocus
|
||||
minRows={1}
|
||||
placeholder={props.placeholder}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SxProps } from '@mui/joy/styles/types';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { settingsCol1Width } from '~/common/app.theme';
|
||||
import { formLabelStartWidth } from '~/common/app.theme';
|
||||
|
||||
|
||||
/**
|
||||
@@ -23,7 +23,7 @@ export const FormLabelStart = (props: {
|
||||
<FormLabel
|
||||
onClick={props.onClick}
|
||||
sx={{
|
||||
minWidth: settingsCol1Width,
|
||||
minWidth: formLabelStartWidth,
|
||||
...(!!props.onClick && { cursor: 'pointer', textDecoration: 'underline' }),
|
||||
...props.sx,
|
||||
}}
|
||||
|
||||
@@ -290,11 +290,12 @@ export const useSpeechRecognition = (onResultCallback: (result: SpeechResult) =>
|
||||
}, []);
|
||||
|
||||
const toggleRecording = React.useCallback(() => {
|
||||
if (refStarted.current)
|
||||
if (refStarted.current || isSpeechError) {
|
||||
stopRecording();
|
||||
else
|
||||
setIsSpeechError(false);
|
||||
} else
|
||||
startRecording();
|
||||
}, [startRecording, stopRecording]);
|
||||
}, [isSpeechError, startRecording, stopRecording]);
|
||||
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,10 +2,9 @@ import * as React from 'react';
|
||||
|
||||
import { Box, Sheet, styled } from '@mui/joy';
|
||||
|
||||
import type { NavItemApp } from '~/common/app.nav';
|
||||
import { checkVisibleNav, NavItemApp } from '~/common/app.nav';
|
||||
import { themeZIndexDesktopDrawer } from '~/common/app.theme';
|
||||
|
||||
import { PageDrawer } from './PageDrawer';
|
||||
import { useOptimaDrawers } from './useOptimaDrawers';
|
||||
import { useOptimaLayout } from './useOptimaLayout';
|
||||
|
||||
@@ -33,19 +32,23 @@ const DesktopDrawerTranslatingSheet = styled(Sheet)(({ theme }) => ({
|
||||
zIndex: themeZIndexDesktopDrawer,
|
||||
|
||||
// styling
|
||||
backgroundColor: 'transparent',
|
||||
// borderTopRightRadius: 'var(--AGI-Optima-Radius)',
|
||||
// borderBottomRightRadius: 'var(--AGI-Optima-Radius)',
|
||||
// contain: 'strict',
|
||||
boxShadow: theme.shadow.md,
|
||||
|
||||
// content layout
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}));
|
||||
})) as typeof Sheet;
|
||||
|
||||
|
||||
export function DesktopDrawer(props: { currentApp?: NavItemApp }) {
|
||||
export function DesktopDrawer(props: { component: React.ElementType, currentApp?: NavItemApp }) {
|
||||
|
||||
// external state
|
||||
const { isDrawerOpen, closeDrawer, openDrawer } = useOptimaDrawers();
|
||||
const { appPaneContent } = useOptimaLayout();
|
||||
const { appDrawerContent } = useOptimaLayout();
|
||||
|
||||
// local state
|
||||
const [softDrawerUnmount, setSoftDrawerUnmount] = React.useState(false);
|
||||
@@ -71,14 +74,14 @@ export function DesktopDrawer(props: { currentApp?: NavItemApp }) {
|
||||
|
||||
|
||||
// Desktop-only?: close the drawer if the current app doesn't use it
|
||||
const currentAppUsesDrawer = !!props.currentApp?.drawer;
|
||||
const currentAppUsesDrawer = !props.currentApp?.hideDrawer;
|
||||
React.useEffect(() => {
|
||||
if (!currentAppUsesDrawer)
|
||||
closeDrawer();
|
||||
}, [closeDrawer, currentAppUsesDrawer]);
|
||||
|
||||
// [special case] remove in the future
|
||||
const shallOpenNavForSharedLink = !!props.currentApp?.drawer && !!props.currentApp?.hideNav;
|
||||
const shallOpenNavForSharedLink = !props.currentApp?.hideDrawer && checkVisibleNav(props.currentApp);
|
||||
React.useEffect(() => {
|
||||
if (shallOpenNavForSharedLink)
|
||||
openDrawer();
|
||||
@@ -93,17 +96,16 @@ export function DesktopDrawer(props: { currentApp?: NavItemApp }) {
|
||||
>
|
||||
|
||||
<DesktopDrawerTranslatingSheet
|
||||
component={props.component}
|
||||
sx={{
|
||||
transform: isDrawerOpen ? 'none' : 'translateX(-100%)',
|
||||
}}
|
||||
>
|
||||
|
||||
{/* [UX Responsiveness] Keep Mounted for now */}
|
||||
{(!softDrawerUnmount || isDrawerOpen || !UNMOUNT_DELAY_MS) && (
|
||||
<PageDrawer currentApp={props.currentApp} onClose={closeDrawer}>
|
||||
{appPaneContent}
|
||||
</PageDrawer>
|
||||
)}
|
||||
{(!softDrawerUnmount || isDrawerOpen || !UNMOUNT_DELAY_MS) &&
|
||||
appDrawerContent
|
||||
}
|
||||
|
||||
</DesktopDrawerTranslatingSheet>
|
||||
|
||||
|
||||
@@ -1,94 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import Router from 'next/router';
|
||||
|
||||
import { Box, IconButton, styled, Tooltip } from '@mui/joy';
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Divider, Tooltip } from '@mui/joy';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
|
||||
import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { AgiSquircleIcon } from '~/common/components/icons/AgiSquircleIcon';
|
||||
import { NavItemApp, navItems } from '~/common/app.nav';
|
||||
import { cssRainbowColorKeyframes, themeZIndexDesktopNav } from '~/common/app.theme';
|
||||
import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav';
|
||||
import { themeZIndexDesktopNav } from '~/common/app.theme';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { BringTheLove } from './components/BringTheLove';
|
||||
import { DesktopNavGroupBox, DesktopNavIcon, navItemClasses } from './components/DesktopNavIcon';
|
||||
import { InvertedBar, InvertedBarCornerItem } from './components/InvertedBar';
|
||||
import { useOptimaDrawers } from './useOptimaDrawers';
|
||||
import { useOptimaLayout } from './useOptimaLayout';
|
||||
|
||||
import { BringTheLove } from '~/common/layout/optima/components/BringTheLove';
|
||||
|
||||
|
||||
// Nav Group
|
||||
|
||||
const DesktopNavGroupButton = styled(Box)({
|
||||
// flex column
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
// nav items, reduce the marginBlock a little
|
||||
'--GroupMarginY': '0.125rem',
|
||||
|
||||
// style
|
||||
// backgroundColor: 'rgba(0 0 0 / 0.5)',
|
||||
// borderRadius: '1rem',
|
||||
// paddingBlock: '0.5rem',
|
||||
// overflow: 'hidden',
|
||||
});
|
||||
|
||||
|
||||
// Nav Item
|
||||
|
||||
const navItemClasses = {
|
||||
active: 'NavButton-active',
|
||||
paneOpen: 'NavButton-paneOpen',
|
||||
attractive: 'NavButton-attractive',
|
||||
const desktopNavBarSx: SxProps = {
|
||||
zIndex: themeZIndexDesktopNav,
|
||||
};
|
||||
|
||||
const DesktopNavItem = styled(IconButton)(({ theme }) => ({
|
||||
// --Bar is defined in InvertedBar
|
||||
'--MarginX': '0.25rem',
|
||||
|
||||
// IconButton customization: the objective is to have a square button, with a smaller group margin,
|
||||
// and with the nice little animation on pane open and hover
|
||||
'--IconButton-size': 'calc(var(--Bar) - 2 * var(--MarginX))',
|
||||
'--Icon-fontSize': '1.5rem',
|
||||
// border: '1px solid red',
|
||||
borderRadius: 'calc(var(--IconButton-size) / 2)',
|
||||
marginBlock: 'var(--GroupMarginY)',
|
||||
//marginInline: .. not needd because we center the items
|
||||
padding: 0,
|
||||
transition: 'border-radius 0.4s, margin 0.2s, padding 0.2s',
|
||||
|
||||
[`&:hover`]: {
|
||||
// backgroundColor: theme.palette.primary.softHoverBg,
|
||||
},
|
||||
|
||||
// pane open: show a connected half
|
||||
[`&.${navItemClasses.paneOpen}`]: {
|
||||
// squircle animation
|
||||
borderStartEndRadius: 0,
|
||||
borderEndEndRadius: 0,
|
||||
marginLeft: 'calc(2 * var(--MarginX))',
|
||||
paddingRight: 'calc(2 * var(--MarginX))',
|
||||
},
|
||||
[`&.${navItemClasses.paneOpen}:hover`]: {
|
||||
borderRadius: 'calc(var(--IconButton-size) / 2)',
|
||||
marginLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
|
||||
// attractive: attract the user to click on this element
|
||||
[`&.${navItemClasses.attractive}`]: {
|
||||
animation: `${cssRainbowColorKeyframes} 5s infinite`,
|
||||
transform: 'scale(1.4)',
|
||||
},
|
||||
|
||||
}));
|
||||
|
||||
|
||||
export function DesktopNav(props: { currentApp?: NavItemApp }) {
|
||||
export function DesktopNav(props: { component: React.ElementType, currentApp?: NavItemApp }) {
|
||||
|
||||
// external state
|
||||
const {
|
||||
@@ -99,11 +35,13 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
|
||||
showModelsSetup, openModelsSetup,
|
||||
} = useOptimaLayout();
|
||||
const noLLMs = useModelsStore(state => !state.llms.length);
|
||||
// ignore the return value, this just makes sure that the nav is refreshed when UX Labs change - while "drawing" is in there
|
||||
const labsDrawing = useUXLabsStore(state => state.labsDrawing);
|
||||
|
||||
|
||||
// show/hide the pane when clicking on the logo
|
||||
const appUsesPane = !!props.currentApp?.drawer;
|
||||
const logoButtonTogglesPane = (appUsesPane && !isDrawerOpen) || isDrawerOpen;
|
||||
const appUsesDrawer = !props.currentApp?.hideDrawer;
|
||||
const logoButtonTogglesPane = (appUsesDrawer && !isDrawerOpen) || isDrawerOpen;
|
||||
const handleLogoButtonClick = React.useCallback(() => {
|
||||
if (logoButtonTogglesPane)
|
||||
toggleDrawer();
|
||||
@@ -112,25 +50,30 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
|
||||
|
||||
// App items
|
||||
const navAppItems = React.useMemo(() => {
|
||||
return navItems.apps.filter(app => !app.hideNav /* .automatic */).map(item => {
|
||||
const isActive = item === props.currentApp;
|
||||
const isPanelable = isActive && !!item.drawer;
|
||||
const isPaneOpen = isPanelable && isDrawerOpen;
|
||||
const isNotForUser = !!item.automatic && !isActive;
|
||||
return (
|
||||
<Tooltip disableInteractive enterDelay={600} key={'n-m-' + item.route.slice(1)} title={item.name}>
|
||||
<DesktopNavItem
|
||||
disabled={isNotForUser}
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
onClick={isPanelable ? toggleDrawer : () => Router.push(item.route)}
|
||||
className={`${isActive ? navItemClasses.active : ''} ${isPaneOpen ? navItemClasses.paneOpen : ''}`}
|
||||
>
|
||||
<item.icon />
|
||||
</DesktopNavItem>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
}, [props.currentApp, isDrawerOpen, toggleDrawer]);
|
||||
return navItems.apps
|
||||
.filter(_app => checkVisibileIcon(_app, false, props.currentApp))
|
||||
.map((app, appIdx) => {
|
||||
const isActive = app === props.currentApp;
|
||||
const isDrawerable = isActive && !app.hideDrawer;
|
||||
const isPaneOpen = isDrawerable && isDrawerOpen;
|
||||
|
||||
if (checkDivider(app))
|
||||
return <Divider key={'div-' + appIdx} sx={{ my: 1, width: '50%', mx: 'auto' }} />;
|
||||
|
||||
return (
|
||||
<Tooltip key={'n-m-' + app.route.slice(1)} disableInteractive enterDelay={600} title={app.name}>
|
||||
<DesktopNavIcon
|
||||
variant={isActive ? 'solid' : undefined}
|
||||
onClick={isDrawerable ? toggleDrawer : () => Router.push(app.landingRoute || app.route)}
|
||||
className={`${navItemClasses.typeApp} ${isActive ? navItemClasses.active : ''} ${isPaneOpen ? navItemClasses.paneOpen : ''}`}
|
||||
>
|
||||
{/*{(isActive && app.iconActive) ? <app.iconActive /> : <app.icon />}*/}
|
||||
<app.icon />
|
||||
</DesktopNavIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
}, [props.currentApp, isDrawerOpen, labsDrawing, toggleDrawer]);
|
||||
|
||||
|
||||
// External link items
|
||||
@@ -168,13 +111,13 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
|
||||
|
||||
return (
|
||||
<Tooltip followCursor key={'n-m-' + item.overlayId} title={isAttractive ? 'Add Language Models - REQUIRED' : item.name}>
|
||||
<DesktopNavItem
|
||||
<DesktopNavIcon
|
||||
variant={isActive ? 'soft' : undefined}
|
||||
onClick={showModal}
|
||||
className={`${isActive ? navItemClasses.active : ''} ${isAttractive ? navItemClasses.attractive : ''}`}
|
||||
className={`${navItemClasses.typeLinkOrModal} ${isActive ? navItemClasses.active : ''} ${isAttractive ? navItemClasses.attractive : ''}`}
|
||||
>
|
||||
<item.icon />
|
||||
</DesktopNavItem>
|
||||
{(isActive && item.iconActive) ? <item.iconActive /> : <item.icon />}
|
||||
</DesktopNavIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -182,24 +125,29 @@ export function DesktopNav(props: { currentApp?: NavItemApp }) {
|
||||
|
||||
|
||||
return (
|
||||
<InvertedBar id='desktop-nav' direction='vertical' sx={{ zIndex: themeZIndexDesktopNav }}>
|
||||
<InvertedBar
|
||||
id='desktop-nav'
|
||||
component={props.component}
|
||||
direction='vertical'
|
||||
sx={desktopNavBarSx}
|
||||
>
|
||||
|
||||
<InvertedBarCornerItem>
|
||||
<Tooltip title={isDrawerOpen ? 'Close' : 'Open Drawer'}>
|
||||
<DesktopNavItem disabled={!logoButtonTogglesPane} onClick={handleLogoButtonClick}>
|
||||
<Tooltip title={isDrawerOpen ? 'Close Drawer' /* for Aria reasons */ : 'Open Drawer'}>
|
||||
<DesktopNavIcon disabled={!logoButtonTogglesPane} onClick={handleLogoButtonClick}>
|
||||
{logoButtonTogglesPane ? <MenuIcon /> : <AgiSquircleIcon inverted sx={{ color: 'white' }} />}
|
||||
</DesktopNavItem>
|
||||
</DesktopNavIcon>
|
||||
</Tooltip>
|
||||
</InvertedBarCornerItem>
|
||||
|
||||
<DesktopNavGroupButton>
|
||||
<DesktopNavGroupBox>
|
||||
{navAppItems}
|
||||
</DesktopNavGroupButton>
|
||||
</DesktopNavGroupBox>
|
||||
|
||||
<DesktopNavGroupButton>
|
||||
<DesktopNavGroupBox sx={{ mb: 'calc(2 * var(--GroupMarginY))' }}>
|
||||
{navExtLinkItems}
|
||||
{navModalItems}
|
||||
</DesktopNavGroupButton>
|
||||
</DesktopNavGroupBox>
|
||||
|
||||
</InvertedBar>
|
||||
);
|
||||
|
||||
@@ -4,19 +4,20 @@ import { Drawer } from '@mui/joy';
|
||||
|
||||
import type { NavItemApp } from '~/common/app.nav';
|
||||
|
||||
import { PageDrawer } from './PageDrawer';
|
||||
import { useOptimaDrawers } from './useOptimaDrawers';
|
||||
import { useOptimaLayout } from './useOptimaLayout';
|
||||
|
||||
|
||||
export function MobileDrawer(props: { currentApp?: NavItemApp }) {
|
||||
export function MobileDrawer(props: { component: React.ElementType, currentApp?: NavItemApp }) {
|
||||
|
||||
// external state
|
||||
const { appPaneContent } = useOptimaLayout();
|
||||
const { appDrawerContent } = useOptimaLayout();
|
||||
const { isDrawerOpen, closeDrawer } = useOptimaDrawers();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
id='mobile-drawer'
|
||||
component={props.component}
|
||||
open={isDrawerOpen}
|
||||
onClose={closeDrawer}
|
||||
sx={{
|
||||
@@ -27,11 +28,19 @@ export function MobileDrawer(props: { currentApp?: NavItemApp }) {
|
||||
// boxSizing: 'border-box',
|
||||
// },
|
||||
}}
|
||||
slotProps={{
|
||||
content: {
|
||||
sx: {
|
||||
// style: round the right drawer corners
|
||||
backgroundColor: 'transparent',
|
||||
borderTopRightRadius: 'var(--AGI-Optima-Radius)',
|
||||
borderBottomRightRadius: 'var(--AGI-Optima-Radius)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
<PageDrawer currentApp={props.currentApp} onClose={closeDrawer}>
|
||||
{appPaneContent}
|
||||
</PageDrawer>
|
||||
{appDrawerContent}
|
||||
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,69 @@
|
||||
import * as React from 'react';
|
||||
import Router from 'next/router';
|
||||
|
||||
import { Typography } from '@mui/joy';
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import type { NavItemApp } from '~/common/app.nav';
|
||||
import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav';
|
||||
|
||||
import { InvertedBar, InvertedBarCornerItem } from './components/InvertedBar';
|
||||
import { useOptimaLayout } from '~/common/layout/optima/useOptimaLayout';
|
||||
import { InvertedBar } from './components/InvertedBar';
|
||||
import { MobileNavGroupBox, MobileNavIcon, mobileNavItemClasses } from './components/MobileNavIcon';
|
||||
|
||||
|
||||
export function MobileNav(props: { currentApp?: NavItemApp, hideOnFocusMode?: boolean }) {
|
||||
export function MobileNav(props: {
|
||||
component: React.ElementType,
|
||||
currentApp?: NavItemApp,
|
||||
hideOnFocusMode?: boolean,
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
|
||||
// external state
|
||||
const { isFocusedMode } = useOptimaLayout();
|
||||
// const { isFocusedMode } = useOptimaLayout();
|
||||
|
||||
|
||||
// App items
|
||||
const navAppItems = React.useMemo(() => {
|
||||
return navItems.apps
|
||||
.filter(app => checkVisibileIcon(app, true, undefined))
|
||||
.map((app) => {
|
||||
const isActive = app === props.currentApp;
|
||||
|
||||
if (checkDivider(app)) {
|
||||
// return <Divider key={'div-' + appIdx} sx={{ mx: 1, height: '50%', my: 'auto' }} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileNavIcon
|
||||
key={'n-m-' + app.route.slice(1)}
|
||||
aria-label={app.name}
|
||||
variant={isActive ? 'solid' : undefined}
|
||||
onClick={() => Router.push(app.landingRoute || app.route)}
|
||||
className={`${mobileNavItemClasses.typeApp} ${isActive ? mobileNavItemClasses.active : ''}`}
|
||||
>
|
||||
{/*{(isActive && app.iconActive) ? <app.iconActive /> : <app.icon />}*/}
|
||||
<app.icon />
|
||||
</MobileNavIcon>
|
||||
);
|
||||
});
|
||||
}, [props.currentApp]);
|
||||
|
||||
|
||||
// NOTE: this may be abrupt a little
|
||||
if (isFocusedMode && props.hideOnFocusMode)
|
||||
return null;
|
||||
// if (isFocusedMode && props.hideOnFocusMode)
|
||||
// return null;
|
||||
|
||||
return (
|
||||
<InvertedBar
|
||||
id='mobile-nav' direction='horizontal'
|
||||
sx={{
|
||||
justifyContent: 'space-around',
|
||||
}}
|
||||
id='mobile-nav'
|
||||
component={props.component}
|
||||
direction='horizontal'
|
||||
sx={props.sx}
|
||||
>
|
||||
<InvertedBarCornerItem sx={{ width: 'auto' }}>
|
||||
<Typography level='title-sm'>
|
||||
Chat
|
||||
</Typography>
|
||||
</InvertedBarCornerItem>
|
||||
<Typography>
|
||||
FIXME: MobileNav
|
||||
</Typography>
|
||||
|
||||
<MobileNavGroupBox>
|
||||
{navAppItems}
|
||||
</MobileNavGroupBox>
|
||||
|
||||
</InvertedBar>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import Router from 'next/router';
|
||||
import { Button, ButtonGroup, ListItem } from '@mui/joy';
|
||||
|
||||
import { NavItemApp, navItems } from '~/common/app.nav';
|
||||
import { Button, ButtonGroup, Divider, ListItem, Tooltip, VariantProp } from '@mui/joy';
|
||||
|
||||
import { checkDivider, checkVisibileIcon, NavItemApp, navItems } from '~/common/app.nav';
|
||||
|
||||
import { BringTheLove } from './components/BringTheLove';
|
||||
|
||||
|
||||
export function MobileNavListItem(props: { currentApp?: NavItemApp }) {
|
||||
/**
|
||||
* This is used from the Menu of the Pagebar, to have nav items on Mobile, before we add
|
||||
* a dedicated Mobile Navigation bar.
|
||||
*/
|
||||
export function MobileNavListItem(props: { variant?: VariantProp, currentApp?: NavItemApp, hideApps?: boolean, hideSocial?: boolean }) {
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
variant='solid'
|
||||
variant={props.variant}
|
||||
sx={{
|
||||
'--ListItem-minHeight': 'var(--AGI-Nav-width)',
|
||||
gap: 1,
|
||||
@@ -19,40 +24,62 @@ export function MobileNavListItem(props: { currentApp?: NavItemApp }) {
|
||||
>
|
||||
|
||||
{/* Group 1: Apps */}
|
||||
<ButtonGroup
|
||||
variant='solid'
|
||||
sx={{
|
||||
'--ButtonGroup-separatorSize': 0,
|
||||
'--ButtonGroup-connected': 0,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{navItems.apps.filter(app => ['Chat', 'Personas', 'News'].includes(app.name)).map(app =>
|
||||
<Button
|
||||
key={'app-' + app.name}
|
||||
disabled={!!app.automatic}
|
||||
size='sm'
|
||||
variant={app == props.currentApp ? 'soft' : 'solid'}
|
||||
onClick={() => Router.push(app.route)}
|
||||
>
|
||||
{app == props.currentApp ? app.name : <app.icon />}
|
||||
</Button>,
|
||||
)}
|
||||
</ButtonGroup>
|
||||
{!props.hideApps && (
|
||||
<ButtonGroup
|
||||
component='nav'
|
||||
variant={props.variant}
|
||||
sx={{
|
||||
'--ButtonGroup-separatorSize': 0,
|
||||
'--ButtonGroup-connected': 0,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{navItems.apps
|
||||
.filter(app => checkVisibileIcon(app, true, undefined))
|
||||
.map((app) => {
|
||||
const isActive = app === props.currentApp;
|
||||
|
||||
if (checkDivider(app))
|
||||
return null;
|
||||
// return <Divider orientation='vertical' key={'div-' + appIdx} />;
|
||||
|
||||
return (
|
||||
<Tooltip key={'n-m-' + app.route.slice(1)} disableInteractive enterDelay={600} title={app.name}>
|
||||
<Button
|
||||
key={'app-' + app.name}
|
||||
size='sm'
|
||||
variant={isActive ? 'soft' : 'solid'}
|
||||
onClick={() => Router.push(app.landingRoute || app.route)}
|
||||
>
|
||||
{/*{isActive ? app.name : <app.icon />}*/}
|
||||
{(isActive && app.name.length <= 4) ? app.name : <app.icon />}
|
||||
{/*{(isActive && app.iconActive) ? <app.iconActive /> : <app.icon />}*/}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
{!props.hideApps && <Divider orientation='vertical' sx={{ my: 1.25 }} />}
|
||||
|
||||
{/* Group 2: Social Links */}
|
||||
<ButtonGroup
|
||||
variant='solid'
|
||||
sx={{
|
||||
'--ButtonGroup-separatorSize': 0,
|
||||
'--ButtonGroup-connected': 0,
|
||||
ml: 'auto',
|
||||
}}
|
||||
>
|
||||
{navItems.links.map(item =>
|
||||
<BringTheLove key={'love-' + item.name} text={item.name} icon={item.icon} link={item.href} />,
|
||||
)}
|
||||
</ButtonGroup>
|
||||
{!props.hideSocial && (
|
||||
<ButtonGroup
|
||||
variant={props.variant}
|
||||
size='sm'
|
||||
sx={{
|
||||
'--ButtonGroup-separatorSize': 0,
|
||||
'--ButtonGroup-connected': 0,
|
||||
ml: 'auto',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{navItems.links.map(item =>
|
||||
<BringTheLove key={'love-' + item.name} text={item.name} icon={item.icon} link={item.href} />,
|
||||
)}
|
||||
</ButtonGroup>
|
||||
)}
|
||||
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PanelGroup } from 'react-resizable-panels';
|
||||
|
||||
import { navItems } from '~/common/app.nav';
|
||||
import { checkVisibleNav, navItems } from '~/common/app.nav';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { DesktopDrawer } from './DesktopDrawer';
|
||||
@@ -11,7 +11,7 @@ import { MobileDrawer } from './MobileDrawer';
|
||||
import { Modals } from './Modals';
|
||||
import { OptimaDrawerProvider } from './useOptimaDrawers';
|
||||
import { OptimaLayoutProvider } from './useOptimaLayout';
|
||||
import { PageContainer } from './PageContainer';
|
||||
import { PageWrapper } from './PageWrapper';
|
||||
|
||||
|
||||
/**
|
||||
@@ -40,24 +40,24 @@ export function OptimaLayout(props: { suspendAutoModelsSetup?: boolean, children
|
||||
|
||||
{isMobile ? <>
|
||||
|
||||
<PageContainer isMobile currentApp={currentApp}>
|
||||
<PageWrapper component='main' isMobile currentApp={currentApp}>
|
||||
{props.children}
|
||||
</PageContainer>
|
||||
</PageWrapper>
|
||||
|
||||
<MobileDrawer currentApp={currentApp} />
|
||||
<MobileDrawer component='aside' currentApp={currentApp} />
|
||||
|
||||
</> : (
|
||||
|
||||
<PanelGroup direction='horizontal' id='desktop-layout'>
|
||||
<PanelGroup direction='horizontal' id='root-layout'>
|
||||
|
||||
{!currentApp?.hideNav && <DesktopNav currentApp={currentApp} />}
|
||||
{checkVisibleNav(currentApp) && <DesktopNav component='nav' currentApp={currentApp} />}
|
||||
|
||||
<DesktopDrawer currentApp={currentApp} />
|
||||
<DesktopDrawer component='aside' currentApp={currentApp} />
|
||||
|
||||
{/*<Panel defaultSize={100}>*/}
|
||||
<PageContainer currentApp={currentApp}>
|
||||
<PageWrapper component='main' currentApp={currentApp}>
|
||||
{props.children}
|
||||
</PageContainer>
|
||||
</PageWrapper>
|
||||
{/*</Panel>*/}
|
||||
|
||||
</PanelGroup>
|
||||
|
||||
@@ -9,7 +9,7 @@ import MenuIcon from '@mui/icons-material/Menu';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
|
||||
import type { NavItemApp } from '~/common/app.nav';
|
||||
import { checkVisibleNav, NavItemApp } from '~/common/app.nav';
|
||||
import { AgiSquircleIcon } from '~/common/components/icons/AgiSquircleIcon';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
@@ -22,24 +22,20 @@ import { useOptimaDrawers } from './useOptimaDrawers';
|
||||
import { useOptimaLayout } from './useOptimaLayout';
|
||||
|
||||
|
||||
function PageBarItemsFallback() {
|
||||
return (
|
||||
const PageBarItemsFallback = (props: { currentApp?: NavItemApp }) =>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { xs: 1, md: 2 },
|
||||
}}>
|
||||
<Link href={ROUTE_INDEX}>
|
||||
<AgiSquircleIcon inverted sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: 'white',
|
||||
}} />
|
||||
<Typography sx={{
|
||||
ml: { xs: 1, md: 2 },
|
||||
color: 'white',
|
||||
textDecoration: 'none',
|
||||
}}>
|
||||
{Brand.Title.Base}
|
||||
</Typography>
|
||||
<AgiSquircleIcon inverted sx={{ width: 32, height: 32, color: 'white' }} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
<Typography level='title-md'>
|
||||
{props.currentApp?.barTitle || props.currentApp?.name || Brand.Title.Base}
|
||||
</Typography>
|
||||
</Box>;
|
||||
|
||||
|
||||
function CommonMenuItems(props: { onClose: () => void }) {
|
||||
@@ -92,7 +88,7 @@ function CommonMenuItems(props: { onClose: () => void }) {
|
||||
/**
|
||||
* The top bar of the application, with pluggable Left and Right menus, and Center component
|
||||
*/
|
||||
export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx?: SxProps }) {
|
||||
export function PageBar(props: { component: React.ElementType, currentApp?: NavItemApp, isMobile?: boolean, sx?: SxProps }) {
|
||||
|
||||
// state
|
||||
// const [value, setValue] = React.useState<ContainedAppType>('chat');
|
||||
@@ -100,7 +96,7 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
|
||||
|
||||
// external state
|
||||
const {
|
||||
appBarItems, appPaneContent, appMenuItems,
|
||||
appBarItems, appDrawerContent, appMenuItems,
|
||||
} = useOptimaLayout();
|
||||
const {
|
||||
openDrawer,
|
||||
@@ -126,18 +122,22 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
|
||||
{/* transition: 'grid-template-rows 1.42s linear',*/}
|
||||
{/*}}>*/}
|
||||
|
||||
<InvertedBar direction='horizontal' sx={props.sx}>
|
||||
<InvertedBar
|
||||
component={props.component}
|
||||
direction='horizontal'
|
||||
sx={props.sx}
|
||||
>
|
||||
|
||||
{/* [Mobile] Drawer button */}
|
||||
{(!!props.isMobile || props.currentApp?.hideNav) && (
|
||||
{(!!props.isMobile || !checkVisibleNav(props.currentApp)) && (
|
||||
<InvertedBarCornerItem>
|
||||
|
||||
{(!appPaneContent || props.currentApp?.hideNav) ? (
|
||||
{(!appDrawerContent || !checkVisibleNav(props.currentApp)) ? (
|
||||
<IconButton component={Link} href={ROUTE_INDEX} noLinkStyle>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton disabled={!appPaneContent} onClick={openDrawer}>
|
||||
<IconButton disabled={!appDrawerContent} onClick={openDrawer}>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -152,12 +152,15 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
|
||||
display: 'flex', flexFlow: 'row wrap', justifyContent: 'center', alignItems: 'center',
|
||||
my: 'auto',
|
||||
}}>
|
||||
{desktopHide ? null : !!appBarItems ? appBarItems : <PageBarItemsFallback />}
|
||||
{appBarItems
|
||||
? appBarItems
|
||||
: <PageBarItemsFallback currentApp={props.currentApp} />
|
||||
}
|
||||
</Box>
|
||||
|
||||
{/* Page Menu Anchor */}
|
||||
<InvertedBarCornerItem>
|
||||
<IconButton disabled={!pageMenuAnchor || (!appMenuItems && !props.isMobile)} onClick={openPageMenu} ref={pageMenuAnchor}>
|
||||
<IconButton disabled={!pageMenuAnchor /*|| (!appMenuItems && !props.isMobile)*/} onClick={openPageMenu} ref={pageMenuAnchor}>
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
</InvertedBarCornerItem>
|
||||
@@ -183,7 +186,7 @@ export function PageBar(props: { currentApp?: NavItemApp, isMobile?: boolean, sx
|
||||
|
||||
{/* [Mobile] Nav is implemented at the bottom of the Page Menu (for now) */}
|
||||
{!!props.isMobile && !!appMenuItems && <ListDivider sx={{ mb: 0 }} />}
|
||||
{!!props.isMobile && <MobileNavListItem currentApp={props.currentApp} />}
|
||||
{!!props.isMobile && <MobileNavListItem variant='solid' currentApp={props.currentApp} />}
|
||||
|
||||
</CloseableMenu>
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box } from '@mui/joy';
|
||||
|
||||
import { themeBgApp, themeZIndexPageBar } from '~/common/app.theme';
|
||||
import type { NavItemApp } from '~/common/app.nav';
|
||||
|
||||
// import { MobileNav } from './MobileNav';
|
||||
import { PageBar } from './PageBar';
|
||||
|
||||
|
||||
const pageCoreSx: SxProps = {
|
||||
// background: 'url(/images/big-agi-background-3.png) no-repeat center bottom fixed',
|
||||
backgroundColor: themeBgApp,
|
||||
height: '100dvh',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
};
|
||||
|
||||
const pageCoreBarSx: SxProps = {
|
||||
zIndex: themeZIndexPageBar,
|
||||
};
|
||||
|
||||
const pageCoreMobileNavSx: SxProps = {
|
||||
flex: 0,
|
||||
};
|
||||
|
||||
|
||||
export const PageCore = (props: {
|
||||
component: React.ElementType,
|
||||
currentApp?: NavItemApp,
|
||||
isMobile?: boolean,
|
||||
children: React.ReactNode,
|
||||
}) =>
|
||||
<Box
|
||||
component={props.component}
|
||||
sx={pageCoreSx}
|
||||
>
|
||||
|
||||
{/* Responsive page bar (pluggable App Center Items and App Menu) */}
|
||||
<PageBar
|
||||
component='header'
|
||||
currentApp={props.currentApp}
|
||||
isMobile={props.isMobile}
|
||||
sx={pageCoreBarSx}
|
||||
/>
|
||||
|
||||
{/* Page (NextJS) must make the assumption they're in a flex-col layout */}
|
||||
{props.children}
|
||||
|
||||
{/* [Mobile] Nav bar at the bottom */}
|
||||
{/*{!!props.isMobile && (*/}
|
||||
{/* <MobileNav*/}
|
||||
{/* component='nav'*/}
|
||||
{/* currentApp={props.currentApp}*/}
|
||||
{/* hideOnFocusMode*/}
|
||||
{/* sx={pageCoreMobileNavSx}*/}
|
||||
{/* />*/}
|
||||
{/*)}*/}
|
||||
|
||||
</Box>;
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { NavItemApp } from '~/common/app.nav';
|
||||
|
||||
import { PageDrawerHeader } from './components/PageDrawerHeader';
|
||||
|
||||
|
||||
export function PageDrawer(props: {
|
||||
currentApp?: NavItemApp,
|
||||
onClose: () => void,
|
||||
children?: React.ReactNode,
|
||||
}) {
|
||||
|
||||
// derived state
|
||||
const drawerTitle = typeof props.currentApp?.drawer === 'string' ? props.currentApp.drawer : false;
|
||||
|
||||
return <>
|
||||
|
||||
{/* Drawer Header */}
|
||||
{drawerTitle && <PageDrawerHeader title={drawerTitle} onClose={props.onClose} />}
|
||||
|
||||
{/* Pluggable Drawer Content */}
|
||||
{props.children}
|
||||
|
||||
</>;
|
||||
}
|
||||
+7
-31
@@ -4,42 +4,18 @@ import { Box, Container } from '@mui/joy';
|
||||
|
||||
import type { NavItemApp } from '~/common/app.nav';
|
||||
import { isPwa } from '~/common/util/pwaUtils';
|
||||
import { themeZIndexPageBar } from '~/common/app.theme';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { PageBar } from './PageBar';
|
||||
import { PageCore } from './PageCore';
|
||||
import { useOptimaDrawers } from './useOptimaDrawers';
|
||||
|
||||
|
||||
const PageCore = (props: { currentApp?: NavItemApp, isMobile?: boolean, children: React.ReactNode }) =>
|
||||
<Box sx={{
|
||||
display: 'flex', flexDirection: 'column',
|
||||
height: '100dvh',
|
||||
}}>
|
||||
|
||||
{/* Responsive page bar (pluggable App Center Items and App Menu) */}
|
||||
<PageBar
|
||||
currentApp={props.currentApp}
|
||||
isMobile={props.isMobile}
|
||||
sx={{
|
||||
zIndex: themeZIndexPageBar,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Page (NextJS) must make the assumption they're in a flex-col layout */}
|
||||
{props.children}
|
||||
|
||||
{/* [Mobile] Nav bar at the bottom */}
|
||||
{/* FIXME: TEMP: Disable mobilenav */}
|
||||
{/*{props.isMobile && <MobileNav hideOnFocusMode currentApp={props.currentApp} />}*/}
|
||||
|
||||
</Box>;
|
||||
|
||||
|
||||
/**
|
||||
* Loaded Application component, fromt the NextJS page router, wrapped in a Container for centering.
|
||||
* Wraps the NextJS Page Component (from the pages router).
|
||||
* - mobile: just the 100dvh pageCore
|
||||
* - desktop: animated left margin (sync with the drawer) and centering via the Container, then the PageCore
|
||||
*/
|
||||
export function PageContainer(props: { currentApp?: NavItemApp, isMobile?: boolean, children: React.ReactNode }) {
|
||||
export function PageWrapper(props: { component: React.ElementType, currentApp?: NavItemApp, isMobile?: boolean, children: React.ReactNode }) {
|
||||
|
||||
// external state
|
||||
const { isDrawerOpen } = useOptimaDrawers();
|
||||
@@ -50,7 +26,7 @@ export function PageContainer(props: { currentApp?: NavItemApp, isMobile?: boole
|
||||
// mobile: no outer containers
|
||||
if (props.isMobile)
|
||||
return (
|
||||
<PageCore isMobile currentApp={props.currentApp}>
|
||||
<PageCore component={props.component} isMobile currentApp={props.currentApp}>
|
||||
{props.children}
|
||||
</PageCore>
|
||||
);
|
||||
@@ -87,7 +63,7 @@ export function PageContainer(props: { currentApp?: NavItemApp, isMobile?: boole
|
||||
}}
|
||||
>
|
||||
|
||||
<PageCore currentApp={props.currentApp}>
|
||||
<PageCore component={props.component} currentApp={props.currentApp}>
|
||||
{props.children}
|
||||
</PageCore>
|
||||
|
||||
@@ -1,46 +1,50 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Button, IconButton, Tooltip } from '@mui/joy';
|
||||
import { Button, Tooltip } from '@mui/joy';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
|
||||
import { DesktopNavIcon, navItemClasses } from './DesktopNavIcon';
|
||||
|
||||
|
||||
export function BringTheLove(props: { text: string, link: string, asIcon?: boolean, icon: React.FC, sx?: SxProps }) {
|
||||
// state
|
||||
const [loved, setLoved] = React.useState(false);
|
||||
const icon = loved ? '❤️' : <props.icon /> ?? null; // '❤️' : '🤍';
|
||||
|
||||
// reset loved after 5 seconds
|
||||
// reset loved after 6.9 seconds
|
||||
React.useEffect(() => {
|
||||
if (loved) {
|
||||
const timer = setTimeout(() => setLoved(false), 5000);
|
||||
const timer = setTimeout(() => setLoved(false), 6900 + 420);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [loved]);
|
||||
|
||||
const icon = loved ? '❤️' : <props.icon /> ?? null; // '❤️' : '🤍';
|
||||
|
||||
return (
|
||||
<Tooltip followCursor title={props.text}>
|
||||
{props.asIcon ? (
|
||||
<IconButton
|
||||
<DesktopNavIcon
|
||||
variant='solid'
|
||||
size='sm'
|
||||
className={navItemClasses.typeLinkOrModal}
|
||||
component={Link} href={props.link} target='_blank'
|
||||
onClick={() => setLoved(true)}
|
||||
component={Link} href={props.link} target='_blank' noLinkStyle
|
||||
sx={{
|
||||
'&:hover': { animation: `${cssRainbowColorKeyframes} 5s linear infinite` },
|
||||
background: 'transparent',
|
||||
textDecoration: 'none',
|
||||
...props.sx,
|
||||
// color: 'text.tertiary',
|
||||
'&:hover': {
|
||||
animation: `${cssRainbowColorKeyframes} 5s linear infinite`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
</DesktopNavIcon>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setLoved(true)}
|
||||
component={Link} href={props.link} target='_blank' noLinkStyle
|
||||
onClick={() => setLoved(true)}
|
||||
sx={{
|
||||
'&:hover': { animation: `${cssRainbowColorKeyframes} 5s linear infinite` },
|
||||
background: 'transparent',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Box, IconButton, styled } from '@mui/joy';
|
||||
|
||||
import { cssRainbowColorKeyframes } from '~/common/app.theme';
|
||||
|
||||
|
||||
export const DesktopNavGroupBox = styled(Box)({
|
||||
// flex column
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
// nav items, reduce the marginBlock a little
|
||||
'--GroupMarginY': '0.125rem',
|
||||
|
||||
// style
|
||||
// backgroundColor: 'rgba(0 0 0 / 0.5)',
|
||||
// borderRadius: '1rem',
|
||||
// paddingBlock: '0.5rem',
|
||||
// overflow: 'hidden',
|
||||
});
|
||||
|
||||
|
||||
export const navItemClasses = {
|
||||
typeApp: 'NavButton-typeApp',
|
||||
typeLinkOrModal: 'NavButton-typeLink',
|
||||
active: 'NavButton-active',
|
||||
paneOpen: 'NavButton-paneOpen',
|
||||
attractive: 'NavButton-attractive',
|
||||
};
|
||||
|
||||
export const DesktopNavIcon = styled(IconButton)(({ theme }) => ({
|
||||
// --Bar is defined in InvertedBar
|
||||
'--MarginX': '0.25rem',
|
||||
|
||||
// border: '1px solid red',
|
||||
marginBlock: 'var(--GroupMarginY)',
|
||||
//marginInline: .. not needd because we center the items
|
||||
padding: 0,
|
||||
|
||||
[`&.${navItemClasses.typeApp},&.${navItemClasses.typeLinkOrModal}`]: {
|
||||
'--Icon-fontSize': '1.25rem',
|
||||
},
|
||||
|
||||
// [`&.${navItemClasses.typeLinkOrModal}`]: {
|
||||
// borderRadius: '50%',
|
||||
// },
|
||||
|
||||
[`&.${navItemClasses.typeApp}`]: {
|
||||
'--IconButton-size': 'calc(var(--Bar) - 2 * var(--MarginX))',
|
||||
transition: 'border-radius 0.4s, margin 0.2s, padding 0.2s', // background-color 0.3s, color 0.2s
|
||||
},
|
||||
|
||||
[`&.${navItemClasses.typeApp}:hover`]: {
|
||||
backgroundColor: 'var(--variant-solidHoverBg)',
|
||||
// backgroundColor: theme.palette.neutral.softHoverBg,
|
||||
color: theme.palette.neutral.softColor,
|
||||
},
|
||||
|
||||
// app active (non hover)
|
||||
// [`&.${navItemClasses.typeApp}.${navItemClasses.active}`]: {},
|
||||
|
||||
// pane open: show a connected half
|
||||
[`&.${navItemClasses.paneOpen}`]: {
|
||||
// squircle animation
|
||||
borderStartStartRadius: 'calc(var(--IconButton-size) / 4)',
|
||||
borderEndStartRadius: 'calc(var(--IconButton-size) / 4)',
|
||||
borderStartEndRadius: 0,
|
||||
borderEndEndRadius: 0,
|
||||
marginLeft: 'calc(2 * var(--MarginX))',
|
||||
paddingRight: 'calc(2 * var(--MarginX))',
|
||||
},
|
||||
[`&.${navItemClasses.paneOpen}:hover`]: {
|
||||
borderRadius: 'var(--joy-radius-md, 0.5rem)',
|
||||
marginLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
|
||||
// attractive: attract the user to click on this element
|
||||
[`&.${navItemClasses.attractive}`]: {
|
||||
animation: `${cssRainbowColorKeyframes} 5s infinite`,
|
||||
transform: 'scale(1.4)',
|
||||
},
|
||||
|
||||
})) as typeof IconButton;
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
import type { SxProps } from '@mui/joy/styles/types';
|
||||
import { Box, Sheet, styled, useTheme } from '@mui/joy';
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const InvertedBarCornerItem = styled(Box)({
|
||||
});
|
||||
|
||||
|
||||
const InvertedBarBase = styled(Sheet)({
|
||||
const StyledSheet = styled(Sheet)({
|
||||
// customization
|
||||
'--Bar': 'var(--AGI-Nav-width)',
|
||||
|
||||
@@ -21,14 +21,14 @@ const InvertedBarBase = styled(Sheet)({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
}) as typeof Sheet;
|
||||
|
||||
|
||||
// This is the AppBar and the MobileAppNav and DesktopNav
|
||||
export const InvertedBar = (props: {
|
||||
id?: string,
|
||||
component: React.ElementType,
|
||||
direction: 'horizontal' | 'vertical',
|
||||
variant?: VariantProp,
|
||||
sx?: SxProps
|
||||
children: React.ReactNode,
|
||||
}) => {
|
||||
@@ -36,26 +36,33 @@ export const InvertedBar = (props: {
|
||||
// check for dark mode
|
||||
const theme = useTheme();
|
||||
const isDark = theme?.palette.mode === 'dark';
|
||||
const variant = isDark ? 'soft' : props.variant || 'solid';
|
||||
|
||||
return <InvertedBarBase
|
||||
id={props.id}
|
||||
variant={variant}
|
||||
invertedColors={variant === 'solid' ? true : undefined}
|
||||
sx={
|
||||
props.direction === 'horizontal'
|
||||
? {
|
||||
// minHeight: 'var(--Bar)',
|
||||
flexDirection: 'row',
|
||||
// overflow: 'hidden',
|
||||
...props.sx,
|
||||
} : {
|
||||
// minWidth: 'var(--Bar)',
|
||||
flexDirection: 'column',
|
||||
...props.sx,
|
||||
}
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</InvertedBarBase>;
|
||||
|
||||
// memoize the Sx for stability, based on direction
|
||||
const sx: SxProps = React.useMemo(() => (
|
||||
props.direction === 'horizontal'
|
||||
? {
|
||||
// minHeight: 'var(--Bar)',
|
||||
flexDirection: 'row',
|
||||
// overflow: 'hidden',
|
||||
...props.sx,
|
||||
} : {
|
||||
// minWidth: 'var(--Bar)',
|
||||
flexDirection: 'column',
|
||||
...props.sx,
|
||||
}
|
||||
), [props.direction, props.sx]);
|
||||
|
||||
|
||||
return (
|
||||
<StyledSheet
|
||||
id={props.id}
|
||||
component={props.component}
|
||||
variant={isDark ? 'soft' : 'solid'}
|
||||
invertedColors={!isDark ? true : undefined}
|
||||
sx={sx}
|
||||
>
|
||||
{props.children}
|
||||
</StyledSheet>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Box, IconButton, styled } from '@mui/joy';
|
||||
|
||||
|
||||
export const MobileNavGroupBox = styled(Box)({
|
||||
// layout
|
||||
flex: 1,
|
||||
minHeight: 'var(--Bar)',
|
||||
|
||||
// contents
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-evenly',
|
||||
alignItems: 'center',
|
||||
|
||||
// style
|
||||
// backgroundColor: 'rgba(0 0 0 / 0.5)', // darken bg
|
||||
|
||||
// debug
|
||||
// '& > *': { border: '1px solid red' },
|
||||
});
|
||||
|
||||
export const mobileNavItemClasses = {
|
||||
typeApp: 'NavButton-typeApp',
|
||||
active: 'NavButton-active',
|
||||
};
|
||||
|
||||
export const MobileNavIcon = styled(IconButton)(({ theme }) => ({
|
||||
|
||||
// custom vars
|
||||
'--MarginY': '0.5rem',
|
||||
'--ExtraPadX': '1rem',
|
||||
|
||||
// IconButton customization
|
||||
'--Icon-fontSize': '1.25rem',
|
||||
'--IconButton-size': 'calc(var(--Bar) - 2 * var(--MarginY))',
|
||||
paddingInline: 'var(--ExtraPadX)',
|
||||
border: 'none',
|
||||
|
||||
[`&.${mobileNavItemClasses.typeApp}:hover`]: {
|
||||
backgroundColor: 'var(--variant-solidHoverBg)',
|
||||
// backgroundColor: theme.palette.neutral.softHoverBg,
|
||||
color: theme.palette.neutral.softColor,
|
||||
},
|
||||
|
||||
// app active (non hover)
|
||||
// [`&.${mobileNavItemClasses.typeApp}.${mobileNavItemClasses.active}`]: {
|
||||
// backgroundColor: ...
|
||||
// },
|
||||
|
||||
})) as typeof IconButton;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user