Compare commits

...

92 Commits

Author SHA1 Message Date
Enrico Ros 28ef74f1e9 Merge branch 'release-1.6.0' 2023-11-28 01:41:30 -08:00
Enrico Ros 70091ac39b 1.6.0 version 2023-11-28 01:40:29 -08:00
Enrico Ros cc1011659d 1.6.0 README and changelog 2023-11-28 01:39:03 -08:00
Enrico Ros 7eaa4a11bd 1.6.0 news 2023-11-28 01:32:14 -08:00
Enrico Ros 495f25e2d4 Update news hiding 2023-11-28 01:30:03 -08:00
Enrico Ros f2396000f2 Update template 2023-11-28 01:29:41 -08:00
Enrico Ros 77533aa385 Fix, thanks lint 2023-11-27 16:02:50 -08:00
Enrico Ros 01b2bf6fa3 Flattener: move to streaming, using a new helper 2023-11-27 16:01:25 -08:00
Enrico Ros 6d7843805e Small bits 2023-11-27 15:33:02 -08:00
Enrico Ros 0a593fb2c6 Fix focusing of imported chats. #233 2023-11-27 13:31:37 -08:00
Enrico Ros 57f277f269 ElevenLabs: improve config UX 2023-11-27 13:24:25 -08:00
Enrico Ros 6924e02a17 Link Import: fix Chat URL 2023-11-27 13:20:47 -08:00
Enrico Ros f4b645fd78 Update config-browse.md 2023-11-25 11:47:28 -08:00
Enrico Ros fdb46d3072 Browse: Improve errors reporting 2023-11-24 15:32:54 -08:00
Enrico Ros 858e9d3cb3 Browse: Local (ws://) in incognito 2023-11-24 15:32:46 -08:00
Enrico Ros 52a9dc7bec Browse: Documentation 2023-11-24 15:19:03 -08:00
Enrico Ros 16fbd3b6a3 Browse: cleanups2 2023-11-24 14:23:14 -08:00
Enrico Ros aa09e60f5f Browse: cleanups 2023-11-24 14:20:50 -08:00
Enrico Ros 3b2983831d Spell 2023-11-24 14:01:30 -08:00
Enrico Ros 16e69d0d0b commands: /help (primitive) 2023-11-24 13:55:47 -08:00
Enrico Ros 548f52c770 Browse: user configuration 2023-11-24 13:50:46 -08:00
Enrico Ros 8adac0d193 Browse: /browse -> loads as assistant response 2023-11-24 13:50:46 -08:00
Enrico Ros c0d3c6c982 Browse: /react support (as 'loadURL' tool) 2023-11-24 13:35:57 -08:00
Enrico Ros c1516e7be0 Browse: Share Target -> Composer attachment 2023-11-24 13:11:44 -08:00
Enrico Ros 8473894be2 Browse: CTRL+V (url) and 'Paste' (url) -> Composer attachment 2023-11-24 13:11:44 -08:00
Enrico Ros d5e2fbed0e Browse: page loading service, using remote Puppeteer
also: moved to tRPC (node)
2023-11-24 12:49:45 -08:00
Enrico Ros 2dfa78fbe0 Voice Calls - Labs option 2023-11-24 10:58:30 -08:00
Enrico Ros dff83c5ede Roll packages 2023-11-24 10:49:01 -08:00
Enrico Ros 483f483c4a Copy to clipboard snacks 2023-11-23 02:15:57 -08:00
Enrico Ros f780daf1b1 Anthropic Claude 2.1 support. Closes #245 2023-11-23 01:34:54 -08:00
Enrico Ros 5e6e5bf017 Improved Models Tooltip 2023-11-23 01:27:31 -08:00
Enrico Ros bfe2882ac3 Adding optional Pricing schema 2023-11-23 01:11:14 -08:00
Enrico Ros 0574be04f4 Update soft knowledge cutoff for 1106 models. 2023-11-22 23:36:04 -08:00
Enrico Ros 53b5da8cb8 OpenAI Shared Chats: import from Clipboard too, and copy json object 2023-11-22 22:32:45 -08:00
Enrico Ros 5387b17c36 Also show the branched title. 2023-11-22 13:03:03 -08:00
Enrico Ros 0e854b8772 Title: show the chat index (1: first, 2: second most recently created, etc) 2023-11-22 04:32:19 -08:00
Enrico Ros d23f247a8c Large Perf Boost on Messages 2023-11-22 04:06:27 -08:00
Enrico Ros ce13c04e96 Perf Boost - large gains on the Nav Drawer 2023-11-22 04:00:28 -08:00
Enrico Ros e55fbe9ad0 Fix missing hook dep 2023-11-22 03:14:16 -08:00
Enrico Ros e5a11af6d2 Rename 2023-11-22 02:32:24 -08:00
Enrico Ros 76f21f8c96 Rename 2023-11-22 02:22:20 -08:00
Enrico Ros ea4d9afff2 Ctrl + Shift + ?: show shortcuts 2023-11-22 01:52:13 -08:00
Enrico Ros d884970a02 Do not require confirmation for 'armed' deletions. 2023-11-22 01:39:23 -08:00
Enrico Ros ee11787dcc README.md - roadmap comment 2023-11-22 01:38:16 -08:00
Enrico Ros 13e1ba977f Update 1.5.0 release notes 2023-11-22 01:25:56 -08:00
Enrico Ros 7137ebdda2 Merge pull request #240 from g1ibby/fix-ollama-listModels
fix: ollama listModel endpoint when a model doesn't have TEMPLATE
2023-11-22 01:07:40 -08:00
Enrico Ros 9b71b08fe1 Chat Layout: push the chatmessagelist two levels down #233 2023-11-22 01:06:35 -08:00
Enrico Ros 45a18edac0 ChatMessageList: undo the Ephemeral move 2023-11-22 00:59:17 -08:00
Enrico Ros f1b1ca0a5f Window manager: split functions 2023-11-22 00:59:03 -08:00
Enrico Ros 0c1718bf9c Split-branch settings 2023-11-22 00:56:58 -08:00
Enrico Ros a934ca548e usePanesManager: optional debug 2023-11-21 22:52:24 -08:00
Enrico Ros 2896bd7287 Move Ephemerals Down 2023-11-21 22:41:55 -08:00
Enrico Ros 5ad103a8a2 Refer. 2023-11-21 22:21:22 -08:00
Enrico Ros 16916db247 Improve routing, and move the action pwa action receiver 2023-11-21 22:17:17 -08:00
g1ibby 669eb1414f fix: ollama listModel endpoint when a model doesn't have TEMPLATE or PARAMETER 2023-11-22 13:14:46 +07:00
Enrico Ros 6ed8529d6a Roll types 2023-11-21 22:06:18 -08:00
Enrico Ros bb36dbc4b9 Removed the Labs page, removed a store 2023-11-21 21:31:21 -08:00
Enrico Ros f9e38c7220 Ctrl + Alt + Left/Right: fast history navigation, closes #207 2023-11-21 19:27:22 -08:00
Enrico Ros 2b5a051a9e Ctrl + Alt + Left/Right: navigates in history 2023-11-21 18:45:19 -08:00
Enrico Ros 9793236941 Shortcuts: use fewer listeners 2023-11-21 18:04:33 -08:00
Enrico Ros 497d1c9559 Snackbar: chat title (disabled for now) 2023-11-21 17:41:00 -08:00
Enrico Ros 75c4fe5e67 Snackbars: useEffect compatible 2023-11-21 17:36:39 -08:00
Enrico Ros f4d3d3bd28 Snackbars: add the 'title' type 2023-11-21 17:36:12 -08:00
Enrico Ros 853aadaa0e Confirm branching. 2023-11-21 16:55:36 -08:00
Enrico Ros 8bf23e121c Snackbar Framework animations - Improves #206 2023-11-21 16:55:25 -08:00
Enrico Ros cbffc3f6d5 Snackbar Framework - Closes #206 2023-11-21 16:41:12 -08:00
Enrico Ros 52fc4ec5d8 Improve Restart messaging 2023-11-21 16:40:42 -08:00
Enrico Ros ab94579a30 Branching: duplication up to a message. Partial #235
This commit also largely cleanups the hierarchy tree of component callbacks/handlers
and sets a common nomenclature.
2023-11-21 15:16:58 -08:00
Enrico Ros 43ddc79939 Roll packages 2023-11-21 13:43:45 -08:00
Enrico Ros 6938c6b8d0 UI: Improve options location - Fixes #236 2023-11-21 13:41:27 -08:00
Enrico Ros ba5d835248 Improve spacing 2023-11-21 13:14:06 -08:00
Enrico Ros 510d58ba69 Cleanup News page - part of #236 2023-11-21 12:57:29 -08:00
Enrico Ros c23b0770bf tRPC: enforce more separation of the runtime
The build system was requiring (erroneously) some nodejs packages
when inside routers in the Edge route.
2023-11-21 02:29:05 -08:00
Enrico Ros cb4fdc56a5 Moved chat/commands 2023-11-21 00:28:59 -08:00
Enrico Ros 3b28767212 Renamed to ChatPane 2023-11-21 00:28:46 -08:00
Enrico Ros a1d6cb8cd0 Window management: separate stores again 2023-11-21 00:16:35 -08:00
Enrico Ros 0a094ef0b0 Improve Stores naming 2023-11-20 17:38:35 -08:00
Enrico Ros 17c349af94 Window management: framework
This includes moving the full responsibility for the active window
(and history) to the panes.
2023-11-20 16:19:04 -08:00
Enrico Ros 97f2a19227 Moved and renamed Trade, where it belongs 2023-11-20 16:07:08 -08:00
Enrico Ros 6fc2415e5d Chats store: removed the activeConversationId 2023-11-20 15:24:39 -08:00
Enrico Ros d68c131bbc Window management: ancillary nothingness 2 2023-11-20 15:18:04 -08:00
Enrico Ros 0b6c217da6 Window management: ancillary nothingness 2023-11-20 14:35:36 -08:00
Enrico Ros 432d78fc9d Window management: ancillary component cleanups 2023-11-20 14:32:06 -08:00
Enrico Ros 769ca1546a Window management: ancillary small changes 2023-11-20 14:20:19 -08:00
Enrico Ros 989684884c Window management: ancillary component changes 2023-11-20 14:19:09 -08:00
Enrico Ros a2b6554e73 ChatMessageList: do not collapse on null conversations, but show an helpful message 2023-11-20 02:16:25 -08:00
Enrico Ros 28555445c9 InlineError: allow 'info' 2023-11-20 02:14:24 -08:00
Enrico Ros 20bddfe6c6 Uniform sxprops 2023-11-20 02:14:06 -08:00
Enrico Ros 01243f7422 globalStoredList: begin abstracting stored lists 2023-11-19 19:02:43 -08:00
Enrico Ros 741edb499c Chat: begin moving window state up 2023-11-19 16:09:48 -08:00
Enrico Ros a3fd877a75 Default mobile corner button 2023-11-19 15:58:09 -08:00
Enrico Ros 0c19c4c8ac Clear for 1.6.0 2023-11-19 15:57:38 -08:00
116 changed files with 3267 additions and 1365 deletions
+29 -1
View File
@@ -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?
```
+14 -4
View File
@@ -13,15 +13,25 @@ Or fork & run on Vercel
[![Deploy with Vercel](https://vercel.com/button)](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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
+64
View File
@@ -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.
[![Official Discord](https://discordapp.com/api/guilds/1098796266906980422/widget.png?style=banner2)](https://discord.gg/MkH4qj2Jp9)
---
Enjoy the enhanced browsing experience within `big-AGI` and explore the web without ever leaving your chat!
+4
View File
@@ -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. |
---
+5
View File
@@ -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';
+201 -95
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-14
View File
@@ -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 (
+1 -1
View File
@@ -25,7 +25,7 @@
}
],
"share_target": {
"action": "/launch",
"action": "/link/share_target",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
-3
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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'}
/>}
+79 -68
View File
@@ -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}
/>
),
+2 -2
View File
@@ -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>
@@ -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>;
};
}
+168 -90
View File
@@ -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={{
+288
View File
@@ -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,
};
}
+38
View File
@@ -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,
});
}
};
+1 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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)
+4 -4
View File
@@ -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 });
+21 -34
View File
@@ -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);
-68
View File
@@ -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&apos;s talk!
</Typography>
</CardContent>
</Card>
</Container>
<Button variant='solid' color='neutral' size='lg' component={Link} href='/' noLinkStyle>
Got it!
</Button>
</Box>
);
}
+2 -2
View File
@@ -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';
+3 -3
View File
@@ -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}
+2 -2
View File
@@ -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);
};
+14 -4
View File
@@ -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 }} />
-2
View File
@@ -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
+14 -8
View File
@@ -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 />*/}
+26 -8
View File
@@ -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&apos;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;
}[];
}
+1 -1
View File
@@ -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);
+21 -11
View File
@@ -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)}/>}
</>;
}
+38 -33
View File
@@ -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...' />*/}
+35 -23
View File
@@ -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>
);
}
+42 -25
View File
@@ -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>
</>;
}
+28 -7
View File
@@ -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;
+1 -1
View File
@@ -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
+12
View File
@@ -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>;
+5 -3
View File
@@ -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>
+3 -3
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -6
View File
@@ -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];
+17 -1
View File
@@ -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';
+33 -1
View File
@@ -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]);
};
+111
View File
@@ -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);
+7 -1
View File
@@ -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,
},
},
},
}}
+14
View File
@@ -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>
);
}
+6 -1
View File
@@ -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 });
+106
View File
@@ -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}
</>;
};
+116
View File
@@ -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
View File
@@ -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);
+27 -41
View File
@@ -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),
};
}
+56
View File
@@ -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',
},
),
);
+19 -5
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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.'] },
+104 -59
View File
@@ -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>
);
-38
View File
@@ -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();
}
+28 -10
View File
@@ -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,
};
+76
View File
@@ -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,
};
}
+1
View File
@@ -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 -2
View File
@@ -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,
+62
View File
@@ -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>*/}
</>;
}
+42
View File
@@ -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'}`;
}
}
+171
View File
@@ -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 />}
+6 -2
View File
@@ -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 -2
View File
@@ -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) => ({
+4 -2
View File
@@ -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
View File
@@ -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 })}
/>}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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