diff --git a/README.md b/README.md index ca72f10260..feb827d3e9 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ 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_ diff --git a/docs/changelog.md b/docs/changelog.md index 1b2a389a25..e82ab4579d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,10 +2,12 @@ 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) ### 1.6.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) + +- 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.6.0](https://github.com/enricoros/big-agi/milestone/6) ### ✨ What's New in 1.5.0 πŸ‘Š - Nov 19, 2023 @@ -20,6 +22,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 @@ -35,7 +48,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 diff --git a/docs/config-browse.md b/docs/config-browse.md new file mode 100644 index 0000000000..040d77220a --- /dev/null +++ b/docs/config-browse.md @@ -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! \ No newline at end of file diff --git a/docs/environment-variables.md b/docs/environment-variables.md index ca357e6aa1..e4b504ca5d 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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. | --- diff --git a/next.config.mjs b/next.config.mjs index 07909cf0ec..7ae446c09b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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'; diff --git a/package-lock.json b/package-lock.json index 4a2698917b..0f4cb69e1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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.44.0", - "@trpc/next": "^10.44.0", - "@trpc/react-query": "^10.44.0", - "@trpc/server": "^10.44.0", + "@trpc/client": "^10.44.1", + "@trpc/next": "^10.44.1", + "@trpc/react-query": "^10.44.1", + "@trpc/server": "^10.44.1", "@types/gapi": "^0.0.47", "@types/gapi.client.bigquery": "^2.0.4", "@types/google.accounts": "^0.0.14", @@ -42,7 +42,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", @@ -52,11 +52,12 @@ "zustand": "~4.3.9" }, "devDependencies": { - "@types/node": "^20.9.3", + "@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.38", - "@types/react-dom": "^18.2.16", + "@types/react-dom": "^18.2.17", "@types/react-katex": "^3.0.3", "@types/react-timeago": "^4.1.6", "@types/uuid": "^9.0.7", @@ -283,6 +284,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", @@ -1126,9 +1144,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": { @@ -1214,20 +1232,20 @@ } }, "node_modules/@trpc/client": { - "version": "10.44.0", - "resolved": "https://registry.npmjs.org/@trpc/client/-/client-10.44.0.tgz", - "integrity": "sha512-6PAL5rXMGTMlsKKGW9aBsij8XFBbLUbOCJ7jNVX3IripGqiQG3b/VJyTsHRWkss/oaz/sZMOn62amhfev1UVfg==", + "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.44.0" + "@trpc/server": "10.44.1" } }, "node_modules/@trpc/next": { - "version": "10.44.0", - "resolved": "https://registry.npmjs.org/@trpc/next/-/next-10.44.0.tgz", - "integrity": "sha512-C4eKsSWdH0rUu0a0kUl+OC8RcI7DCsl3YZgXfKdPOEzRQ/njIgWWAouETRikzeHjulVN4/yz3ekcTyibfDNpKA==", + "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" ], @@ -1236,33 +1254,33 @@ }, "peerDependencies": { "@tanstack/react-query": "^4.18.0", - "@trpc/client": "10.44.0", - "@trpc/react-query": "10.44.0", - "@trpc/server": "10.44.0", + "@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.44.0", - "resolved": "https://registry.npmjs.org/@trpc/react-query/-/react-query-10.44.0.tgz", - "integrity": "sha512-0l9mar1kSamm/sePnHOuhX8v9LFQbgpVJ2U2s/hWnScNexBFzV03SpCjLAkXtiIapw5lkIG0QlveZXu4G86Xnw==", + "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.44.0", - "@trpc/server": "10.44.0", + "@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.44.0", - "resolved": "https://registry.npmjs.org/@trpc/server/-/server-10.44.0.tgz", - "integrity": "sha512-QBq/FnjyU6a7CAl9p2dv6kApn2i8MFuPt8uVLyGr0oa2aFv31rHaX0m+ibdsviE/3fOJIC8EUOfMYOJma5uK2w==", + "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" ], @@ -1338,9 +1356,9 @@ "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, "node_modules/@types/node": { - "version": "20.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.3.tgz", - "integrity": "sha512-nk5wXLAXGBKfrhLB0cyHGbSqopS+nz0BUgZkUQqSHSSgdee0kssp1IAqlQOu333bW+gMNs2QREx7iynm19Abxw==", + "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" @@ -1382,9 +1400,9 @@ } }, "node_modules/@types/react-dom": { - "version": "18.2.16", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.16.tgz", - "integrity": "sha512-766c37araZ9vxtYs25gvY2wNdFWsT2ZiUvOd0zMhTaoGj6B911N8CKQWgXXJoPMLF3J82thpRqQA7Rf3rBwyJw==", + "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": "*" @@ -1418,9 +1436,9 @@ } }, "node_modules/@types/scheduler": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.7.tgz", - "integrity": "sha512-8g25Nl3AuB1KulTlSUsUhUo/oBgBU6XIXQ+XURpeioEbEJvkO7qI4vDfREv3vJYHHzqXjcAHvoJy4pTtSQNZtA==" + "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", @@ -1988,9 +2006,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", @@ -2306,6 +2324,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", @@ -2385,6 +2409,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", @@ -2948,6 +2981,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", @@ -3581,6 +3623,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", @@ -5584,6 +5642,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", @@ -6137,6 +6205,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", @@ -6703,6 +6780,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", @@ -6711,6 +6804,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", diff --git a/package.json b/package.json index 9854ece909..5e74e81b7f 100644 --- a/package.json +++ b/package.json @@ -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.44.0", - "@trpc/next": "^10.44.0", - "@trpc/react-query": "^10.44.0", - "@trpc/server": "^10.44.0", + "@trpc/client": "^10.44.1", + "@trpc/next": "^10.44.1", + "@trpc/react-query": "^10.44.1", + "@trpc/server": "^10.44.1", "@types/gapi": "^0.0.47", "@types/gapi.client.bigquery": "^2.0.4", "@types/google.accounts": "^0.0.14", @@ -46,7 +46,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", @@ -56,11 +56,12 @@ "zustand": "~4.3.9" }, "devDependencies": { - "@types/node": "^20.9.3", + "@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.38", - "@types/react-dom": "^18.2.16", + "@types/react-dom": "^18.2.17", "@types/react-katex": "^3.0.3", "@types/react-timeago": "^4.1.6", "@types/uuid": "^9.0.7", diff --git a/pages/labs.tsx b/pages/labs.tsx deleted file mode 100644 index 0efb28f6fd..0000000000 --- a/pages/labs.tsx +++ /dev/null @@ -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 ( - - - - ); -} \ No newline at end of file diff --git a/pages/launch.tsx b/pages/link/share_target.tsx similarity index 85% rename from pages/launch.tsx rename to pages/link/share_target.tsx index a25fbf174f..79326a7a8b 100644 --- a/pages/launch.tsx +++ b/pages/link/share_target.tsx @@ -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() { - + } - void; + openConversationInSplitPane: (conversationId: DConversationId) => void; navigateHistoryInFocusedPane: (direction: 'back' | 'forward') => boolean; - focusChatPane: (paneIndex: number) => void; + setFocusedPaneIndex: (paneIndex: number) => void; splitChatPane: (numberOfPanes: number) => void; unsplitChatPane: (paneIndexToKeep: number) => void; onConversationsChanged: (conversationIds: DConversationId[]) => void; @@ -67,8 +71,11 @@ const useAppChatPanesStore = create()(persist( // Check if the conversation is already open in the focused pane. const focusedPane = chatPanes[chatPaneFocusIndex]; - if (focusedPane.conversationId === conversationId) + 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); @@ -83,6 +90,9 @@ const useAppChatPanesStore = create()(persist( 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, @@ -90,6 +100,31 @@ const useAppChatPanesStore = create()(persist( }); }, + 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) @@ -102,8 +137,11 @@ const useAppChatPanesStore = create()(persist( newHistoryIndex--; else if (direction === 'forward' && newHistoryIndex < focusedPane.history.length - 1) newHistoryIndex++; - else + else { + if (DEBUG_PANES_MANAGER) + console.log(`navigateHistoryInFocusedPane: no history ${direction} for`, focusedPane); return false; + } const newPanes = [...chatPanes]; newPanes[chatPaneFocusIndex] = { @@ -112,6 +150,9 @@ const useAppChatPanesStore = create()(persist( historyIndex: newHistoryIndex, }; + if (DEBUG_PANES_MANAGER) + console.log(`navigateHistoryInFocusedPane: ${direction} to`, focusedPane, newPanes); + _set({ chatPanes: newPanes, }); @@ -119,9 +160,13 @@ const useAppChatPanesStore = create()(persist( return true; }, - focusChatPane: (paneIndex: number) => - _set({ - chatPaneFocusIndex: paneIndex, + setFocusedPaneIndex: (paneIndex: number) => + _set(state => { + if (state.chatPaneFocusIndex === paneIndex) + return state; + return { + chatPaneFocusIndex: paneIndex >= 0 && paneIndex < state.chatPanes.length ? paneIndex : null, + }; }), splitChatPane: (numberOfPanes: number) => { @@ -206,14 +251,24 @@ const useAppChatPanesStore = create()(persist( export function usePanesManager() { // use Panes const { onConversationsChanged, ...panesFunctions } = useAppChatPanesStore(state => { - const { chatPaneFocusIndex, chatPanes, openConversationInFocusedPane, navigateHistoryInFocusedPane, onConversationsChanged } = state; - const focusedChatPane = chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex] ?? null : null; - return { - // chatPanes: chatPanes as Readonly, - focusedChatPane, + const { + chatPaneFocusIndex, + chatPanes, + navigateHistoryInFocusedPane, + onConversationsChanged, openConversationInFocusedPane, + openConversationInSplitPane, + setFocusedPaneIndex, + } = state; + const focusedConversationId = chatPaneFocusIndex !== null ? chatPanes[chatPaneFocusIndex]?.conversationId ?? null : null; + return { + chatPanes: chatPanes as Readonly, + focusedConversationId, navigateHistoryInFocusedPane, onConversationsChanged, + openConversationInFocusedPane, + openConversationInSplitPane, + setFocusedPaneIndex, }; }, shallow); diff --git a/src/apps/chat/editors/browse-load.ts b/src/apps/chat/editors/browse-load.ts new file mode 100644 index 0000000000..b2214e863f --- /dev/null +++ b/src/apps/chat/editors/browse-load.ts @@ -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) => 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, + }); + } +}; \ No newline at end of file diff --git a/src/apps/chat/editors/chat-stream.ts b/src/apps/chat/editors/chat-stream.ts index 0c3323f7e1..090fc05ff2 100644 --- a/src/apps/chat/editors/chat-stream.ts +++ b/src/apps/chat/editors/chat-stream.ts @@ -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, '...'); diff --git a/src/apps/chat/editors/commands.ts b/src/apps/chat/editors/commands.ts index 001265c7e4..67f6e9329c 100644 --- a/src/apps/chat/editors/commands.ts +++ b/src/apps/chat/editors/commands.ts @@ -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; } \ No newline at end of file diff --git a/src/apps/chat/editors/editors.ts b/src/apps/chat/editors/editors.ts index ca5161f4b2..cc7936bea6 100644 --- a/src/apps/chat/editors/editors.ts +++ b/src/apps/chat/editors/editors.ts @@ -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; @@ -32,12 +32,13 @@ export function createTypingFunction(conversationId: string, return assistantMessage.id; } -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) diff --git a/src/apps/chat/editors/react-tangent.ts b/src/apps/chat/editors/react-tangent.ts index ad48054f95..ecab9d1144 100644 --- a/src/apps/chat/editors/react-tangent.ts +++ b/src/apps/chat/editors/react-tangent.ts @@ -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 }); diff --git a/src/apps/labs/AppLabs.tsx b/src/apps/labs/AppLabs.tsx deleted file mode 100644 index b009ed2ddd..0000000000 --- a/src/apps/labs/AppLabs.tsx +++ /dev/null @@ -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) => setExperimentalLabs(event.target.checked); - - return ( - - - - - Labs - - - - - - - - - The Labs section is where we experiment with new features and ideas. - - - Features {experimentalLabs ? 'enabled' : 'disabled'}: - -
    -
  • Text tools - complete (highlight differences)
  • -
  • YouTube persona synthesizer - alpha, not persisted
  • -
  • Chat mode: follow-up/augmentation - alpha (diagrams)
  • -
  • Relative chats size - complete
  • -
