mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28ef74f1e9 | |||
| 70091ac39b | |||
| cc1011659d | |||
| 7eaa4a11bd | |||
| 495f25e2d4 | |||
| f2396000f2 | |||
| 77533aa385 | |||
| 01b2bf6fa3 | |||
| 6d7843805e | |||
| 0a593fb2c6 | |||
| 57f277f269 | |||
| 6924e02a17 | |||
| f4b645fd78 | |||
| fdb46d3072 | |||
| 858e9d3cb3 | |||
| 52a9dc7bec | |||
| 16fbd3b6a3 | |||
| aa09e60f5f | |||
| 3b2983831d | |||
| 16e69d0d0b | |||
| 548f52c770 | |||
| 8adac0d193 | |||
| c0d3c6c982 | |||
| c1516e7be0 | |||
| 8473894be2 | |||
| d5e2fbed0e | |||
| 2dfa78fbe0 | |||
| dff83c5ede | |||
| 483f483c4a | |||
| f780daf1b1 | |||
| 5e6e5bf017 | |||
| bfe2882ac3 | |||
| 0574be04f4 | |||
| 53b5da8cb8 | |||
| 5387b17c36 | |||
| 0e854b8772 | |||
| d23f247a8c | |||
| ce13c04e96 | |||
| e55fbe9ad0 | |||
| e5a11af6d2 | |||
| 76f21f8c96 | |||
| ea4d9afff2 | |||
| d884970a02 | |||
| ee11787dcc | |||
| 13e1ba977f | |||
| 7137ebdda2 | |||
| 9b71b08fe1 | |||
| 45a18edac0 | |||
| f1b1ca0a5f | |||
| 0c1718bf9c | |||
| a934ca548e | |||
| 2896bd7287 | |||
| 5ad103a8a2 | |||
| 16916db247 | |||
| 669eb1414f | |||
| 6ed8529d6a | |||
| bb36dbc4b9 | |||
| f9e38c7220 | |||
| 2b5a051a9e | |||
| 9793236941 | |||
| 497d1c9559 | |||
| 75c4fe5e67 | |||
| f4d3d3bd28 | |||
| 853aadaa0e | |||
| 8bf23e121c | |||
| cbffc3f6d5 | |||
| 52fc4ec5d8 | |||
| ab94579a30 | |||
| 43ddc79939 | |||
| 6938c6b8d0 | |||
| ba5d835248 | |||
| 510d58ba69 | |||
| c23b0770bf | |||
| cb4fdc56a5 | |||
| 3b28767212 | |||
| a1d6cb8cd0 | |||
| 0a094ef0b0 | |||
| 17c349af94 | |||
| 97f2a19227 | |||
| 6fc2415e5d | |||
| d68c131bbc | |||
| 0b6c217da6 | |||
| 432d78fc9d | |||
| 769ca1546a | |||
| 989684884c | |||
| a2b6554e73 | |||
| 28555445c9 | |||
| 20bddfe6c6 | |||
| 01243f7422 | |||
| 741edb499c | |||
| a3fd877a75 | |||
| 0c19c4c8ac |
@@ -7,7 +7,7 @@ assignees: enricoros
|
||||
|
||||
---
|
||||
|
||||
Release checklist:
|
||||
## Release checklist:
|
||||
|
||||
- [ ] Update the [Roadmap](https://github.com/users/enricoros/projects/4/views/2) calling out shipped features
|
||||
- [ ] Create and update a [Milestone](https://github.com/enricoros/big-agi/milestones) for the release
|
||||
@@ -31,3 +31,31 @@ Release checklist:
|
||||
- Announce:
|
||||
- [ ] Discord announcement
|
||||
- [ ] Twitter announcement
|
||||
|
||||
## Artifacts
|
||||
|
||||
1) first copy and paste the former release `discord announcement`, `news.data.ts`, `changelog.md`, `README.md`
|
||||
2) then copy and paste the milestone and each indivdual issue (content will be downloaded)
|
||||
3) then paste the git changelog 1.2.2...1.2.3
|
||||
|
||||
### news.data.tsx
|
||||
|
||||
```markdown
|
||||
I need the following from you:
|
||||
|
||||
1. a table summarizing all the new features in 1.2.3, 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
|
||||
4. I want you then to update the news.data.tsx for the new release
|
||||
```
|
||||
|
||||
### GitHub release
|
||||
|
||||
Now paste the former release (or 1.5.0 which was accurate and great), including the new contributors and
|
||||
some stats (# of commits, etc.), and roll it for the new release.
|
||||
|
||||
### Discord announcement
|
||||
|
||||
```markdown
|
||||
Can you generate my 1.2.3 big-AGI discord announcement from the GitHub Release announcement, and the in-app News?
|
||||
```
|
||||
|
||||
@@ -13,15 +13,25 @@ Or fork & run on Vercel
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fenricoros%2Fbig-agi&env=OPENAI_API_KEY,OPENAI_API_HOST&envDescription=OpenAI%20KEY%20for%20your%20deployment.%20Set%20HOST%20only%20if%20non-default.)
|
||||
|
||||
## 🗺️ Explore the Roadmap
|
||||
## 👉 [roadmap](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
The development of big-AGI is an open book. Our **[public roadmap](https://github.com/users/enricoros/projects/4/views/2)** is
|
||||
live, providing a detailed look at the current and future development of the application.
|
||||
big-AGI is an open book; our **[public roadmap](https://github.com/users/enricoros/projects/4/views/2)**
|
||||
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.5.0 🌟
|
||||
### What's New in 1.6.0 - Nov 28, 2023 🌟
|
||||
|
||||
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
|
||||
- **Branching Discussions**: Create new conversations from any message
|
||||
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
|
||||
- **Performance Boost**: Faster rendering for a smoother experience
|
||||
- **UI Enhancements**: Refined interface based on user feedback
|
||||
- **New Features**: Anthropic Claude 2.1, `/help` command, and Flattener tool
|
||||
- **For Developers**: Code quality upgrades and snackbar notifications
|
||||
|
||||
### What's New in 1.5.0 - Nov 19, 2023
|
||||
|
||||
- **Continued Voice**: Engage with hands-free interaction for a seamless experience
|
||||
- **Visualization Tool**: Create data representations with our new visualization capabilities
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
|
||||
import { appRouterEdge } from '~/server/api/trpc.router';
|
||||
import { appRouterEdge } from '~/server/api/trpc.router-edge';
|
||||
import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerEdgeRoutes = (req: Request) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
|
||||
import { appRouterNode } from '~/server/api/trpc.router';
|
||||
import { appRouterNode } from '~/server/api/trpc.router-node';
|
||||
import { createTRPCFetchContext } from '~/server/api/trpc.server';
|
||||
|
||||
const handlerNodeRoutes = (req: Request) =>
|
||||
|
||||
+29
-3
@@ -2,10 +2,25 @@
|
||||
|
||||
This is a high-level changelog. Calls out some of the high level features batched
|
||||
by release.
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
- For the live roadmap, please see [the GitHub project](https://github.com/users/enricoros/projects/4/views/2)
|
||||
|
||||
### ✨ What's New in 1.5.0 👊 - Nov 19, 2023
|
||||
### 1.7.0 - Dec 2023
|
||||
|
||||
- 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)
|
||||
- milestone: [1.7.0](https://github.com/enricoros/big-agi/milestone/7)
|
||||
|
||||
### ✨ What's New in 1.6.0 👊 - Nov 28, 2023
|
||||
|
||||
- **Web Browsing**: Download web pages within chats - [browsing guide](https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md)
|
||||
- **Branching Discussions**: Create new conversations from any message
|
||||
- **Keyboard Navigation**: Swift chat navigation with new shortcuts (e.g. ctrl+alt+left/right)
|
||||
- **Performance Boost**: Faster rendering for a smoother experience
|
||||
- **UI Enhancements**: Refined interface based on user feedback
|
||||
- **New Features**: Anthropic Claude 2.1, `/help` command, and Flattener tool
|
||||
- **For Developers**: Code quality upgrades and snackbar notifications
|
||||
|
||||
### What's New in 1.5.0 - Nov 19, 2023
|
||||
|
||||
- **Continued Voice**: Engage with hands-free interaction for a seamless experience
|
||||
- **Visualization Tool**: Create data representations with our new visualization capabilities
|
||||
@@ -17,6 +32,17 @@ by release.
|
||||
- **Cloudflare OpenAI API Gateway**: Integrate with Cloudflare for a robust API gateway
|
||||
- **Helicone for Anthropic**: Utilize Helicone's tools for Anthropic models
|
||||
|
||||
For Developers:
|
||||
|
||||
- Runtime Server-Side configuration: https://github.com/enricoros/big-agi/issues/189. Env vars are
|
||||
not required to be set at build time anymore. The frontend will roundtrip to the backend at the
|
||||
first request to get the configuration. See
|
||||
https://github.com/enricoros/big-agi/blob/main/src/modules/backend/backend.router.ts.
|
||||
- CloudFlare developers: please change the deployment command to
|
||||
`rm app/api/trpc-node/[trpc]/route.ts && npx @cloudflare/next-on-pages@1`,
|
||||
as we transitioned to the App router in NextJS 14. The documentation in
|
||||
[docs/deploy-cloudflare.md](../docs/deploy-cloudflare.md) is updated
|
||||
|
||||
### 1.4.0: Sept/Oct: scale OUT
|
||||
|
||||
- **Expanded Model Support**: Azure and [OpenRouter](https://openrouter.ai/docs#models) models, including gpt-4-32k
|
||||
@@ -32,7 +58,7 @@ by release.
|
||||
- **Backup/Restore** - save chats, and restore them later
|
||||
- **[Local model support with Oobabooga server](../docs/config-local-oobabooga)** - run your own LLMs!
|
||||
- **Flatten conversations** - conversations summarizer with 4 modes
|
||||
- **Fork conversations** - create a new chat, to experiment with different endings
|
||||
- **Fork conversations** - create a new chat, to try with different endings
|
||||
- New commands: /s to add a System message, and /a for an Assistant message
|
||||
- New Chat modes: Write-only - just appends the message, without assistant response
|
||||
- Fix STOP generation - in sync with the Vercel team to fix a long-standing NextJS issue
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Browse Functionality in big-AGI 🌐
|
||||
|
||||
Allows users to load web pages across various components of `big-AGI`. This feature is supported by Puppeteer-based
|
||||
browsing services, which are the most common way to render web pages in a headless environment.
|
||||
|
||||
First of all, you need to procure a Puppteer web browsing service endpoint. `big-AGI` supports services like:
|
||||
|
||||
- [BrightData](https://brightdata.com/products/scraping-browser) Scraping Browser
|
||||
- [Cloudflare](https://developers.cloudflare.com/browser-rendering/) Browser Rendering, or
|
||||
- any other Puppeteer-based service that provides a WebSocket endpoint (WSS)
|
||||
- **including [your own browser](#your-own-chrome-browser)**
|
||||
|
||||
## Configuration
|
||||
|
||||
1. **Procure an Endpoint**: Ensure that your browsing service is running and has a WebSocket endpoint available:
|
||||
- this mustbe in the form: `wss://${auth}@{some host}:{port}`
|
||||
|
||||
2. **Configure `big-AGI`**: navigate to **Preferences** > **Tools** > **Browse** and enter the 'wss://...' connection
|
||||
string provided by your browsing service
|
||||
|
||||
3. **Enable Features**: Choose which browse-related features you want to enable:
|
||||
- **Attach URLs**: Automatically load and attach a page when pasting a URL into the composer
|
||||
- **/browse Command**: Use the `/browse` command in the chat to load a web page
|
||||
- **ReAct**: Enable the `loadURL()` function in ReAct for advanced interactions
|
||||
|
||||
### Server-Side Configuration
|
||||
|
||||
You can set the Puppeteer WebSocket endpoint (`PUPPETEER_WSS_ENDPOINT`) in the deployment before running it.
|
||||
This is useful for self-hosted instances or when you want to pre-configure the endpoint for all users, and will
|
||||
allow your to skip points 2 and 3 above.
|
||||
|
||||
Always deploy your own user authentication, authorization and security solution. For this feature, the tRPC
|
||||
route that provides browsing service, shall be secured with a user authentication and authorization solution,
|
||||
to prevent unauthorized access to the browsing service.
|
||||
|
||||
### Your own Chrome browser
|
||||
|
||||
***EXPERIMENTAL - UNTESTED*** - You can use your own Chrome browser as a browsing service, by configuring it to expose
|
||||
a WebSocket endpoint.
|
||||
|
||||
- close all the Chrome instances (on Windows, check the Task Manager if still running)
|
||||
- start Chrome with the following command line options (on Windows, you can edit the shortcut properties):
|
||||
- `--remote-debugging-port=9222`
|
||||
- go to http://localhost:9222/json/version and copy the `webSocketDebuggerUrl` value
|
||||
- it should be something like: `ws://localhost:9222/...`
|
||||
- paste the value into the Endpoint configuration (see point 2 above)
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, you can start using the browse functionality:
|
||||
|
||||
- **Paste a URL**: Simply paste a URL into the chat, and `big-AGI` will load the page if the Attach URLs feature is enabled
|
||||
- **Use /browse**: Type `/browse [URL]` in the chat to command `big-AGI` to load the specified web page
|
||||
- **ReAct**: ReAct will automatically use the `loadURL()` function whenever a URL is encountered
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have questions about configuring the browse functionality, join our community on Discord for support and discussions.
|
||||
|
||||
[](https://discord.gg/MkH4qj2Jp9)
|
||||
|
||||
---
|
||||
|
||||
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
|
||||
@@ -43,6 +43,8 @@ PRODIA_API_KEY=
|
||||
# Google Custom Search
|
||||
GOOGLE_CLOUD_API_KEY=
|
||||
GOOGLE_CSE_ID=
|
||||
# Browse
|
||||
PUPPETEER_WSS_ENDPOINT=
|
||||
```
|
||||
|
||||
## Variables Documentation
|
||||
@@ -104,6 +106,8 @@ Enable the app to Talk, Draw, and Google things up.
|
||||
| `GOOGLE_CSE_ID` | Google Custom/Programmable Search Engine ID - [Link to PSE](https://programmablesearchengine.google.com/) |
|
||||
| **Text-To-Image** | [Prodia](https://prodia.com/) is a reliable image generation service |
|
||||
| `PRODIA_API_KEY` | Prodia API Key - used with '/imagine ...' |
|
||||
| **Browse** | |
|
||||
| `PUPPETEER_WSS_ENDPOINT` | Puppeteer WebSocket endpoint - used for browsing, etc. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ let nextConfig = {
|
||||
// },
|
||||
// },
|
||||
|
||||
// [puppeteer] https://github.com/puppeteer/puppeteer/issues/11052
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['puppeteer-core'],
|
||||
},
|
||||
|
||||
webpack: (config, _options) => {
|
||||
// @mui/joy: anything material gets redirected to Joy
|
||||
config.resolve.alias['@mui/material'] = '@mui/joy';
|
||||
|
||||
Generated
+201
-95
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "big-agi",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@dqbd/tiktoken": "^1.0.7",
|
||||
@@ -14,17 +14,17 @@
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.16",
|
||||
"@mui/icons-material": "^5.14.18",
|
||||
"@mui/joy": "^5.0.0-beta.15",
|
||||
"@next/bundle-analyzer": "^14.0.3",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@trpc/client": "^10.43.3",
|
||||
"@trpc/next": "^10.43.3",
|
||||
"@trpc/react-query": "^10.43.3",
|
||||
"@trpc/server": "^10.43.3",
|
||||
"@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",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"eventsource-parser": "^1.1.1",
|
||||
@@ -36,7 +36,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-katex": "^3.0.1",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-timeago": "^7.2.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"superjson": "^2.2.1",
|
||||
@@ -46,11 +46,12 @@
|
||||
"zustand": "~4.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.2",
|
||||
"@cloudflare/puppeteer": "^0.0.5",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-katex": "^3.0.3",
|
||||
"@types/react-timeago": "^4.1.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
@@ -58,7 +59,7 @@
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"prettier": "^3.1.0",
|
||||
"prisma": "^5.6.0",
|
||||
"typescript": "^5.2.2"
|
||||
"typescript": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^18.0.0"
|
||||
@@ -74,11 +75,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.22.13",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
|
||||
"integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.4.tgz",
|
||||
"integrity": "sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==",
|
||||
"dependencies": {
|
||||
"@babel/highlight": "^7.22.13",
|
||||
"@babel/highlight": "^7.23.4",
|
||||
"chalk": "^2.4.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -161,9 +162,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.22.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
|
||||
"integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
|
||||
"integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -177,9 +178,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.22.20",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
|
||||
"integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
|
||||
"integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"chalk": "^2.4.2",
|
||||
@@ -254,9 +255,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.2.tgz",
|
||||
"integrity": "sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz",
|
||||
"integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
@@ -265,11 +266,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz",
|
||||
"integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==",
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.4.tgz",
|
||||
"integrity": "sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.22.5",
|
||||
"@babel/helper-string-parser": "^7.23.4",
|
||||
"@babel/helper-validator-identifier": "^7.22.20",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
},
|
||||
@@ -277,6 +278,23 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/puppeteer": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/puppeteer/-/puppeteer-0.0.5.tgz",
|
||||
"integrity": "sha512-K+DLUmDVSM5UNzFokSqie0LPIFAPvdkLKHWnx8Gmck/M41387aCyLlUjWIeUGV3QifSRwaxTRfeMpELQW0lDZg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "4.3.4",
|
||||
"devtools-protocol": "0.0.1019158",
|
||||
"events": "3.3.0",
|
||||
"stream": "0.0.2",
|
||||
"url": "0.11.0",
|
||||
"util": "0.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dqbd/tiktoken": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@dqbd/tiktoken/-/tiktoken-1.0.7.tgz",
|
||||
@@ -1102,9 +1120,9 @@
|
||||
"integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw=="
|
||||
},
|
||||
"node_modules/@rushstack/eslint-patch": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.5.1.tgz",
|
||||
"integrity": "sha512-6i/8UoL0P5y4leBIGzvkZdS85RDMG9y1ihZzmTZQ5LdHUYmZ7pKFoj8X0236s3lusPs1Fa5HTQUpwI+UfTcmeA==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz",
|
||||
"integrity": "sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@sanity/diff-match-patch": {
|
||||
@@ -1190,20 +1208,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@trpc/client": {
|
||||
"version": "10.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-10.43.6.tgz",
|
||||
"integrity": "sha512-gQSxCQgPeBn/wqBEScu5Nq9UKqA16e965vWBj+BbdvI4URV72T44/yg0cl/E6xtBgycCVwdzwn7CuZaM8FA/VQ==",
|
||||
"version": "10.44.1",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-10.44.1.tgz",
|
||||
"integrity": "sha512-vTWsykNcgz1LnwePVl2fKZnhvzP9N3GaaLYPkfGINo314ZOS0OBqe9x0ytB2LLUnRVTAAZ2WoONzARd8nHiqrA==",
|
||||
"funding": [
|
||||
"https://trpc.io/sponsor"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@trpc/server": "10.43.6"
|
||||
"@trpc/server": "10.44.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@trpc/next": {
|
||||
"version": "10.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/next/-/next-10.43.6.tgz",
|
||||
"integrity": "sha512-srV4twQKp8FohivGZ5wxNUdTzgPjjKeuNWG3Bpy5hVrIvg8VHDcglPMHS+3eWTUDDtCbhWpvANre+81B8b9Fgg==",
|
||||
"version": "10.44.1",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/next/-/next-10.44.1.tgz",
|
||||
"integrity": "sha512-ez2oYUzmaQ+pGch627sRBfeEk3h+UIwNicR8WjTAM54TPcdP5W9ZyWCyO5HZTEfjHgGixYM4tCIxewdKOWY9yA==",
|
||||
"funding": [
|
||||
"https://trpc.io/sponsor"
|
||||
],
|
||||
@@ -1212,33 +1230,33 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^4.18.0",
|
||||
"@trpc/client": "10.43.6",
|
||||
"@trpc/react-query": "10.43.6",
|
||||
"@trpc/server": "10.43.6",
|
||||
"@trpc/client": "10.44.1",
|
||||
"@trpc/react-query": "10.44.1",
|
||||
"@trpc/server": "10.44.1",
|
||||
"next": "*",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trpc/react-query": {
|
||||
"version": "10.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-10.43.6.tgz",
|
||||
"integrity": "sha512-Twf0/wvcrDwmaJ6OLf0YVNwiv8+gtoSFyKYqe+5lMkFtUYSl+4KGvSqiN9ynbnofHCvuPgjJmjdS8pxYkcWxCw==",
|
||||
"version": "10.44.1",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-10.44.1.tgz",
|
||||
"integrity": "sha512-Sgi/v0YtdunOXjBRi7om9gILGkOCFYXPzn5KqLuEHiZw5dr5w4qGHFwCeMAvndZxmwfblJrl1tk2AznmsVu8MA==",
|
||||
"funding": [
|
||||
"https://trpc.io/sponsor"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^4.18.0",
|
||||
"@trpc/client": "10.43.6",
|
||||
"@trpc/server": "10.43.6",
|
||||
"@trpc/client": "10.44.1",
|
||||
"@trpc/server": "10.44.1",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@trpc/server": {
|
||||
"version": "10.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-10.43.6.tgz",
|
||||
"integrity": "sha512-ziN7UXGAycxe4i3FwJstTe6jzCcKBlPociCrC9XtfPzFpMTf0hNbRQQlFiZjJk23ZGQSVYDjk9RO4yIHt94mJg==",
|
||||
"version": "10.44.1",
|
||||
"resolved": "https://registry.npmjs.org/@trpc/server/-/server-10.44.1.tgz",
|
||||
"integrity": "sha512-mF7B+K6LjuboX8I1RZgKE5GA/fJhsJ8tKGK2UBt3Bwik7hepEPb4NJgNr7vO6BK5IYwPdBLRLTctRw6XZx0sRg==",
|
||||
"funding": [
|
||||
"https://trpc.io/sponsor"
|
||||
],
|
||||
@@ -1282,9 +1300,9 @@
|
||||
"integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.2.tgz",
|
||||
"integrity": "sha512-WHZXKFCEyIUJzAwh3NyyTHYSR35SevJ6mZ1nWwJafKtiQbqRTIKSRcw3Ma3acqgsent3RRDqeVwpHntMk+9irg==",
|
||||
"version": "20.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
|
||||
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
@@ -1311,14 +1329,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.10.tgz",
|
||||
"integrity": "sha512-mxSnDQxPqsZxmeShFH+uwQ4kO4gcJcGahjjMFeLbKE95IAZiiZyiEepGZjtXJ7hN/yfu0bu9xN2ajcU0JcxX6A=="
|
||||
"version": "15.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
||||
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.37.tgz",
|
||||
"integrity": "sha512-RGAYMi2bhRgEXT3f4B92WTohopH6bIXw05FuGlmJEnv/omEn190+QYEIYxIAuIBdKgboYYdVved2p1AxZVQnaw==",
|
||||
"version": "18.2.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.38.tgz",
|
||||
"integrity": "sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
@@ -1326,9 +1344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.15.tgz",
|
||||
"integrity": "sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg==",
|
||||
"version": "18.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.17.tgz",
|
||||
"integrity": "sha512-rvrT/M7Df5eykWFxn6MYt5Pem/Dbyc1N8Y0S9Mrkw2WFCRiqUgw9P7ul2NpwsXCSM1DVdENzdG9J5SreqfAIWg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
@@ -1362,9 +1380,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.6.tgz",
|
||||
"integrity": "sha512-Vlktnchmkylvc9SnwwwozTv04L/e1NykF5vgoQ0XTmI8DD+wxfjQuHuvHS3p0r2jz2x2ghPs2h1FVeDirIteWA=="
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
|
||||
"integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.2",
|
||||
@@ -1378,15 +1396,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.11.0.tgz",
|
||||
"integrity": "sha512-+whEdjk+d5do5nxfxx73oanLL9ghKO3EwM9kBCkUtWMRwWuPaFv9ScuqlYfQ6pAD6ZiJhky7TZ2ZYhrMsfMxVQ==",
|
||||
"version": "6.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.12.0.tgz",
|
||||
"integrity": "sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.11.0",
|
||||
"@typescript-eslint/types": "6.11.0",
|
||||
"@typescript-eslint/typescript-estree": "6.11.0",
|
||||
"@typescript-eslint/visitor-keys": "6.11.0",
|
||||
"@typescript-eslint/scope-manager": "6.12.0",
|
||||
"@typescript-eslint/types": "6.12.0",
|
||||
"@typescript-eslint/typescript-estree": "6.12.0",
|
||||
"@typescript-eslint/visitor-keys": "6.12.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1406,13 +1424,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.11.0.tgz",
|
||||
"integrity": "sha512-0A8KoVvIURG4uhxAdjSaxy8RdRE//HztaZdG8KiHLP8WOXSk0vlF7Pvogv+vlJA5Rnjj/wDcFENvDaHb+gKd1A==",
|
||||
"version": "6.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.12.0.tgz",
|
||||
"integrity": "sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "6.11.0",
|
||||
"@typescript-eslint/visitor-keys": "6.11.0"
|
||||
"@typescript-eslint/types": "6.12.0",
|
||||
"@typescript-eslint/visitor-keys": "6.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
@@ -1423,9 +1441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.11.0.tgz",
|
||||
"integrity": "sha512-ZbEzuD4DwEJxwPqhv3QULlRj8KYTAnNsXxmfuUXFCxZmO6CF2gM/y+ugBSAQhrqaJL3M+oe4owdWunaHM6beqA==",
|
||||
"version": "6.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.12.0.tgz",
|
||||
"integrity": "sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
@@ -1436,13 +1454,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.11.0.tgz",
|
||||
"integrity": "sha512-Aezzv1o2tWJwvZhedzvD5Yv7+Lpu1by/U1LZ5gLc4tCx8jUmuSCMioPFRjliN/6SJIvY6HpTtJIWubKuYYYesQ==",
|
||||
"version": "6.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.12.0.tgz",
|
||||
"integrity": "sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "6.11.0",
|
||||
"@typescript-eslint/visitor-keys": "6.11.0",
|
||||
"@typescript-eslint/types": "6.12.0",
|
||||
"@typescript-eslint/visitor-keys": "6.12.0",
|
||||
"debug": "^4.3.4",
|
||||
"globby": "^11.1.0",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -1463,12 +1481,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "6.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.11.0.tgz",
|
||||
"integrity": "sha512-+SUN/W7WjBr05uRxPggJPSzyB8zUpaYo2hByKasWbqr3PM8AXfZt8UHdNpBS1v9SA62qnSSMF3380SwDqqprgQ==",
|
||||
"version": "6.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.12.0.tgz",
|
||||
"integrity": "sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "6.11.0",
|
||||
"@typescript-eslint/types": "6.12.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1909,9 +1927,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001563",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001563.tgz",
|
||||
"integrity": "sha512-na2WUmOxnwIZtwnFI2CZ/3er0wdNzU7hN+cPYz/z2ajHThnkWjNBOpEPP4n+4r2WPM847JaMotaJE3bnfzjyKw==",
|
||||
"version": "1.0.30001564",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001564.tgz",
|
||||
"integrity": "sha512-DqAOf+rhof+6GVx1y+xzbFPeOumfQnhYzVnZD6LAXijR77yPtm9mfOcqOnT3mpnJiZVT+kwLAFnRlZcIz+c6bg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -2227,6 +2245,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1019158",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1019158.tgz",
|
||||
"integrity": "sha512-wvq+KscQ7/6spEV7czhnZc9RM/woz1AY+/Vpd8/h2HFMwJSdTliu7f/yr1A6vDdJfKICZsShqsYpEQbdhg8AFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dir-glob": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||
@@ -2306,6 +2330,15 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emitter-component": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz",
|
||||
"integrity": "sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@@ -2869,6 +2902,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource-parser": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.1.tgz",
|
||||
@@ -3502,6 +3544,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz",
|
||||
"integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz",
|
||||
@@ -5500,6 +5558,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/querystring": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
|
||||
"integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==",
|
||||
"deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -6053,6 +6121,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/stream": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz",
|
||||
"integrity": "sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"emitter-component": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
@@ -6496,9 +6573,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||
"integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==",
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz",
|
||||
"integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==",
|
||||
"devOptional": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -6619,6 +6696,22 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
|
||||
"integrity": "sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"punycode": "1.3.2",
|
||||
"querystring": "0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url/node_modules/punycode": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
|
||||
"integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
||||
@@ -6627,6 +6720,19 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-generator-function": "^1.0.7",
|
||||
"is-typed-array": "^1.1.3",
|
||||
"which-typed-array": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
+12
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "big-agi",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -18,17 +18,17 @@
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.16",
|
||||
"@mui/icons-material": "^5.14.18",
|
||||
"@mui/joy": "^5.0.0-beta.15",
|
||||
"@next/bundle-analyzer": "^14.0.3",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@sanity/diff-match-patch": "^3.1.1",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@trpc/client": "^10.43.3",
|
||||
"@trpc/next": "^10.43.3",
|
||||
"@trpc/react-query": "^10.43.3",
|
||||
"@trpc/server": "^10.43.3",
|
||||
"@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",
|
||||
"browser-fs-access": "^0.35.0",
|
||||
"eventsource-parser": "^1.1.1",
|
||||
@@ -40,7 +40,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-katex": "^3.0.1",
|
||||
"react-markdown": "^9.0.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-timeago": "^7.2.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"superjson": "^2.2.1",
|
||||
@@ -50,11 +50,12 @@
|
||||
"zustand": "~4.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.2",
|
||||
"@cloudflare/puppeteer": "^0.0.5",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/plantuml-encoder": "^1.4.2",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@types/react-katex": "^3.0.3",
|
||||
"@types/react-timeago": "^4.1.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
@@ -62,7 +63,7 @@
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"prettier": "^3.1.0",
|
||||
"prisma": "^5.6.0",
|
||||
"typescript": "^5.2.2"
|
||||
"typescript": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^18.0.0"
|
||||
|
||||
+6
-3
@@ -11,6 +11,7 @@ import '~/common/styles/CodePrism.css';
|
||||
import '~/common/styles/GithubMarkdown.css';
|
||||
|
||||
import { ProviderBackend } from '~/common/state/ProviderBackend';
|
||||
import { ProviderSnacks } from '~/common/state/ProviderSnacks';
|
||||
import { ProviderTRPCQueryClient } from '~/common/state/ProviderTRPCQueryClient';
|
||||
import { ProviderTheming } from '~/common/state/ProviderTheming';
|
||||
|
||||
@@ -25,9 +26,11 @@ const MyApp = ({ Component, emotionCache, pageProps }: MyAppProps) =>
|
||||
|
||||
<ProviderTheming emotionCache={emotionCache}>
|
||||
<ProviderTRPCQueryClient>
|
||||
<ProviderBackend>
|
||||
<Component {...pageProps} />
|
||||
</ProviderBackend>
|
||||
<ProviderSnacks>
|
||||
<ProviderBackend>
|
||||
<Component {...pageProps} />
|
||||
</ProviderBackend>
|
||||
</ProviderSnacks>
|
||||
</ProviderTRPCQueryClient>
|
||||
</ProviderTheming>
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { AppLabs } from '../src/apps/labs/AppLabs';
|
||||
|
||||
import { AppLayout } from '~/common/layout/AppLayout';
|
||||
|
||||
|
||||
export default function LabsPage() {
|
||||
return (
|
||||
<AppLayout suspendAutoModelsSetup>
|
||||
<AppLabs />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import { useRouter } from 'next/router';
|
||||
import { Alert, Box, Button, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
import { setComposerStartupText } from '../src/apps/chat/components/composer/store-composer';
|
||||
import { setComposerStartupText } from '../../src/apps/chat/components/composer/store-composer';
|
||||
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { AppLayout } from '~/common/layout/AppLayout';
|
||||
import { LogoProgress } from '~/common/components/LogoProgress';
|
||||
import { asValidURL } from '~/common/util/urlUtils';
|
||||
import { navigateToIndex } from '~/common/app.routes';
|
||||
|
||||
|
||||
/**
|
||||
@@ -28,13 +31,13 @@ function AppShareTarget() {
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const { query, push: routerPush, replace: routerReplace } = useRouter();
|
||||
const { query } = useRouter();
|
||||
|
||||
|
||||
const queueComposerTextAndLaunchApp = React.useCallback((text: string) => {
|
||||
setComposerStartupText(text);
|
||||
void routerReplace('/');
|
||||
}, [routerReplace]);
|
||||
void navigateToIndex(true);
|
||||
}, []);
|
||||
|
||||
|
||||
// Detect the share Intent from the query
|
||||
@@ -71,10 +74,7 @@ function AppShareTarget() {
|
||||
React.useEffect(() => {
|
||||
if (intentURL) {
|
||||
setIsDownloading(true);
|
||||
// TEMP: until the Browse module is ready, just use the URL, verbatim
|
||||
queueComposerTextAndLaunchApp(intentURL);
|
||||
setIsDownloading(false);
|
||||
/*callBrowseFetchSinglePage(intentURL)
|
||||
callBrowseFetchPage(intentURL)
|
||||
.then(pageContent => {
|
||||
if (pageContent)
|
||||
queueComposerTextAndLaunchApp('\n\n```' + intentURL + '\n' + pageContent + '\n```\n');
|
||||
@@ -82,7 +82,7 @@ function AppShareTarget() {
|
||||
setErrorMessage('Could not read any data');
|
||||
})
|
||||
.catch(error => setErrorMessage(error?.message || error || 'Unknown error'))
|
||||
.finally(() => setIsDownloading(false));*/
|
||||
.finally(() => setIsDownloading(false));
|
||||
}
|
||||
}, [intentURL, queueComposerTextAndLaunchApp]);
|
||||
|
||||
@@ -110,7 +110,7 @@ function AppShareTarget() {
|
||||
</Alert>
|
||||
<Button
|
||||
variant='solid' color='danger'
|
||||
onClick={() => routerPush('/')}
|
||||
onClick={() => navigateToIndex()}
|
||||
endDecorator={<ArrowBackIcon />}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
@@ -130,7 +130,7 @@ function AppShareTarget() {
|
||||
|
||||
/**
|
||||
* This page will be invoked on mobile when sharing Text/URLs/Files from other APPs
|
||||
* Example URL: https://get.big-agi.com/launch?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
|
||||
* Example URL: https://localhost:3000/link/share_target?title=This+Title&text=https%3A%2F%2Fexample.com%2Fapp%2Fpath
|
||||
*/
|
||||
export default function LaunchPage() {
|
||||
return (
|
||||
@@ -25,7 +25,7 @@
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/launch",
|
||||
"action": "/link/share_target",
|
||||
"method": "GET",
|
||||
"enctype": "application/x-www-form-urlencoded",
|
||||
"params": {
|
||||
|
||||
@@ -10,9 +10,6 @@ import { CallUI } from './CallUI';
|
||||
import { CallWizard } from './CallWizard';
|
||||
|
||||
|
||||
export const APP_CALL_ENABLED = false;
|
||||
|
||||
|
||||
export function AppCall() {
|
||||
// external state
|
||||
const { query } = useRouter();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Chip, ColorPaletteProp, VariantProp } from '@mui/joy';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
|
||||
|
||||
|
||||
+280
-128
@@ -1,67 +1,120 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box } from '@mui/joy';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
|
||||
import { CmdRunBrowse } from '~/modules/browse/browse.client';
|
||||
import { CmdRunProdia } from '~/modules/prodia/prodia.client';
|
||||
import { CmdRunReact } from '~/modules/aifn/react/react';
|
||||
import { DiagramConfig, DiagramsModal } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
import { FlattenerModal } from '~/modules/aifn/flatten/FlattenerModal';
|
||||
import { TradeConfig, TradeModal } from '~/modules/trade/TradeModal';
|
||||
import { imaginePromptFromText } from '~/modules/aifn/imagine/imaginePromptFromText';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
import { useModelsStore } from '~/modules/llms/store-llms';
|
||||
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { addSnackbar, removeSnackbar } from '~/common/components/useSnackbarsStore';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, useConversation } from '~/common/state/store-chats';
|
||||
import { GlobalShortcutItem, ShortcutKeyName, useGlobalShortcuts } from '~/common/components/useGlobalShortcut';
|
||||
import { useLayoutPluggable } from '~/common/layout/store-applayout';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ChatDrawerItems } from './components/applayout/ChatDrawerItems';
|
||||
import { ChatDrawerItemsMemo } from './components/applayout/ChatDrawerItems';
|
||||
import { ChatDropdowns } from './components/applayout/ChatDropdowns';
|
||||
import { ChatMenuItems } from './components/applayout/ChatMenuItems';
|
||||
import { ChatMessageList } from './components/ChatMessageList';
|
||||
import { ChatModeId } from './components/composer/store-composer';
|
||||
import { CmdAddRoleMessage, extractCommands } from './commands';
|
||||
import { CmdAddRoleMessage, CmdHelp, createCommandsHelpMessage, extractCommands } from './editors/commands';
|
||||
import { Composer } from './components/composer/Composer';
|
||||
import { Ephemerals } from './components/Ephemerals';
|
||||
import { usePanesManager } from './components/usePanesManager';
|
||||
|
||||
import { TradeConfig, TradeModal } from './trade/TradeModal';
|
||||
import { runAssistantUpdatingState } from './editors/chat-stream';
|
||||
import { runBrowseUpdatingState } from './editors/browse-load';
|
||||
import { runImageGenerationUpdatingState } from './editors/image-generate';
|
||||
import { runReActUpdatingState } from './editors/react-tangent';
|
||||
|
||||
|
||||
const SPECIAL_ID_ALL_CHATS = 'all-chats';
|
||||
/**
|
||||
* Mode: how to treat the input from the Composer
|
||||
*/
|
||||
export type ChatModeId = 'immediate' | 'write-user' | 'react' | 'draw-imagine' | 'draw-imagine-plus';
|
||||
|
||||
|
||||
const SPECIAL_ID_WIPE_ALL: DConversationId = 'wipe-chats';
|
||||
|
||||
export function AppChat() {
|
||||
|
||||
// state
|
||||
const [isMessageSelectionMode, setIsMessageSelectionMode] = React.useState(false);
|
||||
const [diagramConfig, setDiagramConfig] = React.useState<DiagramConfig | null>(null);
|
||||
const [tradeConfig, setTradeConfig] = React.useState<TradeConfig | null>(null);
|
||||
const [clearConfirmationId, setClearConfirmationId] = React.useState<string | null>(null);
|
||||
const [deleteConfirmationId, setDeleteConfirmationId] = React.useState<string | null>(null);
|
||||
const [flattenConversationId, setFlattenConversationId] = React.useState<string | null>(null);
|
||||
const [clearConversationId, setClearConversationId] = React.useState<DConversationId | null>(null);
|
||||
const [deleteConversationId, setDeleteConversationId] = React.useState<DConversationId | null>(null);
|
||||
const [flattenConversationId, setFlattenConversationId] = React.useState<DConversationId | null>(null);
|
||||
const showNextTitle = React.useRef(false);
|
||||
const composerTextAreaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// external state
|
||||
const { activeConversationId, isConversationEmpty, hasAnyContent, newConversation, duplicateConversation, deleteAllConversations, setMessages, systemPurposeId, setAutoTitle } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === state.activeConversationId);
|
||||
const isConversationEmpty = conversation ? !conversation.messages.length : true;
|
||||
const hasAnyContent = state.conversations.length > 1 || !isConversationEmpty;
|
||||
return {
|
||||
activeConversationId: state.activeConversationId,
|
||||
isConversationEmpty,
|
||||
hasAnyContent,
|
||||
newConversation: state.createConversationOrSwitch,
|
||||
duplicateConversation: state.duplicateConversation,
|
||||
deleteAllConversations: state.deleteAllConversations,
|
||||
setMessages: state.setMessages,
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
setAutoTitle: state.setAutoTitle,
|
||||
};
|
||||
}, shallow);
|
||||
const {
|
||||
chatPanes,
|
||||
focusedConversationId,
|
||||
navigateHistoryInFocusedPane,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
setFocusedPaneIndex,
|
||||
} = usePanesManager();
|
||||
|
||||
const {
|
||||
title: focusedChatTitle,
|
||||
chatIdx: focusedChatNumber,
|
||||
isChatEmpty: isFocusedChatEmpty,
|
||||
areChatsEmpty,
|
||||
newConversationId,
|
||||
_remove_systemPurposeId: focusedSystemPurposeId,
|
||||
prependNewConversation,
|
||||
branchConversation,
|
||||
deleteConversation,
|
||||
wipeAllConversations,
|
||||
setMessages,
|
||||
} = useConversation(focusedConversationId);
|
||||
|
||||
|
||||
const handleExecuteConversation = async (chatModeId: ChatModeId, conversationId: string, history: DMessage[]) => {
|
||||
// Window actions
|
||||
|
||||
const chatPaneIDs = chatPanes.length > 0 ? chatPanes.map(pane => pane.conversationId) : [null];
|
||||
|
||||
const setActivePaneIndex = React.useCallback((idx: number) => {
|
||||
setFocusedPaneIndex(idx);
|
||||
}, [setFocusedPaneIndex]);
|
||||
|
||||
const setFocusedConversationId = React.useCallback((conversationId: DConversationId | null) => {
|
||||
conversationId && openConversationInFocusedPane(conversationId);
|
||||
}, [openConversationInFocusedPane]);
|
||||
|
||||
const openSplitConversationId = React.useCallback((conversationId: DConversationId | null) => {
|
||||
conversationId && openConversationInSplitPane(conversationId);
|
||||
}, [openConversationInSplitPane]);
|
||||
|
||||
const handleNavigateHistory = React.useCallback((direction: 'back' | 'forward') => {
|
||||
if (navigateHistoryInFocusedPane(direction))
|
||||
showNextTitle.current = true;
|
||||
}, [navigateHistoryInFocusedPane]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showNextTitle.current) {
|
||||
showNextTitle.current = false;
|
||||
const title = (focusedChatNumber >= 0 ? `#${focusedChatNumber + 1} · ` : '') + (focusedChatTitle || 'New Chat');
|
||||
const id = addSnackbar({ key: 'focused-title', message: title, type: 'title' });
|
||||
return () => removeSnackbar(id);
|
||||
}
|
||||
}, [focusedChatNumber, focusedChatTitle]);
|
||||
|
||||
|
||||
// Execution
|
||||
|
||||
const _handleExecute = React.useCallback(async (chatModeId: ChatModeId, conversationId: DConversationId, history: DMessage[]) => {
|
||||
const { chatLLMId } = useModelsStore.getState();
|
||||
if (!chatModeId || !conversationId || !chatLLMId) return;
|
||||
|
||||
@@ -79,20 +132,27 @@ export function AppChat() {
|
||||
setMessages(conversationId, history);
|
||||
return await runReActUpdatingState(conversationId, prompt, chatLLMId);
|
||||
}
|
||||
if (CmdRunBrowse.includes(command) && prompt?.trim() && useBrowseStore.getState().enableCommandBrowse) {
|
||||
setMessages(conversationId, history);
|
||||
return await runBrowseUpdatingState(conversationId, prompt);
|
||||
}
|
||||
if (CmdAddRoleMessage.includes(command)) {
|
||||
lastMessage.role = command.startsWith('/s') ? 'system' : command.startsWith('/a') ? 'assistant' : 'user';
|
||||
lastMessage.sender = 'Bot';
|
||||
lastMessage.text = prompt;
|
||||
return setMessages(conversationId, history);
|
||||
}
|
||||
if (CmdHelp.includes(command)) {
|
||||
return setMessages(conversationId, [...history, createCommandsHelpMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// synchronous long-duration tasks, which update the state as they go
|
||||
if (chatLLMId && systemPurposeId) {
|
||||
if (chatLLMId && focusedSystemPurposeId) {
|
||||
switch (chatModeId) {
|
||||
case 'immediate':
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, systemPurposeId);
|
||||
return await runAssistantUpdatingState(conversationId, history, chatLLMId, focusedSystemPurposeId);
|
||||
case 'write-user':
|
||||
return setMessages(conversationId, history);
|
||||
case 'react':
|
||||
@@ -118,139 +178,225 @@ export function AppChat() {
|
||||
// ISSUE: if we're here, it means we couldn't do the job, at least sync the history
|
||||
console.log('handleExecuteConversation: issue running', chatModeId, conversationId, lastMessage);
|
||||
setMessages(conversationId, history);
|
||||
};
|
||||
}, [focusedSystemPurposeId, setMessages]);
|
||||
|
||||
const _findConversation = (conversationId: string) =>
|
||||
conversationId ? useChatStore.getState().conversations.find(c => c.id === conversationId) ?? null : null;
|
||||
|
||||
const handleExecuteChatHistory = async (conversationId: string, history: DMessage[]) =>
|
||||
await handleExecuteConversation('immediate', conversationId, history);
|
||||
|
||||
const handleDiagramFromText = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
|
||||
|
||||
const handleImagineFromText = async (conversationId: string, messageText: string) => {
|
||||
const conversation = _findConversation(conversationId);
|
||||
const handleComposerNewMessage = async (chatModeId: ChatModeId, conversationId: DConversationId, userText: string) => {
|
||||
const conversation = getConversation(conversationId);
|
||||
if (conversation)
|
||||
return await handleExecuteConversation('draw-imagine-plus', conversationId, [...conversation.messages, createDMessage('user', messageText)]);
|
||||
return await _handleExecute(chatModeId, conversationId, [
|
||||
...conversation.messages,
|
||||
createDMessage('user', userText),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleComposerNewMessage = async (chatModeId: ChatModeId, conversationId: string, userText: string) => {
|
||||
const conversation = _findConversation(conversationId);
|
||||
if (conversation)
|
||||
return await handleExecuteConversation(chatModeId, conversationId, [...conversation.messages, createDMessage('user', userText)]);
|
||||
};
|
||||
const handleConversationExecuteHistory = async (conversationId: DConversationId, history: DMessage[]) =>
|
||||
await _handleExecute('immediate', conversationId, history);
|
||||
|
||||
const handleRegenerateAssistant = async () => {
|
||||
const conversation = activeConversationId ? _findConversation(activeConversationId) : null;
|
||||
if (conversation?.messages?.length) {
|
||||
const lastMessage = conversation.messages[conversation.messages.length - 1];
|
||||
if (lastMessage.role === 'assistant') {
|
||||
const newMessages = [...conversation.messages];
|
||||
newMessages.pop();
|
||||
return await handleExecuteConversation('immediate', conversation.id, newMessages);
|
||||
}
|
||||
const handleMessageRegenerateLast = React.useCallback(async () => {
|
||||
const focusedConversation = getConversation(focusedConversationId);
|
||||
if (focusedConversation?.messages?.length) {
|
||||
const lastMessage = focusedConversation.messages[focusedConversation.messages.length - 1];
|
||||
return await _handleExecute('immediate', focusedConversation.id, lastMessage.role === 'assistant'
|
||||
? focusedConversation.messages.slice(0, -1)
|
||||
: [...focusedConversation.messages],
|
||||
);
|
||||
}
|
||||
}, [focusedConversationId, _handleExecute]);
|
||||
|
||||
const handleTextDiagram = async (diagramConfig: DiagramConfig | null) => setDiagramConfig(diagramConfig);
|
||||
|
||||
const handleTextImaginePlus = async (conversationId: DConversationId, messageText: string) => {
|
||||
const conversation = getConversation(conversationId);
|
||||
if (conversation)
|
||||
return await _handleExecute('draw-imagine-plus', conversationId, [
|
||||
...conversation.messages,
|
||||
createDMessage('user', messageText),
|
||||
]);
|
||||
};
|
||||
|
||||
const handleTextSpeak = async (text: string) => {
|
||||
await speakText(text);
|
||||
};
|
||||
useGlobalShortcut('r', true, true, false, handleRegenerateAssistant);
|
||||
|
||||
|
||||
const handleImportConversation = () => setTradeConfig({ dir: 'import' });
|
||||
// Chat actions
|
||||
|
||||
const handleExportConversation = (conversationId: string | null) => setTradeConfig({ dir: 'export', conversationId });
|
||||
|
||||
const handleFlattenConversation = (conversationId: string) => setFlattenConversationId(conversationId);
|
||||
|
||||
|
||||
useGlobalShortcut('n', true, false, true, () => {
|
||||
newConversation();
|
||||
const handleConversationNew = React.useCallback(() => {
|
||||
// activate an existing new conversation if present, or create another
|
||||
setFocusedConversationId(newConversationId
|
||||
? newConversationId
|
||||
: prependNewConversation(focusedSystemPurposeId ?? undefined),
|
||||
);
|
||||
composerTextAreaRef.current?.focus();
|
||||
});
|
||||
}, [focusedSystemPurposeId, newConversationId, prependNewConversation, setFocusedConversationId]);
|
||||
|
||||
const handleCloneConversation = (conversationId: string) => duplicateConversation(conversationId);
|
||||
useGlobalShortcut('f', true, false, true, () => isConversationEmpty || activeConversationId && handleCloneConversation(activeConversationId));
|
||||
const handleConversationImportDialog = () => setTradeConfig({ dir: 'import' });
|
||||
|
||||
const handleClearConversation = (conversationId: string) => setClearConfirmationId(conversationId);
|
||||
useGlobalShortcut('x', true, false, true, () => isConversationEmpty || setClearConfirmationId(activeConversationId));
|
||||
const handleConversationExport = (conversationId: DConversationId | null) => setTradeConfig({ dir: 'export', conversationId });
|
||||
|
||||
const handleConfirmedClearConversation = () => {
|
||||
if (clearConfirmationId) {
|
||||
setMessages(clearConfirmationId, []);
|
||||
setAutoTitle(clearConfirmationId, '');
|
||||
setClearConfirmationId(null);
|
||||
const handleConversationBranch = React.useCallback((conversationId: DConversationId, messageId: string | null): DConversationId | null => {
|
||||
showNextTitle.current = true;
|
||||
const branchedConversationId = branchConversation(conversationId, messageId);
|
||||
addSnackbar({
|
||||
key: 'branch-conversation',
|
||||
message: 'Branch started.',
|
||||
type: 'success',
|
||||
overrides: {
|
||||
autoHideDuration: 3000,
|
||||
startDecorator: <ForkRightIcon />,
|
||||
},
|
||||
});
|
||||
const branchInAltPanel = useUXLabsStore.getState().labsSplitBranching;
|
||||
if (branchInAltPanel)
|
||||
openSplitConversationId(branchedConversationId);
|
||||
else
|
||||
setFocusedConversationId(branchedConversationId);
|
||||
return branchedConversationId;
|
||||
}, [branchConversation, openSplitConversationId, setFocusedConversationId]);
|
||||
|
||||
const handleConversationFlatten = (conversationId: DConversationId) => setFlattenConversationId(conversationId);
|
||||
|
||||
|
||||
const handleConfirmedClearConversation = React.useCallback(() => {
|
||||
if (clearConversationId) {
|
||||
setMessages(clearConversationId, []);
|
||||
setClearConversationId(null);
|
||||
}
|
||||
};
|
||||
}, [clearConversationId, setMessages]);
|
||||
|
||||
const handleConversationClear = (conversationId: DConversationId) => setClearConversationId(conversationId);
|
||||
|
||||
const handleDeleteAllConversations = () => setDeleteConfirmationId(SPECIAL_ID_ALL_CHATS);
|
||||
|
||||
const handleConfirmedDeleteConversation = () => {
|
||||
if (deleteConfirmationId) {
|
||||
if (deleteConfirmationId === SPECIAL_ID_ALL_CHATS) {
|
||||
deleteAllConversations();
|
||||
}// else
|
||||
// deleteConversation(deleteConfirmationId);
|
||||
setDeleteConfirmationId(null);
|
||||
if (deleteConversationId) {
|
||||
let nextConversationId: DConversationId | null;
|
||||
if (deleteConversationId === SPECIAL_ID_WIPE_ALL)
|
||||
nextConversationId = wipeAllConversations(focusedSystemPurposeId ?? undefined);
|
||||
else
|
||||
nextConversationId = deleteConversation(deleteConversationId);
|
||||
setFocusedConversationId(nextConversationId);
|
||||
setDeleteConversationId(null);
|
||||
}
|
||||
};
|
||||
useGlobalShortcut('d', true, false, true, () => isConversationEmpty || setDeleteConfirmationId(activeConversationId));
|
||||
|
||||
const handleConversationsDeleteAll = () => setDeleteConversationId(SPECIAL_ID_WIPE_ALL);
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId, bypassConfirmation: boolean) => {
|
||||
if (bypassConfirmation)
|
||||
setFocusedConversationId(deleteConversation(conversationId));
|
||||
else
|
||||
setDeleteConversationId(conversationId);
|
||||
}, [deleteConversation, setFocusedConversationId]);
|
||||
|
||||
|
||||
// Shortcuts
|
||||
|
||||
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
|
||||
['r', true, true, false, handleMessageRegenerateLast],
|
||||
['n', true, false, true, handleConversationNew],
|
||||
['b', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationBranch(focusedConversationId, null)],
|
||||
['x', true, false, true, () => isFocusedChatEmpty || focusedConversationId && handleConversationClear(focusedConversationId)],
|
||||
['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, isFocusedChatEmpty]);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
|
||||
// Pluggable ApplicationBar components
|
||||
|
||||
const centerItems = React.useMemo(() =>
|
||||
<ChatDropdowns conversationId={activeConversationId} />,
|
||||
[activeConversationId],
|
||||
<ChatDropdowns conversationId={focusedConversationId} />,
|
||||
[focusedConversationId],
|
||||
);
|
||||
|
||||
const drawerItems = React.useMemo(() =>
|
||||
<ChatDrawerItems
|
||||
conversationId={activeConversationId}
|
||||
onImportConversation={handleImportConversation}
|
||||
onDeleteAllConversations={handleDeleteAllConversations}
|
||||
<ChatDrawerItemsMemo
|
||||
activeConversationId={focusedConversationId}
|
||||
disableNewButton={isFocusedChatEmpty}
|
||||
onConversationActivate={setFocusedConversationId}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
onConversationImportDialog={handleConversationImportDialog}
|
||||
onConversationNew={handleConversationNew}
|
||||
onConversationsDeleteAll={handleConversationsDeleteAll}
|
||||
/>,
|
||||
[activeConversationId],
|
||||
[focusedConversationId, handleConversationDelete, handleConversationNew, isFocusedChatEmpty, setFocusedConversationId],
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() =>
|
||||
<ChatMenuItems
|
||||
conversationId={activeConversationId} isConversationEmpty={isConversationEmpty} hasConversations={hasAnyContent}
|
||||
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onClearConversation={handleClearConversation}
|
||||
onDuplicateConversation={duplicateConversation}
|
||||
onExportConversation={handleExportConversation}
|
||||
onFlattenConversation={handleFlattenConversation}
|
||||
conversationId={focusedConversationId}
|
||||
hasConversations={!areChatsEmpty}
|
||||
isConversationEmpty={isFocusedChatEmpty}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationClear={handleConversationClear}
|
||||
onConversationExport={handleConversationExport}
|
||||
onConversationFlatten={handleConversationFlatten}
|
||||
/>,
|
||||
[activeConversationId, duplicateConversation, hasAnyContent, isConversationEmpty, isMessageSelectionMode],
|
||||
[areChatsEmpty, focusedConversationId, handleConversationBranch, isFocusedChatEmpty, isMessageSelectionMode],
|
||||
);
|
||||
|
||||
useLayoutPluggable(centerItems, drawerItems, menuItems);
|
||||
|
||||
return <>
|
||||
|
||||
<ChatMessageList
|
||||
conversationId={activeConversationId}
|
||||
isMessageSelectionMode={isMessageSelectionMode} setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onExecuteChatHistory={handleExecuteChatHistory}
|
||||
onDiagramFromText={handleDiagramFromText}
|
||||
onImagineFromText={handleImagineFromText}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: 'background.level1',
|
||||
overflowY: 'auto', // overflowY: 'hidden'
|
||||
minHeight: 96,
|
||||
}} />
|
||||
<Box sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex', flexDirection: { xs: 'column', md: 'row' },
|
||||
overflow: 'clip',
|
||||
}}>
|
||||
|
||||
<Ephemerals
|
||||
conversationId={activeConversationId}
|
||||
sx={{
|
||||
// flexGrow: 0.1,
|
||||
flexShrink: 0.5,
|
||||
overflowY: 'auto',
|
||||
minHeight: 64,
|
||||
}} />
|
||||
{chatPaneIDs.map((_conversationId, idx) => (
|
||||
<Box key={'chat-pane-' + idx} onClick={() => setActivePaneIndex(idx)} sx={{
|
||||
flexGrow: 1, flexBasis: 1,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
overflow: 'clip',
|
||||
}}>
|
||||
|
||||
<ChatMessageList
|
||||
conversationId={_conversationId}
|
||||
isMessageSelectionMode={isMessageSelectionMode}
|
||||
setIsMessageSelectionMode={setIsMessageSelectionMode}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationExecuteHistory={handleConversationExecuteHistory}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImaginePlus}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
backgroundColor: 'background.level1',
|
||||
overflowY: 'auto',
|
||||
minHeight: 96,
|
||||
// outline the current focused pane
|
||||
...(chatPaneIDs.length < 2 ? {}
|
||||
: (_conversationId === focusedConversationId)
|
||||
? {
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.solidBg',
|
||||
} : {
|
||||
padding: '2px',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Ephemerals
|
||||
conversationId={_conversationId}
|
||||
sx={{
|
||||
// flexGrow: 0.1,
|
||||
flexShrink: 0.5,
|
||||
overflowY: 'auto',
|
||||
minHeight: 64,
|
||||
}} />
|
||||
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Composer
|
||||
conversationId={activeConversationId} messageId={null}
|
||||
isDeveloperMode={systemPurposeId === 'Developer'}
|
||||
conversationId={focusedConversationId}
|
||||
isDeveloperMode={focusedSystemPurposeId === 'Developer'}
|
||||
composerTextAreaRef={composerTextAreaRef}
|
||||
onNewMessage={handleComposerNewMessage}
|
||||
sx={{
|
||||
@@ -266,25 +412,31 @@ export function AppChat() {
|
||||
{!!diagramConfig && <DiagramsModal config={diagramConfig} onClose={() => setDiagramConfig(null)} />}
|
||||
|
||||
{/* Flatten */}
|
||||
{!!flattenConversationId && <FlattenerModal conversationId={flattenConversationId} onClose={() => setFlattenConversationId(null)} />}
|
||||
{!!flattenConversationId && (
|
||||
<FlattenerModal
|
||||
conversationId={flattenConversationId}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onClose={() => setFlattenConversationId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Import / Export */}
|
||||
{!!tradeConfig && <TradeModal config={tradeConfig} onClose={() => setTradeConfig(null)} />}
|
||||
{!!tradeConfig && <TradeModal config={tradeConfig} onConversationActivate={setFocusedConversationId} onClose={() => setTradeConfig(null)} />}
|
||||
|
||||
|
||||
{/* [confirmation] Reset Conversation */}
|
||||
{!!clearConfirmationId && <ConfirmationModal
|
||||
open onClose={() => setClearConfirmationId(null)} onPositive={handleConfirmedClearConversation}
|
||||
{!!clearConversationId && <ConfirmationModal
|
||||
open onClose={() => setClearConversationId(null)} onPositive={handleConfirmedClearConversation}
|
||||
confirmationText={'Are you sure you want to discard all the messages?'} positiveActionText={'Clear conversation'}
|
||||
/>}
|
||||
|
||||
{/* [confirmation] Delete All */}
|
||||
{!!deleteConfirmationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConfirmationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
|
||||
{!!deleteConversationId && <ConfirmationModal
|
||||
open onClose={() => setDeleteConversationId(null)} onPositive={handleConfirmedDeleteConversation}
|
||||
confirmationText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? 'Are you absolutely sure you want to delete ALL conversations? This action cannot be undone.'
|
||||
: 'Are you sure you want to delete this conversation?'}
|
||||
positiveActionText={deleteConfirmationId === SPECIAL_ID_ALL_CHATS
|
||||
positiveActionText={deleteConversationId === SPECIAL_ID_WIPE_ALL
|
||||
? 'Yes, delete all'
|
||||
: 'Delete conversation'}
|
||||
/>}
|
||||
|
||||
@@ -5,15 +5,15 @@ import { Box, List } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { DiagramConfig } from '~/modules/aifn/digrams/DiagramsModal';
|
||||
import { speakText } from '~/modules/elevenlabs/elevenlabs.client';
|
||||
import { useChatLLM } from '~/modules/llms/store-llms';
|
||||
|
||||
import { GlobalShortcut, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
import { ShortcutKeyName, useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { openLayoutPreferences } from '~/common/layout/store-applayout';
|
||||
import { useCapabilityElevenLabs, useCapabilityProdia } from '~/common/components/useCapabilities';
|
||||
|
||||
import { ChatMessage } from './message/ChatMessage';
|
||||
import { ChatMessageMemo } from './message/ChatMessage';
|
||||
import { CleanerMessage, MessagesSelectionHeader } from './message/CleanerMessage';
|
||||
import { PersonaSelector } from './persona-selector/PersonaSelector';
|
||||
import { useChatShowSystemMessages } from '../store-app-chat';
|
||||
@@ -23,12 +23,14 @@ import { useChatShowSystemMessages } from '../store-app-chat';
|
||||
* A list of ChatMessages
|
||||
*/
|
||||
export function ChatMessageList(props: {
|
||||
conversationId: string | null,
|
||||
conversationId: DConversationId | null,
|
||||
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onExecuteChatHistory: (conversationId: string, history: DMessage[]) => void,
|
||||
onDiagramFromText: (diagramConfig: DiagramConfig | null) => Promise<any>,
|
||||
onImagineFromText: (conversationId: string, selectedText: string) => Promise<any>,
|
||||
sx?: SxProps
|
||||
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>,
|
||||
sx?: SxProps,
|
||||
}) {
|
||||
|
||||
// state
|
||||
@@ -38,10 +40,10 @@ export function ChatMessageList(props: {
|
||||
|
||||
// external state
|
||||
const [showSystemMessages] = useChatShowSystemMessages();
|
||||
const { messages, editMessage, deleteMessage, historyTokenCount } = useChatStore(state => {
|
||||
const { conversationMessages, editMessage, deleteMessage, historyTokenCount } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
messages: conversation ? conversation.messages : [],
|
||||
conversationMessages: conversation ? conversation.messages : [],
|
||||
editMessage: state.editMessage, deleteMessage: state.deleteMessage,
|
||||
historyTokenCount: conversation ? conversation.tokenCount : 0,
|
||||
};
|
||||
@@ -50,53 +52,59 @@ export function ChatMessageList(props: {
|
||||
const { mayWork: isImaginable } = useCapabilityProdia();
|
||||
const { mayWork: isSpeakable } = useCapabilityElevenLabs();
|
||||
|
||||
// derived state
|
||||
const { conversationId, onConversationExecuteHistory, onConversationBranch, onTextDiagram, onTextImagine, onTextSpeak } = props;
|
||||
|
||||
|
||||
// text actions
|
||||
|
||||
const handleAppendMessage = (text: string) =>
|
||||
props.conversationId && props.onExecuteChatHistory(props.conversationId, [...messages, createDMessage('user', text)]);
|
||||
|
||||
const handleTextDiagram = async (messageId: string, text: string) => {
|
||||
if (props.conversationId) {
|
||||
await props.onDiagramFromText({ conversationId: props.conversationId, messageId, text });
|
||||
} else
|
||||
return Promise.reject('No conversation');
|
||||
};
|
||||
|
||||
const handleTextImagine = async (text: string) => {
|
||||
if (!isImaginable) {
|
||||
openLayoutPreferences(2);
|
||||
} else if (props.conversationId) {
|
||||
setIsImagining(true);
|
||||
await props.onImagineFromText(props.conversationId, text);
|
||||
setIsImagining(false);
|
||||
} else
|
||||
return Promise.reject('No conversation');
|
||||
};
|
||||
|
||||
const handleTextSpeak = async (text: string) => {
|
||||
if (!isSpeakable) {
|
||||
openLayoutPreferences(3);
|
||||
} else {
|
||||
setIsSpeaking(true);
|
||||
await speakText(text);
|
||||
setIsSpeaking(false);
|
||||
}
|
||||
};
|
||||
const handleRunExample = (text: string) =>
|
||||
conversationId && onConversationExecuteHistory(conversationId, [...conversationMessages, createDMessage('user', text)]);
|
||||
|
||||
|
||||
// message menu methods proxy
|
||||
|
||||
const handleMessageDelete = (messageId: string) =>
|
||||
props.conversationId && deleteMessage(props.conversationId, messageId);
|
||||
const handleConversationBranch = React.useCallback((messageId: string) => {
|
||||
conversationId && onConversationBranch(conversationId, messageId);
|
||||
}, [conversationId, onConversationBranch]);
|
||||
|
||||
const handleMessageEdit = (messageId: string, newText: string) =>
|
||||
props.conversationId && editMessage(props.conversationId, messageId, { text: newText }, true);
|
||||
const handleConversationRestartFrom = React.useCallback((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, onConversationExecuteHistory]);
|
||||
|
||||
const handleMessageRestartFrom = (messageId: string, offset: number) => {
|
||||
const truncatedHistory = messages.slice(0, messages.findIndex(m => m.id === messageId) + offset + 1);
|
||||
props.conversationId && props.onExecuteChatHistory(props.conversationId, truncatedHistory);
|
||||
};
|
||||
const handleMessageDelete = React.useCallback((messageId: string) => {
|
||||
conversationId && deleteMessage(conversationId, messageId);
|
||||
}, [conversationId, deleteMessage]);
|
||||
|
||||
const handleMessageEdit = React.useCallback((messageId: string, newText: string) => {
|
||||
conversationId && editMessage(conversationId, messageId, { text: newText }, true);
|
||||
}, [conversationId, editMessage]);
|
||||
|
||||
const handleTextDiagram = React.useCallback(async (messageId: string, text: string) => {
|
||||
conversationId && await onTextDiagram({ conversationId: conversationId, messageId, text });
|
||||
}, [conversationId, onTextDiagram]);
|
||||
|
||||
const handleTextImagine = React.useCallback(async (text: string) => {
|
||||
if (!isImaginable)
|
||||
return openLayoutPreferences(2);
|
||||
if (conversationId) {
|
||||
setIsImagining(true);
|
||||
await onTextImagine(conversationId, text);
|
||||
setIsImagining(false);
|
||||
}
|
||||
}, [conversationId, isImaginable, onTextImagine]);
|
||||
|
||||
const handleTextSpeak = React.useCallback(async (text: string) => {
|
||||
if (!isSpeakable)
|
||||
return openLayoutPreferences(3);
|
||||
setIsSpeaking(true);
|
||||
await onTextSpeak(text);
|
||||
setIsSpeaking(false);
|
||||
}, [isSpeakable, onTextSpeak]);
|
||||
|
||||
|
||||
// operate on the local selection set
|
||||
@@ -104,7 +112,7 @@ export function ChatMessageList(props: {
|
||||
const handleSelectAll = (selected: boolean) => {
|
||||
const newSelected = new Set<string>();
|
||||
if (selected)
|
||||
for (const message of messages)
|
||||
for (const message of conversationMessages)
|
||||
newSelected.add(message.id);
|
||||
setSelectedMessages(newSelected);
|
||||
};
|
||||
@@ -116,13 +124,13 @@ export function ChatMessageList(props: {
|
||||
};
|
||||
|
||||
const handleSelectionDelete = () => {
|
||||
if (props.conversationId)
|
||||
if (conversationId)
|
||||
for (const selectedMessage of selectedMessages)
|
||||
deleteMessage(props.conversationId, selectedMessage);
|
||||
deleteMessage(conversationId, selectedMessage);
|
||||
setSelectedMessages(new Set());
|
||||
};
|
||||
|
||||
useGlobalShortcut(props.isMessageSelectionMode && GlobalShortcut.Esc, false, false, false, () => {
|
||||
useGlobalShortcut(props.isMessageSelectionMode && ShortcutKeyName.Esc, false, false, false, () => {
|
||||
props.setIsMessageSelectionMode(false);
|
||||
});
|
||||
|
||||
@@ -130,7 +138,7 @@ export function ChatMessageList(props: {
|
||||
// text-diff functionality, find the messages to diff with
|
||||
|
||||
const { diffMessage, diffText } = React.useMemo(() => {
|
||||
const [msgB, msgA] = messages.filter(m => m.role === 'assistant').reverse();
|
||||
const [msgB, msgA] = conversationMessages.filter(m => m.role === 'assistant').reverse();
|
||||
if (msgB?.text && msgA?.text && !msgB?.typing) {
|
||||
const textA = msgA.text, textB = msgB.text;
|
||||
const lenA = textA.length, lenB = textB.length;
|
||||
@@ -138,21 +146,22 @@ export function ChatMessageList(props: {
|
||||
return { diffMessage: msgB, diffText: textA };
|
||||
}
|
||||
return { diffMessage: undefined, diffText: undefined };
|
||||
}, [messages]);
|
||||
}, [conversationMessages]);
|
||||
|
||||
// no content: show the persona selector
|
||||
|
||||
const filteredMessages = messages
|
||||
const filteredMessages = conversationMessages
|
||||
.filter(m => m.role !== 'system' || showSystemMessages) // hide the System message if the user choses to
|
||||
.reverse(); // 'reverse' is because flexDirection: 'column-reverse' to auto-snap-to-bottom
|
||||
|
||||
// when there are no messages, show the purpose selector
|
||||
if (!filteredMessages.length)
|
||||
return props.conversationId ? (
|
||||
<Box sx={props.sx || {}}>
|
||||
<PersonaSelector conversationId={props.conversationId} runExample={handleAppendMessage} />
|
||||
return (
|
||||
<Box sx={{ ...props.sx }}>
|
||||
{conversationId
|
||||
? <PersonaSelector conversationId={conversationId} runExample={handleRunExample} />
|
||||
: <InlineError severity='info' error='Select a conversation' sx={{ m: 2 }} />}
|
||||
</Box>
|
||||
) : null;
|
||||
);
|
||||
|
||||
return (
|
||||
<List sx={{
|
||||
@@ -160,7 +169,7 @@ export function ChatMessageList(props: {
|
||||
// this makes sure that the the window is scrolled to the bottom (column-reverse)
|
||||
display: 'flex', flexDirection: 'column-reverse',
|
||||
// fix for the double-border on the last message (one by the composer, one to the bottom of the message)
|
||||
marginBottom: '-1px',
|
||||
// marginBottom: '-1px',
|
||||
}}>
|
||||
|
||||
{filteredMessages.map((message, idx) =>
|
||||
@@ -175,17 +184,19 @@ export function ChatMessageList(props: {
|
||||
|
||||
) : (
|
||||
|
||||
<ChatMessage
|
||||
<ChatMessageMemo
|
||||
key={'msg-' + message.id}
|
||||
message={message}
|
||||
diffPreviousText={message === diffMessage ? diffText : undefined}
|
||||
isBottom={idx === 0}
|
||||
isImagining={isImagining} isSpeaking={isSpeaking}
|
||||
onMessageDelete={() => handleMessageDelete(message.id)}
|
||||
onMessageEdit={newText => handleMessageEdit(message.id, newText)}
|
||||
onMessageRunFrom={(offset: number) => handleMessageRestartFrom(message.id, offset)}
|
||||
onTextDiagram={(text: string) => handleTextDiagram(message.id, text)}
|
||||
onTextImagine={handleTextImagine} onTextSpeak={handleTextSpeak}
|
||||
onConversationBranch={handleConversationBranch}
|
||||
onConversationRestartFrom={handleConversationRestartFrom}
|
||||
onMessageDelete={handleMessageDelete}
|
||||
onMessageEdit={handleMessageEdit}
|
||||
onTextDiagram={handleTextDiagram}
|
||||
onTextImagine={handleTextImagine}
|
||||
onTextSpeak={handleTextSpeak}
|
||||
/>
|
||||
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Box, Grid, IconButton, Sheet, Stack, styled, Typography, useTheme } fro
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { DEphemeral, useChatStore } from '~/common/state/store-chats';
|
||||
import { DConversationId, DEphemeral, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
const StateLine = styled(Typography)(({ theme }) => ({
|
||||
@@ -124,7 +124,7 @@ function EphemeralItem({ conversationId, ephemeral }: { conversationId: string,
|
||||
}
|
||||
|
||||
|
||||
export function Ephemerals(props: { conversationId: string | null, sx?: SxProps }) {
|
||||
export function Ephemerals(props: { conversationId: DConversationId | null, sx?: SxProps }) {
|
||||
// global state
|
||||
const theme = useTheme();
|
||||
const ephemerals = useChatStore(state => {
|
||||
|
||||
@@ -6,63 +6,64 @@ import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import FileUploadIcon from '@mui/icons-material/FileUpload';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon';
|
||||
import { closeLayoutDrawer } from '~/common/layout/store-applayout';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ConversationItem } from './ConversationItem';
|
||||
import { ChatNavigationItemMemo } from './ChatNavigationItem';
|
||||
|
||||
|
||||
type ListGrouping = 'off' | 'persona';
|
||||
// type ListGrouping = 'off' | 'persona';
|
||||
|
||||
export function ChatDrawerItems(props: {
|
||||
conversationId: string | null
|
||||
onDeleteAllConversations: () => void,
|
||||
onImportConversation: () => void,
|
||||
export const ChatDrawerItemsMemo = React.memo(ChatDrawerItems);
|
||||
|
||||
function ChatDrawerItems(props: {
|
||||
activeConversationId: DConversationId | null,
|
||||
disableNewButton: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId) => void,
|
||||
onConversationDelete: (conversationId: DConversationId, bypassConfirmation: boolean) => void,
|
||||
onConversationImportDialog: () => void,
|
||||
onConversationNew: () => void,
|
||||
onConversationsDeleteAll: () => void,
|
||||
}) {
|
||||
|
||||
// local state
|
||||
const [grouping] = React.useState<ListGrouping>('off');
|
||||
const { onConversationDelete, onConversationNew, onConversationActivate } = props;
|
||||
// const [grouping] = React.useState<ListGrouping>('off');
|
||||
|
||||
// external state
|
||||
const { conversationIDs, topNewConversationId, maxChatMessages, setActiveConversationId, createConversationOrSwitch, deleteConversation } = useChatStore(state => ({
|
||||
conversationIDs: state.conversations.map(conversation => conversation.id),
|
||||
topNewConversationId: state.conversations.length ? state.conversations[0].messages.length === 0 ? state.conversations[0].id : null : null,
|
||||
maxChatMessages: state.conversations.reduce((longest, conversation) => Math.max(longest, conversation.messages.length), 0),
|
||||
setActiveConversationId: state.setActiveConversationId,
|
||||
createConversationOrSwitch: state.createConversationOrSwitch,
|
||||
deleteConversation: state.deleteConversation,
|
||||
}), shallow);
|
||||
const { experimentalLabs, showSymbols } = useUIPreferencesStore(state => ({
|
||||
experimentalLabs: state.experimentalLabs,
|
||||
showSymbols: state.zenMode !== 'cleaner',
|
||||
}), shallow);
|
||||
const conversations = useChatStore(state => state.conversations, shallow);
|
||||
const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner');
|
||||
const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI);
|
||||
|
||||
|
||||
const totalConversations = conversationIDs.length;
|
||||
// derived state
|
||||
const maxChatMessages = conversations.reduce((longest, _c) => Math.max(longest, _c.messages.length), 1);
|
||||
const totalConversations = conversations.length;
|
||||
const hasChats = totalConversations > 0;
|
||||
const singleChat = totalConversations === 1;
|
||||
const softMaxReached = totalConversations >= 50;
|
||||
|
||||
const handleNew = () => {
|
||||
createConversationOrSwitch();
|
||||
closeLayoutDrawer();
|
||||
};
|
||||
|
||||
const handleConversationActivate = React.useCallback((conversationId: string, closeMenu: boolean) => {
|
||||
setActiveConversationId(conversationId);
|
||||
const handleButtonNew = React.useCallback(() => {
|
||||
onConversationNew();
|
||||
closeLayoutDrawer();
|
||||
}, [onConversationNew]);
|
||||
|
||||
const handleConversationActivate = React.useCallback((conversationId: DConversationId, closeMenu: boolean) => {
|
||||
onConversationActivate(conversationId);
|
||||
if (closeMenu)
|
||||
closeLayoutDrawer();
|
||||
}, [setActiveConversationId]);
|
||||
}, [onConversationActivate]);
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: DConversationId) => {
|
||||
!singleChat && conversationId && onConversationDelete(conversationId, true);
|
||||
}, [onConversationDelete, singleChat]);
|
||||
|
||||
const handleConversationDelete = React.useCallback((conversationId: string) => {
|
||||
if (!singleChat && conversationId)
|
||||
deleteConversation(conversationId);
|
||||
}, [deleteConversation, singleChat]);
|
||||
|
||||
// grouping
|
||||
let sortedIds = conversationIDs;
|
||||
/*let sortedIds = conversationIDs;
|
||||
if (grouping === 'persona') {
|
||||
const conversations = useChatStore.getState().conversations;
|
||||
|
||||
@@ -79,7 +80,7 @@ export function ChatDrawerItems(props: {
|
||||
|
||||
// flatten grouped conversations
|
||||
sortedIds = Object.values(groupedConversations).flat();
|
||||
}
|
||||
}*/
|
||||
|
||||
return <>
|
||||
|
||||
@@ -89,7 +90,7 @@ export function ChatDrawerItems(props: {
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
<MenuItem disabled={!!topNewConversationId && topNewConversationId === props.conversationId} onClick={handleNew}>
|
||||
<MenuItem disabled={props.disableNewButton} onClick={handleButtonNew}>
|
||||
<ListItemDecorator><AddIcon /></ListItemDecorator>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
New
|
||||
@@ -114,22 +115,22 @@ export function ChatDrawerItems(props: {
|
||||
{/* </ToggleButtonGroup>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
{sortedIds.map(conversationId =>
|
||||
<ConversationItem
|
||||
key={'c-id-' + conversationId}
|
||||
conversationId={conversationId}
|
||||
isActive={conversationId === props.conversationId}
|
||||
isSingle={singleChat}
|
||||
{conversations.map(conversation =>
|
||||
<ChatNavigationItemMemo
|
||||
key={'nav-' + conversation.id}
|
||||
conversation={conversation}
|
||||
isActive={conversation.id === props.activeConversationId}
|
||||
isLonely={singleChat}
|
||||
maxChatMessages={(labsEnhancedUI || softMaxReached) ? maxChatMessages : 0}
|
||||
showSymbols={showSymbols}
|
||||
maxChatMessages={(experimentalLabs || softMaxReached) ? maxChatMessages : 0}
|
||||
conversationActivate={handleConversationActivate}
|
||||
conversationDelete={handleConversationDelete}
|
||||
onConversationActivate={handleConversationActivate}
|
||||
onConversationDelete={handleConversationDelete}
|
||||
/>)}
|
||||
</Box>
|
||||
|
||||
<ListDivider sx={{ mt: 0 }} />
|
||||
|
||||
<MenuItem onClick={props.onImportConversation}>
|
||||
<MenuItem onClick={props.onConversationImportDialog}>
|
||||
<ListItemDecorator>
|
||||
<FileUploadIcon />
|
||||
</ListItemDecorator>
|
||||
@@ -137,24 +138,12 @@ export function ChatDrawerItems(props: {
|
||||
<OpenAIIcon sx={{ fontSize: 'xl', ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={!hasChats} onClick={props.onDeleteAllConversations}>
|
||||
<MenuItem disabled={!hasChats} onClick={props.onConversationsDeleteAll}>
|
||||
<ListItemDecorator><DeleteOutlineIcon /></ListItemDecorator>
|
||||
<Typography>
|
||||
Delete {totalConversations >= 2 ? `all ${totalConversations} chats` : 'chat'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{/*<ListItem>*/}
|
||||
{/* <Typography level='body-sm'>*/}
|
||||
{/* Scratchpad*/}
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>*/}
|
||||
{/*<MenuItem>*/}
|
||||
{/* <ListItemDecorator />*/}
|
||||
{/* <Typography sx={{ opacity: 0.5 }}>*/}
|
||||
{/* Feature <Link href={`${Brand.URIs.OpenRepo}/issues/17`} target='_blank'>#17</Link>*/}
|
||||
{/* </Typography>*/}
|
||||
{/*</MenuItem>*/}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
|
||||
import { useChatLLMDropdown } from './useLLMDropdown';
|
||||
import { usePersonaIdDropdown } from './usePersonaDropdown';
|
||||
|
||||
|
||||
export function ChatDropdowns(props: {
|
||||
conversationId: string | null
|
||||
conversationId: DConversationId | null
|
||||
}) {
|
||||
|
||||
// state
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { closeLayoutMenu } from '~/common/layout/store-applayout';
|
||||
import { useUICounter } from '~/common/state/store-ui';
|
||||
@@ -17,12 +18,15 @@ import { useChatShowSystemMessages } from '../../store-app-chat';
|
||||
|
||||
|
||||
export function ChatMenuItems(props: {
|
||||
conversationId: string | null, isConversationEmpty: boolean, hasConversations: boolean,
|
||||
isMessageSelectionMode: boolean, setIsMessageSelectionMode: (isMessageSelectionMode: boolean) => void,
|
||||
onClearConversation: (conversationId: string) => void,
|
||||
onDuplicateConversation: (conversationId: string) => void,
|
||||
onExportConversation: (conversationId: string | null) => void,
|
||||
onFlattenConversation: (conversationId: string) => void,
|
||||
conversationId: DConversationId | null,
|
||||
hasConversations: boolean,
|
||||
isConversationEmpty: boolean,
|
||||
isMessageSelectionMode: boolean,
|
||||
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
|
||||
@@ -32,37 +36,40 @@ export function ChatMenuItems(props: {
|
||||
// derived state
|
||||
const disabled = !props.conversationId || props.isConversationEmpty;
|
||||
|
||||
const handleSystemMessagesToggle = () => setShowSystemMessages(!showSystemMessages);
|
||||
|
||||
const handleConversationExport = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
const closeMenu = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
props.onExportConversation(!disabled ? props.conversationId : null);
|
||||
};
|
||||
|
||||
const handleConversationClear = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationClear(props.conversationId);
|
||||
};
|
||||
|
||||
const handleConversationBranch = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationBranch(props.conversationId, null);
|
||||
};
|
||||
|
||||
const handleConversationExport = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.onConversationExport(!disabled ? props.conversationId : null);
|
||||
shareTouch();
|
||||
};
|
||||
|
||||
const handleConversationDuplicate = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
props.conversationId && props.onDuplicateConversation(props.conversationId);
|
||||
const handleConversationFlatten = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
closeMenu(event);
|
||||
props.conversationId && props.onConversationFlatten(props.conversationId);
|
||||
};
|
||||
|
||||
const handleConversationFlatten = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
props.conversationId && props.onFlattenConversation(props.conversationId);
|
||||
};
|
||||
|
||||
const handleToggleMessageSelectionMode = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
closeLayoutMenu();
|
||||
const handleToggleMessageSelectionMode = (event: React.MouseEvent) => {
|
||||
closeMenu(event);
|
||||
props.setIsMessageSelectionMode(!props.isMessageSelectionMode);
|
||||
};
|
||||
|
||||
const handleConversationClear = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
props.conversationId && props.onClearConversation(props.conversationId);
|
||||
};
|
||||
const handleToggleSystemMessages = () => setShowSystemMessages(!showSystemMessages);
|
||||
|
||||
|
||||
return <>
|
||||
|
||||
@@ -72,29 +79,21 @@ export function ChatMenuItems(props: {
|
||||
{/* </Typography>*/}
|
||||
{/*</ListItem>*/}
|
||||
|
||||
<MenuItem onClick={handleSystemMessagesToggle}>
|
||||
<MenuItem onClick={handleToggleSystemMessages}>
|
||||
<ListItemDecorator><SettingsSuggestIcon /></ListItemDecorator>
|
||||
System message
|
||||
<Switch checked={showSystemMessages} onChange={handleSystemMessagesToggle} sx={{ ml: 'auto' }} />
|
||||
<Switch checked={showSystemMessages} onChange={handleToggleSystemMessages} sx={{ ml: 'auto' }} />
|
||||
</MenuItem>
|
||||
|
||||
<ListDivider inset='startContent' />
|
||||
|
||||
<MenuItem disabled={disabled} onClick={handleConversationDuplicate}>
|
||||
<ListItemDecorator>
|
||||
{/*<Badge size='sm' color='success'>*/}
|
||||
<ForkRightIcon color='success' />
|
||||
{/*</Badge>*/}
|
||||
</ListItemDecorator>
|
||||
Duplicate
|
||||
<MenuItem disabled={disabled} onClick={handleConversationBranch}>
|
||||
<ListItemDecorator><ForkRightIcon /></ListItemDecorator>
|
||||
Branch
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem disabled={disabled} onClick={handleConversationFlatten}>
|
||||
<ListItemDecorator>
|
||||
{/*<Badge size='sm' color='success'>*/}
|
||||
<CompressIcon color='success' />
|
||||
{/*</Badge>*/}
|
||||
</ListItemDecorator>
|
||||
<ListItemDecorator><CompressIcon color='success' /></ListItemDecorator>
|
||||
Flatten
|
||||
</MenuItem>
|
||||
|
||||
|
||||
+53
-50
@@ -1,5 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Avatar, Box, IconButton, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
@@ -9,96 +8,100 @@ import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import { SystemPurposes } from '../../../../data';
|
||||
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { conversationTitle, useChatStore } from '~/common/state/store-chats';
|
||||
import { conversationTitle, DConversation, DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
const DEBUG_CONVERSATION_IDs = false;
|
||||
|
||||
|
||||
export function ConversationItem(props: {
|
||||
conversationId: string,
|
||||
isActive: boolean, isSingle: boolean, showSymbols: boolean, maxChatMessages: number,
|
||||
conversationActivate: (conversationId: string, closeMenu: boolean) => void,
|
||||
conversationDelete: (conversationId: string) => void,
|
||||
export const ChatNavigationItemMemo = React.memo(ChatNavigationItem);
|
||||
|
||||
function ChatNavigationItem(props: {
|
||||
conversation: DConversation,
|
||||
isActive: boolean,
|
||||
isLonely: boolean,
|
||||
maxChatMessages: number,
|
||||
showSymbols: boolean,
|
||||
onConversationActivate: (conversationId: DConversationId, closeMenu: boolean) => void,
|
||||
onConversationDelete: (conversationId: DConversationId) => void,
|
||||
}) {
|
||||
|
||||
const { conversation, isActive } = props;
|
||||
|
||||
// state
|
||||
const [isEditingTitle, setIsEditingTitle] = React.useState(false);
|
||||
const [deleteArmed, setDeleteArmed] = React.useState(false);
|
||||
|
||||
// external state
|
||||
const doubleClickToEdit = useUIPreferencesStore(state => state.doubleClickToEdit);
|
||||
|
||||
// bind to conversation
|
||||
const cState = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return conversation && {
|
||||
isNew: conversation.messages.length === 0,
|
||||
messageCount: conversation.messages.length,
|
||||
assistantTyping: !!conversation.abortController,
|
||||
systemPurposeId: conversation.systemPurposeId,
|
||||
title: conversationTitle(conversation, 'new conversation'),
|
||||
setUserTitle: state.setUserTitle,
|
||||
};
|
||||
}, shallow);
|
||||
// derived state
|
||||
const { id: conversationId } = conversation;
|
||||
const isNew = conversation.messages.length === 0;
|
||||
const messageCount = conversation.messages.length;
|
||||
const assistantTyping = !!conversation.abortController;
|
||||
const systemPurposeId = conversation.systemPurposeId;
|
||||
const title = conversationTitle(conversation, 'new conversation');
|
||||
// const setUserTitle = state.setUserTitle;
|
||||
|
||||
// auto-close the arming menu when clicking away
|
||||
// NOTE: there currently is a bug (race condition) where the menu closes on a new item right after opening
|
||||
// because the isActive prop is not yet updated
|
||||
React.useEffect(() => {
|
||||
if (deleteArmed && !props.isActive)
|
||||
if (deleteArmed && !isActive)
|
||||
setDeleteArmed(false);
|
||||
}, [deleteArmed, props.isActive]);
|
||||
}, [deleteArmed, isActive]);
|
||||
|
||||
// sanity check: shouldn't happen, but just in case
|
||||
if (!cState) return null;
|
||||
const { isNew, messageCount, assistantTyping, setUserTitle, systemPurposeId, title } = cState;
|
||||
|
||||
const handleActivate = () => props.conversationActivate(props.conversationId, true);
|
||||
const handleConversationActivate = () => props.onConversationActivate(conversationId, true);
|
||||
|
||||
const handleEditBegin = () => setIsEditingTitle(true);
|
||||
const handleTitleEdit = () => setIsEditingTitle(true);
|
||||
|
||||
const handleEdited = (text: string) => {
|
||||
const handleTitleEdited = (text: string) => {
|
||||
setIsEditingTitle(false);
|
||||
setUserTitle(props.conversationId, text);
|
||||
useChatStore.getState().setUserTitle(conversationId, text);
|
||||
};
|
||||
|
||||
const handleDeleteBegin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!props.isActive)
|
||||
props.conversationActivate(props.conversationId, false);
|
||||
const handleDeleteButtonShow = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!isActive)
|
||||
props.onConversationActivate(conversationId, false);
|
||||
else
|
||||
setDeleteArmed(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = (e: React.MouseEvent) => {
|
||||
const handleDeleteButtonHide = () => setDeleteArmed(false);
|
||||
|
||||
const handleConversationDelete = (event: React.MouseEvent) => {
|
||||
if (deleteArmed) {
|
||||
setDeleteArmed(false);
|
||||
e.stopPropagation();
|
||||
props.conversationDelete(props.conversationId);
|
||||
event.stopPropagation();
|
||||
props.onConversationDelete(conversationId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => setDeleteArmed(false);
|
||||
|
||||
const textSymbol = SystemPurposes[systemPurposeId]?.symbol || '❓';
|
||||
const buttonSx: SxProps = { ml: 1, ...(props.isActive ? { color: 'white' } : {}) };
|
||||
const buttonSx: SxProps = { ml: 1, ...(isActive ? { color: 'white' } : {}) };
|
||||
|
||||
const progress = props.maxChatMessages ? 100 * messageCount / props.maxChatMessages : 0;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
variant={props.isActive ? 'solid' : 'plain'} color='neutral'
|
||||
selected={props.isActive}
|
||||
onClick={handleActivate}
|
||||
variant={isActive ? 'solid' : 'plain'} color='neutral'
|
||||
selected={isActive}
|
||||
onClick={handleConversationActivate}
|
||||
sx={{
|
||||
// py: 0,
|
||||
position: 'relative',
|
||||
border: 'none', // note, there's a default border of 1px and invisible.. hmm
|
||||
'&:hover > button': { opacity: 1 },
|
||||
...(isActive ? { bgcolor: 'red' } : {}),
|
||||
}}
|
||||
>
|
||||
|
||||
{/* Optional prgoress bar */}
|
||||
{/* Optional progress bar, underlay */}
|
||||
{progress > 0 && (
|
||||
<Box sx={{
|
||||
backgroundColor: 'neutral.softActiveBg',
|
||||
@@ -129,13 +132,13 @@ export function ConversationItem(props: {
|
||||
{/* Text */}
|
||||
{!isEditingTitle ? (
|
||||
|
||||
<Box onDoubleClick={() => doubleClickToEdit ? handleEditBegin() : null} sx={{ flexGrow: 1 }}>
|
||||
{DEBUG_CONVERSATION_IDs ? props.conversationId.slice(0, 10) : title}{assistantTyping && '...'}
|
||||
<Box onDoubleClick={() => doubleClickToEdit ? handleTitleEdit() : null} sx={{ flexGrow: 1 }}>
|
||||
{DEBUG_CONVERSATION_IDs ? conversationId.slice(0, 10) : title}{assistantTyping && '...'}
|
||||
</Box>
|
||||
|
||||
) : (
|
||||
|
||||
<InlineTextarea initialText={title} onEdit={handleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
|
||||
<InlineTextarea initialText={title} onEdit={handleTitleEdited} sx={{ ml: -1.5, mr: -0.5, flexGrow: 1 }} />
|
||||
|
||||
)}
|
||||
|
||||
@@ -151,21 +154,21 @@ export function ConversationItem(props: {
|
||||
{/*</IconButton>*/}
|
||||
|
||||
{/* Delete Arming */}
|
||||
{!props.isSingle && !deleteArmed && (
|
||||
{!props.isLonely && !deleteArmed && (
|
||||
<IconButton
|
||||
variant={props.isActive ? 'solid' : 'outlined'} color='neutral'
|
||||
variant={isActive ? 'solid' : 'outlined'} color='neutral'
|
||||
size='sm' sx={{ opacity: { xs: 1, sm: 0 }, transition: 'opacity 0.3s', ...buttonSx }}
|
||||
onClick={handleDeleteBegin}>
|
||||
onClick={handleDeleteButtonShow}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* Delete / Cancel buttons */}
|
||||
{!props.isSingle && deleteArmed && <>
|
||||
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleDeleteConfirm}>
|
||||
{!props.isLonely && deleteArmed && <>
|
||||
<IconButton size='sm' variant='solid' color='danger' sx={buttonSx} onClick={handleConversationDelete}>
|
||||
<DeleteOutlineIcon />
|
||||
</IconButton>
|
||||
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteCancel}>
|
||||
<IconButton size='sm' variant='solid' color='neutral' sx={buttonSx} onClick={handleDeleteButtonHide}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</>}
|
||||
@@ -4,14 +4,13 @@ import { shallow } from 'zustand/shallow';
|
||||
import { ListItemButton, ListItemDecorator } from '@mui/joy';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
|
||||
import { APP_CALL_ENABLED } from '../../../call/AppCall';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
|
||||
import { AppBarDropdown } from '~/common/layout/AppBarDropdown';
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { launchAppCall } from '~/common/app.routes';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
function AppBarPersonaDropdown(props: {
|
||||
@@ -52,9 +51,10 @@ function AppBarPersonaDropdown(props: {
|
||||
|
||||
}
|
||||
|
||||
export function usePersonaIdDropdown(conversationId: string | null) {
|
||||
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 +69,12 @@ export function usePersonaIdDropdown(conversationId: string | null) {
|
||||
if (conversationId && systemPurposeId)
|
||||
useChatStore.getState().setSystemPurposeId(conversationId, systemPurposeId);
|
||||
}}
|
||||
onCall={APP_CALL_ENABLED ? () => {
|
||||
onCall={labsCalling ? () => {
|
||||
if (conversationId && systemPurposeId)
|
||||
launchAppCall(conversationId, systemPurposeId);
|
||||
} : undefined}
|
||||
/> : null,
|
||||
[conversationId, systemPurposeId],
|
||||
[conversationId, labsCalling, systemPurposeId],
|
||||
);
|
||||
|
||||
return { personaDropdown };
|
||||
|
||||
@@ -5,8 +5,42 @@ import { Box, MenuItem, Radio, Typography } from '@mui/joy';
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ChatModeId, ChatModeItems } from './store-composer';
|
||||
import { ChatModeId } from '../../AppChat';
|
||||
|
||||
|
||||
interface ChatModeDescription {
|
||||
label: string;
|
||||
description: string | React.JSX.Element;
|
||||
shortcut?: string;
|
||||
experimental?: boolean;
|
||||
}
|
||||
|
||||
const ChatModeItems: { [key in ChatModeId]: ChatModeDescription } = {
|
||||
'immediate': {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
},
|
||||
'write-user': {
|
||||
label: 'Write',
|
||||
description: 'Appends a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
},
|
||||
'draw-imagine': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
},
|
||||
'draw-imagine-plus': {
|
||||
label: 'Assisted Draw',
|
||||
description: 'Assisted Image Generation',
|
||||
experimental: true,
|
||||
},
|
||||
'react': {
|
||||
label: 'Reason + Act · α',
|
||||
description: 'Answers questions in multiple steps',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
@@ -15,10 +49,11 @@ function fixNewLineShortcut(shortcut: string, enterIsNewLine: boolean) {
|
||||
return shortcut;
|
||||
}
|
||||
|
||||
export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClose: () => void, experimental: boolean, chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void }) => {
|
||||
export function ChatModeMenu(props: { anchorEl: HTMLAnchorElement | null, onClose: () => void, chatModeId: ChatModeId, onSetChatModeId: (chatMode: ChatModeId) => void }) {
|
||||
|
||||
// external state
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
const labsMagicDraw = useUXLabsStore(state => state.labsMagicDraw);
|
||||
|
||||
return <CloseableMenu
|
||||
placement='top-end' sx={{ minWidth: 320 }}
|
||||
@@ -33,7 +68,7 @@ export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClos
|
||||
|
||||
{/* ChatMode items */}
|
||||
{Object.entries(ChatModeItems)
|
||||
.filter(([, { experimental }]) => props.experimental || !experimental)
|
||||
.filter(([, { experimental }]) => labsMagicDraw || !experimental)
|
||||
.map(([key, data]) =>
|
||||
<MenuItem key={'chat-mode-' + key} onClick={() => props.onSetChatModeId(key as ChatModeId)}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 2 }}>
|
||||
@@ -43,10 +78,10 @@ export const ChatModeMenu = (props: { anchorEl: HTMLAnchorElement | null, onClos
|
||||
<Typography level='body-xs'>{data.description}</Typography>
|
||||
</Box>
|
||||
{(key === props.chatModeId || !!data.shortcut) && (
|
||||
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : "ENTER", enterIsNewline)} />
|
||||
<KeyStroke combo={fixNewLineShortcut((key === props.chatModeId) ? 'ENTER' : data.shortcut ? data.shortcut : 'ENTER', enterIsNewline)} />
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>)}
|
||||
|
||||
</CloseableMenu>;
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Button, ButtonGroup, Card, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Button, ButtonGroup, Card, CircularProgress, Grid, IconButton, Stack, Textarea, Tooltip, Typography } from '@mui/joy';
|
||||
import { ColorPaletteProp, SxProps, VariantProp } from '@mui/joy/styles/types';
|
||||
import AutoModeIcon from '@mui/icons-material/AutoMode';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
@@ -14,14 +14,19 @@ import SendIcon from '@mui/icons-material/Send';
|
||||
import StopOutlinedIcon from '@mui/icons-material/StopOutlined';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { APP_CALL_ENABLED } from '../../../call/AppCall';
|
||||
import type { ChatModeId } from '../../AppChat';
|
||||
|
||||
import { CmdRunReact } from '~/modules/aifn/react/react';
|
||||
import { ContentReducer } from '~/modules/aifn/summarize/ContentReducer';
|
||||
import { LLMOptionsOpenAI } from '~/modules/llms/vendors/openai/openai.vendor';
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
import { useBrowseCapability } from '~/modules/browse/store-module-browsing';
|
||||
import { useChatLLM } from '~/modules/llms/store-llms';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { KeyStroke } from '~/common/components/KeyStroke';
|
||||
import { SpeechResult, useSpeechRecognition } from '~/common/components/useSpeechRecognition';
|
||||
import { asValidURL } from '~/common/util/urlUtils';
|
||||
import { countModelTokens } from '~/common/util/token-counter';
|
||||
import { extractFilePathsWithCommonRadix } from '~/common/util/dropTextUtils';
|
||||
import { getClipboardItems, supportsClipboardRead } from '~/common/util/clipboardUtils';
|
||||
@@ -30,19 +35,19 @@ import { launchAppCall } from '~/common/app.routes';
|
||||
import { openLayoutPreferences } from '~/common/layout/store-applayout';
|
||||
import { pdfToText } from '~/common/util/pdfToText';
|
||||
import { playSoundUrl } from '~/common/util/audioUtils';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useDebouncer } from '~/common/components/useDebouncer';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { ButtonCameraCapture } from './ButtonCameraCapture';
|
||||
import { ButtonClipboardPaste } from './ButtonClipboardPaste';
|
||||
import { ButtonFileAttach } from './ButtonFileAttach';
|
||||
import { ChatModeId, useComposerStartupText } from './store-composer';
|
||||
import { ChatModeMenu } from './ChatModeMenu';
|
||||
import { TokenBadge } from './TokenBadge';
|
||||
import { TokenProgressbar } from './TokenProgressbar';
|
||||
import { useComposerStartupText } from './store-composer';
|
||||
|
||||
|
||||
/// Text template helpers
|
||||
@@ -116,16 +121,18 @@ const DrawOptionsButtonDesktop = (props: { onClick: () => void, sx?: SxProps })
|
||||
* @param {() => void} props.stopGeneration - Function to stop response generation
|
||||
*/
|
||||
export function Composer(props: {
|
||||
conversationId: string | null; messageId: string | null;
|
||||
isDeveloperMode: boolean;
|
||||
conversationId: DConversationId | null;
|
||||
composerTextAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
onNewMessage: (chatModeId: ChatModeId, conversationId: string, text: string) => void;
|
||||
isDeveloperMode: boolean;
|
||||
onNewMessage: (chatModeId: ChatModeId, conversationId: DConversationId, text: string) => void;
|
||||
sx?: SxProps;
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [composeText, debouncedText, setComposeText] = useDebouncer('', 300, 1200, true);
|
||||
const [micContinuation, setMicContinuation] = React.useState(false);
|
||||
const [speechInterimResult, setSpeechInterimResult] = React.useState<SpeechResult | null>(null);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [reducerText, setReducerText] = React.useState('');
|
||||
const [reducerTextTokens, setReducerTextTokens] = React.useState(0);
|
||||
@@ -133,11 +140,13 @@ export function Composer(props: {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const labsCalling = useUXLabsStore(state => state.labsCalling);
|
||||
const [chatModeId, setChatModeId] = React.useState<ChatModeId>('immediate');
|
||||
const [startupText, setStartupText] = useComposerStartupText();
|
||||
const [enterIsNewline, experimentalLabs] = useUIPreferencesStore(state => [state.enterIsNewline, state.experimentalLabs], shallow);
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
const { inComposer: browsingInComposer } = useBrowseCapability();
|
||||
const { assistantTyping, systemPurposeId, tokenCount: conversationTokenCount, stopTyping } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
const conversation = state.conversations.find(_c => _c.id === props.conversationId);
|
||||
return {
|
||||
assistantTyping: conversation ? !!conversation.abortController : false,
|
||||
systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
@@ -147,15 +156,6 @@ export function Composer(props: {
|
||||
}, shallow);
|
||||
const { chatLLMId, chatLLM } = useChatLLM();
|
||||
|
||||
// Effect: load initial text if queued up (e.g. by /launch)
|
||||
React.useEffect(() => {
|
||||
if (startupText) {
|
||||
setStartupText(null);
|
||||
setComposeText(startupText);
|
||||
}
|
||||
}, [setComposeText, setStartupText, startupText]);
|
||||
|
||||
|
||||
// derived state
|
||||
const isDesktop = !isMobile;
|
||||
const tokenLimit = chatLLM?.contextTokens || 0;
|
||||
@@ -167,6 +167,17 @@ export function Composer(props: {
|
||||
const remainingTokens = tokenLimit - directTokens - historyTokens - responseTokens;
|
||||
|
||||
|
||||
// Effect: load initial text if queued up (e.g. by /link/share_targe)
|
||||
React.useEffect(() => {
|
||||
if (startupText) {
|
||||
setStartupText(null);
|
||||
setComposeText(startupText);
|
||||
}
|
||||
}, [setComposeText, setStartupText, startupText]);
|
||||
|
||||
|
||||
// Primary button
|
||||
|
||||
const handleSendClicked = (_chatModeId: ChatModeId) => {
|
||||
const text = (composeText || '').trim();
|
||||
if (text.length && props.conversationId && chatLLMId) {
|
||||
@@ -175,24 +186,6 @@ export function Composer(props: {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
|
||||
const handleDrawOptionsClicked = () => openLayoutPreferences(2);
|
||||
|
||||
|
||||
const handleToggleChatMode = (event: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
|
||||
const handleHideChatMode = () => setChatModeMenuAnchor(null);
|
||||
|
||||
const handleSetChatModeId = (_chatModeId: ChatModeId) => {
|
||||
handleHideChatMode();
|
||||
setChatModeId(_chatModeId);
|
||||
};
|
||||
|
||||
const handleStopClicked = () => props.conversationId && stopTyping(props.conversationId);
|
||||
|
||||
const handleTextareaKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter')
|
||||
return;
|
||||
@@ -212,9 +205,29 @@ export function Composer(props: {
|
||||
};
|
||||
|
||||
|
||||
const micIsRunning = !!speechInterimResult;
|
||||
const micTurnBackOn = !assistantTyping && !micIsRunning && micContinuation;
|
||||
const micIsContinuing = micIsRunning && micContinuation;
|
||||
// Secondary buttons
|
||||
|
||||
const handleCallClicked = () => props.conversationId && systemPurposeId && launchAppCall(props.conversationId, systemPurposeId);
|
||||
|
||||
const handleDrawOptionsClicked = () => openLayoutPreferences(2);
|
||||
|
||||
|
||||
// Mode menu
|
||||
|
||||
const handleModeSelectorHide = () => setChatModeMenuAnchor(null);
|
||||
|
||||
const handleModeSelectorShow = (event: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
setChatModeMenuAnchor(anchor => anchor ? null : event.currentTarget);
|
||||
|
||||
const handleModeChange = (_chatModeId: ChatModeId) => {
|
||||
handleModeSelectorHide();
|
||||
setChatModeId(_chatModeId);
|
||||
};
|
||||
|
||||
const handleStopClicked = () => props.conversationId && stopTyping(props.conversationId);
|
||||
|
||||
|
||||
// Mic typing & continuation mode
|
||||
|
||||
const onSpeechResultCallback = React.useCallback((result: SpeechResult) => {
|
||||
setSpeechInterimResult(result.done ? null : { ...result });
|
||||
@@ -245,25 +258,46 @@ export function Composer(props: {
|
||||
const { isSpeechEnabled, isSpeechError, isRecordingAudio, isRecordingSpeech, toggleRecording } =
|
||||
useSpeechRecognition(onSpeechResultCallback, 2000, 'm');
|
||||
|
||||
const handleMicClicked = () => {
|
||||
if (micIsContinuing)
|
||||
const micIsRunning = !!speechInterimResult;
|
||||
const micContinuationTrigger = micContinuation && !micIsRunning && !assistantTyping;
|
||||
const micColor: ColorPaletteProp = isSpeechError ? 'danger' : isRecordingSpeech ? 'primary' : isRecordingAudio ? 'neutral' : 'neutral';
|
||||
const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'outlined' : 'plain';
|
||||
|
||||
const handleToggleMic = () => {
|
||||
if (micIsRunning && micContinuation)
|
||||
setMicContinuation(false);
|
||||
toggleRecording();
|
||||
};
|
||||
|
||||
const handleToggleMicContinuation = () => setMicContinuation(continued => !continued);
|
||||
|
||||
// autostart the microphone if the assistant stopped typing
|
||||
React.useEffect(() => {
|
||||
if (micTurnBackOn)
|
||||
// autostart the microphone if the assistant stopped typing
|
||||
if (micContinuationTrigger)
|
||||
toggleRecording();
|
||||
}, [toggleRecording, micTurnBackOn]);
|
||||
|
||||
const micColor: ColorPaletteProp = isSpeechError ? 'danger' : isRecordingSpeech ? 'primary' : isRecordingAudio ? 'neutral' : 'neutral';
|
||||
const micVariant: VariantProp = isRecordingSpeech ? 'solid' : isRecordingAudio ? 'outlined' : 'plain';
|
||||
}, [toggleRecording, micContinuationTrigger]);
|
||||
|
||||
|
||||
async function loadAndAttachFiles(files: FileList, overrideFileNames?: string[]): Promise<void> {
|
||||
// Attachments: Files
|
||||
|
||||
const handleAttachWebpage = React.useCallback(async (url: string, fileName: string) => {
|
||||
setIsDownloading(true);
|
||||
let urlContent: string | null;
|
||||
try {
|
||||
urlContent = await callBrowseFetchPage(url);
|
||||
} catch (error: any) {
|
||||
// ignore errors
|
||||
urlContent = `[Web Download] Issue loading website: ${error?.message || typeof error === 'string' ? error : JSON.stringify(error)}`;
|
||||
}
|
||||
setIsDownloading(false);
|
||||
if (urlContent) {
|
||||
setComposeText(expandPromptTemplate(PromptTemplates.PasteFile, { fileName, fileText: urlContent }));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [setComposeText]);
|
||||
|
||||
const handleAttachFiles = async (files: FileList, overrideFileNames?: string[]): Promise<void> => {
|
||||
|
||||
// NOTE: we tried to get the common 'root prefix' of the files here, so that we could attach files with a name that's relative
|
||||
// to the common root, but the files[].webkitRelativePath property is not providing that information
|
||||
@@ -301,20 +335,48 @@ export function Composer(props: {
|
||||
|
||||
// within the budget, so just append
|
||||
setComposeText(text => expandPromptTemplate(PromptTemplates.Concatenate, { text: newText })(text));
|
||||
}
|
||||
|
||||
const handleContentReducerClose = () => {
|
||||
setReducerText('');
|
||||
};
|
||||
|
||||
const handleContentReducerText = (newText: string) => {
|
||||
handleContentReducerClose();
|
||||
setComposeText(text => text + newText);
|
||||
const handleTextareaCtrlV = async (event: React.ClipboardEvent) => {
|
||||
|
||||
// if 'pasting' a file, attach it
|
||||
if (event.clipboardData.files?.length) {
|
||||
event.preventDefault();
|
||||
await handleAttachFiles(event.clipboardData.files, []);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the clipboard contains a single url, download and attach it
|
||||
if (event.clipboardData.types.includes('text/plain')) {
|
||||
const textString = event.clipboardData.getData('text/plain');
|
||||
const textIsUrl = asValidURL(textString);
|
||||
if (browsingInComposer) {
|
||||
if (!isDownloading && textIsUrl && !composeText.startsWith(CmdRunReact[0])) {
|
||||
// if we wanted to stop the paste of the URL itself, we can call e.preventDefault() here (before the await)
|
||||
// e.preventDefault();
|
||||
await handleAttachWebpage(textIsUrl, textString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// paste not intercepted, continue with default behavior
|
||||
};
|
||||
|
||||
const handleCameraOCR = (text: string) => text && setComposeText(expandPromptTemplate(PromptTemplates.PasteMarkdown, { clipboard: text }));
|
||||
|
||||
const handlePasteClipboard = React.useCallback(async () => {
|
||||
// Attachments: Text
|
||||
|
||||
const handleReducerClose = () => setReducerText('');
|
||||
|
||||
const handleReducedText = (text: string) => {
|
||||
handleReducerClose();
|
||||
setComposeText(_t => _t + text);
|
||||
};
|
||||
|
||||
const handleCameraOCRText = (text: string) => {
|
||||
text && setComposeText(expandPromptTemplate(PromptTemplates.PasteMarkdown, { clipboard: text }));
|
||||
};
|
||||
|
||||
const handlePasteFromClipboard = React.useCallback(async () => {
|
||||
for (const clipboardItem of await getClipboardItems()) {
|
||||
|
||||
// when pasting html, only process tables as markdown (e.g. from Excel), or fallback to text
|
||||
@@ -336,6 +398,11 @@ export function Composer(props: {
|
||||
try {
|
||||
const textItem = await clipboardItem.getType('text/plain');
|
||||
const textString = await textItem.text();
|
||||
const textIsUrl = asValidURL(textString);
|
||||
if (browsingInComposer) {
|
||||
if (textIsUrl && await handleAttachWebpage(textIsUrl, textString))
|
||||
continue;
|
||||
}
|
||||
setComposeText(expandPromptTemplate(PromptTemplates.PasteMarkdown, { clipboard: textString }));
|
||||
continue;
|
||||
} catch (error) {
|
||||
@@ -345,22 +412,12 @@ export function Composer(props: {
|
||||
// no text/html or text/plain item found
|
||||
console.log('Clipboard item has no text/html or text/plain item.', clipboardItem.types, clipboardItem);
|
||||
}
|
||||
}, [setComposeText]);
|
||||
}, [browsingInComposer, handleAttachWebpage, setComposeText]);
|
||||
|
||||
useGlobalShortcut(supportsClipboardRead ? 'v' : false, true, true, false, handlePasteClipboard);
|
||||
useGlobalShortcut(supportsClipboardRead ? 'v' : false, true, true, false, handlePasteFromClipboard);
|
||||
|
||||
const handleTextareaCtrlV = async (event: React.ClipboardEvent) => {
|
||||
|
||||
// paste local files
|
||||
if (event.clipboardData.files?.length) {
|
||||
event.preventDefault();
|
||||
await loadAndAttachFiles(event.clipboardData.files, []);
|
||||
return;
|
||||
}
|
||||
|
||||
// paste not intercepted, continue with default behavior
|
||||
};
|
||||
|
||||
// Drag & Drop
|
||||
|
||||
const eatDragEvent = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -394,7 +451,7 @@ export function Composer(props: {
|
||||
const plainText = e.dataTransfer.getData('text/plain');
|
||||
overrideFileNames = extractFilePathsWithCommonRadix(plainText);
|
||||
}
|
||||
return loadAndAttachFiles(e.dataTransfer.files, overrideFileNames);
|
||||
return handleAttachFiles(e.dataTransfer.files, overrideFileNames);
|
||||
}
|
||||
|
||||
// special case: detect failure of dropping from VSCode
|
||||
@@ -442,16 +499,16 @@ export function Composer(props: {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 0, md: 2 } }}>
|
||||
|
||||
{/* [mobile] Mic button */}
|
||||
{isMobile && isSpeechEnabled && <MicButton variant={micVariant} color={micColor} onClick={handleMicClicked} />}
|
||||
{isMobile && isSpeechEnabled && <MicButton variant={micVariant} color={micColor} onClick={handleToggleMic} />}
|
||||
|
||||
{/* Responsive Camera OCR button */}
|
||||
<ButtonCameraCapture isMobile={isMobile} onOCR={handleCameraOCR} />
|
||||
<ButtonCameraCapture isMobile={isMobile} onOCR={handleCameraOCRText} />
|
||||
|
||||
{/* Responsive Attach button */}
|
||||
<ButtonFileAttach isMobile={isMobile} onAttachFiles={loadAndAttachFiles} />
|
||||
<ButtonFileAttach isMobile={isMobile} onAttachFiles={handleAttachFiles} />
|
||||
|
||||
{/* Responsive Paste button */}
|
||||
{supportsClipboardRead && <ButtonClipboardPaste isMobile={isMobile} isDeveloperMode={props.isDeveloperMode} onPaste={handlePasteClipboard} />}
|
||||
{supportsClipboardRead && <ButtonClipboardPaste isMobile={isMobile} isDeveloperMode={props.isDeveloperMode} onPaste={handlePasteFromClipboard} />}
|
||||
|
||||
</Box>
|
||||
|
||||
@@ -466,7 +523,7 @@ export function Composer(props: {
|
||||
minRows={5} maxRows={10}
|
||||
placeholder={textPlaceholder}
|
||||
value={composeText}
|
||||
onChange={(e) => setComposeText(e.target.value)}
|
||||
onChange={(event) => setComposeText(event.target.value)}
|
||||
onDragEnter={handleTextareaDragEnter}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
onPasteCapture={handleTextareaCtrlV}
|
||||
@@ -500,7 +557,7 @@ export function Composer(props: {
|
||||
m: 1,
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{isDesktop && <MicButton variant={micVariant} color={micColor} onClick={handleMicClicked} />}
|
||||
{isDesktop && <MicButton variant={micVariant} color={micColor} onClick={handleToggleMic} />}
|
||||
|
||||
{micIsRunning && (
|
||||
<MicContinuationButton
|
||||
@@ -557,6 +614,26 @@ export function Composer(props: {
|
||||
</Typography>
|
||||
</Card>
|
||||
|
||||
{isDownloading && <Card
|
||||
color='success' invertedColors variant='soft'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0, top: 0,
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
border: '1px solid',
|
||||
borderColor: 'success.solidBg',
|
||||
borderRadius: 'xs',
|
||||
zIndex: 20,
|
||||
}}>
|
||||
<CircularProgress />
|
||||
<Typography level='title-md' sx={{ mt: 1 }}>
|
||||
Loading & Attaching Website
|
||||
</Typography>
|
||||
<Typography level='body-xs'>
|
||||
This will take up to 15 seconds
|
||||
</Typography>
|
||||
</Card>}
|
||||
|
||||
</Box>
|
||||
|
||||
</Stack></Grid>
|
||||
@@ -568,11 +645,13 @@ export function Composer(props: {
|
||||
{/* first row of buttons */}
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
|
||||
{/* [mobile, corner] Call secondary button */}
|
||||
{isMobile && isChat && <CallButtonMobile disabled={!APP_CALL_ENABLED || !props.conversationId || !chatLLM} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />}
|
||||
|
||||
{/* [mobile, corner] Draw Options secondary button */}
|
||||
{isMobile && (isDraw || isDrawPlus) && <DrawOptionsButtonMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />}
|
||||
{/* [mobile] bottom-corner secondary button */}
|
||||
{isMobile && (isChat
|
||||
? <CallButtonMobile disabled={!labsCalling || !props.conversationId || !chatLLM} onClick={handleCallClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: (isDraw || isDrawPlus)
|
||||
? <DrawOptionsButtonMobile onClick={handleDrawOptionsClicked} sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
: <IconButton disabled variant='plain' color='neutral' sx={{ mr: { xs: 1, md: 2 } }} />
|
||||
)}
|
||||
|
||||
{/* Responsive Send/Stop buttons */}
|
||||
{assistantTyping
|
||||
@@ -589,12 +668,12 @@ export function Composer(props: {
|
||||
<Button
|
||||
fullWidth variant={isWriteUser ? 'soft' : 'solid'} color={isReAct ? 'success' : (isDraw || isDrawPlus) ? 'warning' : 'primary'} disabled={!props.conversationId || !chatLLM}
|
||||
onClick={() => handleSendClicked(chatModeId)}
|
||||
endDecorator={micIsContinuing ? <AutoModeIcon /> : isWriteUser ? <SendIcon sx={{ fontSize: 18 }} /> : isReAct ? <PsychologyIcon /> : <TelegramIcon />}
|
||||
endDecorator={micContinuation ? <AutoModeIcon /> : isWriteUser ? <SendIcon sx={{ fontSize: 18 }} /> : isReAct ? <PsychologyIcon /> : <TelegramIcon />}
|
||||
>
|
||||
{micIsContinuing && 'Voice '}
|
||||
{micContinuation && 'Voice '}
|
||||
{isWriteUser ? 'Write' : isReAct ? 'ReAct' : isDraw ? 'Draw' : isDrawPlus ? 'Draw+' : 'Chat'}
|
||||
</Button>
|
||||
<IconButton disabled={!props.conversationId || !chatLLM || !!chatModeMenuAnchor} onClick={handleToggleChatMode}>
|
||||
<IconButton disabled={!props.conversationId || !chatLLM || !!chatModeMenuAnchor} onClick={handleModeSelectorShow}>
|
||||
<ExpandLessIcon />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
@@ -606,7 +685,7 @@ export function Composer(props: {
|
||||
{isDesktop && <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1, justifyContent: 'flex-end' }}>
|
||||
|
||||
{/* [desktop] Call secondary button */}
|
||||
{isChat && <CallButtonDesktop disabled={!APP_CALL_ENABLED || !props.conversationId || !chatLLM} onClick={handleCallClicked} />}
|
||||
{isChat && <CallButtonDesktop disabled={!labsCalling || !props.conversationId || !chatLLM} onClick={handleCallClicked} />}
|
||||
|
||||
{/* [desktop] Draw Options secondary button */}
|
||||
{(isDraw || isDrawPlus) && <DrawOptionsButtonDesktop onClick={handleDrawOptionsClicked} />}
|
||||
@@ -620,9 +699,8 @@ export function Composer(props: {
|
||||
{/* Mode selector */}
|
||||
{!!chatModeMenuAnchor && (
|
||||
<ChatModeMenu
|
||||
anchorEl={chatModeMenuAnchor} onClose={handleHideChatMode}
|
||||
experimental={experimentalLabs}
|
||||
chatModeId={chatModeId} onSetChatModeId={handleSetChatModeId}
|
||||
anchorEl={chatModeMenuAnchor} onClose={handleModeSelectorHide}
|
||||
chatModeId={chatModeId} onSetChatModeId={handleModeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -630,7 +708,7 @@ export function Composer(props: {
|
||||
{reducerText?.length >= 1 &&
|
||||
<ContentReducer
|
||||
initialText={reducerText} initialTokens={reducerTextTokens} tokenLimit={remainingTokens}
|
||||
onReducedText={handleContentReducerText} onClose={handleContentReducerClose}
|
||||
onReducedText={handleReducedText} onClose={handleReducerClose}
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
|
||||
export type ChatModeId = 'immediate' | 'write-user' | 'react' | 'draw-imagine' | 'draw-imagine-plus';
|
||||
|
||||
/// Describe the chat modes
|
||||
export const ChatModeItems: {
|
||||
[key in ChatModeId]: {
|
||||
label: string;
|
||||
description: string | React.JSX.Element;
|
||||
shortcut?: string;
|
||||
experimental?: boolean
|
||||
}
|
||||
} = {
|
||||
'immediate': {
|
||||
label: 'Chat',
|
||||
description: 'Persona replies',
|
||||
},
|
||||
'write-user': {
|
||||
label: 'Write',
|
||||
description: 'Appends a message',
|
||||
shortcut: 'Alt + Enter',
|
||||
},
|
||||
'draw-imagine': {
|
||||
label: 'Draw',
|
||||
description: 'AI Image Generation',
|
||||
},
|
||||
'draw-imagine-plus': {
|
||||
label: 'Assisted Draw',
|
||||
description: 'Assisted Image Generation',
|
||||
experimental: true,
|
||||
},
|
||||
'react': {
|
||||
label: 'Reason + Act · α',
|
||||
description: 'Answers questions in multiple steps',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/// Composer Store
|
||||
|
||||
interface ComposerStore {
|
||||
@@ -59,20 +22,11 @@ const useComposerStore = create<ComposerStore>()(
|
||||
{
|
||||
name: 'app-composer',
|
||||
version: 1,
|
||||
/*migrate: (state: any, version): ComposerStore => {
|
||||
// 0 -> 1: rename history to sentMessages
|
||||
if (state && version === 0) {
|
||||
state.sentMessages = state.history;
|
||||
delete state.history;
|
||||
}
|
||||
return state as ComposerStore;
|
||||
},*/
|
||||
}),
|
||||
);
|
||||
|
||||
export const setComposerStartupText = (text: string | null) =>
|
||||
useComposerStore.getState().setStartupText(text);
|
||||
|
||||
export const useComposerStartupText = (): [string | null, (text: string | null) => void] =>
|
||||
useComposerStore(state => [state.startupText, state.setStartupText], shallow);
|
||||
|
||||
export const setComposerStartupText = (text: string | null) =>
|
||||
useComposerStore.getState().setStartupText(text);
|
||||
useComposerStore(state => [state.startupText, state.setStartupText], shallow);
|
||||
@@ -11,7 +11,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import DifferenceIcon from '@mui/icons-material/Difference';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import Face6Icon from '@mui/icons-material/Face6';
|
||||
import FastForwardIcon from '@mui/icons-material/FastForward';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import PaletteOutlinedIcon from '@mui/icons-material/PaletteOutlined';
|
||||
@@ -19,6 +19,7 @@ import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import SettingsSuggestIcon from '@mui/icons-material/SettingsSuggest';
|
||||
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { CloseableMenu } from '~/common/components/CloseableMenu';
|
||||
import { DMessage } from '~/common/state/store-chats';
|
||||
@@ -185,6 +186,8 @@ function useSanityTextDiffs(text: string, diffText: string | undefined, enabled:
|
||||
}
|
||||
|
||||
|
||||
export const ChatMessageMemo = React.memo(ChatMessage);
|
||||
|
||||
/**
|
||||
* The Message component is a customizable chat message UI component that supports
|
||||
* different roles (user, assistant, and system), text editing, syntax highlighting,
|
||||
@@ -200,10 +203,11 @@ export function ChatMessage(props: {
|
||||
noMarkdown?: boolean, diagramMode?: boolean,
|
||||
isBottom?: boolean, noBottomBorder?: boolean,
|
||||
isImagining?: boolean, isSpeaking?: boolean,
|
||||
onMessageDelete?: () => void,
|
||||
onMessageEdit?: (text: string) => void,
|
||||
onMessageRunFrom?: (offset: number) => void,
|
||||
onTextDiagram?: (text: string) => Promise<void>
|
||||
onConversationBranch?: (messageId: string) => void,
|
||||
onConversationRestartFrom?: (messageId: string, offset: number) => void,
|
||||
onMessageDelete?: (messageId: string) => void,
|
||||
onMessageEdit?: (messageId: string, text: string) => void,
|
||||
onTextDiagram?: (messageId: string, text: string) => Promise<void>
|
||||
onTextImagine?: (text: string) => Promise<void>
|
||||
onTextSpeak?: (text: string) => Promise<void>
|
||||
sx?: SxProps,
|
||||
@@ -228,6 +232,7 @@ export function ChatMessage(props: {
|
||||
|
||||
// derived state
|
||||
const {
|
||||
id: messageId,
|
||||
text: messageText,
|
||||
sender: messageSender,
|
||||
avatar: messageAvatar,
|
||||
@@ -256,7 +261,7 @@ export function ChatMessage(props: {
|
||||
const handleTextEdited = (editedText: string) => {
|
||||
setIsEditing(false);
|
||||
if (props.onMessageEdit && editedText?.trim() && editedText !== messageText)
|
||||
props.onMessageEdit(editedText);
|
||||
props.onMessageEdit(messageId, editedText);
|
||||
};
|
||||
|
||||
const handleUncollapse = () => setForceUserExpanded(true);
|
||||
@@ -267,7 +272,7 @@ export function ChatMessage(props: {
|
||||
const closeOperationsMenu = () => setOpsMenuAnchor(null);
|
||||
|
||||
const handleOpsCopy = (e: React.MouseEvent) => {
|
||||
copyToClipboard(textSel);
|
||||
copyToClipboard(textSel, 'Text');
|
||||
e.preventDefault();
|
||||
closeOperationsMenu();
|
||||
closeSelectionMenu();
|
||||
@@ -280,12 +285,24 @@ export function ChatMessage(props: {
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleOpsConversationBranch = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
props.onConversationBranch && props.onConversationBranch(messageId);
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleOpsConversationRestartFrom = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
props.onConversationRestartFrom && props.onConversationRestartFrom(messageId, fromAssistant ? -1 : 0);
|
||||
closeOperationsMenu();
|
||||
};
|
||||
|
||||
const handleOpsToggleShowDiff = () => setShowDiff(!showDiff);
|
||||
|
||||
const handleOpsDiagram = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onTextDiagram) {
|
||||
await props.onTextDiagram(textSel);
|
||||
await props.onTextDiagram(messageId, textSel);
|
||||
closeOperationsMenu();
|
||||
closeSelectionMenu();
|
||||
}
|
||||
@@ -309,12 +326,8 @@ export function ChatMessage(props: {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpsRunAgain = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (props.onMessageRunFrom) {
|
||||
props.onMessageRunFrom(fromAssistant ? -1 : 0);
|
||||
closeOperationsMenu();
|
||||
}
|
||||
const handleOpsDelete = (e: React.MouseEvent) => {
|
||||
props.onMessageDelete && props.onMessageDelete(messageId);
|
||||
};
|
||||
|
||||
|
||||
@@ -500,7 +513,7 @@ export function ChatMessage(props: {
|
||||
: block.type === 'code'
|
||||
? <RenderCode key={'code-' + index} codeBlock={block} sx={codeSx} noCopyButton={props.diagramMode} />
|
||||
: block.type === 'image'
|
||||
? <RenderImage key={'image-' + index} imageBlock={block} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsRunAgain} />
|
||||
? <RenderImage key={'image-' + index} imageBlock={block} allowRunAgain={props.isBottom === true} onRunAgain={handleOpsConversationRestartFrom} />
|
||||
: block.type === 'latex'
|
||||
? <RenderLatex key={'latex-' + index} latexBlock={block} />
|
||||
: block.type === 'diff'
|
||||
@@ -566,13 +579,13 @@ export function ChatMessage(props: {
|
||||
</MenuItem>
|
||||
)}
|
||||
<ListDivider />
|
||||
{!!props.onMessageRunFrom && (
|
||||
<MenuItem onClick={handleOpsRunAgain}>
|
||||
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <FastForwardIcon />}</ListItemDecorator>
|
||||
{!!props.onConversationRestartFrom && (
|
||||
<MenuItem onClick={handleOpsConversationRestartFrom}>
|
||||
<ListItemDecorator>{fromAssistant ? <ReplayIcon /> : <TelegramIcon />}</ListItemDecorator>
|
||||
{!fromAssistant
|
||||
? 'Run from here'
|
||||
? <>Restart <span style={{ opacity: 0.5 }}>from here</span></>
|
||||
: !props.isBottom
|
||||
? 'Retry from here'
|
||||
? <>Retry <span style={{ opacity: 0.5 }}>from here</span></>
|
||||
: <Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||
Retry
|
||||
<KeyStroke combo='Ctrl + Shift + R' />
|
||||
@@ -580,7 +593,16 @@ export function ChatMessage(props: {
|
||||
}
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram || props.isImagining}>
|
||||
{!!props.onConversationBranch && (
|
||||
<MenuItem onClick={handleOpsConversationBranch} disabled={fromSystem}>
|
||||
<ListItemDecorator>
|
||||
<ForkRightIcon />
|
||||
</ListItemDecorator>
|
||||
Branch {!props.isBottom && <span style={{ opacity: 0.5 }}>from here</span>}
|
||||
</MenuItem>
|
||||
)}
|
||||
{!!props.onConversationBranch && <ListDivider />}
|
||||
{!!props.onTextDiagram && <MenuItem onClick={handleOpsDiagram} disabled={!couldDiagram}>
|
||||
<ListItemDecorator><AccountTreeIcon color='success' /></ListItemDecorator>
|
||||
Visualize ...
|
||||
</MenuItem>}
|
||||
@@ -592,9 +614,9 @@ export function ChatMessage(props: {
|
||||
<ListItemDecorator>{props.isSpeaking ? <CircularProgress size='sm' /> : <RecordVoiceOverIcon color='success' />}</ListItemDecorator>
|
||||
Speak
|
||||
</MenuItem>}
|
||||
{!!props.onMessageRunFrom && <ListDivider />}
|
||||
{!!props.onConversationRestartFrom && <ListDivider />}
|
||||
{!!props.onMessageDelete && (
|
||||
<MenuItem onClick={props.onMessageDelete} disabled={false /*fromSystem*/}>
|
||||
<MenuItem onClick={handleOpsDelete} disabled={false /*fromSystem*/}>
|
||||
<ListItemDecorator><ClearIcon /></ListItemDecorator>
|
||||
Delete
|
||||
</MenuItem>
|
||||
|
||||
@@ -115,7 +115,7 @@ function RenderCodeImpl(props: {
|
||||
|
||||
const handleCopyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(blockCode);
|
||||
copyToClipboard(blockCode, 'Code');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -31,10 +31,12 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const useMermaidStore = create<{
|
||||
interface MermaidAPIStore {
|
||||
mermaidAPI: MermaidAPI | null,
|
||||
loadingError: string | null,
|
||||
}>()(
|
||||
}
|
||||
|
||||
const useMermaidStore = create<MermaidAPIStore>()(
|
||||
() => ({
|
||||
mermaidAPI: null,
|
||||
loadingError: null,
|
||||
|
||||
@@ -59,7 +59,7 @@ export function RenderHtml(props: { htmlBlock: HtmlBlock, sx?: SxProps }) {
|
||||
|
||||
const handleCopyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard(props.htmlBlock.html);
|
||||
copyToClipboard(props.htmlBlock.html, 'HTML');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { Chip, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { extractCommands } from '../../commands';
|
||||
import { extractCommands } from '../../editors/commands';
|
||||
|
||||
import { TextBlock } from './blocks';
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import ScienceIcon from '@mui/icons-material/Science';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { useChatStore } from '~/common/state/store-chats';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
import { SystemPurposeId, SystemPurposes } from '../../../../data';
|
||||
import { usePurposeStore } from './store-purposes';
|
||||
@@ -38,7 +39,7 @@ const getRandomElement = <T, >(array: T[]): T | undefined =>
|
||||
/**
|
||||
* Purpose selector for the current chat. Clicking on any item activates it for the current chat.
|
||||
*/
|
||||
export function PersonaSelector(props: { conversationId: string, runExample: (example: string) => void }) {
|
||||
export function PersonaSelector(props: { conversationId: DConversationId, runExample: (example: string) => void }) {
|
||||
// state
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [filteredIDs, setFilteredIDs] = React.useState<SystemPurposeId[] | null>(null);
|
||||
@@ -46,6 +47,7 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
|
||||
|
||||
// external state
|
||||
const showFinder = useUIPreferencesStore(state => state.showPurposeFinder);
|
||||
const labsPersonaYTCreator = useUXLabsStore(state => state.labsPersonaYTCreator);
|
||||
const { systemPurposeId, setSystemPurposeId } = useChatStore(state => {
|
||||
const conversation = state.conversations.find(conversation => conversation.id === props.conversationId);
|
||||
return {
|
||||
@@ -184,7 +186,7 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
|
||||
</Grid>
|
||||
))}
|
||||
{/* Button to start the YouTube persona creator */}
|
||||
<Grid>
|
||||
{labsPersonaYTCreator && <Grid>
|
||||
<Button
|
||||
variant='soft' color='neutral'
|
||||
component={Link} noLinkStyle href='/personas'
|
||||
@@ -207,9 +209,8 @@ export function PersonaSelector(props: { conversationId: string, runExample: (ex
|
||||
YouTube persona creator
|
||||
</div>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>}
|
||||
</Grid>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import * as React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { DConversationId, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
// change this to increase/decrease the number history steps per pane
|
||||
const MAX_HISTORY_LENGTH = 10;
|
||||
|
||||
// change to true to enable verbose console logging
|
||||
const DEBUG_PANES_MANAGER = false;
|
||||
|
||||
|
||||
interface ChatPane {
|
||||
|
||||
conversationId: DConversationId | null;
|
||||
|
||||
history: DConversationId[]; // History of the conversationIds for this pane
|
||||
historyIndex: number; // Current position in the history for this pane
|
||||
|
||||
}
|
||||
|
||||
interface AppChatPanesStore {
|
||||
|
||||
// state
|
||||
chatPanes: ChatPane[];
|
||||
chatPaneFocusIndex: number | null;
|
||||
chatPaneInputMode: 'focused' | 'broadcast';
|
||||
|
||||
// actions
|
||||
openConversationInFocusedPane: (conversationId: DConversationId) => void;
|
||||
openConversationInSplitPane: (conversationId: DConversationId) => void;
|
||||
navigateHistoryInFocusedPane: (direction: 'back' | 'forward') => boolean;
|
||||
setFocusedPaneIndex: (paneIndex: number) => void;
|
||||
splitChatPane: (numberOfPanes: number) => void;
|
||||
unsplitChatPane: (paneIndexToKeep: number) => void;
|
||||
onConversationsChanged: (conversationIds: DConversationId[]) => void;
|
||||
|
||||
}
|
||||
|
||||
function createPane(conversationId: DConversationId | null = null): ChatPane {
|
||||
return {
|
||||
conversationId,
|
||||
history: conversationId ? [conversationId] : [],
|
||||
historyIndex: conversationId ? 0 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
const useAppChatPanesStore = create<AppChatPanesStore>()(persist(
|
||||
(_set, _get) => ({
|
||||
|
||||
// Initial state: no panes
|
||||
chatPanes: [] as ChatPane[],
|
||||
chatPaneFocusIndex: null as number | null,
|
||||
chatPaneInputMode: 'focused' as 'focused' | 'broadcast',
|
||||
|
||||
openConversationInFocusedPane: (conversationId: DConversationId) => {
|
||||
_set((state) => {
|
||||
const { chatPanes, chatPaneFocusIndex } = state;
|
||||
|
||||
// If there's no pane or no focused pane, create and focus a new one.
|
||||
if (!chatPanes.length || chatPaneFocusIndex === null) {
|
||||
const newPane = createPane(conversationId);
|
||||
return {
|
||||
chatPanes: [newPane],
|
||||
chatPaneFocusIndex: 0, // Focus the new pane
|
||||
};
|
||||
}
|
||||
|
||||
// Check if the conversation is already open in the focused pane.
|
||||
const focusedPane = chatPanes[chatPaneFocusIndex];
|
||||
if (focusedPane.conversationId === conversationId) {
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`open-focuses: ${conversationId} is open in focused pane`, chatPaneFocusIndex, chatPanes);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Truncate the future history before adding the new conversation.
|
||||
const truncatedHistory = focusedPane.history.slice(0, focusedPane.historyIndex + 1);
|
||||
const newHistory = [...truncatedHistory, conversationId].slice(-MAX_HISTORY_LENGTH);
|
||||
|
||||
// Update the focused pane with the new conversation.
|
||||
const newPanes = [...chatPanes];
|
||||
newPanes[chatPaneFocusIndex] = {
|
||||
...focusedPane,
|
||||
conversationId,
|
||||
history: newHistory,
|
||||
historyIndex: newHistory.length - 1,
|
||||
};
|
||||
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`open-focuses: set ${conversationId} in focused pane`, chatPaneFocusIndex, chatPanes);
|
||||
|
||||
// Return the updated state.
|
||||
return {
|
||||
chatPanes: newPanes,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
openConversationInSplitPane: (conversationId: DConversationId) => {
|
||||
// Open a conversation in a new pane, reusing an existing pane if possible.
|
||||
const { chatPanes, chatPaneFocusIndex, openConversationInFocusedPane } = _get();
|
||||
|
||||
// one pane open: split it
|
||||
if (chatPanes.length === 1) {
|
||||
_set({
|
||||
chatPanes: Array.from({ length: 2 }, () => ({ ...chatPanes[0] })),
|
||||
chatPaneFocusIndex: 1,
|
||||
});
|
||||
}
|
||||
// more than 2 panes, reuse the alt pane
|
||||
else if (chatPanes.length >= 2 && chatPaneFocusIndex !== null) {
|
||||
_set({
|
||||
chatPaneFocusIndex: chatPaneFocusIndex === 0 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
|
||||
// will create a pane if none exists, or load the conversation in the focused pane
|
||||
openConversationInFocusedPane(conversationId);
|
||||
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`open-split-pane: after:`, _get().chatPanes);
|
||||
},
|
||||
|
||||
navigateHistoryInFocusedPane: (direction: 'back' | 'forward'): boolean => {
|
||||
const { chatPanes, chatPaneFocusIndex } = _get();
|
||||
if (chatPaneFocusIndex === null)
|
||||
return false;
|
||||
|
||||
const focusedPane = chatPanes[chatPaneFocusIndex];
|
||||
let newHistoryIndex = focusedPane.historyIndex;
|
||||
|
||||
if (direction === 'back' && newHistoryIndex > 0)
|
||||
newHistoryIndex--;
|
||||
else if (direction === 'forward' && newHistoryIndex < focusedPane.history.length - 1)
|
||||
newHistoryIndex++;
|
||||
else {
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`navigateHistoryInFocusedPane: no history ${direction} for`, focusedPane);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newPanes = [...chatPanes];
|
||||
newPanes[chatPaneFocusIndex] = {
|
||||
...focusedPane,
|
||||
conversationId: focusedPane.history[newHistoryIndex],
|
||||
historyIndex: newHistoryIndex,
|
||||
};
|
||||
|
||||
if (DEBUG_PANES_MANAGER)
|
||||
console.log(`navigateHistoryInFocusedPane: ${direction} to`, focusedPane, newPanes);
|
||||
|
||||
_set({
|
||||
chatPanes: newPanes,
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
setFocusedPaneIndex: (paneIndex: number) =>
|
||||
_set(state => {
|
||||
if (state.chatPaneFocusIndex === paneIndex)
|
||||
return state;
|
||||
return {
|
||||
chatPaneFocusIndex: paneIndex >= 0 && paneIndex < state.chatPanes.length ? paneIndex : null,
|
||||
};
|
||||
}),
|
||||
|
||||
splitChatPane: (numberOfPanes: number) => {
|
||||
const { chatPanes, chatPaneFocusIndex } = _get();
|
||||
const focusedPane = (chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex] : null) ?? createPane();
|
||||
|
||||
_set({
|
||||
chatPanes: Array.from({ length: numberOfPanes }, () => ({ ...focusedPane })),
|
||||
chatPaneFocusIndex: 0,
|
||||
});
|
||||
},
|
||||
|
||||
unsplitChatPane: (paneIndexToKeep: number) =>
|
||||
_set(state => ({
|
||||
chatPanes: [state.chatPanes[paneIndexToKeep] || createPane()],
|
||||
chatPaneFocusIndex: 0,
|
||||
})),
|
||||
|
||||
|
||||
/**
|
||||
* This function is vital, as is invoked when the conversationId[] changes in the global chats store.
|
||||
* It takes care of `creating the first pane` as well as `removing invalid history items, reassiging
|
||||
* conversationIds, and re-focusing the pane`.
|
||||
*/
|
||||
onConversationsChanged: (conversationIds: DConversationId[]) =>
|
||||
_set(state => {
|
||||
const { chatPanes, chatPaneFocusIndex } = state;
|
||||
|
||||
// handle panes
|
||||
let untouched = true;
|
||||
const newPanes: ChatPane[] = chatPanes.map(chatPane => {
|
||||
const { conversationId, history, historyIndex } = chatPane;
|
||||
|
||||
// adjust history if any is deleted
|
||||
let newHistoryIndex = historyIndex;
|
||||
const newHistory = history.filter((_hId, index) => {
|
||||
const historyStillPresent = conversationIds.includes(_hId);
|
||||
if (!historyStillPresent && index <= historyIndex)
|
||||
newHistoryIndex--;
|
||||
return historyStillPresent;
|
||||
});
|
||||
if (newHistoryIndex < 0 && newHistory.length > 0)
|
||||
newHistoryIndex = 0;
|
||||
|
||||
// check if pointing to a valid conversationId
|
||||
const needsNewConversationId = !conversationId || !conversationIds.includes(conversationId);
|
||||
if (!needsNewConversationId && newHistory.length === history.length)
|
||||
return chatPane;
|
||||
|
||||
const nextConversationId = newHistoryIndex >= 0 && newHistoryIndex < newHistory.length
|
||||
? newHistory[newHistoryIndex]
|
||||
: newHistory.length > 0
|
||||
? newHistory[newHistory.length - 1]
|
||||
: conversationIds[0] ?? null;
|
||||
|
||||
untouched = false;
|
||||
return {
|
||||
...chatPane,
|
||||
conversationId: nextConversationId,
|
||||
history: newHistory,
|
||||
historyIndex: newHistoryIndex,
|
||||
};
|
||||
}).filter(pane => !!pane.conversationId);
|
||||
|
||||
// if untouched, return state as-is
|
||||
if (untouched && newPanes.length >= 1)
|
||||
return state;
|
||||
|
||||
// play it safe, and make sure a pane exists, and is focused
|
||||
return {
|
||||
chatPanes: newPanes.length ? newPanes : [createPane(conversationIds[0] ?? null)],
|
||||
chatPaneFocusIndex: (newPanes.length && chatPaneFocusIndex !== null && chatPaneFocusIndex < newPanes.length) ? state.chatPaneFocusIndex : 0,
|
||||
};
|
||||
}),
|
||||
|
||||
}), {
|
||||
name: 'app-app-chat-panes',
|
||||
},
|
||||
));
|
||||
|
||||
|
||||
export function usePanesManager() {
|
||||
// use Panes
|
||||
const { onConversationsChanged, ...panesFunctions } = useAppChatPanesStore(state => {
|
||||
const {
|
||||
chatPaneFocusIndex,
|
||||
chatPanes,
|
||||
navigateHistoryInFocusedPane,
|
||||
onConversationsChanged,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
setFocusedPaneIndex,
|
||||
} = state;
|
||||
const focusedConversationId = chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex]?.conversationId ?? null : null;
|
||||
return {
|
||||
chatPanes: chatPanes as Readonly<ChatPane[]>,
|
||||
focusedConversationId,
|
||||
navigateHistoryInFocusedPane,
|
||||
onConversationsChanged,
|
||||
openConversationInFocusedPane,
|
||||
openConversationInSplitPane,
|
||||
setFocusedPaneIndex,
|
||||
};
|
||||
}, shallow);
|
||||
|
||||
// use Conversation IDs[]
|
||||
const conversationIDs: DConversationId[] = useChatStore(state => {
|
||||
return state.conversations.map(_c => _c.id);
|
||||
}, shallow);
|
||||
|
||||
// [Effect] Ensure all Panes have a valid Conversation ID
|
||||
React.useEffect(() => {
|
||||
onConversationsChanged(conversationIDs);
|
||||
}, [conversationIDs, onConversationsChanged]);
|
||||
|
||||
return {
|
||||
...panesFunctions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
|
||||
import { DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
import { createAssistantTypingMessage } from './editors';
|
||||
|
||||
|
||||
export const runBrowseUpdatingState = async (conversationId: string, url: string) => {
|
||||
|
||||
const { editMessage } = useChatStore.getState();
|
||||
|
||||
// create a blank and 'typing' message for the assistant - to be filled when we're done
|
||||
// const assistantModelStr = 'react-' + assistantModelId.slice(4, 7); // HACK: this is used to change the Avatar animation
|
||||
// noinspection HttpUrlsUsage
|
||||
const shortUrl = url.replace('https://www.', '').replace('https://', '').replace('http://', '').replace('www.', '');
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, 'web', undefined, `Loading page at ${shortUrl}...`);
|
||||
const updateAssistantMessage = (update: Partial<DMessage>) => editMessage(conversationId, assistantMessageId, update, false);
|
||||
|
||||
try {
|
||||
|
||||
const text = await callBrowseFetchPage(url);
|
||||
if (!text) {
|
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new Error('No text found.');
|
||||
}
|
||||
updateAssistantMessage({
|
||||
text: text,
|
||||
typing: false,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
updateAssistantMessage({
|
||||
text: 'Issue: browse did not produce an answer (error: ' + (error?.message || error?.toString() || 'unknown') + ').',
|
||||
typing: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -20,7 +20,7 @@ export async function runAssistantUpdatingState(conversationId: string, history:
|
||||
const { autoSpeak, autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat } = getChatAutoAI();
|
||||
|
||||
// update the system message from the active Purpose, if not manually edited
|
||||
history = updatePurposeInHistory(conversationId, history, systemPurpose);
|
||||
history = updatePurposeInHistory(conversationId, history, assistantLlmId, systemPurpose);
|
||||
|
||||
// create a blank and 'typing' message for the assistant
|
||||
const assistantMessageId = createAssistantTypingMessage(conversationId, assistantLlmId, history[0].purposeId, '...');
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { CmdRunBrowse } from '~/modules/browse/browse.client';
|
||||
import { CmdRunProdia } from '~/modules/prodia/prodia.client';
|
||||
import { CmdRunReact } from '~/modules/aifn/react/react';
|
||||
import { CmdRunSearch } from '~/modules/google/search.client';
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { createDMessage, DMessage } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
export const CmdAddRoleMessage: string[] = ['/assistant', '/a', '/system', '/s'];
|
||||
|
||||
export const commands = [...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage];
|
||||
export const CmdHelp: string[] = ['/help', '/h', '/?'];
|
||||
|
||||
export const commands = [...CmdRunBrowse, ...CmdRunProdia, ...CmdRunReact, ...CmdRunSearch, ...CmdAddRoleMessage, ...CmdHelp];
|
||||
|
||||
export interface SentencePiece {
|
||||
type: 'text' | 'cmd';
|
||||
@@ -16,6 +22,9 @@ export interface SentencePiece {
|
||||
* Used by rendering functions, as well as input processing functions.
|
||||
*/
|
||||
export function extractCommands(input: string): SentencePiece[] {
|
||||
// 'help' commands are the only without a space and text after
|
||||
if (CmdHelp.includes(input))
|
||||
return [{ type: 'cmd', value: input }, { type: 'text', value: '' }];
|
||||
const regexFromTags = commands.map(tag => `^\\${tag} `).join('\\b|') + '\\b';
|
||||
const pattern = new RegExp(regexFromTags, 'g');
|
||||
const result: SentencePiece[] = [];
|
||||
@@ -37,4 +46,12 @@ export function extractCommands(input: string): SentencePiece[] {
|
||||
result.push({ type: 'text', value: input.substring(lastIndex) });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createCommandsHelpMessage(): DMessage {
|
||||
let text = 'Available Chat Commands:\n';
|
||||
text += commands.map(c => ` - ${c}`).join('\n');
|
||||
const helpMessage = createDMessage('assistant', text);
|
||||
helpMessage.originLLM = Brand.Title.Base;
|
||||
return helpMessage;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { SystemPurposeId, SystemPurposes } from '../../../data';
|
||||
import { createDMessage, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
|
||||
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | 'prodia' | 'react-...', assistantPurposeId: SystemPurposeId | undefined, text: string): string {
|
||||
export function createAssistantTypingMessage(conversationId: string, assistantLlmLabel: DLLMId | 'prodia' | 'react-...' | 'web', assistantPurposeId: SystemPurposeId | undefined, text: string): string {
|
||||
const assistantMessage: DMessage = createDMessage('assistant', text);
|
||||
assistantMessage.typing = true;
|
||||
assistantMessage.purposeId = assistantPurposeId;
|
||||
@@ -14,12 +14,13 @@ export function createAssistantTypingMessage(conversationId: string, assistantLl
|
||||
}
|
||||
|
||||
|
||||
export function updatePurposeInHistory(conversationId: string, history: DMessage[], purposeId: SystemPurposeId): DMessage[] {
|
||||
export function updatePurposeInHistory(conversationId: string, history: DMessage[], assistantLlmId: DLLMId, purposeId: SystemPurposeId): DMessage[] {
|
||||
const systemMessageIndex = history.findIndex(m => m.role === 'system');
|
||||
const systemMessage: DMessage = systemMessageIndex >= 0 ? history.splice(systemMessageIndex, 1)[0] : createDMessage('system', '');
|
||||
if (!systemMessage.updated && purposeId && SystemPurposes[purposeId]?.systemMessage) {
|
||||
systemMessage.purposeId = purposeId;
|
||||
systemMessage.text = SystemPurposes[purposeId].systemMessage
|
||||
.replaceAll('{{Cutoff}}', assistantLlmId.includes('1106') ? '2023-04' : '2021-09')
|
||||
.replaceAll('{{Today}}', new Date().toISOString().split('T')[0]);
|
||||
|
||||
// HACK: this is a special case for the "Custom" persona, to set the message in stone (so it doesn't get updated when switching to another persona)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Agent } from '~/modules/aifn/react/react';
|
||||
import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { createDEphemeral, DMessage, useChatStore } from '~/common/state/store-chats';
|
||||
|
||||
@@ -11,6 +12,7 @@ import { createAssistantTypingMessage } from './editors';
|
||||
*/
|
||||
export async function runReActUpdatingState(conversationId: string, question: string, assistantLlmId: DLLMId) {
|
||||
|
||||
const { enableReactTool: enableBrowse } = useBrowseStore.getState();
|
||||
const { appendEphemeral, updateEphemeralText, updateEphemeralState, deleteEphemeral, editMessage } = useChatStore.getState();
|
||||
|
||||
// create a blank and 'typing' message for the assistant - to be filled when we're done
|
||||
@@ -30,15 +32,13 @@ export async function runReActUpdatingState(conversationId: string, question: st
|
||||
ephemeralText += (text.length > 300 ? text.slice(0, 300) + '...' : text) + '\n';
|
||||
updateEphemeralText(conversationId, ephemeral.id, ephemeralText);
|
||||
};
|
||||
const showStateInEphemeral = (state: object) => updateEphemeralState(conversationId, ephemeral.id, state);
|
||||
|
||||
try {
|
||||
|
||||
// react loop
|
||||
const agent = new Agent();
|
||||
const reactResult = await agent.reAct(question, assistantLlmId, 5,
|
||||
logToEphemeral,
|
||||
(state: object) => updateEphemeralState(conversationId, ephemeral.id, state),
|
||||
);
|
||||
const reactResult = await agent.reAct(question, assistantLlmId, 5, enableBrowse, logToEphemeral, showStateInEphemeral);
|
||||
|
||||
setTimeout(() => deleteEphemeral(conversationId, ephemeral.id), 2 * 1000);
|
||||
updateAssistantMessage({ text: reactResult, typing: false });
|
||||
|
||||
@@ -5,9 +5,10 @@ import { persist } from 'zustand/middleware';
|
||||
|
||||
export type ChatAutoSpeakType = 'off' | 'firstLine' | 'all';
|
||||
|
||||
interface AppChatState {
|
||||
|
||||
// Chat AI
|
||||
// Chat Settings (Chat AI & Chat UI)
|
||||
|
||||
interface AppChatStore {
|
||||
|
||||
autoSpeak: ChatAutoSpeakType;
|
||||
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void;
|
||||
@@ -19,9 +20,7 @@ interface AppChatState {
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void;
|
||||
|
||||
autoTitleChat: boolean;
|
||||
setautoTitleChat: (autoTitleChat: boolean) => void;
|
||||
|
||||
// Chat
|
||||
setAutoTitleChat: (autoTitleChat: boolean) => void;
|
||||
|
||||
showTextDiff: boolean;
|
||||
setShowTextDiff: (showTextDiff: boolean) => void;
|
||||
@@ -31,40 +30,40 @@ interface AppChatState {
|
||||
|
||||
}
|
||||
|
||||
const useAppChatStore = create<AppChatState>()(persist(
|
||||
(set) => ({
|
||||
|
||||
// Chat AI
|
||||
const useAppChatStore = create<AppChatStore>()(persist(
|
||||
(_set, _get) => ({
|
||||
|
||||
autoSpeak: 'off',
|
||||
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => set({ autoSpeak }),
|
||||
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => _set({ autoSpeak }),
|
||||
|
||||
autoSuggestDiagrams: false,
|
||||
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => set({ autoSuggestDiagrams }),
|
||||
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => _set({ autoSuggestDiagrams }),
|
||||
|
||||
autoSuggestQuestions: false,
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => set({ autoSuggestQuestions }),
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => _set({ autoSuggestQuestions }),
|
||||
|
||||
autoTitleChat: true,
|
||||
setautoTitleChat: (autoTitleChat: boolean) => set({ autoTitleChat }),
|
||||
setAutoTitleChat: (autoTitleChat: boolean) => _set({ autoTitleChat }),
|
||||
|
||||
showTextDiff: false,
|
||||
setShowTextDiff: (showTextDiff: boolean) => set({ showTextDiff }),
|
||||
setShowTextDiff: (showTextDiff: boolean) => _set({ showTextDiff }),
|
||||
|
||||
showSystemMessages: false,
|
||||
setShowSystemMessages: (showSystemMessages: boolean) => set({ showSystemMessages }),
|
||||
setShowSystemMessages: (showSystemMessages: boolean) => _set({ showSystemMessages }),
|
||||
|
||||
}), {
|
||||
name: 'app-app-chat',
|
||||
version: 1,
|
||||
|
||||
// for now, let text diff be off by default
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state)
|
||||
state.showTextDiff = false;
|
||||
if (!state) return;
|
||||
|
||||
// for now, let text diff be off by default
|
||||
state.showTextDiff = false;
|
||||
},
|
||||
|
||||
migrate: (state: any, fromVersion: number): AppChatState => {
|
||||
migrate: (state: any, fromVersion: number): AppChatStore => {
|
||||
// 0 -> 1: autoTitleChat was off by mistake - turn it on [Remove past Dec 1, 2023]
|
||||
if (state && fromVersion < 1)
|
||||
state.autoTitleChat = true;
|
||||
@@ -74,18 +73,7 @@ const useAppChatStore = create<AppChatState>()(persist(
|
||||
));
|
||||
|
||||
|
||||
// Chat AI
|
||||
|
||||
export const useChatAutoAI = (): {
|
||||
autoSpeak: ChatAutoSpeakType,
|
||||
autoSuggestDiagrams: boolean,
|
||||
autoSuggestQuestions: boolean,
|
||||
autoTitleChat: boolean,
|
||||
setAutoSpeak: (autoSpeak: ChatAutoSpeakType) => void,
|
||||
setAutoSuggestDiagrams: (autoSuggestDiagrams: boolean) => void,
|
||||
setAutoSuggestQuestions: (autoSuggestQuestions: boolean) => void,
|
||||
setautoTitleChat: (autoTitleChat: boolean) => void,
|
||||
} => useAppChatStore(state => ({
|
||||
export const useChatAutoAI = () => useAppChatStore(state => ({
|
||||
autoSpeak: state.autoSpeak,
|
||||
autoSuggestDiagrams: state.autoSuggestDiagrams,
|
||||
autoSuggestQuestions: state.autoSuggestQuestions,
|
||||
@@ -93,7 +81,7 @@ export const useChatAutoAI = (): {
|
||||
setAutoSpeak: state.setAutoSpeak,
|
||||
setAutoSuggestDiagrams: state.setAutoSuggestDiagrams,
|
||||
setAutoSuggestQuestions: state.setAutoSuggestQuestions,
|
||||
setautoTitleChat: state.setautoTitleChat,
|
||||
setAutoTitleChat: state.setAutoTitleChat,
|
||||
}), shallow);
|
||||
|
||||
export const getChatAutoAI = (): {
|
||||
@@ -103,12 +91,11 @@ export const getChatAutoAI = (): {
|
||||
autoTitleChat: boolean,
|
||||
} => useAppChatStore.getState();
|
||||
|
||||
// Chat
|
||||
|
||||
export const useChatShowTextDiff = (): [boolean, (showDiff: boolean) => void] =>
|
||||
useAppChatStore(state => [state.showTextDiff, state.setShowTextDiff], shallow);
|
||||
|
||||
export const getChatShowSystemMessages = (): boolean => useAppChatStore.getState().showSystemMessages;
|
||||
export const getChatShowSystemMessages = (): boolean =>
|
||||
useAppChatStore.getState().showSystemMessages;
|
||||
|
||||
export const useChatShowSystemMessages = (): [boolean, (showSystemMessages: boolean) => void] =>
|
||||
useAppChatStore(state => [state.showSystemMessages, state.setShowSystemMessages], shallow);
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Button, Card, CardContent, Container, Switch, Typography } from '@mui/joy';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
export function AppLabs() {
|
||||
|
||||
// external state
|
||||
const { experimentalLabs, setExperimentalLabs } = useUIPreferencesStore(state => ({
|
||||
experimentalLabs: state.experimentalLabs, setExperimentalLabs: state.setExperimentalLabs,
|
||||
}), shallow);
|
||||
|
||||
const handleLabsChange = (event: React.ChangeEvent<HTMLInputElement>) => setExperimentalLabs(event.target.checked);
|
||||
|
||||
return (
|
||||
|
||||
<Box sx={{
|
||||
backgroundColor: 'background.level1',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
minHeight: 96,
|
||||
p: { xs: 3, md: 6 },
|
||||
gap: 4,
|
||||
}}>
|
||||
|
||||
<Typography level='h1' sx={{ fontSize: '3.6rem' }}>
|
||||
Labs <ScienceIcon sx={{ fontSize: '3.3rem' }} />
|
||||
</Typography>
|
||||
|
||||
<Switch checked={experimentalLabs} onChange={handleLabsChange}
|
||||
endDecorator={experimentalLabs ? 'On' : 'Off'}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
|
||||
|
||||
<Container disableGutters maxWidth='sm'>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography>
|
||||
The Labs section is where we experiment with new features and ideas.
|
||||
</Typography>
|
||||
<Typography level='title-md' sx={{ mt: 2 }}>
|
||||
Features {experimentalLabs ? 'enabled' : 'disabled'}:
|
||||
</Typography>
|
||||
<ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 32 }}>
|
||||
<li><b>Text tools</b> - complete (highlight differences)</li>
|
||||
<li><b>YouTube persona synthesizer</b> - alpha, not persisted</li>
|
||||
<li><b>Chat mode: follow-up/augmentation</b> - alpha (diagrams)</li>
|
||||
<li><b>Relative chats size</b> - complete</li>
|
||||
</ul>
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
For any questions and creative idea, please join us on Discord, and let's talk!
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Container>
|
||||
|
||||
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
|
||||
Got it!
|
||||
</Button>
|
||||
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Box, Typography } from '@mui/joy';
|
||||
|
||||
import { createConversationFromJsonV1 } from '../chat/trade/trade.client';
|
||||
import { useHasChatLinkItems } from '../chat/trade/store-sharing';
|
||||
import { createConversationFromJsonV1 } from '~/modules/trade/trade.client';
|
||||
import { useHasChatLinkItems } from '~/modules/trade/store-module-trade';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { InlineError } from '~/common/components/InlineError';
|
||||
|
||||
@@ -4,12 +4,12 @@ import TimeAgo from 'react-timeago';
|
||||
import { Box, ListDivider, ListItem, ListItemDecorator, MenuItem, Typography } from '@mui/joy';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
|
||||
import { useChatLinkItems } from '../chat/trade/store-sharing';
|
||||
import { useChatLinkItems } from '~/modules/trade/store-module-trade';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { closeLayoutDrawer } from '~/common/layout/store-applayout';
|
||||
import { getChatLinkRelativePath, getHomeLink } from '~/common/app.routes';
|
||||
import { getChatLinkRelativePath, ROUTE_INDEX } from '~/common/app.routes';
|
||||
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ export function AppChatLinkDrawerItems() {
|
||||
|
||||
<MenuItem
|
||||
onClick={closeLayoutDrawer}
|
||||
component={Link} href={getHomeLink()} noLinkStyle
|
||||
component={Link} href={ROUTE_INDEX} noLinkStyle
|
||||
>
|
||||
<ListItemDecorator><ArrowBackIcon /></ListItemDecorator>
|
||||
{Brand.Title.Base}
|
||||
|
||||
@@ -57,8 +57,8 @@ export function ViewChatLink(props: { conversation: DConversation, storedAt: Dat
|
||||
|
||||
const handleClone = async (canOverwrite: boolean) => {
|
||||
setCloning(true);
|
||||
useChatStore.getState().importConversation({ ...props.conversation }, !canOverwrite);
|
||||
await navigateToChat();
|
||||
const importedId = useChatStore.getState().importConversation({ ...props.conversation }, !canOverwrite);
|
||||
await navigateToChat(importedId);
|
||||
setCloning(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Box, Chip, IconButton, List, ListItem, ListItemButton, Tooltip, Typography } from '@mui/joy';
|
||||
import { Box, Chip, IconButton, List, ListItem, ListItemButton, Typography } from '@mui/joy';
|
||||
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DLLM, DModelSourceId, useModelsStore } from '~/modules/llms/store-llms'
|
||||
import { IModelVendor } from '~/modules/llms/vendors/IModelVendor';
|
||||
import { findVendorById } from '~/modules/llms/vendors/vendor.registry';
|
||||
|
||||
import { GoodTooltip } from '~/common/components/GoodTooltip';
|
||||
import { openLayoutLLMOptions } from '~/common/layout/store-applayout';
|
||||
|
||||
|
||||
@@ -17,18 +18,27 @@ function ModelItem(props: { llm: DLLM, vendor: IModelVendor, chipChat: boolean,
|
||||
// derived
|
||||
const llm = props.llm;
|
||||
const label = llm.label;
|
||||
const tooltip = `${llm._source.label}${llm.description ? ' - ' + llm.description : ''} - ${llm.contextTokens?.toLocaleString() || 'unknown tokens size'}`;
|
||||
let tooltip = llm._source.label;
|
||||
if (llm.description)
|
||||
tooltip += ' - ' + llm.description;
|
||||
tooltip += ' - ';
|
||||
if (llm.contextTokens) {
|
||||
tooltip += llm.contextTokens.toLocaleString() + ' tokens';
|
||||
// if (llm.maxOutputTokens)
|
||||
// tooltip += ' / ' + llm.maxOutputTokens.toLocaleString() + ' max';
|
||||
} else
|
||||
tooltip += 'unknown tokens size';
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemButton onClick={() => openLayoutLLMOptions(llm.id)} sx={{ alignItems: 'center', gap: 1 }}>
|
||||
|
||||
{/* Model Name */}
|
||||
<Tooltip title={tooltip}>
|
||||
<GoodTooltip title={tooltip}>
|
||||
<Typography sx={llm.hidden ? { color: 'neutral.plainDisabledColor' } : undefined}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</GoodTooltip>
|
||||
|
||||
{/* --> */}
|
||||
<Box sx={{ flex: 1 }} />
|
||||
|
||||
@@ -9,7 +9,6 @@ import { createModelSourceForDefaultVendor, findVendorById } from '~/modules/llm
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { closeLayoutModelsSetup, openLayoutModelsSetup, useLayoutModelsSetup } from '~/common/layout/store-applayout';
|
||||
import { settingsGap } from '~/common/app.theme';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
|
||||
import { LLMOptionsModal } from './LLMOptionsModal';
|
||||
import { ModelsList } from './ModelsList';
|
||||
@@ -36,7 +35,6 @@ export function ModelsModal(props: { suspendAutoModelsSetup?: boolean }) {
|
||||
modelSources: state.sources,
|
||||
llmCount: state.llms.length,
|
||||
}), shallow);
|
||||
useGlobalShortcut('m', true, true, false, openLayoutModelsSetup);
|
||||
|
||||
// auto-select the first source - note: we could use a useEffect() here, but this is more efficient
|
||||
// also note that state-persistence is unneeded
|
||||
|
||||
@@ -5,6 +5,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { ROUTE_INDEX } from '~/common/app.routes';
|
||||
import { capitalizeFirstLetter } from '~/common/util/textUtils';
|
||||
|
||||
import { newsCallout, NewsItems } from './news.data';
|
||||
@@ -42,6 +43,17 @@ export function AppNews() {
|
||||
{capitalizeFirstLetter(Brand.Title.Base)} has been updated to version {firstNews?.versionName}.
|
||||
</Typography>
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
variant='solid' color='neutral' size='lg'
|
||||
component={Link} href={ROUTE_INDEX} noLinkStyle
|
||||
endDecorator='✨'
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
Sweet
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{!!newsCallout && <Container disableGutters maxWidth='sm'>{newsCallout}</Container>}
|
||||
|
||||
{!!news && <Container disableGutters maxWidth='sm'>
|
||||
@@ -52,12 +64,12 @@ export function AppNews() {
|
||||
const addPadding = !firstCard; // || showExpander;
|
||||
return <Card key={'news-' + idx} sx={{ mb: 2, minHeight: 32 }}>
|
||||
<CardContent sx={{ position: 'relative', pr: addPadding ? 4 : 0 }}>
|
||||
{!!ni.text && <Typography component='div'>
|
||||
{!!ni.text && <Typography level='title-lg' component='div'>
|
||||
{ni.text}
|
||||
</Typography>}
|
||||
|
||||
{!!ni.items && (ni.items.length > 0) && <ul style={{ marginTop: 8, marginBottom: 8, paddingInlineStart: 24 }}>
|
||||
{ni.items.map((item, idx) => <li key={idx}>
|
||||
{ni.items.filter(item => item.dev !== true).map((item, idx) => <li key={idx}>
|
||||
<Typography component='div'>
|
||||
{item.text}
|
||||
</Typography>
|
||||
@@ -85,12 +97,6 @@ export function AppNews() {
|
||||
})}
|
||||
</Container>}
|
||||
|
||||
<Box>
|
||||
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
|
||||
Got it!
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/*<Typography sx={{ textAlign: 'center' }}>*/}
|
||||
{/* Enjoy!*/}
|
||||
{/* <br /><br />*/}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Button, Card, CardContent, Grid, Typography } from '@mui/joy';
|
||||
import { Box, Button, Card, CardContent, Chip, Grid, Typography } from '@mui/joy';
|
||||
import LaunchIcon from '@mui/icons-material/Launch';
|
||||
|
||||
import { Brand } from '~/common/app.config';
|
||||
@@ -9,7 +9,7 @@ import { clientUtmSource } from '~/common/util/pwaUtils';
|
||||
|
||||
|
||||
// update this variable every time you want to broadcast a new version to clients
|
||||
export const incrementalVersion: number = 6;
|
||||
export const incrementalVersion: number = 7;
|
||||
|
||||
const B = (props: { href?: string, children: React.ReactNode }) => {
|
||||
const boldText = <Typography color={!!props.href ? 'primary' : 'warning'} sx={{ fontWeight: 600 }}>{props.children}</Typography>;
|
||||
@@ -26,8 +26,8 @@ const RIssues = `${OpenRepo}/issues`;
|
||||
export const newsCallout =
|
||||
<Card>
|
||||
<CardContent sx={{ gap: 2 }}>
|
||||
<Typography level='h2'>
|
||||
Open Roadmap ✨
|
||||
<Typography level='h3'>
|
||||
Open Roadmap
|
||||
</Typography>
|
||||
<Typography>
|
||||
The roadmap is officially out. For the first time you get a look at what's brewing, up and coming, and get a chance to pick up cool features!
|
||||
@@ -35,7 +35,7 @@ export const newsCallout =
|
||||
<Grid container spacing={1}>
|
||||
<Grid xs={12} sm={7}>
|
||||
<Button
|
||||
fullWidth variant='solid' color='primary' size='lg' endDecorator={<LaunchIcon />}
|
||||
fullWidth variant='soft' color='primary' endDecorator={<LaunchIcon />}
|
||||
component={Link} href={OpenProject} noLinkStyle target='_blank'
|
||||
>
|
||||
Explore the Roadmap
|
||||
@@ -43,7 +43,7 @@ export const newsCallout =
|
||||
</Grid>
|
||||
<Grid xs={12} sm={5} sx={{ display: 'flex', flexAlign: 'center', justifyContent: 'center' }}>
|
||||
<Button
|
||||
fullWidth variant='outlined' color='primary' endDecorator={<LaunchIcon />}
|
||||
fullWidth variant='plain' color='primary' endDecorator={<LaunchIcon />}
|
||||
component={Link} href={RIssues + '/new?template=roadmap-request.md&title=%5BSuggestion%5D'} noLinkStyle target='_blank'
|
||||
>
|
||||
Suggest a Feature
|
||||
@@ -57,11 +57,28 @@ export const newsCallout =
|
||||
// news and feature surfaces
|
||||
export const NewsItems: NewsItem[] = [
|
||||
/*{
|
||||
versionName: 'NEXT',
|
||||
// https://github.com/enricoros/big-agi/milestone/7
|
||||
// https://github.com/users/enricoros/projects/4/views/2
|
||||
versionName: '1.7.0',
|
||||
items: [
|
||||
{ text: <><B href='...'>Voice Calling</B> ...</> },
|
||||
// multi-window support
|
||||
// phone calls
|
||||
],
|
||||
},*/
|
||||
{
|
||||
versionName: '1.6.0',
|
||||
text: 'Surf\'s Up in Chat Waves:',
|
||||
items: [
|
||||
{ text: <><B href={RIssues + '/237'}>Web Browsing</B> support, see the <B href={RCode + '/docs/config-browse.md'}>browsing user guide</B></> },
|
||||
{ text: <><B href={RIssues + '/235'}>Branching Discussions</B> at any message</> },
|
||||
{ text: <><B href={RIssues + '/207'}>Keyboard Navigation</B>: use Ctrl+Shift+Left/Right to navigate chats</> },
|
||||
{ text: <><B href={RIssues + '/236'}>UI fixes</B> (thanks to the first sponsor)</> },
|
||||
{ text: <>Added support for Anthropic Claude 2.1</> },
|
||||
{ text: <>Large rendering performance optimization</> },
|
||||
{ text: <>More: <Chip>/help</Chip>, import ChatGPT from source, new Flattener</> },
|
||||
{ text: <>Devs: improved code quality, snackbar framework</>, dev: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
versionName: '1.5.0',
|
||||
text: 'Enjoy what\'s new:',
|
||||
@@ -125,5 +142,6 @@ interface NewsItem {
|
||||
text?: string | React.JSX.Element;
|
||||
items?: {
|
||||
text: string | React.JSX.Element;
|
||||
dev?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function YTPersonaCreator() {
|
||||
</Alert>
|
||||
<Tooltip title='Copy system prompt' variant='solid'>
|
||||
<IconButton
|
||||
variant='outlined' color='neutral' onClick={() => copyToClipboard(chainOutput)}
|
||||
variant='outlined' color='neutral' onClick={() => copyToClipboard(chainOutput, 'Persona prompt')}
|
||||
sx={{
|
||||
position: 'absolute', right: 0, zIndex: 10,
|
||||
// opacity: 0, transition: 'opacity 0.3s',
|
||||
|
||||
@@ -11,9 +11,9 @@ import { useChatAutoAI } from '../chat/store-app-chat';
|
||||
export function AppChatSettingsAI() {
|
||||
|
||||
// external state
|
||||
const { autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat, setAutoSuggestDiagrams, setAutoSuggestQuestions, setautoTitleChat } = useChatAutoAI();
|
||||
const { autoSuggestDiagrams, autoSuggestQuestions, autoTitleChat, setAutoSuggestDiagrams, setAutoSuggestQuestions, setAutoTitleChat } = useChatAutoAI();
|
||||
|
||||
const handleAutoSetChatTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => setautoTitleChat(event.target.checked);
|
||||
const handleAutoSetChatTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => setAutoTitleChat(event.target.checked);
|
||||
|
||||
const handleAutoSuggestDiagramsChange = (event: React.ChangeEvent<HTMLInputElement>) => setAutoSuggestDiagrams(event.target.checked);
|
||||
|
||||
|
||||
@@ -2,25 +2,35 @@ import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Button, FormControl, Radio, RadioGroup, Switch } from '@mui/joy';
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
import WidthNormalIcon from '@mui/icons-material/WidthNormal';
|
||||
import WidthWideIcon from '@mui/icons-material/WidthWide';
|
||||
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { isPwa } from '~/common/util/pwaUtils';
|
||||
import { openLayoutModelsSetup } from '~/common/layout/store-applayout';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { ShortcutsModal } from './ShortcutsModal';
|
||||
|
||||
|
||||
// configuration
|
||||
const SHOW_PURPOSE_FINDER = false;
|
||||
|
||||
|
||||
export function AppChatSettingsUI() {
|
||||
const ModelOptionsButton = () =>
|
||||
<Button
|
||||
// variant='soft' color='success'
|
||||
onClick={openLayoutModelsSetup}
|
||||
startDecorator={<BuildCircleIcon />}
|
||||
sx={{
|
||||
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
|
||||
}}
|
||||
>
|
||||
Models
|
||||
</Button>;
|
||||
|
||||
// local state
|
||||
const [showShortcuts, setShowShortcuts] = React.useState<boolean>(false);
|
||||
|
||||
export function AppChatSettingsUI() {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
@@ -54,6 +64,12 @@ export function AppChatSettingsUI() {
|
||||
|
||||
return <>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title='AI Models'
|
||||
description='Setup' />
|
||||
<ModelOptionsButton />
|
||||
</FormControl>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
|
||||
<FormLabelStart title='Enter sends ⏎'
|
||||
description={enterIsNewline ? 'New line' : 'Sends message'} />
|
||||
@@ -106,11 +122,5 @@ export function AppChatSettingsUI() {
|
||||
</RadioGroup>
|
||||
</FormControl>}
|
||||
|
||||
{!isMobile && <Button variant='soft' onClick={() => setShowShortcuts(true)} sx={{ ml: 'auto' }}>
|
||||
👉 See Shortcuts
|
||||
</Button>}
|
||||
|
||||
{showShortcuts && <ShortcutsModal onClose={() => setShowShortcuts(false)}/>}
|
||||
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -2,19 +2,18 @@ import * as React from 'react';
|
||||
|
||||
import { Accordion, AccordionDetails, accordionDetailsClasses, AccordionGroup, AccordionSummary, accordionSummaryClasses, Avatar, Button, Divider, ListItemContent, Stack, styled, Tab, tabClasses, TabList, TabPanel, Tabs } from '@mui/joy';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import BuildCircleIcon from '@mui/icons-material/BuildCircle';
|
||||
import ScienceIcon from '@mui/icons-material/Science';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import TelegramIcon from '@mui/icons-material/Telegram';
|
||||
|
||||
import { BrowseSettings } from '~/modules/browse/BrowseSettings';
|
||||
import { ElevenlabsSettings } from '~/modules/elevenlabs/ElevenlabsSettings';
|
||||
import { GoogleSearchSettings } from '~/modules/google/GoogleSearchSettings';
|
||||
import { ProdiaSettings } from '~/modules/prodia/ProdiaSettings';
|
||||
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { closeLayoutPreferences, openLayoutModelsSetup, openLayoutPreferences, useLayoutPreferencesTab } from '~/common/layout/store-applayout';
|
||||
import { closeLayoutPreferences, openLayoutShortcuts, useLayoutPreferencesTab } from '~/common/layout/store-applayout';
|
||||
import { settingsGap } from '~/common/app.theme';
|
||||
import { useGlobalShortcut } from '~/common/components/useGlobalShortcut';
|
||||
import { useIsMobile } from '~/common/components/useMatchMedia';
|
||||
|
||||
import { AppChatSettingsAI } from './AppChatSettingsAI';
|
||||
import { AppChatSettingsUI } from './AppChatSettingsUI';
|
||||
@@ -44,14 +43,17 @@ const Topics = styled(AccordionGroup)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function Topic(props: { title: string, icon?: string | React.ReactNode, startCollapsed?: boolean, children?: React.ReactNode }) {
|
||||
function Topic(props: { title?: string, icon?: string | React.ReactNode, startCollapsed?: boolean, children?: React.ReactNode }) {
|
||||
|
||||
// state
|
||||
const [expanded, setExpanded] = React.useState(props.startCollapsed !== true);
|
||||
|
||||
// derived state
|
||||
const hideTitleBar = !props.title && !props.icon;
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={expanded}
|
||||
expanded={expanded || hideTitleBar}
|
||||
onChange={(_event, expanded) => setExpanded(expanded)}
|
||||
sx={{
|
||||
'&:not(:last-child)': {
|
||||
@@ -63,23 +65,25 @@ function Topic(props: { title: string, icon?: string | React.ReactNode, startCol
|
||||
}}
|
||||
>
|
||||
|
||||
<AccordionSummary
|
||||
color='primary'
|
||||
variant={expanded ? 'plain' : 'soft'}
|
||||
indicator={<AddIcon />}
|
||||
>
|
||||
{!!props.icon && (
|
||||
<Avatar
|
||||
color='primary'
|
||||
variant={expanded ? 'soft' : 'plain'}
|
||||
>
|
||||
{props.icon}
|
||||
</Avatar>
|
||||
)}
|
||||
<ListItemContent>
|
||||
{props.title}
|
||||
</ListItemContent>
|
||||
</AccordionSummary>
|
||||
{!hideTitleBar && (
|
||||
<AccordionSummary
|
||||
color='primary'
|
||||
variant={expanded ? 'plain' : 'soft'}
|
||||
indicator={<AddIcon />}
|
||||
>
|
||||
{!!props.icon && (
|
||||
<Avatar
|
||||
color='primary'
|
||||
variant={expanded ? 'soft' : 'plain'}
|
||||
>
|
||||
{props.icon}
|
||||
</Avatar>
|
||||
)}
|
||||
<ListItemContent>
|
||||
{props.title}
|
||||
</ListItemContent>
|
||||
</AccordionSummary>
|
||||
)}
|
||||
|
||||
<AccordionDetails>
|
||||
<Stack sx={{ gap: settingsGap, border: 'none' }}>
|
||||
@@ -99,8 +103,8 @@ function Topic(props: { title: string, icon?: string | React.ReactNode, startCol
|
||||
export function SettingsModal() {
|
||||
|
||||
// external state
|
||||
const isMobile = useIsMobile();
|
||||
const settingsTabIndex = useLayoutPreferencesTab();
|
||||
useGlobalShortcut('p', true, true, false, openLayoutPreferences);
|
||||
|
||||
const tabFixSx = { fontFamily: 'body', flex: 1, p: 0, m: 0 };
|
||||
|
||||
@@ -108,13 +112,11 @@ export function SettingsModal() {
|
||||
<GoodModal
|
||||
title='Preferences' strongerTitle
|
||||
open={!!settingsTabIndex} onClose={closeLayoutPreferences}
|
||||
startButton={
|
||||
<Button variant='soft' color='success' onClick={openLayoutModelsSetup} startDecorator={<BuildCircleIcon />} sx={{
|
||||
'--Icon-fontSize': 'var(--joy-fontSize-xl2)',
|
||||
}}>
|
||||
Models
|
||||
startButton={isMobile ? undefined : (
|
||||
<Button variant='soft' onClick={openLayoutShortcuts}>
|
||||
👉 See Shortcuts
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
sx={{
|
||||
'--Card-padding': { xs: '8px', sm: '16px', lg: '24px' },
|
||||
}}
|
||||
@@ -143,7 +145,7 @@ export function SettingsModal() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab disableIndicator value={1} sx={tabFixSx}>UX</Tab>
|
||||
<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>
|
||||
@@ -151,7 +153,7 @@ export function SettingsModal() {
|
||||
|
||||
<TabPanel value={1} sx={{ p: 'var(--Tabs-gap)' }}>
|
||||
<Topics>
|
||||
<Topic icon={<TelegramIcon />} title='User Interface'>
|
||||
<Topic>
|
||||
<AppChatSettingsUI />
|
||||
</Topic>
|
||||
<Topic icon='🧠' title='Chat AI' startCollapsed>
|
||||
@@ -184,7 +186,10 @@ export function SettingsModal() {
|
||||
|
||||
<TabPanel value={4} sx={{ p: 'var(--Tabs-gap)' }}>
|
||||
<Topics>
|
||||
<Topic icon={<SearchIcon />} title='Google Search API'>
|
||||
<Topic icon={<SearchIcon />} title='Browsing' startCollapsed>
|
||||
<BrowseSettings />
|
||||
</Topic>
|
||||
<Topic icon={<SearchIcon />} title='Google Search API' startCollapsed>
|
||||
<GoogleSearchSettings />
|
||||
</Topic>
|
||||
{/*<Topic icon='🛠' title='Other tools...' />*/}
|
||||
|
||||
@@ -3,38 +3,50 @@ import * as React from 'react';
|
||||
import { ChatMessage } from '../chat/components/message/ChatMessage';
|
||||
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { closeLayoutShortcuts, useLayoutShortcuts } from '~/common/layout/store-applayout';
|
||||
import { createDMessage } from '~/common/state/store-chats';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
|
||||
|
||||
const shortcutsMd = `
|
||||
|
||||
| Shortcut | Description |
|
||||
|------------------|-------------------------------------------------|
|
||||
| **Edit** | |
|
||||
| Shift + Enter | Newline (don't send) |
|
||||
| Alt + Enter | Append message (don't send) |
|
||||
| Ctrl + Shift + R | Regenerate answer |
|
||||
| Ctrl + Shift + V | Attach clipboard (better than Ctrl + V) |
|
||||
| Ctrl + M | Microphone (voice typing) |
|
||||
| **Chats** | |
|
||||
| Ctrl + Alt + N | **New** chat |
|
||||
| Ctrl + Alt + X | **Reset** chat |
|
||||
| Ctrl + Alt + D | **Delete** chat |
|
||||
| Ctrl + Alt + F | **Clone** chat |
|
||||
| **Settings** | |
|
||||
| Ctrl + Shift + M | 🧠 Models |
|
||||
| Ctrl + Shift + P | ⚙️ Preferences |
|
||||
| Shortcut | Description |
|
||||
|---------------------|-------------------------------------------------|
|
||||
| **Edit** | |
|
||||
| Shift + Enter | Newline |
|
||||
| Alt + Enter | Append (no response) |
|
||||
| Ctrl + Shift + R | Regenerate answer |
|
||||
| Ctrl + Shift + V | Attach clipboard (better than Ctrl + V) |
|
||||
| Ctrl + M | Microphone (voice typing) |
|
||||
| **Chats** | |
|
||||
| Ctrl + Alt + Left | **Previous** chat (in history) |
|
||||
| Ctrl + Alt + Right | **Next** chat (in history) |
|
||||
| Ctrl + Alt + N | **New** chat |
|
||||
| Ctrl + Alt + X | **Reset** chat |
|
||||
| Ctrl + Alt + D | **Delete** chat |
|
||||
| Ctrl + Alt + B | **Branch** chat |
|
||||
| **Settings** | |
|
||||
| Ctrl + Shift + M | 🧠 Models |
|
||||
| Ctrl + Shift + P | ⚙️ Preferences |
|
||||
| Ctrl + Shift + ? | Shortcuts |
|
||||
|
||||
`.trim();
|
||||
|
||||
const shortcutsMessage = createDMessage('assistant', platformAwareKeystrokes(shortcutsMd));
|
||||
|
||||
|
||||
export const ShortcutsModal = (props: { onClose: () => void }) =>
|
||||
<GoodModal
|
||||
open title='Desktop Shortcuts'
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<ChatMessage message={shortcutsMessage} hideAvatars noBottomBorder sx={{ p: 0, m: 0 }} />
|
||||
</GoodModal>;
|
||||
export function ShortcutsModal() {
|
||||
|
||||
// external state
|
||||
const showShortcuts = useLayoutShortcuts();
|
||||
|
||||
return (
|
||||
<GoodModal
|
||||
open={showShortcuts}
|
||||
title='Desktop Shortcuts'
|
||||
onClose={closeLayoutShortcuts}
|
||||
>
|
||||
<ChatMessage message={shortcutsMessage} hideAvatars noBottomBorder sx={{ p: 0, m: 0 }} />
|
||||
</GoodModal>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,58 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Button, FormControl, Switch } from '@mui/joy';
|
||||
import { FormControl, Typography } from '@mui/joy';
|
||||
import CallIcon from '@mui/icons-material/Call';
|
||||
import FormatPaintIcon from '@mui/icons-material/FormatPaint';
|
||||
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
|
||||
import YouTubeIcon from '@mui/icons-material/YouTube';
|
||||
|
||||
import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { closeLayoutPreferences } from '~/common/layout/store-applayout';
|
||||
import { navigateToLabs } from '~/common/app.routes';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
import { FormSwitchControl } from '~/common/components/forms/FormSwitchControl';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { useUXLabsStore } from '~/common/state/store-ux-labs';
|
||||
|
||||
|
||||
export function UxLabsSettings() {
|
||||
|
||||
// external state
|
||||
const {
|
||||
experimentalLabs, setExperimentalLabs,
|
||||
} = useUIPreferencesStore(state => ({
|
||||
experimentalLabs: state.experimentalLabs, setExperimentalLabs: state.setExperimentalLabs,
|
||||
}), shallow);
|
||||
|
||||
const handleExperimentalLabsChange = (event: React.ChangeEvent<HTMLInputElement>) => setExperimentalLabs(event.target.checked);
|
||||
|
||||
labsCalling, /*labsEnhancedUI,*/ labsMagicDraw, labsPersonaYTCreator, labsSplitBranching,
|
||||
setLabsCalling, /*setLabsEnhancedUI,*/ setLabsMagicDraw, setLabsPersonaYTCreator, setLabsSplitBranching,
|
||||
} = useUXLabsStore();
|
||||
|
||||
return <>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between' }}>
|
||||
<FormLabelStart title='Experiments'
|
||||
description={experimentalLabs ? 'Enabled' : 'Disabled'} />
|
||||
<Switch checked={experimentalLabs} onChange={handleExperimentalLabsChange}
|
||||
endDecorator={experimentalLabs ? 'On' : 'Off'}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }} />
|
||||
</FormControl>
|
||||
<FormSwitchControl
|
||||
title={<><YouTubeIcon /> YouTube Personas</>} description={labsPersonaYTCreator ? 'Creator Enabled' : 'Disabled'}
|
||||
checked={labsPersonaYTCreator} onChange={setLabsPersonaYTCreator}
|
||||
/>
|
||||
|
||||
<Button variant='soft' onClick={() => {
|
||||
closeLayoutPreferences();
|
||||
void navigateToLabs();
|
||||
}} sx={{ ml: 'auto' }}>
|
||||
👉 See Experiments
|
||||
</Button>
|
||||
<FormSwitchControl
|
||||
title={<><FormatPaintIcon />Assisted Draw</>} description={labsMagicDraw ? 'Enabled' : 'Disabled'}
|
||||
checked={labsMagicDraw} onChange={setLabsMagicDraw}
|
||||
/>
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><CallIcon /> Voice Calls</>} description={labsCalling ? 'Call AGI' : 'Disabled'}
|
||||
checked={labsCalling} onChange={setLabsCalling}
|
||||
/>
|
||||
|
||||
<FormSwitchControl
|
||||
title={<><VerticalSplitIcon /> Split Branching</>} description={labsSplitBranching ? 'Enabled' : 'Disabled'} disabled
|
||||
checked={labsSplitBranching} onChange={setLabsSplitBranching}
|
||||
/>
|
||||
|
||||
{/*<FormSwitchControl*/}
|
||||
{/* title='Enhanced UI' description={labsEnhancedUI ? 'Enabled' : 'Disabled'}*/}
|
||||
{/* checked={labsEnhancedUI} onChange={setLabsEnhancedUI}*/}
|
||||
{/*/>*/}
|
||||
|
||||
<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/192' target='_blank'>Auto Diagrams</Link> · Relative chat size · Text Tools
|
||||
</Typography>
|
||||
</FormControl>
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -6,17 +6,38 @@
|
||||
|
||||
import Router from 'next/router';
|
||||
|
||||
const APP_CHAT = '/';
|
||||
const APP_LINK_CHAT = '/link/chat/:linkId';
|
||||
const APP_LABS = '/labs';
|
||||
import type { DConversationId } from '~/common/state/store-chats';
|
||||
|
||||
export const getHomeLink = () => APP_CHAT;
|
||||
|
||||
export const getChatLinkRelativePath = (chatLinkId: string) => APP_LINK_CHAT.replace(':linkId', chatLinkId);
|
||||
export const ROUTE_INDEX = '/';
|
||||
export const ROUTE_APP_CHAT = '/';
|
||||
export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId';
|
||||
export const ROUTE_APP_NEWS = '/news';
|
||||
|
||||
export const navigateToChat = async () => await Router.push(APP_CHAT);
|
||||
export const getIndexLink = () => ROUTE_INDEX;
|
||||
|
||||
export const navigateToLabs = async () => await Router.push(APP_LABS);
|
||||
export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT.replace(':linkId', chatLinkId);
|
||||
|
||||
const navigateFn = (path: string) => (replace?: boolean): Promise<boolean> =>
|
||||
Router[replace ? 'replace' : 'push'](path);
|
||||
|
||||
export const navigateToIndex = navigateFn(ROUTE_INDEX);
|
||||
export const navigateToChat = async (conversationId?: DConversationId) => {
|
||||
if (conversationId) {
|
||||
await Router.push(
|
||||
{
|
||||
pathname: ROUTE_APP_CHAT,
|
||||
query: {
|
||||
conversationId,
|
||||
},
|
||||
},
|
||||
ROUTE_APP_CHAT,
|
||||
);
|
||||
} else {
|
||||
await Router.push(ROUTE_APP_CHAT, ROUTE_APP_CHAT);
|
||||
}
|
||||
};
|
||||
export const navigateToNews = navigateFn(ROUTE_APP_NEWS);
|
||||
|
||||
export const navigateBack = Router.back;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { KeyboardEvent } from 'react';
|
||||
|
||||
import { ClickAwayListener, Popper, PopperPlacementType } from '@mui/base';
|
||||
import { MenuList, styled, VariantProp } from '@mui/joy';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
|
||||
// adds the 'sx' prop to the Popper, and defaults zIndex to 1000
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Tooltip } from '@mui/joy';
|
||||
|
||||
|
||||
/**
|
||||
* Tooltip with text that wraps to multiple lines (doesn't go too long)
|
||||
*/
|
||||
export const GoodTooltip = (props: { title: string | React.JSX.Element, children: React.JSX.Element }) =>
|
||||
<Tooltip title={props.title} sx={{ maxWidth: { sm: '50vw', md: '25vw' } }}>
|
||||
{props.children}
|
||||
</Tooltip>;
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, Typography } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
export function InlineError(props: { error: React.JSX.Element | null | any, severity?: 'warning' | 'danger' }) {
|
||||
export function InlineError(props: { error: React.JSX.Element | null | any, severity?: 'warning' | 'danger' | 'info', sx?: SxProps }) {
|
||||
const color = props.severity === 'info' ? 'primary' : props.severity || 'warning';
|
||||
return (
|
||||
<Alert variant='soft' color={props.severity || 'warning'} sx={{ mt: 1 }}>
|
||||
<Typography level='body-sm' color={props.severity || 'warning'}>
|
||||
<Alert variant='soft' color={color} sx={{ mt: 1, ...props.sx }}>
|
||||
<Typography level='body-sm' color={color}>
|
||||
{props.error?.message || props.error || 'Unknown error'}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Textarea } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import { ColorPaletteProp, SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
|
||||
export function InlineTextarea(props: { initialText: string, onEdit: (text: string) => void, sx?: SxProps }) {
|
||||
export function InlineTextarea(props: { initialText: string, color?: ColorPaletteProp, onEdit: (text: string) => void, sx?: SxProps }) {
|
||||
|
||||
const [text, setText] = React.useState(props.initialText);
|
||||
const enterIsNewline = useUIPreferencesStore(state => state.enterIsNewline);
|
||||
@@ -27,7 +27,7 @@ export function InlineTextarea(props: { initialText: string, onEdit: (text: stri
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
variant='soft' color='warning' autoFocus minRows={1}
|
||||
variant='soft' color={props.color || 'warning'} autoFocus minRows={1}
|
||||
value={text} onChange={handleEditTextChanged}
|
||||
onKeyDown={handleEditKeyDown} onBlur={handleEditBlur}
|
||||
slotProps={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Chip } from '@mui/joy';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
import { hideOnMobile } from '~/common/app.theme';
|
||||
import { isMacUser } from '~/common/util/pwaUtils';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { SvgIcon } from '@mui/joy';
|
||||
import { SxProps } from '@mui/system';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
|
||||
export const LogoSquircle = (props: {
|
||||
sx?: SxProps
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, FormHelperText, FormLabel, Tooltip } from '@mui/joy';
|
||||
import { Box, FormHelperText, FormLabel } from '@mui/joy';
|
||||
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';
|
||||
|
||||
|
||||
@@ -28,9 +29,9 @@ export const FormLabelStart = (props: {
|
||||
}}
|
||||
>
|
||||
{props.title} {props.tooltip && (
|
||||
<Tooltip title={props.tooltip} sx={{ maxWidth: { sm: '50vw', md: '25vw' } }}>
|
||||
<GoodTooltip title={props.tooltip}>
|
||||
<InfoIcon sx={{ mx: 0.5, cursor: 'pointer', fontSize: 'md', color: 'primary.solidBg' }} />
|
||||
</Tooltip>
|
||||
</GoodTooltip>
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
|
||||
@@ -10,16 +10,19 @@ import { FormLabelStart } from './FormLabelStart';
|
||||
*/
|
||||
export function FormSwitchControl(props: {
|
||||
title: string | React.JSX.Element, description?: string | React.JSX.Element,
|
||||
value: boolean, onChange: (on: boolean) => void,
|
||||
on?: string, off?: string, fullWidth?: boolean,
|
||||
checked: boolean, onChange: (on: boolean) => void,
|
||||
disabled?: boolean,
|
||||
}) {
|
||||
return (
|
||||
<FormControl orientation='horizontal' sx={{ flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormControl orientation='horizontal' disabled={props.disabled} sx={{ flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title={props.title} description={props.description} />
|
||||
<Switch
|
||||
checked={props.value}
|
||||
checked={props.checked}
|
||||
onChange={event => props.onChange(event.target.checked)}
|
||||
endDecorator={props.value ? 'Enabled' : 'Off'}
|
||||
sx={{ flexGrow: 1 }}
|
||||
endDecorator={props.checked ? props.on || 'On' : props.off || 'Off'}
|
||||
sx={props.fullWidth ? { flexGrow: 1 } : undefined}
|
||||
slotProps={{ endDecorator: { sx: { minWidth: 26 } } }}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from 'react';
|
||||
import { FormControl, FormLabel, Radio, RadioGroup } from '@mui/joy';
|
||||
|
||||
|
||||
export type FormRadioOption<T extends string> = { label: string, value: T, experimental?: boolean };
|
||||
export type FormRadioOption<T extends string> = { label: string, value: T, disabled?: boolean };
|
||||
|
||||
|
||||
/**
|
||||
@@ -14,9 +14,6 @@ export function useFormRadio<T extends string>(initialValue: T, options: FormRad
|
||||
// state
|
||||
const [value, setValue] = React.useState<T | null>(initialValue);
|
||||
|
||||
// external state
|
||||
// const experimentalLabs = useUIPreferencesStore(state => state.experimentalLabs);
|
||||
|
||||
const handleChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(event.target.value as T | null);
|
||||
}, []);
|
||||
@@ -31,10 +28,10 @@ export function useFormRadio<T extends string>(initialValue: T, options: FormRad
|
||||
value={value} onChange={handleChange}
|
||||
>
|
||||
{options.map((option) =>
|
||||
<Radio key={option.value} disabled={!!option.experimental /*&& !experimentalLabs*/} value={option.value} label={option.label} />)}
|
||||
<Radio key={option.value} disabled={option.disabled} value={option.value} label={option.label} />)}
|
||||
</RadioGroup>
|
||||
</FormControl>,
|
||||
[/*experimentalLabs,*/ handleChange, hidden, label, options, value],
|
||||
[handleChange, hidden, label, options, value],
|
||||
);
|
||||
|
||||
return [value, component];
|
||||
|
||||
@@ -39,4 +39,20 @@ export interface CapabilityProdiaImageGeneration {
|
||||
mayWork: boolean;
|
||||
}
|
||||
|
||||
export { useCapability as useCapabilityProdia } from '~/modules/prodia/prodia.client';
|
||||
export { useCapability as useCapabilityProdia } from '~/modules/prodia/prodia.client';
|
||||
|
||||
|
||||
/// Browsing
|
||||
|
||||
export interface CapabilityBrowsing {
|
||||
mayWork: boolean;
|
||||
isServerConfig: boolean;
|
||||
isClientConfig: boolean;
|
||||
isClientValid: boolean;
|
||||
inCommand: boolean;
|
||||
inComposer: boolean;
|
||||
inReact: boolean;
|
||||
inPersonas: boolean;
|
||||
}
|
||||
|
||||
// export { useBrowseCapability as useCapabilityBrowse } from '~/modules/browse/store-module-browsing';
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export const GlobalShortcut = {
|
||||
export const ShortcutKeyName = {
|
||||
Esc: 'Escape',
|
||||
Left: 'ArrowLeft',
|
||||
Right: 'ArrowRight',
|
||||
@@ -31,4 +31,36 @@ export const useGlobalShortcut = (shortcutKey: string | false, useCtrl: boolean,
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [callback, shortcutKey, useAlt, useCtrl, useShift]);
|
||||
};
|
||||
|
||||
|
||||
export type GlobalShortcutItem = [key: string, ctrl: boolean, shift: boolean, alt: boolean, action: () => void];
|
||||
|
||||
|
||||
/**
|
||||
* Registers multiple global keyboard shortcuts to activate callbacks.
|
||||
*
|
||||
* @param shortcuts An array of shortcut objects.
|
||||
*/
|
||||
export const useGlobalShortcuts = (shortcuts: GlobalShortcutItem[]) => {
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
for (const [key, useCtrl, useShift, useAlt, action] of shortcuts) {
|
||||
if (
|
||||
key &&
|
||||
(useCtrl === event.ctrlKey) &&
|
||||
(useShift === event.shiftKey) &&
|
||||
(useAlt === event.altKey) &&
|
||||
event.key.toLowerCase() === key.toLowerCase()
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [shortcuts]);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { SnackbarTypeMap } from '@mui/joy';
|
||||
|
||||
|
||||
export const SNACKBAR_ANIMATION_DURATION = 200;
|
||||
|
||||
export interface SnackbarMessage {
|
||||
key: string;
|
||||
message: string;
|
||||
type: 'success' | 'issue' | 'title';
|
||||
closeButton?: boolean,
|
||||
overrides?: Partial<SnackbarTypeMap['props']>;
|
||||
}
|
||||
|
||||
interface SnackbarStore {
|
||||
|
||||
// state
|
||||
activeSnackbar: SnackbarMessage | null;
|
||||
activeSnackbarOpen: boolean;
|
||||
snackbarQueue: SnackbarMessage[];
|
||||
|
||||
// actions
|
||||
addSnackbar: (snackbar: SnackbarMessage) => string;
|
||||
animateCloseSnackbar: () => void;
|
||||
closeSnackbar: () => void;
|
||||
removeSnackbar: (key: string) => void;
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const useSnackbarsStore = create<SnackbarStore>()(
|
||||
(_set, _get) => ({
|
||||
|
||||
activeSnackbar: null,
|
||||
activeSnackbarOpen: true,
|
||||
snackbarQueue: [],
|
||||
|
||||
addSnackbar: (snackbar: SnackbarMessage): string => {
|
||||
const { activeSnackbar } = _get();
|
||||
let { key, ...rest } = snackbar;
|
||||
|
||||
// unique key
|
||||
key += '-' + uuidv4();
|
||||
|
||||
// append the snackbar
|
||||
const newSnackbar = { key, ...rest };
|
||||
_set(activeSnackbar === null
|
||||
? {
|
||||
activeSnackbar: newSnackbar,
|
||||
activeSnackbarOpen: true,
|
||||
}
|
||||
: {
|
||||
snackbarQueue: [..._get().snackbarQueue, newSnackbar],
|
||||
});
|
||||
|
||||
return key;
|
||||
},
|
||||
|
||||
closeSnackbar: () =>
|
||||
_set((state) => {
|
||||
let nextActiveSnackbar = null;
|
||||
let nextQueue = [...state.snackbarQueue];
|
||||
if (nextQueue.length > 0)
|
||||
nextActiveSnackbar = nextQueue.shift(); // Remove the first snackbar from the queue
|
||||
return {
|
||||
activeSnackbar: nextActiveSnackbar,
|
||||
activeSnackbarOpen: nextActiveSnackbar !== null,
|
||||
snackbarQueue: nextQueue,
|
||||
};
|
||||
}),
|
||||
|
||||
animateCloseSnackbar: () => {
|
||||
_set({
|
||||
activeSnackbarOpen: false,
|
||||
});
|
||||
setTimeout(() => {
|
||||
_get().closeSnackbar();
|
||||
}, SNACKBAR_ANIMATION_DURATION); // Delay needs to match match your CSS animation duration
|
||||
},
|
||||
|
||||
// mostly added for useEffect's unmounts
|
||||
removeSnackbar: (key: string) =>
|
||||
_set((state) => {
|
||||
let nextActiveSnackbar = state.activeSnackbar;
|
||||
let nextQueue = [...state.snackbarQueue];
|
||||
if (nextActiveSnackbar?.key === key) {
|
||||
if (nextQueue.length > 0)
|
||||
nextActiveSnackbar = nextQueue.shift() as SnackbarMessage; // Remove the first snackbar from the queue
|
||||
else
|
||||
nextActiveSnackbar = null;
|
||||
return {
|
||||
activeSnackbar: nextActiveSnackbar,
|
||||
activeSnackbarOpen: nextActiveSnackbar !== null,
|
||||
snackbarQueue: nextQueue,
|
||||
};
|
||||
}
|
||||
return {
|
||||
snackbarQueue: nextQueue.filter(snackbar => snackbar.key !== key),
|
||||
};
|
||||
}),
|
||||
|
||||
}),
|
||||
);
|
||||
|
||||
export const addSnackbar = (snackbar: SnackbarMessage) =>
|
||||
useSnackbarsStore.getState().addSnackbar(snackbar);
|
||||
|
||||
export const removeSnackbar = (key: string) =>
|
||||
useSnackbarsStore.getState().removeSnackbar(key);
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Box, Divider, ListDivider, ListItemDecorator, Option, Select } from '@mui/joy';
|
||||
import { Box, Divider, ListDivider, listItemButtonClasses, ListItemDecorator, Option, optionClasses, Select } from '@mui/joy';
|
||||
import { SxProps } from '@mui/joy/styles/types';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
|
||||
@@ -52,6 +52,12 @@ export function AppBarDropdown<TValue extends string>(props: {
|
||||
// do not exceed the height of the screen (minus top bar) with any listbox menu
|
||||
maxHeight: 'calc(100dvh - 56px)',
|
||||
maxWidth: '90dvw',
|
||||
[`& .${optionClasses.root}`]: {
|
||||
minWidth: 160,
|
||||
},
|
||||
[`& .${listItemButtonClasses.root}`]: {
|
||||
minWidth: 160,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -5,13 +5,16 @@ import { Box, Container } from '@mui/joy';
|
||||
|
||||
import { ModelsModal } from '../../apps/models-modal/ModelsModal';
|
||||
import { SettingsModal } from '../../apps/settings-modal/SettingsModal';
|
||||
import { ShortcutsModal } from '../../apps/settings-modal/ShortcutsModal';
|
||||
|
||||
import { isPwa } from '~/common/util/pwaUtils';
|
||||
import { useAppStateStore } from '~/common/state/store-appstate';
|
||||
import { useUIPreferencesStore } from '~/common/state/store-ui';
|
||||
|
||||
import { AppBar } from './AppBar';
|
||||
import { GlobalShortcutItem, useGlobalShortcuts } from '../components/useGlobalShortcut';
|
||||
import { NoSSR } from '../components/NoSSR';
|
||||
import { openLayoutModelsSetup, openLayoutPreferences, openLayoutShortcuts } from './store-applayout';
|
||||
|
||||
|
||||
export function AppLayout(props: {
|
||||
@@ -24,6 +27,14 @@ export function AppLayout(props: {
|
||||
// usage counter, for progressive disclosure of features
|
||||
useAppStateStore(state => state.usageCount);
|
||||
|
||||
// global shortcuts for modals
|
||||
const shortcuts = React.useMemo((): GlobalShortcutItem[] => [
|
||||
['m', true, true, false, openLayoutModelsSetup],
|
||||
['p', true, true, false, openLayoutPreferences],
|
||||
['?', true, true, false, openLayoutShortcuts],
|
||||
], []);
|
||||
useGlobalShortcuts(shortcuts);
|
||||
|
||||
return (
|
||||
// Global NoSSR wrapper: the overall Container could have hydration issues when using localStorage and non-default maxWidth
|
||||
<NoSSR>
|
||||
@@ -60,6 +71,9 @@ export function AppLayout(props: {
|
||||
{/* Overlay Models (& Model Options )*/}
|
||||
<ModelsModal suspendAutoModelsSetup={props.suspendAutoModelsSetup} />
|
||||
|
||||
{/* Overlay Shortcuts */}
|
||||
<ShortcutsModal />
|
||||
|
||||
</NoSSR>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface AppLayoutStore {
|
||||
preferencesTab: number; // 0: closed, 1..N: tab index
|
||||
modelsSetupOpen: boolean;
|
||||
llmOptionsId: DLLMId | null;
|
||||
shortcutsOpen: boolean;
|
||||
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ const useAppLayoutStore = create<AppLayoutStore>()(
|
||||
preferencesTab: 0,
|
||||
modelsSetupOpen: false,
|
||||
llmOptionsId: null,
|
||||
shortcutsOpen: false,
|
||||
|
||||
}),
|
||||
);
|
||||
@@ -74,4 +76,7 @@ export const useLayoutModelsSetup = (): [open: boolean, llmId: DLLMId | null] =>
|
||||
export const openLayoutModelsSetup = () => useAppLayoutStore.setState({ modelsSetupOpen: true });
|
||||
export const closeLayoutModelsSetup = () => useAppLayoutStore.setState({ modelsSetupOpen: false });
|
||||
export const openLayoutLLMOptions = (llmId: DLLMId) => useAppLayoutStore.setState({ llmOptionsId: llmId });
|
||||
export const closeLayoutLLMOptions = () => useAppLayoutStore.setState({ llmOptionsId: null });
|
||||
export const closeLayoutLLMOptions = () => useAppLayoutStore.setState({ llmOptionsId: null });
|
||||
export const useLayoutShortcuts = () => useAppLayoutStore(state => state.shortcutsOpen);
|
||||
export const openLayoutShortcuts = () => useAppLayoutStore.setState({ shortcutsOpen: true });
|
||||
export const closeLayoutShortcuts = () => useAppLayoutStore.setState({ shortcutsOpen: false });
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { IconButton, Snackbar, SnackbarTypeMap } from '@mui/joy';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { SNACKBAR_ANIMATION_DURATION, SnackbarMessage, useSnackbarsStore } from '../components/useSnackbarsStore';
|
||||
|
||||
|
||||
const defaultTypeConfig: {
|
||||
[key in SnackbarMessage['type']]: (Partial<SnackbarTypeMap['props']> & {
|
||||
clickAway: boolean,
|
||||
closeButton: boolean;
|
||||
})
|
||||
} = {
|
||||
success: {
|
||||
color: 'success',
|
||||
variant: 'soft',
|
||||
autoHideDuration: 5000,
|
||||
clickAway: false,
|
||||
closeButton: true,
|
||||
},
|
||||
issue: {
|
||||
color: 'warning',
|
||||
variant: 'solid',
|
||||
autoHideDuration: null, // Will not auto-hide
|
||||
clickAway: false,
|
||||
closeButton: true,
|
||||
},
|
||||
title: {
|
||||
color: 'neutral',
|
||||
variant: 'plain',
|
||||
autoHideDuration: 2000,
|
||||
clickAway: false,
|
||||
closeButton: false,
|
||||
anchorOrigin: { vertical: 'top', horizontal: 'center' },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Simple cycler through the snackbars.
|
||||
*/
|
||||
export const ProviderSnacks = (props: { children: React.ReactNode }) => {
|
||||
|
||||
// external state
|
||||
const { activeSnackbar, activeSnackbarOpen, animateCloseSnackbar } = useSnackbarsStore();
|
||||
|
||||
// Memoize the rendered snack bars to prevent unnecessary re-renders
|
||||
const memoizedSnackbar = React.useMemo(() => {
|
||||
if (!activeSnackbar)
|
||||
return null;
|
||||
|
||||
const { key, message, type, closeButton, overrides } = activeSnackbar;
|
||||
|
||||
const config = {
|
||||
...defaultTypeConfig[type],
|
||||
...overrides,
|
||||
...(closeButton === undefined ? {} : { closeButton }),
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
key={key}
|
||||
open={activeSnackbarOpen}
|
||||
color={config.color}
|
||||
variant={config.variant}
|
||||
autoHideDuration={config.autoHideDuration ?? null}
|
||||
animationDuration={SNACKBAR_ANIMATION_DURATION}
|
||||
invertedColors={config.closeButton}
|
||||
anchorOrigin={config.anchorOrigin || { vertical: 'bottom', horizontal: 'right' }}
|
||||
onClose={(_event, reason) => {
|
||||
if (reason === 'timeout' || ((reason === 'clickaway' || reason === 'escapeKeyDown') && config.clickAway)) {
|
||||
animateCloseSnackbar();
|
||||
}
|
||||
}}
|
||||
startDecorator={config.startDecorator}
|
||||
endDecorator={!config.closeButton ? undefined : (
|
||||
<IconButton
|
||||
onClick={animateCloseSnackbar}
|
||||
size='sm'
|
||||
sx={{ my: '-0.4rem' }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
sx={theme => ({
|
||||
...(type === 'title' && {
|
||||
'--Snackbar-inset': '64px',
|
||||
borderRadius: 'md',
|
||||
boxShadow: 'md',
|
||||
bgcolor: `rgba(${theme.vars.palette.neutral.lightChannel} / 0.1)`,
|
||||
backdropFilter: 'blur(6px)',
|
||||
}),
|
||||
// '--Snackbar-padding': config.closeButton ? '0.5rem' : '1rem',
|
||||
})}
|
||||
>
|
||||
{message}
|
||||
</Snackbar>
|
||||
);
|
||||
}, [activeSnackbar, activeSnackbarOpen, animateCloseSnackbar]);
|
||||
|
||||
return <>
|
||||
{props.children}
|
||||
{memoizedSnackbar}
|
||||
</>;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
|
||||
type TaggedListItem<TId extends string, TTag extends string> = {
|
||||
id: TId;
|
||||
listTags: TTag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a persistent list of type TaggedListItem that will handle list functions, and item editing functions.
|
||||
* - your item can have all the properties you want, but must have an id and listTags property
|
||||
* - your item will be serialized/de-serialized to/from localStorage, make sure it's JSON-serializable
|
||||
*/
|
||||
export function createStoredTaggetList<TItem extends TaggedListItem<string, string>>(persistName: string) {
|
||||
|
||||
// Infer TId and TTag from TItem's id and listTags array type
|
||||
type TId = TItem['id'];
|
||||
type TTag = TItem['listTags'][number];
|
||||
|
||||
type SelectionMap = {
|
||||
[K in TTag]?: TId;
|
||||
};
|
||||
|
||||
type TaggedListState = {
|
||||
// State
|
||||
items: TItem[];
|
||||
selections: SelectionMap; // Maps TTag to selected TId
|
||||
|
||||
// Actions
|
||||
addItem: (item: TItem) => void;
|
||||
removeItem: (itemId: TId) => void;
|
||||
modifyItem: (itemId: TId, changes: Partial<TItem>) => void;
|
||||
modifyItemDeep: (itemId: TId, updater: (item: TItem) => TItem) => void;
|
||||
|
||||
selectItemForTag: (tag: TTag, itemId: TId) => void;
|
||||
};
|
||||
|
||||
return create<TaggedListState>()(persist((set, get) => ({
|
||||
|
||||
items: [] as TItem[],
|
||||
selections: {} as SelectionMap,
|
||||
|
||||
|
||||
addItem: (item: TItem) => set((state: TaggedListState) => ({
|
||||
items: [...state.items, item],
|
||||
})),
|
||||
|
||||
removeItem: (itemId: TId) => set((state: TaggedListState) => ({
|
||||
items: state.items.filter((item: TItem) => item.id !== itemId),
|
||||
selections: Object.fromEntries(
|
||||
Object.entries(state.selections).filter(([, selectedId]) => selectedId !== itemId),
|
||||
) as SelectionMap,
|
||||
})),
|
||||
|
||||
modifyItem: (itemId: TId, changes: Partial<TItem>) => set((state: TaggedListState) => ({
|
||||
items: state.items.map((item: TItem) => item.id === itemId ? { ...item, ...changes } : item),
|
||||
})),
|
||||
|
||||
modifyItemDeep: (itemId: TId, updater: (item: TItem) => TItem) => set((state: TaggedListState) => ({
|
||||
items: state.items.map((item: TItem) => item.id === itemId ? updater(item) : item),
|
||||
})),
|
||||
|
||||
|
||||
selectItemForTag: (tag: TTag, itemId: TId) => {
|
||||
const item = get().items.find((item) => item.id === itemId);
|
||||
if (item && item.listTags.includes(tag)) {
|
||||
set((state) => ({
|
||||
selections: { ...state.selections, [tag]: itemId },
|
||||
}));
|
||||
} else {
|
||||
console.warn(`Item with id ${itemId} does not support tag ${tag} and cannot be selected for it.`);
|
||||
}
|
||||
},
|
||||
|
||||
}),
|
||||
|
||||
{
|
||||
name: persistName,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* Example:
|
||||
|
||||
// Define the specific subtype for VoiceOutModel
|
||||
type VoiceModelId = string;
|
||||
type VoiceModelTag = 'voice' | 'text' | 'image' | 'video' | 'audio';
|
||||
|
||||
// Define the VoiceOutModel interface
|
||||
export interface VoiceOutModel extends TaggedListItem<VoiceModelId, VoiceModelTag> {
|
||||
music: string;
|
||||
count: number;
|
||||
fruits: string[];
|
||||
}
|
||||
|
||||
// Create the Zustand store with the specific VoiceOutModel type
|
||||
const useVoiceOutModels = createStoredTaggetList<VoiceOutModel>('app-voice-synth');
|
||||
|
||||
export const useVoiceModel = (modelId: VoiceModelId) => {
|
||||
const { item, modifyItem } = useVoiceOutModels(state => ({
|
||||
item: state.items.find(item => item.id === modelId) as Readonly<VoiceOutModel>,
|
||||
modifyItem: state.modifyItem,
|
||||
}), shallow);
|
||||
|
||||
// Memoize all the update functions at once
|
||||
const { setMusic, setCount, setFruits } = React.useMemo(() => ({
|
||||
setMusic: (music: string) => modifyItem(modelId, { music }),
|
||||
setCount: (count: number) => modifyItem(modelId, { count }),
|
||||
setFruits: (fruits: string[]) => modifyItem(modelId, { fruits }),
|
||||
}), [modifyItem, modelId]);
|
||||
|
||||
return { item, setMusic, setCount, setFruits };
|
||||
};
|
||||
*/
|
||||
+181
-144
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { DLLMId, useModelsStore } from '~/modules/llms/store-llms';
|
||||
@@ -9,6 +10,8 @@ import { defaultSystemPurposeId, SystemPurposeId } from '../../data';
|
||||
import { IDB_MIGRATION_INITIAL, idbStateStorage } from '../util/idbUtils';
|
||||
|
||||
|
||||
export type DConversationId = string;
|
||||
|
||||
/**
|
||||
* Conversation, a list of messages between humans and bots
|
||||
* Future:
|
||||
@@ -16,7 +19,7 @@ import { IDB_MIGRATION_INITIAL, idbStateStorage } from '../util/idbUtils';
|
||||
* - isMuted: boolean; isArchived: boolean; isStarred: boolean; participants: string[];
|
||||
*/
|
||||
export interface DConversation {
|
||||
id: string;
|
||||
id: DConversationId;
|
||||
messages: DMessage[];
|
||||
systemPurposeId: SystemPurposeId;
|
||||
userTitle?: string;
|
||||
@@ -108,17 +111,15 @@ export function createDEphemeral(title: string, initialText: string): DEphemeral
|
||||
|
||||
interface ChatState {
|
||||
conversations: DConversation[];
|
||||
activeConversationId: string | null;
|
||||
}
|
||||
|
||||
interface ChatActions {
|
||||
// store setters
|
||||
createConversationOrSwitch: () => void;
|
||||
duplicateConversation: (conversationId: string) => void;
|
||||
importConversation: (conversation: DConversation, preventClash: boolean) => void;
|
||||
deleteConversation: (conversationId: string) => void;
|
||||
deleteAllConversations: () => void;
|
||||
setActiveConversationId: (conversationId: string) => void;
|
||||
prependNewConversation: (personaId: SystemPurposeId | undefined) => DConversationId;
|
||||
importConversation: (conversation: DConversation, preventClash: boolean) => DConversationId;
|
||||
branchConversation: (conversationId: DConversationId, messageId: string | null) => DConversationId | null;
|
||||
deleteConversation: (conversationId: DConversationId) => DConversationId | null;
|
||||
wipeAllConversations: (personaId: SystemPurposeId | undefined) => DConversationId;
|
||||
|
||||
// within a conversation
|
||||
startTyping: (conversationId: string, abortController: AbortController | null) => void;
|
||||
@@ -140,146 +141,156 @@ interface ChatActions {
|
||||
_editConversation: (conversationId: string, update: Partial<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
type ConversationsStore = ChatState & ChatActions;
|
||||
|
||||
export const useChatStore = create<ConversationsStore>()(devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
(_set, _get) => ({
|
||||
|
||||
// default state
|
||||
conversations: defaultConversations,
|
||||
activeConversationId: defaultConversations[0].id,
|
||||
|
||||
prependNewConversation: (personaId: SystemPurposeId | undefined): DConversationId => {
|
||||
const newConversation = createDConversation(personaId);
|
||||
|
||||
createConversationOrSwitch: () =>
|
||||
set(state => {
|
||||
_set(state => ({
|
||||
conversations: [
|
||||
newConversation,
|
||||
...state.conversations,
|
||||
],
|
||||
}));
|
||||
|
||||
// if the first conversation is empty, switch to it
|
||||
const conversations = state.conversations;
|
||||
if (conversations.length && conversations[0].messages.length === 0) {
|
||||
return {
|
||||
activeConversationId: conversations[0].id,
|
||||
};
|
||||
}
|
||||
return newConversation.id;
|
||||
},
|
||||
|
||||
// inherit some values from the active conversation (matches users' expectations)
|
||||
const activeConversation = conversations.find((conversation: DConversation): boolean => conversation.id === state.activeConversationId);
|
||||
const conversation = createDConversation(activeConversation?.systemPurposeId);
|
||||
return {
|
||||
conversations: [
|
||||
conversation,
|
||||
...conversations,
|
||||
],
|
||||
activeConversationId: conversation.id,
|
||||
};
|
||||
}),
|
||||
importConversation: (conversation: DConversation, preventClash: boolean): DConversationId => {
|
||||
const { conversations } = _get();
|
||||
|
||||
duplicateConversation: (conversationId: string) =>
|
||||
set(state => {
|
||||
const conversation = state.conversations.find((conversation: DConversation): boolean => conversation.id === conversationId);
|
||||
if (!conversation)
|
||||
return {};
|
||||
|
||||
// create a deep copy of the conversation
|
||||
const deepCopy: DConversation = JSON.parse(JSON.stringify(conversation));
|
||||
const duplicate: DConversation = {
|
||||
...deepCopy,
|
||||
id: uuidv4(),
|
||||
messages: deepCopy.messages.map((message: DMessage): DMessage => ({
|
||||
...message,
|
||||
id: uuidv4(),
|
||||
typing: false,
|
||||
})),
|
||||
updated: Date.now(),
|
||||
abortController: null,
|
||||
ephemerals: [],
|
||||
};
|
||||
|
||||
return {
|
||||
conversations: [
|
||||
duplicate,
|
||||
...state.conversations,
|
||||
],
|
||||
activeConversationId: duplicate.id,
|
||||
};
|
||||
}),
|
||||
|
||||
importConversation: (conversation: DConversation, preventClash) => {
|
||||
// if we're importing a conversation with the same id as an existing one, we need to change the id
|
||||
if (preventClash) {
|
||||
const exists = get().conversations.some(c => c.id === conversation.id);
|
||||
if (exists) {
|
||||
// if there's a clash, abort the former conversation, and optionally change the ID
|
||||
const existing = conversations.find(_c => _c.id === conversation.id);
|
||||
if (existing) {
|
||||
existing?.abortController?.abort();
|
||||
if (preventClash) {
|
||||
conversation.id = uuidv4();
|
||||
console.warn('Conversation ID clash, changing ID to', conversation.id);
|
||||
}
|
||||
}
|
||||
get().deleteConversation(conversation.id);
|
||||
set(state => {
|
||||
conversation.tokenCount = updateTokenCounts(conversation.messages, true, 'importConversation');
|
||||
return {
|
||||
// NOTE: the .filter below is superfluous (we delete the conversation above), but it's a reminder that we don't want to corrupt the state
|
||||
conversations: [
|
||||
conversation,
|
||||
...state.conversations.filter((other: DConversation) => other.id !== conversation.id),
|
||||
],
|
||||
activeConversationId: conversation.id,
|
||||
};
|
||||
|
||||
conversation.tokenCount = updateTokenCounts(conversation.messages, true, 'importConversation');
|
||||
|
||||
_set({
|
||||
conversations: [
|
||||
conversation,
|
||||
...conversations.filter(_c => _c.id !== conversation.id),
|
||||
],
|
||||
});
|
||||
|
||||
return conversation.id;
|
||||
},
|
||||
|
||||
deleteConversation: (conversationId: string) =>
|
||||
set(state => {
|
||||
branchConversation: (conversationId: DConversationId, messageId: string | null): DConversationId | null => {
|
||||
const { conversations } = _get();
|
||||
const conversation = conversations.find(_c => _c.id === conversationId);
|
||||
if (!conversation)
|
||||
return null;
|
||||
|
||||
// abort any pending requests on this conversation
|
||||
const cIndex = state.conversations.findIndex((conversation: DConversation): boolean => conversation.id === conversationId);
|
||||
if (cIndex >= 0)
|
||||
state.conversations[cIndex].abortController?.abort();
|
||||
// create a deep copy of the conversation
|
||||
const deepCopy: DConversation = JSON.parse(JSON.stringify(conversation));
|
||||
let messageIndex = deepCopy.messages.length; // By default, include all messages if messageId is null
|
||||
if (messageId !== null) {
|
||||
messageIndex = deepCopy.messages.findIndex(_m => _m.id === messageId);
|
||||
messageIndex = messageIndex >= 0 ? messageIndex + 1 : deepCopy.messages.length; // If message is found, include it
|
||||
}
|
||||
|
||||
// remove from the list
|
||||
const conversations = state.conversations.filter((conversation: DConversation): boolean => conversation.id !== conversationId);
|
||||
// title this branched chat differently
|
||||
const newTitle = getNextBranchTitle(conversationTitle(conversation));
|
||||
|
||||
// update the active conversation to the next in list
|
||||
let activeConversationId = undefined;
|
||||
if (state.activeConversationId === conversationId && cIndex >= 0)
|
||||
activeConversationId = conversations.length
|
||||
? conversations[cIndex < conversations.length ? cIndex : conversations.length - 1].id
|
||||
: null;
|
||||
const branched: DConversation = {
|
||||
...deepCopy,
|
||||
id: uuidv4(), // roll conversation ID
|
||||
messages: deepCopy.messages
|
||||
.slice(0, messageIndex)
|
||||
.map((message: DMessage): DMessage => ({
|
||||
...message,
|
||||
id: uuidv4(), // roll message ID
|
||||
typing: false,
|
||||
})),
|
||||
updated: Date.now(),
|
||||
// Set the new title for the branched conversation
|
||||
autoTitle: newTitle,
|
||||
// reset ephemerals
|
||||
abortController: null,
|
||||
ephemerals: [],
|
||||
// TODO: set references to parent conversation & message?
|
||||
};
|
||||
|
||||
return {
|
||||
conversations,
|
||||
...(activeConversationId !== undefined ? { activeConversationId } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
deleteAllConversations: () => {
|
||||
set(state => {
|
||||
// inherit some values from the active conversation (matches users' expectations)
|
||||
const activeConversation = state.conversations.find((conversation: DConversation): boolean => conversation.id === state.activeConversationId);
|
||||
const conversation = createDConversation(activeConversation?.systemPurposeId);
|
||||
|
||||
// abort any pending requests on all conversations
|
||||
state.conversations.forEach((conversation: DConversation) => conversation.abortController?.abort());
|
||||
|
||||
// delete all, but be left with one
|
||||
return {
|
||||
conversations: [conversation],
|
||||
activeConversationId: conversation.id,
|
||||
};
|
||||
_set({
|
||||
conversations: [
|
||||
branched,
|
||||
...conversations,
|
||||
],
|
||||
});
|
||||
|
||||
return branched.id;
|
||||
},
|
||||
|
||||
setActiveConversationId: (conversationId: string) =>
|
||||
set({ activeConversationId: conversationId }),
|
||||
deleteConversation: (conversationId: DConversationId): DConversationId | null => {
|
||||
let { conversations } = _get();
|
||||
|
||||
// abort pending requests on this conversation
|
||||
const cIndex = conversations.findIndex((conversation: DConversation): boolean => conversation.id === conversationId);
|
||||
if (cIndex >= 0)
|
||||
conversations[cIndex].abortController?.abort();
|
||||
|
||||
// remove from the list
|
||||
conversations = conversations.filter(_c => _c.id !== conversationId);
|
||||
_set({
|
||||
conversations,
|
||||
});
|
||||
|
||||
// return the next conversation Id in line, if valid
|
||||
return conversations.length
|
||||
? conversations[(cIndex >= 0 && cIndex < conversations.length) ? cIndex : conversations.length - 1].id
|
||||
: null;
|
||||
},
|
||||
|
||||
wipeAllConversations: (personaId: SystemPurposeId | undefined): DConversationId => {
|
||||
const { conversations } = _get();
|
||||
|
||||
// abort any pending requests on all conversations
|
||||
conversations.forEach(conversation => conversation.abortController?.abort());
|
||||
|
||||
const conversation = createDConversation(personaId);
|
||||
|
||||
_set({
|
||||
conversations: [conversation],
|
||||
});
|
||||
|
||||
return conversation.id;
|
||||
},
|
||||
|
||||
|
||||
// within a conversation
|
||||
|
||||
_editConversation: (conversationId: string, update: Partial<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) =>
|
||||
_set(state => ({
|
||||
conversations: state.conversations.map((conversation: DConversation): DConversation =>
|
||||
conversation.id === conversationId
|
||||
? {
|
||||
...conversation,
|
||||
...(typeof update === 'function' ? update(conversation) : update),
|
||||
}
|
||||
: conversation),
|
||||
})),
|
||||
|
||||
startTyping: (conversationId: string, abortController: AbortController | null) =>
|
||||
get()._editConversation(conversationId, () =>
|
||||
_get()._editConversation(conversationId, () =>
|
||||
({
|
||||
abortController: abortController,
|
||||
})),
|
||||
|
||||
stopTyping: (conversationId: string) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
conversation.abortController?.abort();
|
||||
return {
|
||||
abortController: null,
|
||||
@@ -287,10 +298,13 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
setMessages: (conversationId: string, newMessages: DMessage[]) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
conversation.abortController?.abort();
|
||||
return {
|
||||
messages: newMessages,
|
||||
...(!!newMessages.length ? {} : {
|
||||
autoTitle: undefined,
|
||||
}),
|
||||
tokenCount: updateTokenCounts(newMessages, false, 'setMessages'),
|
||||
updated: Date.now(),
|
||||
abortController: null,
|
||||
@@ -299,7 +313,7 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
appendMessage: (conversationId: string, message: DMessage) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
|
||||
if (!message.typing)
|
||||
updateTokenCounts([message], true, 'appendMessage');
|
||||
@@ -314,7 +328,7 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
deleteMessage: (conversationId: string, messageId: string) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
|
||||
const messages = conversation.messages.filter(message => message.id !== messageId);
|
||||
|
||||
@@ -326,7 +340,7 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
editMessage: (conversationId: string, messageId: string, updatedMessage: Partial<DMessage>, setUpdated: boolean) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
|
||||
const chatLLMId = useModelsStore.getState().chatLLMId;
|
||||
const messages = conversation.messages.map((message: DMessage): DMessage =>
|
||||
@@ -349,25 +363,25 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
setSystemPurposeId: (conversationId: string, systemPurposeId: SystemPurposeId) =>
|
||||
get()._editConversation(conversationId,
|
||||
_get()._editConversation(conversationId,
|
||||
{
|
||||
systemPurposeId,
|
||||
}),
|
||||
|
||||
setAutoTitle: (conversationId: string, autoTitle: string) =>
|
||||
get()._editConversation(conversationId,
|
||||
_get()._editConversation(conversationId,
|
||||
{
|
||||
autoTitle,
|
||||
}),
|
||||
|
||||
setUserTitle: (conversationId: string, userTitle: string) =>
|
||||
get()._editConversation(conversationId,
|
||||
_get()._editConversation(conversationId,
|
||||
{
|
||||
userTitle,
|
||||
}),
|
||||
|
||||
appendEphemeral: (conversationId: string, ephemeral: DEphemeral) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
const ephemerals = [...conversation.ephemerals, ephemeral];
|
||||
return {
|
||||
ephemerals,
|
||||
@@ -375,7 +389,7 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
deleteEphemeral: (conversationId: string, ephemeralId: string) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
const ephemerals = conversation.ephemerals?.filter((e: DEphemeral): boolean => e.id !== ephemeralId) || [];
|
||||
return {
|
||||
ephemerals,
|
||||
@@ -383,7 +397,7 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
updateEphemeralText: (conversationId: string, ephemeralId: string, text: string) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
const ephemerals = conversation.ephemerals?.map((e: DEphemeral): DEphemeral =>
|
||||
e.id === ephemeralId
|
||||
? { ...e, text }
|
||||
@@ -394,7 +408,7 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
}),
|
||||
|
||||
updateEphemeralState: (conversationId: string, ephemeralId: string, state: object) =>
|
||||
get()._editConversation(conversationId, conversation => {
|
||||
_get()._editConversation(conversationId, conversation => {
|
||||
const ephemerals = conversation.ephemerals?.map((e: DEphemeral): DEphemeral =>
|
||||
e.id === ephemeralId
|
||||
? { ...e, state: state }
|
||||
@@ -404,17 +418,6 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
};
|
||||
}),
|
||||
|
||||
_editConversation: (conversationId: string, update: Partial<DConversation> | ((conversation: DConversation) => Partial<DConversation>)) =>
|
||||
set(state => ({
|
||||
conversations: state.conversations.map((conversation: DConversation): DConversation =>
|
||||
conversation.id === conversationId
|
||||
? {
|
||||
...conversation,
|
||||
...(typeof update === 'function' ? update(conversation) : update),
|
||||
}
|
||||
: conversation),
|
||||
})),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-chats',
|
||||
@@ -428,7 +431,7 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
storage: createJSONStorage(() => idbStateStorage),
|
||||
|
||||
// Migrations
|
||||
migrate: (persistedState: unknown, fromVersion: number): ChatState & ChatActions => {
|
||||
migrate: (persistedState: unknown, fromVersion: number): ConversationsStore => {
|
||||
// -1 -> 3: migration loading from localStorage to IndexedDB
|
||||
if (fromVersion === IDB_MIGRATION_INITIAL)
|
||||
return _migrateLocalStorageData() as any;
|
||||
@@ -463,10 +466,6 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
conversation.abortController = null;
|
||||
conversation.ephemerals = [];
|
||||
}
|
||||
|
||||
// select the first conversation if none is selected
|
||||
if (!state.activeConversationId && state.conversations.length)
|
||||
state.activeConversationId = state.conversations[0].id;
|
||||
},
|
||||
|
||||
}),
|
||||
@@ -480,6 +479,16 @@ export const useChatStore = create<ChatState & ChatActions>()(devtools(
|
||||
export const conversationTitle = (conversation: DConversation, fallback?: string): string =>
|
||||
conversation.userTitle || conversation.autoTitle || fallback || ''; // 👋💬🗨️
|
||||
|
||||
function getNextBranchTitle(currentTitle: string): string {
|
||||
const numberPrefixRegex = /^\((\d+)\)\s+/; // Regex to find "(number) " at the beginning of the title
|
||||
const match = currentTitle.match(numberPrefixRegex);
|
||||
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10) + 1;
|
||||
return currentTitle.replace(numberPrefixRegex, `(${number}) `);
|
||||
} else
|
||||
return `(1) ${currentTitle}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the chats stored in the localStorage, and rename the key for
|
||||
@@ -501,7 +510,6 @@ function _migrateLocalStorageData(): ChatState | {} {
|
||||
// match the state from localstorage
|
||||
return {
|
||||
conversations: localStorageState?.conversations ?? [],
|
||||
activeConversationId: localStorageState?.activeConversationId ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('LocalStorage migration error', error);
|
||||
@@ -524,4 +532,33 @@ function updateDMessageTokenCount(message: DMessage, llmId: DLLMId | null, force
|
||||
function updateTokenCounts(messages: DMessage[], forceUpdate: boolean, debugFrom: string): number {
|
||||
const { chatLLMId } = useModelsStore.getState();
|
||||
return 3 + messages.reduce((sum, message) => 4 + updateDMessageTokenCount(message, chatLLMId, forceUpdate, debugFrom) + sum, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export const getConversation = (conversationId: DConversationId | null): DConversation | null =>
|
||||
conversationId ? useChatStore.getState().conversations.find(_c => _c.id === conversationId) ?? null : null;
|
||||
|
||||
export const useConversation = (conversationId: DConversationId | null) => useChatStore(state => {
|
||||
const { conversations } = state;
|
||||
|
||||
// this object will change if any sub-prop changes as well
|
||||
const conversation = conversationId ? conversations.find(_c => _c.id === conversationId) ?? null : null;
|
||||
const title = conversation ? conversationTitle(conversation) : null;
|
||||
const chatIdx = conversation ? conversations.findIndex(_c => _c.id === conversation.id) : -1;
|
||||
const isChatEmpty = conversation ? !conversation.messages.length : true;
|
||||
const areChatsEmpty = isChatEmpty && conversations.length < 2;
|
||||
const newConversationId: DConversationId | null = (conversations.length && !conversations[0].messages.length) ? conversations[0].id : null;
|
||||
|
||||
return {
|
||||
title,
|
||||
chatIdx,
|
||||
isChatEmpty,
|
||||
areChatsEmpty,
|
||||
newConversationId,
|
||||
_remove_systemPurposeId: conversation?.systemPurposeId ?? null,
|
||||
prependNewConversation: state.prependNewConversation,
|
||||
branchConversation: state.branchConversation,
|
||||
deleteConversation: state.deleteConversation,
|
||||
wipeAllConversations: state.wipeAllConversations,
|
||||
setMessages: state.setMessages,
|
||||
};
|
||||
}, shallow);
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
// UI Counters
|
||||
|
||||
interface UICountersStore {
|
||||
actionCounters: Record<string, number>;
|
||||
incrementActionCounter: (key: string) => void;
|
||||
clearActionCounter: (key: string) => void;
|
||||
clearAllActionCounters: () => void;
|
||||
}
|
||||
|
||||
const useUICountersStore = create<UICountersStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
actionCounters: {},
|
||||
incrementActionCounter: (key: string) =>
|
||||
set((state) => ({
|
||||
actionCounters: { ...state.actionCounters, [key]: (state.actionCounters[key] || 0) + 1 },
|
||||
})),
|
||||
clearActionCounter: (key: string) =>
|
||||
set((state) => ({
|
||||
actionCounters: { ...state.actionCounters, [key]: 0 },
|
||||
})),
|
||||
clearAllActionCounters: () => set({ actionCounters: {} }),
|
||||
}),
|
||||
{
|
||||
name: 'app-ui-counters',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
type UiCounterKey = 'export-share' | 'share-chat-link' | 'call-wizard';
|
||||
|
||||
export function useUICounter(key: UiCounterKey) {
|
||||
const value = useUICountersStore((state) => state.actionCounters[key] || 0);
|
||||
return { value, novel: !value, touch: () => useUICountersStore.getState().incrementActionCounter(key) };
|
||||
}
|
||||
|
||||
// UI Preferences
|
||||
|
||||
interface UIPreferencesStore {
|
||||
|
||||
// UI Features
|
||||
|
||||
preferredLanguage: string;
|
||||
setPreferredLanguage: (preferredLanguage: string) => void;
|
||||
|
||||
@@ -52,9 +20,6 @@ interface UIPreferencesStore {
|
||||
enterIsNewline: boolean;
|
||||
setEnterIsNewline: (enterIsNewline: boolean) => void;
|
||||
|
||||
experimentalLabs: boolean;
|
||||
setExperimentalLabs: (experimentalLabs: boolean) => void;
|
||||
|
||||
renderMarkdown: boolean;
|
||||
setRenderMarkdown: (renderMarkdown: boolean) => void;
|
||||
|
||||
@@ -64,12 +29,19 @@ interface UIPreferencesStore {
|
||||
zenMode: 'clean' | 'cleaner';
|
||||
setZenMode: (zenMode: 'clean' | 'cleaner') => void;
|
||||
|
||||
// UI Counters
|
||||
|
||||
actionCounters: Record<string, number>;
|
||||
incrementActionCounter: (key: string) => void;
|
||||
|
||||
}
|
||||
|
||||
export const useUIPreferencesStore = create<UIPreferencesStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
// UI Features
|
||||
|
||||
preferredLanguage: (typeof navigator !== 'undefined') && navigator.language || 'en-US',
|
||||
setPreferredLanguage: (preferredLanguage: string) => set({ preferredLanguage }),
|
||||
|
||||
@@ -82,9 +54,6 @@ export const useUIPreferencesStore = create<UIPreferencesStore>()(
|
||||
enterIsNewline: false,
|
||||
setEnterIsNewline: (enterIsNewline: boolean) => set({ enterIsNewline }),
|
||||
|
||||
experimentalLabs: false,
|
||||
setExperimentalLabs: (experimentalLabs: boolean) => set({ experimentalLabs }),
|
||||
|
||||
renderMarkdown: true,
|
||||
setRenderMarkdown: (renderMarkdown: boolean) => set({ renderMarkdown }),
|
||||
|
||||
@@ -95,6 +64,14 @@ export const useUIPreferencesStore = create<UIPreferencesStore>()(
|
||||
zenMode: 'clean',
|
||||
setZenMode: (zenMode: 'clean' | 'cleaner') => set({ zenMode }),
|
||||
|
||||
// UI Counters
|
||||
|
||||
actionCounters: {},
|
||||
incrementActionCounter: (key: string) =>
|
||||
set((state) => ({
|
||||
actionCounters: { ...state.actionCounters, [key]: (state.actionCounters[key] || 0) + 1 },
|
||||
})),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-ui',
|
||||
@@ -113,3 +90,12 @@ export const useUIPreferencesStore = create<UIPreferencesStore>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function useUICounter(key: 'export-share' | 'share-chat-link' | 'call-wizard') {
|
||||
const value = useUIPreferencesStore((state) => state.actionCounters[key] || 0);
|
||||
return {
|
||||
value,
|
||||
novel: !value,
|
||||
touch: () => useUIPreferencesStore.getState().incrementActionCounter(key),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
|
||||
// UX Labs Experiments
|
||||
|
||||
/**
|
||||
* Graduated:
|
||||
* - Persona YT Creator: still under a 'true' flag, to disable it if needed
|
||||
* - Text Tools: dinamically shown where applicable
|
||||
* - Chat Mode: follow-ups; moved to Chat Advanced UI, itemized (Auto-title, Auto-diagram)
|
||||
*/
|
||||
interface UXLabsStore {
|
||||
|
||||
labsCalling: boolean;
|
||||
setLabsCalling: (labsCalling: boolean) => void;
|
||||
|
||||
labsEnhancedUI: boolean;
|
||||
setLabsEnhancedUI: (labsEnhancedUI: boolean) => void;
|
||||
|
||||
labsMagicDraw: boolean;
|
||||
setLabsMagicDraw: (labsMagicDraw: boolean) => void;
|
||||
|
||||
labsPersonaYTCreator: boolean;
|
||||
setLabsPersonaYTCreator: (labsPersonaYTCreator: boolean) => void;
|
||||
|
||||
labsSplitBranching: boolean;
|
||||
setLabsSplitBranching: (labsSplitBranching: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
export const useUXLabsStore = create<UXLabsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
labsCalling: false,
|
||||
setLabsCalling: (labsCalling: boolean) => set({ labsCalling }),
|
||||
|
||||
labsEnhancedUI: false,
|
||||
setLabsEnhancedUI: (labsEnhancedUI: boolean) => set({ labsEnhancedUI }),
|
||||
|
||||
labsMagicDraw: false,
|
||||
setLabsMagicDraw: (labsMagicDraw: boolean) => set({ labsMagicDraw }),
|
||||
|
||||
labsPersonaYTCreator: true, // NOTE: default to true, as it is a graduated experiment
|
||||
setLabsPersonaYTCreator: (labsPersonaYTCreator: boolean) => set({ labsPersonaYTCreator }),
|
||||
|
||||
labsSplitBranching: false,
|
||||
setLabsSplitBranching: (labsSplitBranching: boolean) => set({ labsSplitBranching }),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-ux-labs',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,10 +1,24 @@
|
||||
import { addSnackbar } from '../components/useSnackbarsStore';
|
||||
import { isBrowser, isFirefox } from './pwaUtils';
|
||||
|
||||
export function copyToClipboard(text: string) {
|
||||
if (isBrowser)
|
||||
window.navigator.clipboard.writeText(text)
|
||||
.then(() => console.log('Message copied to clipboard'))
|
||||
.catch((err) => console.error('Failed to copy message: ', err));
|
||||
export function copyToClipboard(text: string, typeLabel: string) {
|
||||
if (!isBrowser)
|
||||
return;
|
||||
window.navigator.clipboard.writeText(text)
|
||||
.then(() => {
|
||||
addSnackbar({
|
||||
key: 'copy-to-clipboard',
|
||||
message: `${typeLabel} copied to clipboard`,
|
||||
type: 'success',
|
||||
closeButton: false,
|
||||
overrides: {
|
||||
autoHideDuration: 1400,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to copy message: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: this could be implemented in a platform-agnostic manner with !!.read, but we call it out here for clarity
|
||||
|
||||
@@ -10,7 +10,8 @@ import { createTRPCProxyClient, httpBatchLink, httpLink, loggerLink } from '@trp
|
||||
import { createTRPCNext } from '@trpc/next';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import { type AppRouterEdge, type AppRouterNode } from '~/server/api/trpc.router';
|
||||
import type { AppRouterEdge } from '~/server/api/trpc.router-edge';
|
||||
import type { AppRouterNode } from '~/server/api/trpc.router-node';
|
||||
|
||||
import { getBaseUrl } from './urlUtils';
|
||||
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ export const SystemPurposes: { [key in SystemPurposeId]: SystemPurposeData } = {
|
||||
description: 'Helps you write business emails',
|
||||
systemMessage: 'You are an AI corporate assistant. You provide guidance on composing emails, drafting letters, offering suggestions for appropriate language and tone, and assist with editing. You are concise. ' +
|
||||
'You explain your process step-by-step and concisely. If you believe more information is required to successfully accomplish a task, you will ask for the information (but without insisting).\n' +
|
||||
'Knowledge cutoff: 2021-09\nCurrent date: {{Today}}',
|
||||
'Knowledge cutoff: {{Cutoff}}\nCurrent date: {{Today}}',
|
||||
symbol: '👔',
|
||||
examples: ['draft a letter to the board', 'write a memo to the CEO', 'help me with a SWOT analysis', 'how do I team build?', 'improve decision-making'],
|
||||
call: { starters: ['Let\'s get to business.', 'Corporate assistant here. What\'s the task?', 'Ready for business.', 'Hello.'] },
|
||||
@@ -67,7 +67,7 @@ export const SystemPurposes: { [key in SystemPurposeId]: SystemPurposeData } = {
|
||||
Generic: {
|
||||
title: 'Default',
|
||||
description: 'Helps you think',
|
||||
systemMessage: 'You are ChatGPT, a large language model trained by OpenAI, based on the GPT-4 architecture.\nKnowledge cutoff: 2021-09\nCurrent date: {{Today}}',
|
||||
systemMessage: 'You are ChatGPT, a large language model trained by OpenAI, based on the GPT-4 architecture.\nKnowledge cutoff: {{Cutoff}}\nCurrent date: {{Today}}\n',
|
||||
symbol: '🧠',
|
||||
examples: ['help me plan a trip to Japan', 'what is the meaning of life?', 'how do I get a job at OpenAI?', 'what are some healthy meal ideas?'],
|
||||
call: { starters: ['Hey, how can I assist?', 'AI assistant ready. What do you need?', 'Ready to assist.', 'Hello.'] },
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Alert, Box, Button, CircularProgress, Divider, FormControl, FormLabel, IconButton, List, ListDivider, ListItem, ListItemButton, ListItemContent, ListItemDecorator, Typography } from '@mui/joy';
|
||||
import ForkRightIcon from '@mui/icons-material/ForkRight';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
|
||||
import { useStreamChatText } from '~/modules/aifn/useStreamChatText';
|
||||
|
||||
import { ConfirmationModal } from '~/common/components/ConfirmationModal';
|
||||
import { GoodModal } from '~/common/components/GoodModal';
|
||||
import { InlineTextarea } from '~/common/components/InlineTextarea';
|
||||
import { createDMessage, DConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { createDMessage, DConversationId, DMessage, getConversation, useChatStore } from '~/common/state/store-chats';
|
||||
import { useFormRadioLlmType } from '~/common/components/forms/useFormRadioLlmType';
|
||||
|
||||
import { FLATTEN_PROFILES, FlattenStyleType } from './flatten.data';
|
||||
import { flattenConversation } from './flatten';
|
||||
|
||||
|
||||
function StylesList(props: { selectedStyle: FlattenStyleType | null, onSelectedStyle: (type: FlattenStyleType) => void }) {
|
||||
@@ -41,12 +44,12 @@ function StylesList(props: { selectedStyle: FlattenStyleType | null, onSelectedS
|
||||
);
|
||||
}
|
||||
|
||||
function FlatteningProgress(props: { llmLabel: string }) {
|
||||
function FlatteningProgress(props: { llmLabel: string, partialText: string | null }) {
|
||||
return (
|
||||
<Box sx={{ mx: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress />
|
||||
<Typography>
|
||||
Flattening...
|
||||
{props.partialText?.length ? `${props.partialText.length} characters` : 'Flattening'}...
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
This may take up to a minute.
|
||||
@@ -59,60 +62,87 @@ function FlatteningProgress(props: { llmLabel: string }) {
|
||||
}
|
||||
|
||||
|
||||
export function FlattenerModal(props: { conversationId: string | null, onClose: () => void }) {
|
||||
function encodeConversationAsUserMessage(userPrompt: string, messages: DMessage[]): string {
|
||||
let encodedMessages = '';
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'system') continue;
|
||||
const author = message.role === 'user' ? 'User' : 'Assistant';
|
||||
const text = message.text.replace(/\n/g, '\n\n');
|
||||
encodedMessages += `---${author}---\n${text}\n\n`;
|
||||
}
|
||||
|
||||
return userPrompt ? userPrompt + '\n\n' + encodedMessages.trim() : encodedMessages.trim();
|
||||
}
|
||||
|
||||
|
||||
export function FlattenerModal(props: {
|
||||
conversationId: string | null,
|
||||
onConversationBranch: (conversationId: DConversationId, messageId: string | null) => DConversationId | null,
|
||||
onClose: () => void,
|
||||
}) {
|
||||
|
||||
// state
|
||||
const [selectedStyle, setSelectedStyle] = React.useState<FlattenStyleType | null>(null);
|
||||
const [selectedLLMLabel, setSelectedLLMLabel] = React.useState<string | null>(null);
|
||||
const [flattenedText, setFlattenedText] = React.useState<string | null>(null);
|
||||
const [confirmOverwrite, setConfirmOverwrite] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
|
||||
// external state
|
||||
const [diagramLlm, llmComponent] = useFormRadioLlmType();
|
||||
const [llm, llmComponent] = useFormRadioLlmType();
|
||||
const {
|
||||
isStreaming, text: flattenedText, partialText, streamError,
|
||||
startStreaming, setText, resetText,
|
||||
} = useStreamChatText();
|
||||
|
||||
const handlePerformFlattening = async (type: FlattenStyleType) => {
|
||||
if (!props.conversationId || !type) return;
|
||||
const conversation: DConversation | undefined = useChatStore.getState().conversations.find(c => c.id === props.conversationId);
|
||||
if (!conversation) return;
|
||||
|
||||
// begin working...
|
||||
setSelectedStyle(type);
|
||||
const handlePerformFlattening = React.useCallback(async (flattenStyle: FlattenStyleType) => {
|
||||
|
||||
// select model
|
||||
if (!diagramLlm) {
|
||||
setErrorMessage('No model selected');
|
||||
return;
|
||||
}
|
||||
setSelectedLLMLabel(diagramLlm.label);
|
||||
// validate config (or set error)
|
||||
const conversation = getConversation(props.conversationId);
|
||||
const messages = conversation?.messages;
|
||||
if (!messages || !messages.length)
|
||||
return setErrorMessage('No messages in conversation');
|
||||
if (!llm)
|
||||
return setErrorMessage('No model selected');
|
||||
const flattenProfile = FLATTEN_PROFILES.find(s => s.type === flattenStyle);
|
||||
if (!flattenProfile)
|
||||
return setErrorMessage('No style selected');
|
||||
|
||||
let text: string | null = null;
|
||||
try {
|
||||
text = await flattenConversation(diagramLlm.id, conversation, type);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error?.message || error?.toString() || 'Unknown error');
|
||||
}
|
||||
setSelectedStyle(flattenStyle);
|
||||
setSelectedLLMLabel(llm.label);
|
||||
setErrorMessage(null);
|
||||
|
||||
// ...got the message (or error)
|
||||
setFlattenedText(text || 'Issue: the flattened text was blank.');
|
||||
};
|
||||
// start (auto-abort previous and at unmount)
|
||||
await startStreaming(llm.id, [
|
||||
{ role: 'system', content: flattenProfile.systemPrompt },
|
||||
{ role: 'user', content: encodeConversationAsUserMessage(flattenProfile.userPrompt, messages) },
|
||||
]);
|
||||
|
||||
}, [llm, props.conversationId, startStreaming]);
|
||||
|
||||
const handleReplaceConversation = () => {
|
||||
if (!props.conversationId || !selectedStyle || !flattenedText) return;
|
||||
const newRootMessage = createDMessage('user', flattenedText);
|
||||
useChatStore.getState().setMessages(props.conversationId, [newRootMessage]);
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const handleErrorRetry = () => {
|
||||
setSelectedStyle(null);
|
||||
setFlattenedText(null);
|
||||
setErrorMessage(null);
|
||||
resetText();
|
||||
};
|
||||
|
||||
|
||||
const isFlattening = selectedStyle && !flattenedText;
|
||||
const isDone = !!flattenedText || !!errorMessage;
|
||||
const isError = !!errorMessage;
|
||||
const handleReplaceConversation = (branch: boolean) => {
|
||||
if (!props.conversationId || !selectedStyle || !flattenedText) return;
|
||||
let newConversationId: string | null = props.conversationId;
|
||||
if (branch)
|
||||
newConversationId = props.onConversationBranch(props.conversationId, null);
|
||||
if (newConversationId) {
|
||||
const newRootMessage = createDMessage('user', flattenedText);
|
||||
useChatStore.getState().setMessages(newConversationId, [newRootMessage]);
|
||||
}
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const isSuccess = !!flattenedText;
|
||||
const isError = !!errorMessage || !!streamError;
|
||||
|
||||
return (
|
||||
<GoodModal
|
||||
@@ -128,44 +158,59 @@ export function FlattenerModal(props: { conversationId: string | null, onClose:
|
||||
</FormControl>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{isFlattening && !!selectedLLMLabel && <FlatteningProgress llmLabel={selectedLLMLabel} />}
|
||||
{isStreaming && !!selectedLLMLabel && (
|
||||
<FlatteningProgress llmLabel={selectedLLMLabel} partialText={partialText} />
|
||||
)}
|
||||
|
||||
{/* Group post-execution */}
|
||||
{isDone && <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, flexGrow: 1 }}>
|
||||
{(isSuccess || isError) && <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, flexGrow: 1 }}>
|
||||
|
||||
{/* Possible Error */}
|
||||
{errorMessage && <Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}>
|
||||
{isError && <Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}>
|
||||
<Alert variant='soft' color='danger' sx={{ my: 1, flexGrow: 1 }}>
|
||||
<Typography>{errorMessage}</Typography>
|
||||
{!!errorMessage && <Typography>{errorMessage}</Typography>}
|
||||
{!!streamError && <Typography>LLM issue: {streamError}</Typography>}
|
||||
</Alert>
|
||||
<IconButton variant='solid' color='danger' onClick={handleErrorRetry}>
|
||||
<ReplayIcon />
|
||||
</IconButton>
|
||||
</Box>}
|
||||
|
||||
{/* Review Text */}
|
||||
{!!flattenedText && <InlineTextarea initialText={flattenedText} onEdit={setFlattenedText} />}
|
||||
|
||||
{/* Proceed*/}
|
||||
{isDone && !isError && <Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<IconButton
|
||||
variant={isError ? 'solid' : 'plain'} color={isError ? 'danger' : 'primary'}
|
||||
onClick={handleErrorRetry}
|
||||
>
|
||||
<ReplayIcon />
|
||||
</IconButton>
|
||||
{isSuccess && !isError && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}>
|
||||
<IconButton
|
||||
variant={isError ? 'solid' : 'plain'} color={isError ? 'danger' : 'primary'}
|
||||
onClick={handleErrorRetry}
|
||||
>
|
||||
<ReplayIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* TODO: ask confirmation? */}
|
||||
<Button onClick={handleReplaceConversation} sx={{ minWidth: 142 }}>
|
||||
Looks Good
|
||||
</Button>
|
||||
</Box>}
|
||||
<Button variant='outlined' onClick={() => setConfirmOverwrite(true)} sx={{ ml: 'auto' }}>
|
||||
Replace Chat
|
||||
</Button>
|
||||
<Button variant='solid' onClick={() => handleReplaceConversation(true)} startDecorator={<ForkRightIcon />}>
|
||||
Branch
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Review or Edit Text */}
|
||||
{isSuccess && <InlineTextarea initialText={flattenedText} onEdit={setText} />}
|
||||
|
||||
</Box>}
|
||||
|
||||
{!isDone && !isFlattening && !!llmComponent && <Divider />}
|
||||
{!isSuccess && !isStreaming && !!llmComponent && <Divider />}
|
||||
|
||||
{!isDone && !isFlattening && llmComponent}
|
||||
{!isSuccess && !isStreaming && llmComponent}
|
||||
|
||||
|
||||
{/* [confirmation] Overwrite Conversation */}
|
||||
{confirmOverwrite && <ConfirmationModal
|
||||
open onClose={() => setConfirmOverwrite(false)} onPositive={() => handleReplaceConversation(false)}
|
||||
confirmationText='Are you sure you want to overwrite the conversation with the flattened text?'
|
||||
positiveActionText='Replace conversation'
|
||||
/>}
|
||||
|
||||
</GoodModal>
|
||||
);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { callChatGenerate } from '~/modules/llms/transports/chatGenerate';
|
||||
|
||||
import { DConversation } from '~/common/state/store-chats';
|
||||
|
||||
import { FLATTEN_PROFILES, FlattenStyleType } from './flatten.data';
|
||||
|
||||
|
||||
export async function flattenConversation(llmId: DLLMId, conversation: DConversation, type: FlattenStyleType): Promise<string | null> {
|
||||
|
||||
// get flattening instruction
|
||||
const flattenStyle = FLATTEN_PROFILES.find(s => s.type === type);
|
||||
const systemInstruction = flattenStyle?.systemPrompt;
|
||||
const userPrefixPrompt = flattenStyle?.userPrompt;
|
||||
if (!systemInstruction) throw new Error('flattenConversation: no prompt found for type: ' + type);
|
||||
|
||||
// call the flattening function
|
||||
const chatResponse = await callChatGenerate(llmId, [
|
||||
{ role: 'system', content: systemInstruction },
|
||||
{ role: 'user', content: encodeConversationAsUserMessage(userPrefixPrompt || '', conversation) },
|
||||
]);
|
||||
|
||||
return chatResponse.content?.trim() ?? null;
|
||||
}
|
||||
|
||||
|
||||
function encodeConversationAsUserMessage(userPrompt: string, conversation: DConversation): string {
|
||||
let encodedMessages = '';
|
||||
|
||||
for (const message of conversation.messages) {
|
||||
if (message.role === 'system') continue;
|
||||
const author = message.role === 'user' ? 'User' : 'Assistant';
|
||||
const text = message.text.replace(/\n/g, '\n\n');
|
||||
encodedMessages += `---${author}---\n${text}\n\n`;
|
||||
}
|
||||
|
||||
return userPrompt ? userPrompt + '\n\n' + encodedMessages.trim() : encodedMessages.trim();
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
import { DLLMId } from '~/modules/llms/store-llms';
|
||||
import { callApiSearchGoogle } from '~/modules/google/search.client';
|
||||
import { callBrowseFetchPage } from '~/modules/browse/browse.client';
|
||||
import { callChatGenerate, VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
|
||||
|
||||
|
||||
// prompt to implement the ReAct paradigm: https://arxiv.org/abs/2210.03629
|
||||
const reActPrompt: string =
|
||||
const reActPrompt = (enableBrowse: boolean): string =>
|
||||
`You are a Question Answering AI with reasoning ability.
|
||||
You will receive a Question from the User.
|
||||
In order to answer any Question, you run in a loop of Thought, Action, PAUSE, Observation.
|
||||
@@ -28,7 +29,11 @@ e.g. google: Django
|
||||
Returns google custom search results
|
||||
ALWAYS look up on google when the question is related to live events or factual information, such as sports, news, or weather.
|
||||
|
||||
calculate:
|
||||
` + (enableBrowse ? `loadUrl:
|
||||
e.g. loadUrl: https://arxiv.org/abs/1706.03762
|
||||
Opens the given URL and displays it
|
||||
|
||||
` : '') + `calculate:
|
||||
e.g. calculate: 4 * 7 / 3
|
||||
Runs a calculation and returns the number - uses Python so be sure to use floating point syntax if necessary
|
||||
|
||||
@@ -76,16 +81,18 @@ interface State {
|
||||
export class Agent {
|
||||
|
||||
// NOTE: this is here for demo, but the whole loop could be moved to the caller's event loop
|
||||
async reAct(question: string, llmId: DLLMId, maxTurns = 5, log: (...data: any[]) => void = console.log, show: (state: object) => void): Promise<string> {
|
||||
async reAct(question: string, llmId: DLLMId, maxTurns = 5, enableBrowse = false,
|
||||
appendLog: (...data: any[]) => void = console.log,
|
||||
showState: (state: object) => void): Promise<string> {
|
||||
let i = 0;
|
||||
// TODO: to initialize with previous chat messages to provide context.
|
||||
const S: State = this.initialize(`Question: ${question}`);
|
||||
show(S);
|
||||
const S: State = this.initialize(`Question: ${question}`, enableBrowse);
|
||||
showState(S);
|
||||
while (i < maxTurns && S.result === undefined) {
|
||||
i++;
|
||||
log(`\n## Turn ${i}`);
|
||||
await this.step(S, llmId, log);
|
||||
show(S);
|
||||
appendLog(`\n## Turn ${i}`);
|
||||
await this.step(S, llmId, appendLog);
|
||||
showState(S);
|
||||
}
|
||||
// return only the 'Answer: ' part of the result
|
||||
if (S.result) {
|
||||
@@ -96,9 +103,9 @@ export class Agent {
|
||||
return S.result || 'No result';
|
||||
}
|
||||
|
||||
initialize(question: string): State {
|
||||
initialize(question: string, enableBrowse: boolean): State {
|
||||
return {
|
||||
messages: [{ role: 'system', content: reActPrompt.replaceAll('{{currentDate}}', new Date().toISOString().slice(0, 10)) }],
|
||||
messages: [{ role: 'system', content: reActPrompt(enableBrowse).replaceAll('{{currentDate}}', new Date().toISOString().slice(0, 10)) }],
|
||||
nextPrompt: question,
|
||||
lastObservation: '',
|
||||
result: undefined,
|
||||
@@ -178,10 +185,21 @@ async function search(query: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function browse(url: string): Promise<string> {
|
||||
try {
|
||||
const data = await callBrowseFetchPage(url);
|
||||
return JSON.stringify(data ? { text: data } : { error: 'Issue reading the page' });
|
||||
} catch (error) {
|
||||
console.error('Error browsing:', (error as Error).message);
|
||||
return 'An error occurred while browsing to the URL. Missing WSS Key?';
|
||||
}
|
||||
}
|
||||
|
||||
const calculate = async (what: string): Promise<string> => String(eval(what));
|
||||
|
||||
const knownActions: { [key: string]: ActionFunction } = {
|
||||
wikipedia: wikipedia,
|
||||
google: search,
|
||||
loadUrl: browse,
|
||||
calculate: calculate,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import type { DLLMId } from '~/modules/llms/store-llms';
|
||||
import type { VChatMessageIn } from '~/modules/llms/transports/chatGenerate';
|
||||
import { streamChat } from '~/modules/llms/transports/streamChat';
|
||||
|
||||
|
||||
export function useStreamChatText() {
|
||||
|
||||
// state
|
||||
const [text, setText] = React.useState<string | null>(null);
|
||||
const [partialText, setPartialText] = React.useState<string | null>(null);
|
||||
const [streamError, setStreamError] = React.useState<string | null>(null);
|
||||
const abortControllerRef = React.useRef<AbortController | null>(null);
|
||||
|
||||
|
||||
const startStreaming = React.useCallback(async (llmId: DLLMId, prompt: VChatMessageIn[]) => {
|
||||
setStreamError(null);
|
||||
setPartialText(null);
|
||||
setText(null);
|
||||
|
||||
// Cancel any existing stream before starting a new one
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
let lastText = '';
|
||||
await streamChat(llmId, prompt, abortControllerRef.current.signal, (update) => {
|
||||
if (update.text) {
|
||||
lastText = update.text;
|
||||
setPartialText(lastText);
|
||||
}
|
||||
});
|
||||
// Since streamChat has finished, we can assume the stream is complete
|
||||
setText(lastText);
|
||||
} catch (error: any) {
|
||||
setStreamError(error?.name !== 'AbortError'
|
||||
? error?.message || error?.toString() || JSON.stringify(error) || 'Unknown error'
|
||||
: 'Interrupted.',
|
||||
);
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopStreaming = React.useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const resetText = React.useCallback(() => {
|
||||
setText(null);
|
||||
setPartialText(null);
|
||||
setStreamError(null);
|
||||
}, []);
|
||||
|
||||
|
||||
// Clean up the abort controller when the component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => stopStreaming();
|
||||
}, [stopStreaming]);
|
||||
|
||||
|
||||
return {
|
||||
// properties
|
||||
isStreaming: !!abortControllerRef.current,
|
||||
text,
|
||||
partialText,
|
||||
streamError,
|
||||
// methods
|
||||
startStreaming,
|
||||
stopStreaming,
|
||||
setText,
|
||||
resetText,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const backendRouter = createTRPCRouter({
|
||||
.query(async () => {
|
||||
return {
|
||||
hasDB: !!env.POSTGRES_PRISMA_URL && !!env.POSTGRES_URL_NON_POOLING,
|
||||
hasBrowsing: !!env.PUPPETEER_WSS_ENDPOINT,
|
||||
hasGoogleCustomSearch: !!env.GOOGLE_CSE_ID && !!env.GOOGLE_CLOUD_API_KEY,
|
||||
hasImagingProdia: !!env.PRODIA_API_KEY,
|
||||
hasLlmAnthropic: !!env.ANTHROPIC_API_KEY,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { shallow } from 'zustand/shallow';
|
||||
|
||||
export interface BackendCapabilities {
|
||||
hasDB: boolean;
|
||||
hasBrowsing: boolean;
|
||||
hasGoogleCustomSearch: boolean;
|
||||
hasImagingProdia: boolean;
|
||||
hasLlmAnthropic: boolean;
|
||||
@@ -14,16 +15,17 @@ export interface BackendCapabilities {
|
||||
hasVoiceElevenLabs: boolean;
|
||||
}
|
||||
|
||||
type BackendState = {
|
||||
type BackendStore = {
|
||||
loadedCapabilities: boolean;
|
||||
setCapabilities: (capabilities: Partial<BackendCapabilities>) => void;
|
||||
} & BackendCapabilities;
|
||||
|
||||
const useBackendStore = create<BackendState>()(
|
||||
const useBackendStore = create<BackendStore>()(
|
||||
(set) => ({
|
||||
|
||||
// capabilities
|
||||
hasDB: false,
|
||||
hasBrowsing: false,
|
||||
hasGoogleCustomSearch: false,
|
||||
hasImagingProdia: false,
|
||||
hasLlmAnthropic: false,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { Checkbox, FormControl, FormHelperText } from '@mui/joy';
|
||||
|
||||
import { FormInputKey } from '~/common/components/forms/FormInputKey';
|
||||
import { Link } from '~/common/components/Link';
|
||||
import { platformAwareKeystrokes } from '~/common/components/KeyStroke';
|
||||
|
||||
import { useBrowseCapability, useBrowseStore } from './store-module-browsing';
|
||||
|
||||
|
||||
export function BrowseSettings() {
|
||||
|
||||
// external state
|
||||
const { mayWork, isServerConfig, isClientValid, inCommand, inComposer, inReact } = useBrowseCapability();
|
||||
const { wssEndpoint, setWssEndpoint, setEnableCommandBrowse, setEnableComposerAttach, setEnableReactTool } = useBrowseStore(state => ({
|
||||
wssEndpoint: state.wssEndpoint,
|
||||
setWssEndpoint: state.setWssEndpoint,
|
||||
setEnableCommandBrowse: state.setEnableCommandBrowse,
|
||||
setEnableComposerAttach: state.setEnableComposerAttach,
|
||||
setEnableReactTool: state.setEnableReactTool,
|
||||
}), shallow);
|
||||
|
||||
return <>
|
||||
|
||||
<FormHelperText sx={{ display: 'block' }}>
|
||||
Configure a browsing service to enable loading links and pages. See the <Link
|
||||
href='https://github.com/enricoros/big-agi/blob/main/docs/config-browse.md' target='_blank' noLinkStyle>
|
||||
browse functionality guide</Link> for more information.
|
||||
</FormHelperText>
|
||||
|
||||
{!isServerConfig && <FormInputKey
|
||||
id='browse-wss' label='WSS Endpoint' noKey
|
||||
value={wssEndpoint} onChange={setWssEndpoint}
|
||||
rightLabel={!isServerConfig ? 'must be valid' : '✔️ already set in server'}
|
||||
required={!isServerConfig} isError={!isClientValid}
|
||||
placeholder='wss://...'
|
||||
/>}
|
||||
|
||||
<FormControl disabled={!mayWork}>
|
||||
<Checkbox variant='outlined' label='Attach URLs' checked={inComposer} onChange={(event) => setEnableComposerAttach(event.target.checked)} />
|
||||
<FormHelperText>{platformAwareKeystrokes('Load and attach a page when pasting a URL')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl disabled={!mayWork}>
|
||||
<Checkbox variant='outlined' label='/browse' checked={inCommand} onChange={(event) => setEnableCommandBrowse(event.target.checked)} />
|
||||
<FormHelperText>{platformAwareKeystrokes('Use /browse to load a web page')}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl disabled={!mayWork}>
|
||||
<Checkbox variant='outlined' label='ReAct' checked={inReact} onChange={(event) => setEnableReactTool(event.target.checked)} />
|
||||
<FormHelperText>Enables loadURL() in ReAct</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/*<FormControl disabled>*/}
|
||||
{/* <Checkbox variant='outlined' label='Personas' checked={inPersonas} onChange={(event) => setEnablePersonaTool(event.target.checked)} />*/}
|
||||
{/* <FormHelperText>Enable loading URLs by Personas</FormHelperText>*/}
|
||||
{/*</FormControl>*/}
|
||||
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useBrowseStore } from '~/modules/browse/store-module-browsing';
|
||||
|
||||
import { apiAsyncNode } from '~/common/util/trpc.client';
|
||||
|
||||
|
||||
export const CmdRunBrowse: string[] = ['/browse'];
|
||||
|
||||
|
||||
export async function callBrowseFetchPage(url: string): Promise<string | null> {
|
||||
|
||||
// thow if no URL is provided
|
||||
url = url?.trim() || '';
|
||||
if (!url)
|
||||
throw new Error('Invalid URL');
|
||||
|
||||
// assume https if no protocol is provided
|
||||
// noinspection HttpUrlsUsage
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://'))
|
||||
url = 'https://' + url;
|
||||
|
||||
try {
|
||||
|
||||
const clientWssEndpoint = useBrowseStore.getState().wssEndpoint;
|
||||
|
||||
const results = await apiAsyncNode.browse.fetchPages.mutate({
|
||||
access: {
|
||||
dialect: 'browse-wss',
|
||||
...(!!clientWssEndpoint && { wssEndpoint: clientWssEndpoint }),
|
||||
},
|
||||
subjects: [{ url }],
|
||||
});
|
||||
|
||||
if (results.objects.length !== 1)
|
||||
return `Browsing error: expected 1 result, got ${results.objects.length}`;
|
||||
|
||||
const firstResult = results.objects[0];
|
||||
return !firstResult.error ? firstResult.content : `Browsing service error: ${JSON.stringify(firstResult)}`;
|
||||
|
||||
} catch (error: any) {
|
||||
return `Browsing error: ${error?.message || error?.toString() || 'Unknown fetch error'}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { z } from 'zod';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { connect, Page, TimeoutError } from '@cloudflare/puppeteer';
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from '~/server/api/trpc.server';
|
||||
import { env } from '~/server/env.mjs';
|
||||
|
||||
|
||||
// change the page load and scrape timeout
|
||||
const WORKER_TIMEOUT = 10 * 1000; // 10 seconds
|
||||
|
||||
|
||||
// Input schemas
|
||||
|
||||
const browseAccessSchema = z.object({
|
||||
dialect: z.enum(['browse-wss']),
|
||||
wssEndpoint: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
const fetchPageInputSchema = z.object({
|
||||
access: browseAccessSchema,
|
||||
subjects: z.array(z.object({
|
||||
url: z.string().url(),
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
// Output schemas
|
||||
|
||||
const fetchPageWorkerOutputSchema = z.object({
|
||||
url: z.string(),
|
||||
content: z.string(),
|
||||
error: z.string().optional(),
|
||||
stopReason: z.enum(['end', 'timeout', 'error']),
|
||||
screenshot: z.object({
|
||||
base64: z.string(),
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const fetchPagesOutputSchema = z.object({
|
||||
objects: z.array(fetchPageWorkerOutputSchema),
|
||||
});
|
||||
|
||||
|
||||
export const browseRouter = createTRPCRouter({
|
||||
|
||||
fetchPages: publicProcedure
|
||||
.input(fetchPageInputSchema)
|
||||
.output(fetchPagesOutputSchema)
|
||||
.mutation(async ({ input: { access, subjects } }) => {
|
||||
const results: FetchPageWorkerOutputSchema[] = [];
|
||||
|
||||
for (const subject of subjects) {
|
||||
try {
|
||||
results.push(await workerPuppeteer(access, subject.url));
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
url: subject.url,
|
||||
content: '',
|
||||
error: error?.message || JSON.stringify(error) || 'Unknown fetch error',
|
||||
stopReason: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { objects: results };
|
||||
}),
|
||||
|
||||
});
|
||||
|
||||
|
||||
type BrowseAccessSchema = z.infer<typeof browseAccessSchema>;
|
||||
type FetchPageWorkerOutputSchema = z.infer<typeof fetchPageWorkerOutputSchema>;
|
||||
|
||||
async function workerPuppeteer(access: BrowseAccessSchema, targetUrl: string): Promise<FetchPageWorkerOutputSchema> {
|
||||
|
||||
// access
|
||||
const browserWSEndpoint = (access.wssEndpoint || env.PUPPETEER_WSS_ENDPOINT || '').trim();
|
||||
if (!browserWSEndpoint || !(browserWSEndpoint.startsWith('wss://') || browserWSEndpoint.startsWith('ws://')))
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Invalid wss:// endpoint',
|
||||
});
|
||||
|
||||
const result: FetchPageWorkerOutputSchema = {
|
||||
url: targetUrl,
|
||||
content: '(no content)',
|
||||
error: undefined,
|
||||
stopReason: 'error',
|
||||
screenshot: undefined,
|
||||
};
|
||||
|
||||
// [puppeteer] start the remote session
|
||||
const browser = await connect({ browserWSEndpoint });
|
||||
|
||||
// for local testing, open an incognito context, to seaparate cookies
|
||||
let page: Page;
|
||||
if (browserWSEndpoint.startsWith('ws://')) {
|
||||
const context = await browser.createIncognitoBrowserContext();
|
||||
page = await context.newPage();
|
||||
} else {
|
||||
page = await browser.newPage();
|
||||
}
|
||||
|
||||
// open url
|
||||
try {
|
||||
page.setDefaultNavigationTimeout(WORKER_TIMEOUT);
|
||||
await page.goto(targetUrl);
|
||||
result.stopReason = 'end';
|
||||
} catch (error: any) {
|
||||
const isExpected: boolean = error instanceof TimeoutError;
|
||||
result.stopReason = isExpected ? 'timeout' : 'error';
|
||||
if (!isExpected) {
|
||||
result.error = '[Puppeteer] Loading issue: ' + error?.message || error?.toString() || 'Unknown error';
|
||||
console.error('workerPuppeteer: page.goto', error);
|
||||
}
|
||||
}
|
||||
|
||||
// transform the content of the page as text
|
||||
try {
|
||||
if (result.stopReason !== 'error') {
|
||||
result.content = await page.evaluate(() => {
|
||||
const content = document.body.innerText || document.textContent;
|
||||
if (!content)
|
||||
throw new Error('No content');
|
||||
return content;
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('workerPuppeteer: page.evaluate', error);
|
||||
}
|
||||
|
||||
// get a screenshot of the page
|
||||
try {
|
||||
const width = 100;
|
||||
const height = 100;
|
||||
const scale = 0.1; // 10%
|
||||
|
||||
await page.setViewport({ width: width / scale, height: height / scale, deviceScaleFactor: scale });
|
||||
|
||||
result.screenshot = {
|
||||
base64: await page.screenshot({
|
||||
type: 'webp',
|
||||
clip: { x: 0, y: 0, width: width / scale, height: height / scale },
|
||||
encoding: 'base64',
|
||||
}) as string,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('workerPuppeteer: page.screenshot', error);
|
||||
}
|
||||
|
||||
// close the page
|
||||
try {
|
||||
await page.close();
|
||||
} catch (error: any) {
|
||||
console.error('workerPuppeteer: page.close', error);
|
||||
}
|
||||
|
||||
// close the browse (important!)
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (error: any) {
|
||||
console.error('workerPuppeteer: browser.close', error);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { CapabilityBrowsing } from '~/common/components/useCapabilities';
|
||||
import { backendCaps } from '~/modules/backend/state-backend';
|
||||
|
||||
|
||||
interface BrowseState {
|
||||
|
||||
wssEndpoint: string;
|
||||
setWssEndpoint: (url: string) => void;
|
||||
|
||||
enableCommandBrowse: boolean;
|
||||
setEnableCommandBrowse: (value: boolean) => void;
|
||||
|
||||
enableComposerAttach: boolean;
|
||||
setEnableComposerAttach: (value: boolean) => void;
|
||||
|
||||
enableReactTool: boolean;
|
||||
setEnableReactTool: (value: boolean) => void;
|
||||
|
||||
enablePersonaTool: boolean;
|
||||
setEnablePersonaTool: (value: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
export const useBrowseStore = create<BrowseState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
wssEndpoint: '', // default WSS endpoint
|
||||
setWssEndpoint: (wssEndpoint: string) => set(() => ({ wssEndpoint })),
|
||||
|
||||
enableCommandBrowse: true,
|
||||
setEnableCommandBrowse: (enableCommandBrowse: boolean) => set(() => ({ enableCommandBrowse })),
|
||||
|
||||
enableComposerAttach: true,
|
||||
setEnableComposerAttach: (enableComposerAttach: boolean) => set(() => ({ enableComposerAttach })),
|
||||
|
||||
enableReactTool: true,
|
||||
setEnableReactTool: (enableReactTool: boolean) => set(() => ({ enableReactTool })),
|
||||
|
||||
enablePersonaTool: true,
|
||||
setEnablePersonaTool: (enablePersonaTool: boolean) => set(() => ({ enablePersonaTool })),
|
||||
|
||||
}),
|
||||
{
|
||||
name: 'app-module-browse',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
export function useBrowseCapability(): CapabilityBrowsing {
|
||||
// server config
|
||||
const isServerConfig = backendCaps().hasBrowsing;
|
||||
|
||||
// external client state
|
||||
const { wssEndpoint, enableCommandBrowse, enableComposerAttach, enableReactTool, enablePersonaTool } = useBrowseStore();
|
||||
|
||||
// derived state
|
||||
const isClientConfig = !!wssEndpoint;
|
||||
const isClientValid = (wssEndpoint?.startsWith('wss://') && wssEndpoint?.length > 10) || (wssEndpoint?.startsWith('ws://') && wssEndpoint?.length > 9);
|
||||
const mayWork = isServerConfig || (isClientConfig && isClientValid);
|
||||
|
||||
return {
|
||||
mayWork,
|
||||
isServerConfig,
|
||||
isClientConfig,
|
||||
isClientValid,
|
||||
inCommand: mayWork && enableCommandBrowse,
|
||||
inComposer: mayWork && enableComposerAttach,
|
||||
inReact: mayWork && enableReactTool,
|
||||
inPersonas: mayWork && enablePersonaTool,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
|
||||
interface ElevenlabsStore {
|
||||
interface ModuleElevenlabsStore {
|
||||
|
||||
// ElevenLabs Text to Speech settings
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ElevenlabsStore {
|
||||
|
||||
}
|
||||
|
||||
const useElevenlabsStore = create<ElevenlabsStore>()(
|
||||
const useElevenlabsStore = create<ModuleElevenlabsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ function VoicesDropdown(props: {
|
||||
return (
|
||||
<Select
|
||||
value={props.voiceId} onChange={handleVoiceChange}
|
||||
variant='outlined' disabled={props.disabled}
|
||||
variant='outlined' disabled={props.disabled || !props.voices.length}
|
||||
// color={props.isErrorVoices ? 'danger' : undefined}
|
||||
placeholder={props.isErrorVoices ? 'Issue loading voices' : props.isValidKey ? 'Select a voice' : 'Enter valid API Key'}
|
||||
placeholder={props.isErrorVoices ? 'Issue loading voices' : props.isValidKey ? 'Select a voice' : 'Missing API Key'}
|
||||
startDecorator={<RecordVoiceOverIcon />}
|
||||
endDecorator={props.isValidKey && props.isLoadingVoices && <CircularProgress size='sm' />}
|
||||
indicator={<KeyboardArrowDownIcon />}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { FormControl, Input } from '@mui/joy';
|
||||
import { FormControl, FormHelperText, Input } from '@mui/joy';
|
||||
import KeyIcon from '@mui/icons-material/Key';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FormLabelStart } from '~/common/components/forms/FormLabelStart';
|
||||
import { Link } from '~/common/components/Link';
|
||||
|
||||
import { isValidGoogleCloudApiKey, isValidGoogleCseId } from './search.client';
|
||||
import { useGoogleSearchStore } from './store-google';
|
||||
import { useGoogleSearchStore } from './store-module-google';
|
||||
|
||||
|
||||
export function GoogleSearchSettings() {
|
||||
@@ -36,6 +36,10 @@ export function GoogleSearchSettings() {
|
||||
|
||||
return <>
|
||||
|
||||
<FormHelperText sx={{ display: 'block' }}>
|
||||
Configure the Programmable Search Engine to enable searching the web for links.
|
||||
</FormHelperText>
|
||||
|
||||
<FormControl orientation='horizontal' sx={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<FormLabelStart title='GCP API Key'
|
||||
description={<>Create one <Link href='https://console.cloud.google.com/apis/credentials' noLinkStyle target='_blank'>here</Link></>}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useGoogleSearchStore } from '~/modules/google/store-google';
|
||||
|
||||
import { apiAsync } from '~/common/util/trpc.client';
|
||||
|
||||
import { Search } from './search.types';
|
||||
import { useGoogleSearchStore } from './store-module-google';
|
||||
|
||||
|
||||
export const isValidGoogleCloudApiKey = (apiKey?: string) => !!apiKey && apiKey.trim()?.length >= 39;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
|
||||
interface GoogleSearchStore {
|
||||
interface ModuleGoogleSearchStore {
|
||||
|
||||
// Google Custom Search settings
|
||||
|
||||
@@ -14,7 +14,7 @@ interface GoogleSearchStore {
|
||||
|
||||
}
|
||||
|
||||
export const useGoogleSearchStore = create<GoogleSearchStore>()(
|
||||
export const useGoogleSearchStore = create<ModuleGoogleSearchStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
@@ -78,7 +78,9 @@ interface ModelsActions {
|
||||
setFuncLLMId: (id: DLLMId | null) => void;
|
||||
}
|
||||
|
||||
export const useModelsStore = create<ModelsData & ModelsActions>()(
|
||||
type LlmsStore = ModelsData & ModelsActions;
|
||||
|
||||
export const useModelsStore = create<LlmsStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
|
||||
@@ -175,7 +177,7 @@ export const useModelsStore = create<ModelsData & ModelsActions>()(
|
||||
* 1: adds maxOutputTokens (default to half of contextTokens)
|
||||
*/
|
||||
version: 1,
|
||||
migrate: (state: any, fromVersion: number): ModelsData & ModelsActions => {
|
||||
migrate: (state: any, fromVersion: number): LlmsStore => {
|
||||
|
||||
// 0 -> 1: add 'maxOutputTokens' where missing,
|
||||
if (state && fromVersion === 0)
|
||||
|
||||
@@ -5,20 +5,41 @@ import { LLM_IF_OAI_Chat } from '../../../store-llms';
|
||||
const roundTime = (date: string) => Math.round(new Date(date).getTime() / 1000);
|
||||
|
||||
export const hardcodedAnthropicModels: ModelDescriptionSchema[] = [
|
||||
{
|
||||
id: 'claude-2.1',
|
||||
label: 'Claude 2.1',
|
||||
created: roundTime('2023-11-21'),
|
||||
description: 'Superior performance on tasks that require complex reasoning, with reduced model hallucination rates',
|
||||
contextWindow: 200000,
|
||||
pricing: {
|
||||
cpmPrompt: 0.008,
|
||||
cpmCompletion: 0.024,
|
||||
},
|
||||
interfaces: [LLM_IF_OAI_Chat],
|
||||
},
|
||||
{
|
||||
id: 'claude-2.0',
|
||||
label: 'Claude 2',
|
||||
created: roundTime('2023-07-11'),
|
||||
description: 'Claude-2 is the latest version of Claude',
|
||||
description: 'Superior performance on tasks that require complex reasoning',
|
||||
contextWindow: 100000,
|
||||
pricing: {
|
||||
cpmPrompt: 0.008,
|
||||
cpmCompletion: 0.024,
|
||||
},
|
||||
interfaces: [LLM_IF_OAI_Chat],
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-instant-1.2',
|
||||
label: 'Claude Instant 1.2',
|
||||
created: roundTime('2023-08-09'),
|
||||
description: 'Precise and faster',
|
||||
description: 'Low-latency, high throughput model',
|
||||
contextWindow: 100000,
|
||||
pricing: {
|
||||
cpmPrompt: 0.00163,
|
||||
cpmCompletion: 0.00551,
|
||||
},
|
||||
interfaces: [LLM_IF_OAI_Chat],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -182,8 +182,8 @@ export const llmOllamaRouter = createTRPCRouter({
|
||||
const wireOllamaModelInfoSchema = z.object({
|
||||
license: z.string().optional(),
|
||||
modelfile: z.string(),
|
||||
parameters: z.string(),
|
||||
template: z.string(),
|
||||
parameters: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
});
|
||||
const modelInfo = wireOllamaModelInfoSchema.parse(wireModelInfo);
|
||||
return { ...model, ...modelInfo };
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
import { LLM_IF_OAI_Chat, LLM_IF_OAI_Complete, LLM_IF_OAI_Fn, LLM_IF_OAI_Vision } from '../../store-llms';
|
||||
|
||||
const pricingSchema = z.object({
|
||||
cpmPrompt: z.number().optional(), // Cost per thousand prompt tokens
|
||||
cpmCompletion: z.number().optional(), // Cost per thousand completion tokens
|
||||
});
|
||||
|
||||
const modelDescriptionSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -10,6 +14,7 @@ const modelDescriptionSchema = z.object({
|
||||
description: z.string(),
|
||||
contextWindow: z.number(),
|
||||
maxCompletionTokens: z.number().optional(),
|
||||
pricing: pricingSchema.optional(),
|
||||
interfaces: z.array(z.enum([LLM_IF_OAI_Chat, LLM_IF_OAI_Fn, LLM_IF_OAI_Complete, LLM_IF_OAI_Vision])),
|
||||
hidden: z.boolean().optional(),
|
||||
});
|
||||
|
||||
+2
-2
@@ -95,12 +95,12 @@ export function OpenAISourceSetup(props: { sourceId: DModelSourceId }) {
|
||||
</Alert>}
|
||||
|
||||
{advanced.on && <FormSwitchControl
|
||||
title='Moderation'
|
||||
title='Moderation' on='Enabled' fullWidth
|
||||
description={<>
|
||||
<Link level='body-sm' href='https://platform.openai.com/docs/guides/moderation/moderation' target='_blank'>Overview</Link>,
|
||||
{' '}<Link level='body-sm' href='https://openai.com/policies/usage-policies' target='_blank'>policy</Link>
|
||||
</>}
|
||||
value={moderationCheck}
|
||||
checked={moderationCheck}
|
||||
onChange={on => updateSetup({ moderationCheck: on })}
|
||||
/>}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { InlineError } from '~/common/components/InlineError';
|
||||
import { apiQuery } from '~/common/util/trpc.client';
|
||||
import { useToggleableBoolean } from '~/common/util/useToggleableBoolean';
|
||||
|
||||
import { DEFAULT_PRODIA_RESOLUTION, HARDCODED_PRODIA_RESOLUTIONS, useProdiaStore } from './store-prodia';
|
||||
import { DEFAULT_PRODIA_RESOLUTION, HARDCODED_PRODIA_RESOLUTIONS, useProdiaStore } from './store-module-prodia';
|
||||
import { isValidProdiaApiKey } from './prodia.client';
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { backendCaps } from '~/modules/backend/state-backend';
|
||||
import { CapabilityProdiaImageGeneration } from '~/common/components/useCapabilities';
|
||||
import { apiAsync } from '~/common/util/trpc.client';
|
||||
|
||||
import { useProdiaStore } from './store-prodia';
|
||||
import { useProdiaStore } from './store-module-prodia';
|
||||
|
||||
|
||||
export const isValidProdiaApiKey = (apiKey?: string) => !!apiKey && apiKey.trim()?.length >= 36;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user