- - For any questions and creative idea, please join us on Discord, and let's talk! - -
-
-
- - - -
- ); -} \ No newline at end of file diff --git a/src/apps/link/AppChatLinkDrawerItems.tsx b/src/apps/link/AppChatLinkDrawerItems.tsx index 3704200444..10f4590a07 100644 --- a/src/apps/link/AppChatLinkDrawerItems.tsx +++ b/src/apps/link/AppChatLinkDrawerItems.tsx @@ -9,7 +9,7 @@ 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() { {Brand.Title.Base} diff --git a/src/apps/models-modal/ModelsList.tsx b/src/apps/models-modal/ModelsList.tsx index 930efcf02c..683b0ba370 100644 --- a/src/apps/models-modal/ModelsList.tsx +++ b/src/apps/models-modal/ModelsList.tsx @@ -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 ( openLayoutLLMOptions(llm.id)} sx={{ alignItems: 'center', gap: 1 }}> {/* Model Name */} - + {label} - + {/* --> */} diff --git a/src/apps/news/AppNews.tsx b/src/apps/news/AppNews.tsx index dcc5967747..56b034fdbb 100644 --- a/src/apps/news/AppNews.tsx +++ b/src/apps/news/AppNews.tsx @@ -5,10 +5,10 @@ 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'; -import { ROUTE_APP_CHAT } from '~/common/app.routes'; export function AppNews() { @@ -46,7 +46,7 @@ export function AppNews() { )} @@ -189,7 +186,10 @@ export function SettingsModal() { - } title='Google Search API'> + } title='Browsing' startCollapsed> + + + } title='Google Search API' startCollapsed> {/**/} @@ -199,8 +199,6 @@ export function SettingsModal() { - {showShortcuts && setShowShortcuts(false)} />} - ); } diff --git a/src/apps/settings-modal/ShortcutsModal.tsx b/src/apps/settings-modal/ShortcutsModal.tsx index 2ac4441fc5..c9a1481650 100644 --- a/src/apps/settings-modal/ShortcutsModal.tsx +++ b/src/apps/settings-modal/ShortcutsModal.tsx @@ -3,6 +3,7 @@ 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'; @@ -12,8 +13,8 @@ const shortcutsMd = ` | Shortcut | Description | |---------------------|-------------------------------------------------| | **Edit** | | -| Shift + Enter | Newline (don't send) | -| Alt + Enter | Append message (don't send) | +| 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) | @@ -27,16 +28,25 @@ const shortcutsMd = ` | **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 }) => - - null} /> - ; \ No newline at end of file +export function ShortcutsModal() { + + // external state + const showShortcuts = useLayoutShortcuts(); + + return ( + + null} /> + + ); +} diff --git a/src/apps/settings-modal/UxLabsSettings.tsx b/src/apps/settings-modal/UxLabsSettings.tsx index bbdfdd189a..40874c6e12 100644 --- a/src/apps/settings-modal/UxLabsSettings.tsx +++ b/src/apps/settings-modal/UxLabsSettings.tsx @@ -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) => setExperimentalLabs(event.target.checked); - + labsCalling, /*labsEnhancedUI,*/ labsMagicDraw, labsPersonaYTCreator, labsSplitBranching, + setLabsCalling, /*setLabsEnhancedUI,*/ setLabsMagicDraw, setLabsPersonaYTCreator, setLabsSplitBranching, + } = useUXLabsStore(); return <> - - - + YouTube Personas} description={labsPersonaYTCreator ? 'Creator Enabled' : 'Disabled'} + checked={labsPersonaYTCreator} onChange={setLabsPersonaYTCreator} + /> + + Assisted Draw} description={labsMagicDraw ? 'Enabled' : 'Disabled'} + checked={labsMagicDraw} onChange={setLabsMagicDraw} + /> + + Voice Calls} description={labsCalling ? 'Call AGI' : 'Disabled'} + checked={labsCalling} onChange={setLabsCalling} + /> + + Split Branching} description={labsSplitBranching ? 'Enabled' : 'Disabled'} disabled + checked={labsSplitBranching} onChange={setLabsSplitBranching} + /> + + {/**/} + + + + + Auto Diagrams Β· Relative chat size Β· Text Tools + - - ; } \ No newline at end of file diff --git a/src/common/app.routes.ts b/src/common/app.routes.ts index 8d0ecd69e9..faf4a0cb36 100644 --- a/src/common/app.routes.ts +++ b/src/common/app.routes.ts @@ -6,17 +6,21 @@ import Router from 'next/router'; -export const ROUTE_APP_CHAT = '/'; -const APP_LINK_CHAT = '/link/chat/:linkId'; -const APP_LABS = '/labs'; +export const ROUTE_INDEX = '/'; +export const ROUTE_APP_CHAT = '/chat'; +export const ROUTE_APP_LINK_CHAT = '/link/chat/:linkId'; +export const ROUTE_APP_NEWS = '/news'; -export const getHomeLink = () => ROUTE_APP_CHAT; +export const getIndexLink = () => ROUTE_INDEX; -export const getChatLinkRelativePath = (chatLinkId: string) => APP_LINK_CHAT.replace(':linkId', chatLinkId); +export const getChatLinkRelativePath = (chatLinkId: string) => ROUTE_APP_LINK_CHAT.replace(':linkId', chatLinkId); -export const navigateToChat = async () => await Router.push(ROUTE_APP_CHAT); +const navigateFn = (path: string) => (replace?: boolean): Promise => + Router[replace ? 'replace' : 'push'](path); -export const navigateToLabs = async () => await Router.push(APP_LABS); +export const navigateToIndex = navigateFn(ROUTE_INDEX); +export const navigateToChat = async () => await Router.push(ROUTE_APP_CHAT); +export const navigateToNews = navigateFn(ROUTE_APP_NEWS); export const navigateBack = Router.back; diff --git a/src/common/components/GoodTooltip.tsx b/src/common/components/GoodTooltip.tsx new file mode 100644 index 0000000000..85f47407be --- /dev/null +++ b/src/common/components/GoodTooltip.tsx @@ -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 }) => + + {props.children} + ; diff --git a/src/common/components/forms/FormLabelStart.tsx b/src/common/components/forms/FormLabelStart.tsx index 109b2256be..014f8dcfb3 100644 --- a/src/common/components/forms/FormLabelStart.tsx +++ b/src/common/components/forms/FormLabelStart.tsx @@ -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 && ( - + - + )} diff --git a/src/common/components/forms/FormSwitchControl.tsx b/src/common/components/forms/FormSwitchControl.tsx index 51056738fa..1c9246babf 100644 --- a/src/common/components/forms/FormSwitchControl.tsx +++ b/src/common/components/forms/FormSwitchControl.tsx @@ -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 ( - + 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 } } }} /> ); diff --git a/src/common/components/forms/useFormRadio.tsx b/src/common/components/forms/useFormRadio.tsx index 1fa857224a..b6680d8d30 100644 --- a/src/common/components/forms/useFormRadio.tsx +++ b/src/common/components/forms/useFormRadio.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { FormControl, FormLabel, Radio, RadioGroup } from '@mui/joy'; -export type FormRadioOption = { label: string, value: T, experimental?: boolean }; +export type FormRadioOption = { label: string, value: T, disabled?: boolean }; /** @@ -14,9 +14,6 @@ export function useFormRadio(initialValue: T, options: FormRad // state const [value, setValue] = React.useState(initialValue); - // external state - // const experimentalLabs = useUIPreferencesStore(state => state.experimentalLabs); - const handleChange = React.useCallback((event: React.ChangeEvent) => { setValue(event.target.value as T | null); }, []); @@ -31,10 +28,10 @@ export function useFormRadio(initialValue: T, options: FormRad value={value} onChange={handleChange} > {options.map((option) => - )} + )} , - [/*experimentalLabs,*/ handleChange, hidden, label, options, value], + [handleChange, hidden, label, options, value], ); return [value, component]; diff --git a/src/common/components/useCapabilities.ts b/src/common/components/useCapabilities.ts index 490e78f803..74afb6b5bc 100644 --- a/src/common/components/useCapabilities.ts +++ b/src/common/components/useCapabilities.ts @@ -39,4 +39,20 @@ export interface CapabilityProdiaImageGeneration { mayWork: boolean; } -export { useCapability as useCapabilityProdia } from '~/modules/prodia/prodia.client'; \ No newline at end of file +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'; \ No newline at end of file diff --git a/src/common/components/useSnackbarsStore.ts b/src/common/components/useSnackbarsStore.ts index 2d3af0ea30..791db1fafc 100644 --- a/src/common/components/useSnackbarsStore.ts +++ b/src/common/components/useSnackbarsStore.ts @@ -1,7 +1,8 @@ -import * as React from 'react'; import { create } from 'zustand'; import { v4 as uuidv4 } from 'uuid'; +import type { SnackbarTypeMap } from '@mui/joy'; + export const SNACKBAR_ANIMATION_DURATION = 200; @@ -9,8 +10,8 @@ export interface SnackbarMessage { key: string; message: string; type: 'success' | 'issue' | 'title'; - autoHideDuration?: number | null; - startDecorator?: React.ReactNode; + closeButton?: boolean, + overrides?: Partial; } interface SnackbarStore { diff --git a/src/common/layout/AppLayout.tsx b/src/common/layout/AppLayout.tsx index b1384be42e..46e52373b1 100644 --- a/src/common/layout/AppLayout.tsx +++ b/src/common/layout/AppLayout.tsx @@ -5,6 +5,7 @@ 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'; @@ -13,7 +14,7 @@ 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 } from './store-applayout'; +import { openLayoutModelsSetup, openLayoutPreferences, openLayoutShortcuts } from './store-applayout'; export function AppLayout(props: { @@ -30,6 +31,7 @@ export function AppLayout(props: { const shortcuts = React.useMemo((): GlobalShortcutItem[] => [ ['m', true, true, false, openLayoutModelsSetup], ['p', true, true, false, openLayoutPreferences], + ['?', true, true, false, openLayoutShortcuts], ], []); useGlobalShortcuts(shortcuts); @@ -69,6 +71,9 @@ export function AppLayout(props: { {/* Overlay Models (& Model Options )*/} + {/* Overlay Shortcuts */} + + ); } \ No newline at end of file diff --git a/src/common/layout/store-applayout.ts b/src/common/layout/store-applayout.ts index 9c1e37e5d1..2bd211966c 100644 --- a/src/common/layout/store-applayout.ts +++ b/src/common/layout/store-applayout.ts @@ -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()( 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 }); \ No newline at end of file +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 }); \ No newline at end of file diff --git a/src/common/state/ProviderSnacks.tsx b/src/common/state/ProviderSnacks.tsx index 1811783c66..d115966471 100644 --- a/src/common/state/ProviderSnacks.tsx +++ b/src/common/state/ProviderSnacks.tsx @@ -32,6 +32,7 @@ const defaultTypeConfig: { autoHideDuration: 2000, clickAway: false, closeButton: false, + anchorOrigin: { vertical: 'top', horizontal: 'center' }, }, }; @@ -49,12 +50,12 @@ export const ProviderSnacks = (props: { children: React.ReactNode }) => { if (!activeSnackbar) return null; - const { key, message, type, autoHideDuration, startDecorator } = activeSnackbar; + const { key, message, type, closeButton, overrides } = activeSnackbar; const config = { ...defaultTypeConfig[type], - autoHideDuration: autoHideDuration ?? defaultTypeConfig[type].autoHideDuration, - startDecorator: startDecorator ?? defaultTypeConfig[type].startDecorator, + ...overrides, + ...(closeButton === undefined ? {} : { closeButton }), }; return ( @@ -66,10 +67,7 @@ export const ProviderSnacks = (props: { children: React.ReactNode }) => { autoHideDuration={config.autoHideDuration ?? null} animationDuration={SNACKBAR_ANIMATION_DURATION} invertedColors={config.closeButton} - anchorOrigin={{ - vertical: type === 'title' ? 'top' : 'bottom', - horizontal: type === 'title' ? 'center' : 'right', - }} + anchorOrigin={config.anchorOrigin || { vertical: 'bottom', horizontal: 'right' }} onClose={(_event, reason) => { if (reason === 'timeout' || ((reason === 'clickaway' || reason === 'escapeKeyDown') && config.clickAway)) { animateCloseSnackbar(); diff --git a/src/common/state/store-chats.ts b/src/common/state/store-chats.ts index 0128afc587..ea75631cc2 100644 --- a/src/common/state/store-chats.ts +++ b/src/common/state/store-chats.ts @@ -547,12 +547,14 @@ export const useConversation = (conversationId: DConversationId | null) => useCh // 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, diff --git a/src/common/state/store-ui.ts b/src/common/state/store-ui.ts index 702594f381..f15d4092e6 100644 --- a/src/common/state/store-ui.ts +++ b/src/common/state/store-ui.ts @@ -1,45 +1,13 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -// UI Counters - -interface UICountersStore { - actionCounters: Record; - incrementActionCounter: (key: string) => void; - clearActionCounter: (key: string) => void; - clearAllActionCounters: () => void; -} - -const useUICountersStore = create()( - 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; + incrementActionCounter: (key: string) => void; + } export const useUIPreferencesStore = create()( 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()( 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()( 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()( }, ), ); + +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), + }; +} \ No newline at end of file diff --git a/src/common/state/store-ux-labs.ts b/src/common/state/store-ux-labs.ts new file mode 100644 index 0000000000..721c68cc9d --- /dev/null +++ b/src/common/state/store-ux-labs.ts @@ -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()( + 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', + }, + ), +); \ No newline at end of file diff --git a/src/common/util/clipboardUtils.ts b/src/common/util/clipboardUtils.ts index 55eb243d94..d4109c007a 100644 --- a/src/common/util/clipboardUtils.ts +++ b/src/common/util/clipboardUtils.ts @@ -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 diff --git a/src/data.ts b/src/data.ts index 0c7d48f96f..7bb0014fc5 100644 --- a/src/data.ts +++ b/src/data.ts @@ -60,7 +60,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.'] }, @@ -78,7 +78,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.'] }, diff --git a/src/modules/aifn/react/react.ts b/src/modules/aifn/react/react.ts index a86f361dd2..be1d87aa9a 100644 --- a/src/modules/aifn/react/react.ts +++ b/src/modules/aifn/react/react.ts @@ -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 { + async reAct(question: string, llmId: DLLMId, maxTurns = 5, enableBrowse = false, + appendLog: (...data: any[]) => void = console.log, + showState: (state: object) => void): Promise { 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 { } } +async function browse(url: string): Promise { + 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(eval(what)); const knownActions: { [key: string]: ActionFunction } = { wikipedia: wikipedia, google: search, + loadUrl: browse, calculate: calculate, }; \ No newline at end of file diff --git a/src/modules/backend/backend.router.ts b/src/modules/backend/backend.router.ts index ea7e2584b6..97b38d9b0c 100644 --- a/src/modules/backend/backend.router.ts +++ b/src/modules/backend/backend.router.ts @@ -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, diff --git a/src/modules/backend/state-backend.ts b/src/modules/backend/state-backend.ts index 786af6c0eb..10c70339b2 100644 --- a/src/modules/backend/state-backend.ts +++ b/src/modules/backend/state-backend.ts @@ -4,6 +4,7 @@ import { shallow } from 'zustand/shallow'; export interface BackendCapabilities { hasDB: boolean; + hasBrowsing: boolean; hasGoogleCustomSearch: boolean; hasImagingProdia: boolean; hasLlmAnthropic: boolean; @@ -24,6 +25,7 @@ const useBackendStore = create()( // capabilities hasDB: false, + hasBrowsing: false, hasGoogleCustomSearch: false, hasImagingProdia: false, hasLlmAnthropic: false, diff --git a/src/modules/browse/BrowseSettings.tsx b/src/modules/browse/BrowseSettings.tsx new file mode 100644 index 0000000000..de00da87de --- /dev/null +++ b/src/modules/browse/BrowseSettings.tsx @@ -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 <> + + + Configure a browsing service to enable loading links and pages. See the + browse functionality guide for more information. + + + {!isServerConfig && } + + + setEnableComposerAttach(event.target.checked)} /> + {platformAwareKeystrokes('Load and attach a page when pasting a URL')} + + + + setEnableCommandBrowse(event.target.checked)} /> + {platformAwareKeystrokes('Use /browse to load a web page')} + + + + setEnableReactTool(event.target.checked)} /> + Enables loadURL() in ReAct + + + {/**/} + {/* setEnablePersonaTool(event.target.checked)} />*/} + {/* Enable loading URLs by Personas*/} + {/**/} + + ; +} \ No newline at end of file diff --git a/src/modules/browse/browse.client.ts b/src/modules/browse/browse.client.ts new file mode 100644 index 0000000000..533a539c96 --- /dev/null +++ b/src/modules/browse/browse.client.ts @@ -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 { + + // 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'}`; + } +} diff --git a/src/modules/browse/browse.router.ts b/src/modules/browse/browse.router.ts new file mode 100644 index 0000000000..83fa988bb7 --- /dev/null +++ b/src/modules/browse/browse.router.ts @@ -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; +type FetchPageWorkerOutputSchema = z.infer; + +async function workerPuppeteer(access: BrowseAccessSchema, targetUrl: string): Promise { + + // 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; +} diff --git a/src/modules/browse/store-module-browsing.tsx b/src/modules/browse/store-module-browsing.tsx new file mode 100644 index 0000000000..09fe0d1079 --- /dev/null +++ b/src/modules/browse/store-module-browsing.tsx @@ -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()( + 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, + }; +} \ No newline at end of file diff --git a/src/modules/google/GoogleSearchSettings.tsx b/src/modules/google/GoogleSearchSettings.tsx index bf07ba457c..0fa15149af 100644 --- a/src/modules/google/GoogleSearchSettings.tsx +++ b/src/modules/google/GoogleSearchSettings.tsx @@ -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'; @@ -36,6 +36,10 @@ export function GoogleSearchSettings() { return <> + + Configure the Programmable Search Engine to enable searching the web for links. + + Create one here} diff --git a/src/modules/llms/transports/server/anthropic/anthropic.models.ts b/src/modules/llms/transports/server/anthropic/anthropic.models.ts index 2ac10cf32d..eb4e4117a2 100644 --- a/src/modules/llms/transports/server/anthropic/anthropic.models.ts +++ b/src/modules/llms/transports/server/anthropic/anthropic.models.ts @@ -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], }, { diff --git a/src/modules/llms/transports/server/ollama/ollama.router.ts b/src/modules/llms/transports/server/ollama/ollama.router.ts index b450dc2069..ecb3673108 100644 --- a/src/modules/llms/transports/server/ollama/ollama.router.ts +++ b/src/modules/llms/transports/server/ollama/ollama.router.ts @@ -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 }; diff --git a/src/modules/llms/transports/server/server.schemas.ts b/src/modules/llms/transports/server/server.schemas.ts index b0fe40e1fc..4614f4ba3b 100644 --- a/src/modules/llms/transports/server/server.schemas.ts +++ b/src/modules/llms/transports/server/server.schemas.ts @@ -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(), }); diff --git a/src/modules/llms/vendors/openai/OpenAISourceSetup.tsx b/src/modules/llms/vendors/openai/OpenAISourceSetup.tsx index e23d93dcf4..2a0ad05457 100644 --- a/src/modules/llms/vendors/openai/OpenAISourceSetup.tsx +++ b/src/modules/llms/vendors/openai/OpenAISourceSetup.tsx @@ -95,12 +95,12 @@ export function OpenAISourceSetup(props: { sourceId: DModelSourceId }) { } {advanced.on && Overview, {' '}policy } - value={moderationCheck} + checked={moderationCheck} onChange={on => updateSetup({ moderationCheck: on })} />} diff --git a/src/modules/trade/ExportChats.tsx b/src/modules/trade/ExportChats.tsx index da3ce1cbf8..04fa3fd0ae 100644 --- a/src/modules/trade/ExportChats.tsx +++ b/src/modules/trade/ExportChats.tsx @@ -18,7 +18,8 @@ import { conversationTitle, DConversationId, getConversation } from '~/common/st import { isBrowser } from '~/common/util/pwaUtils'; import { useUICounter } from '~/common/state/store-ui'; -import type { PublishedSchema, StoragePutSchema } from './server/trade.router'; +import type { PublishedSchema } from './server/pastegg'; +import type { StoragePutSchema } from './server/link'; import { ExportedChatLink } from './ExportedChatLink'; import { ExportedPublish } from './ExportedPublish'; import { addChatLinkItem, useLinkStorageOwnerId } from './store-module-trade'; diff --git a/src/modules/trade/ExportedChatLink.tsx b/src/modules/trade/ExportedChatLink.tsx index 59622b04e1..2f30ec7c9b 100644 --- a/src/modules/trade/ExportedChatLink.tsx +++ b/src/modules/trade/ExportedChatLink.tsx @@ -19,7 +19,7 @@ import { getChatLinkRelativePath } from '~/common/app.routes'; import { getOriginUrl } from '~/common/util/urlUtils'; import { webShare, webSharePresent } from '~/common/util/pwaUtils'; -import type { StorageDeleteSchema, StoragePutSchema } from './server/trade.router'; +import type { StorageDeleteSchema, StoragePutSchema } from './server/link'; import { removeChatLinkItem } from './store-module-trade'; @@ -50,7 +50,7 @@ export function ExportedChatLink(props: { onClose: () => void, response: Storage const onOpen = () => setOpened(true); const onCopy = () => { - copyToClipboard(fullUrl); + copyToClipboard(fullUrl, 'Public link'); setCopied(true); }; diff --git a/src/modules/trade/ExportedPublish.tsx b/src/modules/trade/ExportedPublish.tsx index c03685a9ce..2255b3b4fa 100644 --- a/src/modules/trade/ExportedPublish.tsx +++ b/src/modules/trade/ExportedPublish.tsx @@ -4,7 +4,7 @@ import { Alert, Box, Button, Divider, Input, Modal, ModalDialog, Stack, Typograp import { Link } from '~/common/components/Link'; -import type { PublishedSchema } from './server/trade.router'; +import type { PublishedSchema } from './server/pastegg'; /** diff --git a/src/modules/trade/ImportChats.tsx b/src/modules/trade/ImportChats.tsx index d81266f78e..0b859d39a6 100644 --- a/src/modules/trade/ImportChats.tsx +++ b/src/modules/trade/ImportChats.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { fileOpen, FileWithHandle } from 'browser-fs-access'; -import { Box, Button, FormControl, Input, Sheet, Typography } from '@mui/joy'; +import { Box, Button, FormControl, Input, Sheet, Textarea, Typography } from '@mui/joy'; import FileUploadIcon from '@mui/icons-material/FileUpload'; import { Brand } from '~/common/app.config'; -import { FormLabelStart } from '~/common/components/forms/FormLabelStart'; +import { FormRadioOption, useFormRadio } from '~/common/components/forms/useFormRadio'; import { InlineError } from '~/common/components/InlineError'; import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon'; import { apiAsyncNode } from '~/common/util/trpc.client'; @@ -19,6 +19,12 @@ import { ImportedOutcome, ImportOutcomeModal } from './ImportOutcomeModal'; export type ImportConfig = { dir: 'import' }; + +const chatGptMedia: FormRadioOption<'source' | 'link'>[] = [ + { label: 'Shared Chat URL', value: 'link' }, + { label: 'Page Source', value: 'source' }, +]; + /** * Components and functionality to import conversations * Supports our own JSON files, and ChatGPT Share Links @@ -26,13 +32,19 @@ export type ImportConfig = { dir: 'import' }; export function ImportConversations(props: { onClose: () => void }) { // state + const [importMedia, importMediaControl] = useFormRadio('link', chatGptMedia); const [chatGptEdit, setChatGptEdit] = React.useState(false); const [chatGptUrl, setChatGptUrl] = React.useState(''); + const [chatGptSource, setChatGptSource] = React.useState(''); + const [importJson, setImportJson] = React.useState(null); const [importOutcome, setImportOutcome] = React.useState(null); // derived state + const isUrl = importMedia === 'link'; + const isSource = importMedia === 'source'; const chatGptUrlValid = chatGptUrl.startsWith('https://chat.openai.com/share/') && chatGptUrl.length > 40; + const handleImportFromFiles = async () => { // pick file(s) let blobs: FileWithHandle[]; @@ -67,10 +79,12 @@ export function ImportConversations(props: { onClose: () => void }) { setImportOutcome(outcome); }; + const handleChatGptToggleShown = () => setChatGptEdit(!chatGptEdit); - const handleChatGptLoadFromURL = async () => { - if (!chatGptUrlValid) + const handleChatGptLoad = async () => { + setImportJson(null); + if ((isUrl && !chatGptUrlValid) || (isSource && !chatGptSource)) return; const outcome: ImportedOutcome = { conversations: [] }; @@ -78,13 +92,16 @@ export function ImportConversations(props: { onClose: () => void }) { // load the conversation let conversationId: DConversationId, data: ChatGptSharedChatSchema; try { - ({ conversationId, data } = await apiAsyncNode.trade.importChatGptShare.query({ url: chatGptUrl })); + ({ conversationId, data } = await apiAsyncNode.trade.importChatGptShare.mutate(isUrl ? { url: chatGptUrl } : { htmlPage: chatGptSource })); } catch (error) { outcome.conversations.push({ fileName: 'chatgpt', success: false, error: (error as any)?.message || error?.toString() || 'unknown error' }); setImportOutcome(outcome); return; } + // save as JSON + setImportJson(JSON.stringify(data, null, 2)); + // transform to our data structure const conversation = createDConversation(); conversation.id = conversationId; @@ -148,24 +165,31 @@ export function ImportConversations(props: { onClose: () => void }) { {chatGptEdit && - - + {importMediaControl} + + {isUrl && } + - - setChatGptUrl(event.target.value)} - /> + />} + {isSource &&