From 737a511d0f2fede53872661ac8bf4104490b88e9 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Sat, 7 Sep 2024 16:45:20 +0530 Subject: [PATCH 01/22] use refactor from 1850 --- .vscode/settings.json | 3 + package-lock.json | 186 +++++++++++++++--- package.json | 2 +- quadratic-api/package.json | 6 +- quadratic-api/src/routes/ai_chat.ts | 86 +++----- quadratic-client/package.json | 2 +- quadratic-client/src/app/ui/hooks/useAI.tsx | 95 +++++++++ .../ui/menus/CodeEditor/AICodeBlockParser.tsx | 8 +- .../app/ui/menus/CodeEditor/AiAssistant.tsx | 129 +++--------- .../ui/menus/CodeEditor/CodeEditorContext.tsx | 4 +- .../src/shared/constants/routes.ts | 6 + quadratic-shared/package.json | 8 +- quadratic-shared/tsconfig.json | 13 ++ quadratic-shared/typesAndSchemasAI.ts | 17 ++ 14 files changed, 369 insertions(+), 196 deletions(-) create mode 100644 quadratic-client/src/app/ui/hooks/useAI.tsx create mode 100644 quadratic-shared/tsconfig.json create mode 100644 quadratic-shared/typesAndSchemasAI.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index f19841d139..7a5a0e93d4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -78,5 +78,8 @@ }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/package-lock.json b/package-lock.json index fae534ea43..fcc0d790f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "dependencies": { "tsc": "^2.0.4", "vitest": "^1.5.0", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@types/jest": "^29.5.3", @@ -10032,6 +10032,28 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@types/offscreencanvas": { "version": "2019.7.3", "license": "MIT" @@ -10055,8 +10077,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.9.11", - "license": "MIT" + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" }, "node_modules/@types/range-parser": { "version": "1.2.7", @@ -10835,6 +10858,17 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "license": "MIT", @@ -10897,6 +10931,17 @@ "node": ">= 6.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "license": "MIT", @@ -14647,6 +14692,14 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "3.1.2", "license": "MIT" @@ -15186,6 +15239,31 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "dev": true, @@ -15953,6 +16031,14 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "license": "MIT", @@ -20284,7 +20370,6 @@ }, "node_modules/node-domexception": { "version": "1.0.0", - "dev": true, "funding": [ { "type": "github", @@ -20656,30 +20741,76 @@ } }, "node_modules/openai": { - "version": "3.3.0", - "license": "MIT", + "version": "4.58.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.58.1.tgz", + "integrity": "sha512-n9fN4RIjbj4PbZU6IN/FOBBbxHbHEcW18rDZ4nW2cDNfZP2+upm/FM20UCmRNMQTvhOvw/2Tw4vgioQyQb5nlA==", "dependencies": { - "axios": "^0.26.0", - "form-data": "^4.0.0" + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "@types/qs": "^6.9.15", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "qs": "^6.10.3" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, - "node_modules/openai/node_modules/axios": { - "version": "0.26.1", - "license": "MIT", + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.50", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.50.tgz", + "integrity": "sha512-xonK+NRrMBRtkL1hVCc3G+uXtjh1Al4opBLjqVmipe5ZAaBYWW6cNAiBVZ1BvmkBhep698rP3UM3aRAdSALuhg==", "dependencies": { - "follow-redirects": "^1.14.8" + "undici-types": "~5.26.4" } }, - "node_modules/openai/node_modules/form-data": { - "version": "4.0.0", - "license": "MIT", + "node_modules/openai/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" + "whatwg-url": "^5.0.0" }, "engines": { - "node": ">= 6" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/openai/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/openai/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/opentype.js": { @@ -26728,8 +26859,9 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "license": "MIT", + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -26771,11 +26903,11 @@ "multer": "^1.4.5-lts.1", "multer-s3": "^3.0.1", "newrelic": "^11.17.0", - "openai": "^3.2.1", + "openai": "^4.58.1", "pg": "^8.11.3", "stripe": "^14.16.0", "supertest": "^6.3.3", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@aws-sdk/types": "^3.449.0", @@ -27099,7 +27231,13 @@ }, "quadratic-shared": { "version": "1.0.0", - "license": "ISC" + "license": "ISC", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": "18.x" + } } } } diff --git a/package.json b/package.json index 8f6872eefd..b0026bfd39 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "dependencies": { "tsc": "^2.0.4", "vitest": "^1.5.0", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@types/jest": "^29.5.3", diff --git a/quadratic-api/package.json b/quadratic-api/package.json index 3a440aad90..21a8eb9593 100644 --- a/quadratic-api/package.json +++ b/quadratic-api/package.json @@ -9,7 +9,7 @@ "start:prod": "dotenv -e .env -- node dist/src/server.js --max-old-space-size=8192", "postinstall": "prisma generate", "prebuild": "rm -rf dist", - "build": "tsc --project tsconfig.production.json && tsc ../quadratic-shared/*.ts", + "build": "tsc --project tsconfig.production.json && tsc --project ../quadratic-shared/tsconfig.json", "postbuild": "npm run copy:grid-files", "build:prod": "npm run prebuild && NODE_ENV=production npm run build", "copy:grid-files": "cp ./src/data/*.grid ./dist/src/data/", @@ -55,11 +55,11 @@ "multer": "^1.4.5-lts.1", "multer-s3": "^3.0.1", "newrelic": "^11.17.0", - "openai": "^3.2.1", + "openai": "^4.58.1", "pg": "^8.11.3", "stripe": "^14.16.0", "supertest": "^6.3.3", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { "@aws-sdk/types": "^3.449.0", diff --git a/quadratic-api/src/routes/ai_chat.ts b/quadratic-api/src/routes/ai_chat.ts index df00f5f18e..f28c2e2325 100644 --- a/quadratic-api/src/routes/ai_chat.ts +++ b/quadratic-api/src/routes/ai_chat.ts @@ -1,17 +1,16 @@ import express from 'express'; import rateLimit from 'express-rate-limit'; -import { Configuration, OpenAIApi } from 'openai'; -import { z } from 'zod'; +import OpenAI from 'openai'; +import { AIAutoCompleteRequestBodySchema } from 'quadratic-shared/typesAndSchemasAI'; import { OPENAI_API_KEY, RATE_LIMIT_AI_REQUESTS_MAX, RATE_LIMIT_AI_WINDOW_MS } from '../env-vars'; import { validateAccessToken } from '../middleware/validateAccessToken'; import { Request } from '../types/Request'; const ai_chat_router = express.Router(); -const configuration = new Configuration({ - apiKey: OPENAI_API_KEY, +const openai = new OpenAI({ + apiKey: OPENAI_API_KEY || '', }); -const openai = new OpenAIApi(configuration); const ai_rate_limiter = rateLimit({ windowMs: Number(RATE_LIMIT_AI_WINDOW_MS) || 3 * 60 * 60 * 1000, // 3 hours @@ -23,40 +22,14 @@ const ai_rate_limiter = rateLimit({ }, }); -const AIMessage = z.object({ - // role can be only "user" or "bot" - role: z.enum(['system', 'user', 'assistant']), - content: z.string(), - stream: z.boolean().optional(), -}); - -const AIAutoCompleteRequestBody = z.object({ - messages: z.array(AIMessage), - // optional model - model: z.enum(['gpt-4o']).optional(), -}); - -type AIAutoCompleteRequestBodyType = z.infer; - -const log_ai_request = (req: any, req_json: AIAutoCompleteRequestBodyType) => { - const to_log = req_json.messages.filter((message) => message.role !== AIMessage.shape.role.Values.system); - console.log('API Chat Request: ', req?.auth?.sub, to_log); -}; - ai_chat_router.post('/chat', validateAccessToken, ai_rate_limiter, async (request, response) => { - const r_json = AIAutoCompleteRequestBody.parse(request.body); - - log_ai_request(request, r_json); - try { - const result = await openai.createChatCompletion({ - model: r_json.model || 'gpt-4o', - messages: r_json.messages, - }); - - response.json({ - data: result.data, + const { model, messages } = AIAutoCompleteRequestBodySchema.parse(request.body); + const result = await openai.chat.completions.create({ + model, + messages, }); + response.json(result.choices[0].message); } catch (error: any) { if (error.response) { response.status(error.response.status).json(error.response.data); @@ -67,32 +40,25 @@ ai_chat_router.post('/chat', validateAccessToken, ai_rate_limiter, async (reques }); ai_chat_router.post('/chat/stream', validateAccessToken, ai_rate_limiter, async (request: Request, response) => { - const r_json = AIAutoCompleteRequestBody.parse(request.body); + try { + const { model, messages } = AIAutoCompleteRequestBodySchema.parse(request.body); - log_ai_request(request, r_json); + response.setHeader('Content-Type', 'text/event-stream'); + response.setHeader('Cache-Control', 'no-cache'); + response.setHeader('Connection', 'keep-alive'); - response.setHeader('Content-Type', 'text/event-stream'); - response.setHeader('Cache-Control', 'no-cache'); - response.setHeader('Connection', 'keep-alive'); + const completion = await openai.chat.completions.create({ + model, + messages, + stream: true, + }); - try { - await openai - .createChatCompletion( - { - model: r_json.model || 'gpt-4o', - messages: r_json.messages, - stream: true, - }, - { responseType: 'stream' } - ) - .then((oai_response: any) => { - // Pipe the response from axios to the SSE response - oai_response.data.pipe(response); - }) - .catch((error: any) => { - console.error(error); - response.status(500).send('Error streaming data'); - }); + for await (const chunk of completion) { + response.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + + response.write('data: [DONE]\n\n'); + response.end(); } catch (error: any) { if (error.response) { response.status(error.response.status).json(error.response.data); @@ -104,4 +70,4 @@ ai_chat_router.post('/chat/stream', validateAccessToken, ai_rate_limiter, async } }); -export default ai_chat_router; +export default ai_chat_router; \ No newline at end of file diff --git a/quadratic-client/package.json b/quadratic-client/package.json index 92e06b4ea9..c8dd6ec58d 100644 --- a/quadratic-client/package.json +++ b/quadratic-client/package.json @@ -86,7 +86,7 @@ "start": "export VITE_VERSION=$(git rev-parse HEAD) && export NODE_OPTIONS=--max-old-space-size=16384 && vite dev", "start:no-hmr": "npm run build && vite preview --port 3000", "build": "export VITE_VERSION=$(git rev-parse HEAD) && export NODE_OPTIONS=--max-old-space-size=16384 && vite build", - "build:prod": "tsc ../quadratic-shared/*.ts && export VITE_VERSION=$GIT_COMMIT && export NODE_OPTIONS=--max-old-space-size=16384 && vite build", + "build:prod": "tsc --project ../quadratic-shared/tsconfig.json && export VITE_VERSION=$GIT_COMMIT && export NODE_OPTIONS=--max-old-space-size=16384 && vite build", "build:docker": "export VITE_VERSION=$(git rev-parse HEAD) && export NODE_OPTIONS=--max-old-space-size=16384 && vite build --mode docker", "build:wasm": "npm run build:wasm:javascript && npm run build:wasm:nodejs && npm run build:wasm:types", "build:wasm:types": "cd .. && cd quadratic-core && cargo run --bin export_types", diff --git a/quadratic-client/src/app/ui/hooks/useAI.tsx b/quadratic-client/src/app/ui/hooks/useAI.tsx new file mode 100644 index 0000000000..507d2639b0 --- /dev/null +++ b/quadratic-client/src/app/ui/hooks/useAI.tsx @@ -0,0 +1,95 @@ +import { authClient } from '@/auth'; +import { AI } from '@/shared/constants/routes'; +import { AIMessage, AIModel } from 'quadratic-shared/typesAndSchemasAI'; +import { useCallback } from 'react'; + +type HandleAIPromptProps = { + model?: AIModel; + systemMessages: AIMessage[]; + messages: AIMessage[]; + setMessages: (value: React.SetStateAction) => void; + signal: AbortSignal; +}; + +export function useAI() { + const handleAIStream = useCallback( + async ({ + model, + systemMessages, + messages, + setMessages, + signal, + }: HandleAIPromptProps): Promise<{ error?: boolean; content: string }> => { + let responseMessage: AIMessage = { role: 'assistant', content: '' }; + try { + const token = await authClient.getTokenOrRedirect(); + const response = await fetch(AI.STREAM, { + method: 'POST', + signal, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + messages: [...systemMessages, ...messages], + }), + }); + + if (!response.ok) { + const error = + response.status === 429 + ? 'You have exceeded the maximum number of requests. Please try again later.' + : `Looks like there was a problem. Status Code: ${response.status}`; + setMessages((prev) => [...prev, { role: 'assistant', content: error }]); + if (response.status !== 429) { + console.error(`Error retrieving data from AI API: ${response.status}`); + } + return { error: true, content: error }; + } + + setMessages((prev) => [...prev, responseMessage]); + + const reader = response.body?.getReader(); + if (!reader) throw new Error('Response body is not readable'); + + const decoder = new TextDecoder(); + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.choices[0].delta.content) { + responseMessage.content += data.choices[0].delta.content; + setMessages((prev) => [...prev.slice(0, -1), { ...responseMessage }]); + } + } catch (err) { + // Not JSON or unexpected format, skip + } + } + } + } + return { content: responseMessage.content }; + } catch (err: any) { + if (err.name === 'AbortError') { + return { error: false, content: 'Aborted by user' }; + } else { + responseMessage.content += '\n\nAn error occurred while processing the response.'; + setMessages((prev) => [...prev.slice(0, -1), { ...responseMessage }]); + console.error('Error in AI prompt handling:', err); + return { error: true, content: 'An error occurred while processing the response.' }; + } + } + }, + [] + ); + + return handleAIStream; +} diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AICodeBlockParser.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AICodeBlockParser.tsx index 84f9fffcdb..91b0791922 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AICodeBlockParser.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AICodeBlockParser.tsx @@ -15,17 +15,19 @@ export function parseCodeBlocks(input: string): Array { // Add any text before the current code block if (lastIndex < match.index) { - blocks.push({input.substring(lastIndex, match.index)}); + blocks.push( + {input.substring(lastIndex, match.index)} + ); } // Add the code block as a CodeSnippet component - blocks.push(); + blocks.push(); lastIndex = CODE_BLOCK_REGEX.lastIndex; } // Add any remaining text after the last code block if (lastIndex < input.length) { - blocks.push({input.substring(lastIndex)}); + blocks.push({input.substring(lastIndex)}); } return blocks; diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 46c86c2887..0f61bc974c 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -4,22 +4,23 @@ import { KeyboardSymbols } from '@/app/helpers/keyboardSymbols'; import { colors } from '@/app/theme/colors'; import ConditionalWrapper from '@/app/ui/components/ConditionalWrapper'; import { TooltipHint } from '@/app/ui/components/TooltipHint'; +import { useAI } from '@/app/ui/hooks/useAI'; import { AI } from '@/app/ui/icons'; import { useCodeEditor } from '@/app/ui/menus/CodeEditor/CodeEditorContext'; -import { authClient } from '@/auth'; +import { QuadraticDocs } from '@/app/ui/menus/CodeEditor/QuadraticDocs'; import { useRootRouteLoaderData } from '@/routes/_root'; -import { apiClient } from '@/shared/api/apiClient'; import { Avatar } from '@/shared/components/Avatar'; import { useConnectionSchemaBrowser } from '@/shared/hooks/useConnectionSchemaBrowser'; import { Textarea } from '@/shared/shadcn/ui/textarea'; import { Send, Stop } from '@mui/icons-material'; import { CircularProgress, IconButton } from '@mui/material'; import mixpanel from 'mixpanel-browser'; -import { useEffect, useRef } from 'react'; +import { AIMessage } from 'quadratic-shared/typesAndSchemasAI'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useRecoilValue } from 'recoil'; -import { CodeBlockParser } from './AICodeBlockParser'; + +import { CodeBlockParser } from '@/app/ui/menus/CodeEditor/AICodeBlockParser'; import './AiAssistant.css'; -import { QuadraticDocs } from './QuadraticDocs'; export type AiMessage = { role: 'user' | 'system' | 'assistant'; @@ -47,10 +48,11 @@ export const AiAssistant = ({ autoFocus }: { autoFocus?: boolean }) => { const schemaJsonForAi = schemaData ? JSON.stringify(schemaData) : ''; // TODO: This is only sent with the first message, we should refresh the content with each message. - const systemMessages = [ - { - role: 'system', - content: ` + const systemMessages = useMemo( + () => [ + { + role: 'system', + content: ` You are a helpful assistant inside of a spreadsheet application called Quadratic. Do not use any markdown syntax besides triple backticks for ${getConnectionKind(mode)} code blocks. Do not reply with plain text code blocks. @@ -65,8 +67,10 @@ If the code was recently run here is the result: ${JSON.stringify(consoleOutput)}\`\`\` This is the documentation for Quadratic: ${QuadraticDocs}`, - }, - ] as AiMessage[]; + }, + ], + [consoleOutput, editorContent, mode, schemaJsonForAi, selectedCell.x, selectedCell.y] + ); // Focus the input when relevant & the tab comes into focus useEffect(() => { @@ -90,103 +94,26 @@ ${QuadraticDocs}`, setLoading(false); }; - const submitPrompt = async () => { + const handleAIStream = useAI(); + const submitPrompt = useCallback(async () => { if (loading) return; - controllerRef.current = new AbortController(); setLoading(true); - const token = await authClient.getTokenOrRedirect(); - const updatedMessages = [...messages, { role: 'user', content: prompt }] as AiMessage[]; - const request_body = { - model: 'gpt-4o', - messages: [...systemMessages, ...updatedMessages], - }; + controllerRef.current = new AbortController(); + + const updatedMessages: AIMessage[] = [...messages, { role: 'user', content: prompt }]; setMessages(updatedMessages); setPrompt(''); - await fetch(`${apiClient.getApiUrl()}/ai/chat/stream`, { - method: 'POST', + await handleAIStream({ + model: 'gpt-4o', + systemMessages, + messages: updatedMessages, + setMessages, signal: controllerRef.current.signal, - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request_body), - }) - .then((response) => { - if (response.status !== 200) { - if (response.status === 429) { - setMessages((old) => [ - ...old, - { - role: 'assistant', - content: 'You have exceeded the maximum number of requests. Please try again later.', - }, - ]); - } else { - setMessages((old) => [ - ...old, - { - role: 'assistant', - content: 'Looks like there was a problem. Status Code: ' + response.status, - }, - ]); - console.error(`error retrieving data from AI API: ${response.status}`); - } - return; - } - - const reader = response.body?.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - let responseMessage = { - role: 'assistant', - content: '', - } as AiMessage; - setMessages((old) => [...old, responseMessage]); - - return reader?.read().then(function processResult(result): any { - buffer += decoder.decode(result.value || new Uint8Array(), { stream: !result.done }); - const parts = buffer.split('\n'); - buffer = parts.pop() || ''; - for (const part of parts) { - const message = part.replace(/^data: /, ''); - try { - const data = JSON.parse(message); - - // Do something with the JSON data here - if (data.choices[0].delta.content !== undefined) { - responseMessage.content += data.choices[0].delta.content; - setMessages((old) => { - old.pop(); - old.push(responseMessage); - return [...old]; - }); - } - } catch (err) { - // Not JSON, nothing to do. - } - } - if (result.done) { - // stream complete - return; - } - return reader.read().then(processResult); - }); - }) - .catch((err) => { - // not sure what would cause this to happen - if (err.name !== 'AbortError') { - console.log(err); - return; - } - }); - // eslint-disable-next-line no-unreachable + }); setLoading(false); - }; - - const displayMessages = messages.filter((message, index) => message.role !== 'system'); + }, [controllerRef, handleAIStream, loading, messages, prompt, setLoading, setMessages, setPrompt, systemMessages]); // Designed to live in a box that takes up the full height of its container return ( @@ -208,7 +135,7 @@ ${QuadraticDocs}`, data-enable-grammarly="false" >
- {displayMessages.map((message, index) => ( + {messages.map((message, index) => (
; loading: [boolean, React.Dispatch>]; - messages: [AiMessage[], React.Dispatch>]; + messages: [AIMessage[], React.Dispatch>]; prompt: [string, React.Dispatch>]; }; // `undefined` is used here as a loading state. Once the editor mounts, it becomes a string (possibly empty) diff --git a/quadratic-client/src/shared/constants/routes.ts b/quadratic-client/src/shared/constants/routes.ts index 4fae79cfcf..acdf14d1f8 100644 --- a/quadratic-client/src/shared/constants/routes.ts +++ b/quadratic-client/src/shared/constants/routes.ts @@ -1,4 +1,5 @@ import { UrlParamsDevState } from '@/app/gridGL/pixiApp/urlParams/UrlParamsDev'; +import { apiClient } from '@/shared/api/apiClient'; import { ConnectionType } from 'quadratic-shared/typesAndSchemasConnections'; // Any routes referenced outside of the root router are stored here @@ -59,3 +60,8 @@ export const SEARCH_PARAMS = { SNACKBAR_MSG: { KEY: 'snackbar-msg' }, // VALUE can be any message you want to display SNACKBAR_SEVERITY: { KEY: 'snackbar-severity', VALUE: { ERROR: 'error' } }, }; + +export const AI = { + CHAT: `${apiClient.getApiUrl()}/ai/chat`, + STREAM: `${apiClient.getApiUrl()}/ai/chat/stream`, +}; diff --git a/quadratic-shared/package.json b/quadratic-shared/package.json index 31b7296f6b..52305ddd61 100644 --- a/quadratic-shared/package.json +++ b/quadratic-shared/package.json @@ -7,5 +7,11 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", - "license": "ISC" + "license": "ISC", + "engines": { + "node": "18.x" + }, + "dependencies": { + "zod": "^3.23.8" + } } diff --git a/quadratic-shared/tsconfig.json b/quadratic-shared/tsconfig.json new file mode 100644 index 0000000000..f820af0b33 --- /dev/null +++ b/quadratic-shared/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "module": "commonjs", + "esModuleInterop": true, + "target": "es6", + "noImplicitAny": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "skipLibCheck": true + }, + "include": ["./*.ts"] +} diff --git a/quadratic-shared/typesAndSchemasAI.ts b/quadratic-shared/typesAndSchemasAI.ts new file mode 100644 index 0000000000..42ef507545 --- /dev/null +++ b/quadratic-shared/typesAndSchemasAI.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const AIMessageSchema = z.object({ + role: z.enum(['system', 'user', 'assistant']), + content: z.string(), + stream: z.boolean().optional(), +}); +export type AIMessage = z.infer; + +export const AIModelSchema = z.enum(['gpt-4o', 'gpt-4o-2024-08-06']).default('gpt-4o'); +export type AIModel = z.infer; + +export const AIAutoCompleteRequestBodySchema = z.object({ + messages: z.array(AIMessageSchema), + model: AIModelSchema, +}); +export type AIAutoCompleteRequestBody = z.infer; From fc3a6eaf62d4b650ce7ee86e4d834314449e9659 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Sat, 7 Sep 2024 19:54:21 +0530 Subject: [PATCH 02/22] add claude to ai assistant in code editor --- package-lock.json | 61 +++++++ quadratic-api/.env.example | 1 + quadratic-api/package.json | 5 +- quadratic-api/src/app.ts | 6 +- quadratic-api/src/env-vars.ts | 3 +- quadratic-api/src/routes/ai/anthropic.ts | 81 ++++++++++ .../src/routes/{ai_chat.ts => ai/openai.ts} | 24 +-- quadratic-client/src/app/ui/hooks/useAI.tsx | 151 +++++++++++++----- .../app/ui/menus/CodeEditor/AiAssistant.tsx | 89 ++++++++--- .../ui/menus/CodeEditor/CodeEditorContext.tsx | 5 +- .../src/shared/constants/routes.ts | 10 +- quadratic-shared/typesAndSchemasAI.ts | 28 +++- 12 files changed, 372 insertions(+), 92 deletions(-) create mode 100644 quadratic-api/src/routes/ai/anthropic.ts rename quadratic-api/src/routes/{ai_chat.ts => ai/openai.ts} (68%) diff --git a/package-lock.json b/package-lock.json index fcc0d790f0..5fcd923dc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -148,6 +148,66 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.27.2.tgz", + "integrity": "sha512-Q6gOx4fyHQ+NCSaVeXEKFZfoFWCR3ctUA+sK5oGB7RKUkzUvK64aYM7v1T9ekJKwn8TwRq6IGjqS31n9PbjCIA==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { + "version": "18.19.50", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.50.tgz", + "integrity": "sha512-xonK+NRrMBRtkL1hVCc3G+uXtjh1Al4opBLjqVmipe5ZAaBYWW6cNAiBVZ1BvmkBhep698rP3UM3aRAdSALuhg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/@anthropic-ai/sdk/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/@anthropic-ai/sdk/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/@auth0/auth0-spa-js": { "version": "2.1.3", "license": "MIT" @@ -26879,6 +26939,7 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@anthropic-ai/sdk": "^0.27.2", "@aws-sdk/client-s3": "^3.427.0", "@aws-sdk/client-secrets-manager": "^3.441.0", "@aws-sdk/s3-request-presigner": "^3.427.0", diff --git a/quadratic-api/.env.example b/quadratic-api/.env.example index 986edfc86a..ae36980141 100644 --- a/quadratic-api/.env.example +++ b/quadratic-api/.env.example @@ -16,6 +16,7 @@ AWS_S3_ACCESS_KEY_ID=test AWS_S3_SECRET_ACCESS_KEY=test AWS_S3_BUCKET_NAME=quadratic-api-docker +ANTHROPIC_API_KEY= OPENAI_API_KEY= SENTRY_DSN= diff --git a/quadratic-api/package.json b/quadratic-api/package.json index 21a8eb9593..7b864a7277 100644 --- a/quadratic-api/package.json +++ b/quadratic-api/package.json @@ -31,8 +31,9 @@ }, "author": "David Kircos", "dependencies": { - "@aws-sdk/client-secrets-manager": "^3.441.0", + "@anthropic-ai/sdk": "^0.27.2", "@aws-sdk/client-s3": "^3.427.0", + "@aws-sdk/client-secrets-manager": "^3.441.0", "@aws-sdk/s3-request-presigner": "^3.427.0", "@prisma/client": "^4.12.0", "@sendgrid/mail": "^8.1.0", @@ -69,9 +70,9 @@ "@types/auth0": "^3.3.4", "@types/aws-sdk": "^2.7.0", "@types/cors": "^2.8.12", - "@types/pg": "^8.10.7", "@types/multer": "^1.4.8", "@types/multer-s3": "^3.0.1", + "@types/pg": "^8.10.7", "@typescript-eslint/eslint-plugin": "^6.13.1", "@typescript-eslint/parser": "^6.13.1", "dotenv-cli": "^7.1.0", diff --git a/quadratic-api/src/app.ts b/quadratic-api/src/app.ts index 3a265385d9..6dab3eecb4 100644 --- a/quadratic-api/src/app.ts +++ b/quadratic-api/src/app.ts @@ -7,7 +7,8 @@ import fs from 'fs'; import helmet from 'helmet'; import path from 'path'; import { CORS, NODE_ENV, SENTRY_DSN } from './env-vars'; -import ai_chat_router from './routes/ai_chat'; +import anthropic_router from './routes/ai/anthropic'; +import openai_router from './routes/ai/openai'; import internal_router from './routes/internal'; import { ApiError } from './utils/ApiError'; export const app = express(); @@ -68,7 +69,8 @@ app.get('/', (req, res) => { // App routes // TODO: eventually move all of these into the `v0` directory and register them dynamically -app.use('/ai', ai_chat_router); +app.use('/ai', anthropic_router); +app.use('/ai', openai_router); // Internal routes app.use('/v0/internal', internal_router); diff --git a/quadratic-api/src/env-vars.ts b/quadratic-api/src/env-vars.ts index 190540f353..fc94885e7c 100644 --- a/quadratic-api/src/env-vars.ts +++ b/quadratic-api/src/env-vars.ts @@ -42,11 +42,12 @@ export const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY as string; // Required in prod, optional locally export const M2M_AUTH_TOKEN = process.env.M2M_AUTH_TOKEN; export const OPENAI_API_KEY = process.env.OPENAI_API_KEY; +export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; export const SLACK_FEEDBACK_URL = process.env.SLACK_FEEDBACK_URL; export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ''; if (NODE_ENV === 'production') { - ['M2M_AUTH_TOKEN', 'OPENAI_API_KEY', 'SLACK_FEEDBACK_URL'].forEach(ensureEnvVarExists); + ['M2M_AUTH_TOKEN', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'SLACK_FEEDBACK_URL'].forEach(ensureEnvVarExists); } ensureSampleTokenNotUsedInProduction(); diff --git a/quadratic-api/src/routes/ai/anthropic.ts b/quadratic-api/src/routes/ai/anthropic.ts new file mode 100644 index 0000000000..209389b947 --- /dev/null +++ b/quadratic-api/src/routes/ai/anthropic.ts @@ -0,0 +1,81 @@ +import Anthropic from '@anthropic-ai/sdk'; +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { AnthropicAutoCompleteRequestBodySchema } from 'quadratic-shared/typesAndSchemasAI'; +import { ANTHROPIC_API_KEY, RATE_LIMIT_AI_REQUESTS_MAX, RATE_LIMIT_AI_WINDOW_MS } from '../../env-vars'; +import { validateAccessToken } from '../../middleware/validateAccessToken'; +import { Request } from '../../types/Request'; + +const anthropic_router = express.Router(); + +const anthropic = new Anthropic({ + apiKey: ANTHROPIC_API_KEY, +}); + +const ai_rate_limiter = rateLimit({ + windowMs: Number(RATE_LIMIT_AI_WINDOW_MS) || 3 * 60 * 60 * 1000, // 3 hours + max: Number(RATE_LIMIT_AI_REQUESTS_MAX) || 25, // Limit number of requests per windowMs + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + keyGenerator: (request: Request) => { + return request.auth?.sub || 'anonymous'; + }, +}); + +anthropic_router.post('/anthropic/chat', validateAccessToken, ai_rate_limiter, async (request, response) => { + try { + const { model, messages } = AnthropicAutoCompleteRequestBodySchema.parse(request.body); + const message = await anthropic.messages.create({ + model, + messages, + temperature: 0, + max_tokens: 8192, + }); + response.json(message); + } catch (error: any) { + if (error.response) { + response.status(error.response.status).json(error.response.data); + } else { + response.status(400).json(error.message); + } + } +}); + +anthropic_router.post( + '/anthropic/chat/stream', + validateAccessToken, + ai_rate_limiter, + async (request: Request, response) => { + try { + const { model, messages } = AnthropicAutoCompleteRequestBodySchema.parse(request.body); + + response.setHeader('Content-Type', 'text/event-stream'); + response.setHeader('Cache-Control', 'no-cache'); + response.setHeader('Connection', 'keep-alive'); + + const chunks = await anthropic.messages.create({ + model, + messages, + max_tokens: 8192, + stream: true, + }); + + for await (const chunk of chunks) { + response.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + + response.write('[DONE]\n\n'); + response.end(); + } catch (error: any) { + if (error.response) { + response.status(error.response.status).json(error.response.data); + console.log(error.response.status, error.response.data); + } else { + response.status(400).json(error.message); + console.log(error.message); + } + } + } +); + +export default anthropic_router; diff --git a/quadratic-api/src/routes/ai_chat.ts b/quadratic-api/src/routes/ai/openai.ts similarity index 68% rename from quadratic-api/src/routes/ai_chat.ts rename to quadratic-api/src/routes/ai/openai.ts index f28c2e2325..77255676ee 100644 --- a/quadratic-api/src/routes/ai_chat.ts +++ b/quadratic-api/src/routes/ai/openai.ts @@ -1,12 +1,12 @@ import express from 'express'; import rateLimit from 'express-rate-limit'; import OpenAI from 'openai'; -import { AIAutoCompleteRequestBodySchema } from 'quadratic-shared/typesAndSchemasAI'; -import { OPENAI_API_KEY, RATE_LIMIT_AI_REQUESTS_MAX, RATE_LIMIT_AI_WINDOW_MS } from '../env-vars'; -import { validateAccessToken } from '../middleware/validateAccessToken'; -import { Request } from '../types/Request'; +import { OpenAIAutoCompleteRequestBodySchema } from 'quadratic-shared/typesAndSchemasAI'; +import { OPENAI_API_KEY, RATE_LIMIT_AI_REQUESTS_MAX, RATE_LIMIT_AI_WINDOW_MS } from '../../env-vars'; +import { validateAccessToken } from '../../middleware/validateAccessToken'; +import { Request } from '../../types/Request'; -const ai_chat_router = express.Router(); +const openai_router = express.Router(); const openai = new OpenAI({ apiKey: OPENAI_API_KEY || '', @@ -22,12 +22,13 @@ const ai_rate_limiter = rateLimit({ }, }); -ai_chat_router.post('/chat', validateAccessToken, ai_rate_limiter, async (request, response) => { +openai_router.post('/openai/chat', validateAccessToken, ai_rate_limiter, async (request, response) => { try { - const { model, messages } = AIAutoCompleteRequestBodySchema.parse(request.body); + const { model, messages } = OpenAIAutoCompleteRequestBodySchema.parse(request.body); const result = await openai.chat.completions.create({ model, messages, + temperature: 0, }); response.json(result.choices[0].message); } catch (error: any) { @@ -39,9 +40,9 @@ ai_chat_router.post('/chat', validateAccessToken, ai_rate_limiter, async (reques } }); -ai_chat_router.post('/chat/stream', validateAccessToken, ai_rate_limiter, async (request: Request, response) => { +openai_router.post('/openai/chat/stream', validateAccessToken, ai_rate_limiter, async (request: Request, response) => { try { - const { model, messages } = AIAutoCompleteRequestBodySchema.parse(request.body); + const { model, messages } = OpenAIAutoCompleteRequestBodySchema.parse(request.body); response.setHeader('Content-Type', 'text/event-stream'); response.setHeader('Cache-Control', 'no-cache'); @@ -50,6 +51,7 @@ ai_chat_router.post('/chat/stream', validateAccessToken, ai_rate_limiter, async const completion = await openai.chat.completions.create({ model, messages, + temperature: 0, stream: true, }); @@ -57,7 +59,7 @@ ai_chat_router.post('/chat/stream', validateAccessToken, ai_rate_limiter, async response.write(`data: ${JSON.stringify(chunk)}\n\n`); } - response.write('data: [DONE]\n\n'); + response.write('[DONE]\n\n'); response.end(); } catch (error: any) { if (error.response) { @@ -70,4 +72,4 @@ ai_chat_router.post('/chat/stream', validateAccessToken, ai_rate_limiter, async } }); -export default ai_chat_router; \ No newline at end of file +export default openai_router; diff --git a/quadratic-client/src/app/ui/hooks/useAI.tsx b/quadratic-client/src/app/ui/hooks/useAI.tsx index 507d2639b0..990c7065f9 100644 --- a/quadratic-client/src/app/ui/hooks/useAI.tsx +++ b/quadratic-client/src/app/ui/hooks/useAI.tsx @@ -1,39 +1,129 @@ import { authClient } from '@/auth'; import { AI } from '@/shared/constants/routes'; -import { AIMessage, AIModel } from 'quadratic-shared/typesAndSchemasAI'; +import { + AIMessage, + AnthropicModel, + AnthropicModelSchema, + OpenAIMessage, + OpenAIModel, +} from 'quadratic-shared/typesAndSchemasAI'; import { useCallback } from 'react'; -type HandleAIPromptProps = { - model?: AIModel; - systemMessages: AIMessage[]; +type HandleOpenAIPromptProps = { + model: OpenAIModel; + messages: OpenAIMessage[]; + setMessages: (value: React.SetStateAction) => void; + signal: AbortSignal; +}; + +type HandleAnthropicAIPromptProps = { + model: AnthropicModel; messages: AIMessage[]; setMessages: (value: React.SetStateAction) => void; signal: AbortSignal; }; export function useAI() { + const parseOpenAIStream = useCallback( + async ( + reader: ReadableStreamDefaultReader, + responseMessage: AIMessage, + setMessages: (value: React.SetStateAction) => void + ): Promise<{ error?: boolean; content: string }> => { + const decoder = new TextDecoder(); + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.choices && data.choices[0] && data.choices[0].delta && data.choices[0].delta.content) { + responseMessage.content += data.choices[0].delta.content; + setMessages((prev) => [...prev.slice(0, -1), { ...responseMessage }]); + } else if (data.error) { + responseMessage.content += '\n\nAn error occurred while processing the response.'; + setMessages((prev) => [...prev.slice(0, -1), { ...responseMessage }]); + console.error('Error in AI prompt handling:', data.error); + return { error: true, content: 'An error occurred while processing the response.' }; + } + } catch (error) { + console.error('Error in AI prompt handling:', error); + // Not JSON or unexpected format, skip + } + } + } + } + return { content: responseMessage.content }; + }, + [] + ); + + const parseAnthropicStream = useCallback( + async ( + reader: ReadableStreamDefaultReader, + responseMessage: AIMessage, + setMessages: (value: React.SetStateAction) => void + ): Promise<{ error?: boolean; content: string }> => { + const decoder = new TextDecoder(); + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + if (data.type === 'content_block_delta') { + responseMessage.content += data.delta.text; + setMessages((prev) => [...prev.slice(0, -1), { ...responseMessage }]); + } else if (data.type === 'message_start') { + // message start + } else if (data.type === 'message_stop') { + // message stop + } else if (data.type === 'error') { + responseMessage.content += '\n\nAn error occurred while processing the response.'; + setMessages((prev) => [...prev.slice(0, -1), { ...responseMessage }]); + console.error('Error in AI prompt handling:', data.error); + return { error: true, content: 'An error occurred while processing the response.' }; + } + } catch (error) { + console.error('Error in AI prompt handling:', error); + // Not JSON or unexpected format, skip + } + } + } + } + return { content: responseMessage.content }; + }, + [] + ); + const handleAIStream = useCallback( async ({ model, - systemMessages, messages, setMessages, signal, - }: HandleAIPromptProps): Promise<{ error?: boolean; content: string }> => { + }: HandleOpenAIPromptProps | HandleAnthropicAIPromptProps): Promise<{ error?: boolean; content: string }> => { let responseMessage: AIMessage = { role: 'assistant', content: '' }; + const isAnthropic = AnthropicModelSchema.safeParse(model).success; try { const token = await authClient.getTokenOrRedirect(); - const response = await fetch(AI.STREAM, { + const endpoint = isAnthropic ? AI.ANTHROPIC.STREAM : AI.OPENAI.STREAM; + const response = await fetch(endpoint, { method: 'POST', signal, - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model, - messages: [...systemMessages, ...messages], - }), + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ model, messages }), }); if (!response.ok) { @@ -53,30 +143,11 @@ export function useAI() { const reader = response.body?.getReader(); if (!reader) throw new Error('Response body is not readable'); - const decoder = new TextDecoder(); - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)); - if (data.choices[0].delta.content) { - responseMessage.content += data.choices[0].delta.content; - setMessages((prev) => [...prev.slice(0, -1), { ...responseMessage }]); - } - } catch (err) { - // Not JSON or unexpected format, skip - } - } - } + if (isAnthropic) { + return parseAnthropicStream(reader, responseMessage, setMessages); + } else { + return parseOpenAIStream(reader, responseMessage, setMessages); } - return { content: responseMessage.content }; } catch (err: any) { if (err.name === 'AbortError') { return { error: false, content: 'Aborted by user' }; @@ -88,8 +159,8 @@ export function useAI() { } } }, - [] + [parseAnthropicStream, parseOpenAIStream] ); - return handleAIStream; + return { handleAIStream }; } diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 0f61bc974c..22d9f2b545 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -6,6 +6,7 @@ import ConditionalWrapper from '@/app/ui/components/ConditionalWrapper'; import { TooltipHint } from '@/app/ui/components/TooltipHint'; import { useAI } from '@/app/ui/hooks/useAI'; import { AI } from '@/app/ui/icons'; +import { CodeBlockParser } from '@/app/ui/menus/CodeEditor/AICodeBlockParser'; import { useCodeEditor } from '@/app/ui/menus/CodeEditor/CodeEditorContext'; import { QuadraticDocs } from '@/app/ui/menus/CodeEditor/QuadraticDocs'; import { useRootRouteLoaderData } from '@/routes/_root'; @@ -15,27 +16,21 @@ import { Textarea } from '@/shared/shadcn/ui/textarea'; import { Send, Stop } from '@mui/icons-material'; import { CircularProgress, IconButton } from '@mui/material'; import mixpanel from 'mixpanel-browser'; -import { AIMessage } from 'quadratic-shared/typesAndSchemasAI'; +import { AIMessage, AnthropicModelSchema, OpenAIMessage } from 'quadratic-shared/typesAndSchemasAI'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useRecoilValue } from 'recoil'; - -import { CodeBlockParser } from '@/app/ui/menus/CodeEditor/AICodeBlockParser'; import './AiAssistant.css'; -export type AiMessage = { - role: 'user' | 'system' | 'assistant'; - content: string; -}; - export const AiAssistant = ({ autoFocus }: { autoFocus?: boolean }) => { const textareaRef = useRef(null); const aiResponseRef = useRef(null); const { aiAssistant: { - prompt: [prompt, setPrompt], + controllerRef, loading: [loading, setLoading], messages: [messages, setMessages], - controllerRef, + prompt: [prompt, setPrompt], + model: [model, setModel], }, consoleOutput: [consoleOutput], editorContent: [editorContent], @@ -48,11 +43,8 @@ export const AiAssistant = ({ autoFocus }: { autoFocus?: boolean }) => { const schemaJsonForAi = schemaData ? JSON.stringify(schemaData) : ''; // TODO: This is only sent with the first message, we should refresh the content with each message. - const systemMessages = useMemo( - () => [ - { - role: 'system', - content: ` + const quadraticContext = useMemo( + () => ` You are a helpful assistant inside of a spreadsheet application called Quadratic. Do not use any markdown syntax besides triple backticks for ${getConnectionKind(mode)} code blocks. Do not reply with plain text code blocks. @@ -67,8 +59,6 @@ If the code was recently run here is the result: ${JSON.stringify(consoleOutput)}\`\`\` This is the documentation for Quadratic: ${QuadraticDocs}`, - }, - ], [consoleOutput, editorContent, mode, schemaJsonForAi, selectedCell.x, selectedCell.y] ); @@ -94,7 +84,7 @@ ${QuadraticDocs}`, setLoading(false); }; - const handleAIStream = useAI(); + const { handleAIStream } = useAI(); const submitPrompt = useCallback(async () => { if (loading) return; setLoading(true); @@ -104,16 +94,49 @@ ${QuadraticDocs}`, setMessages(updatedMessages); setPrompt(''); - await handleAIStream({ - model: 'gpt-4o', - systemMessages, - messages: updatedMessages, - setMessages, - signal: controllerRef.current.signal, - }); + if (AnthropicModelSchema.safeParse(model).success) { + const aiMessage: AIMessage[] = [ + { + role: 'user', + content: quadraticContext + '\n\n' + updatedMessages[0].content, + }, + ...updatedMessages.slice(1), + ]; + await handleAIStream({ + model: 'claude-3-5-sonnet-20240620', + messages: aiMessage, + setMessages, + signal: controllerRef.current.signal, + }); + } else { + const aiMessage: OpenAIMessage[] = [ + { + role: 'system', + content: quadraticContext, + }, + ...updatedMessages, + ]; + handleAIStream({ + model: 'gpt-4o', + messages: aiMessage, + setMessages, + signal: controllerRef.current.signal, + }); + } setLoading(false); - }, [controllerRef, handleAIStream, loading, messages, prompt, setLoading, setMessages, setPrompt, systemMessages]); + }, [ + controllerRef, + handleAIStream, + loading, + messages, + model, + prompt, + quadraticContext, + setLoading, + setMessages, + setPrompt, + ]); // Designed to live in a box that takes up the full height of its container return ( @@ -176,6 +199,20 @@ ${QuadraticDocs}`,
+ +
+ +
+
{ diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx index 6cec9a0404..49de436924 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx @@ -3,7 +3,7 @@ import { CodeEditor } from '@/app/ui/menus/CodeEditor/CodeEditor'; import { EvaluationResult } from '@/app/web-workers/pythonWebWorker/pythonTypes'; import { Monaco } from '@monaco-editor/react'; import monaco from 'monaco-editor'; -import { AIMessage } from 'quadratic-shared/typesAndSchemasAI'; +import { AIMessage, AnthropicModel, OpenAIModel } from 'quadratic-shared/typesAndSchemasAI'; import React, { createContext, useContext, useRef, useState } from 'react'; import { PanelTab } from './panels//CodeEditorPanelBottom'; @@ -13,6 +13,7 @@ type Context = { loading: [boolean, React.Dispatch>]; messages: [AIMessage[], React.Dispatch>]; prompt: [string, React.Dispatch>]; + model: [AnthropicModel | OpenAIModel, React.Dispatch>]; }; // `undefined` is used here as a loading state. Once the editor mounts, it becomes a string (possibly empty) codeString: [string | undefined, React.Dispatch>]; @@ -36,6 +37,7 @@ const CodeEditorContext = createContext({ loading: [false, () => {}], messages: [[], () => {}], prompt: ['', () => {}], + model: ['claude-3-5-sonnet-20240620', () => {}], }, codeString: [undefined, () => {}], consoleOutput: [undefined, () => {}], @@ -55,6 +57,7 @@ export const CodeEditorProvider = () => { loading: useState(false), messages: useState([]), controllerRef: useRef(null), + model: useState('claude-3-5-sonnet-20240620'), }; const codeString = useState(undefined); // update code cell const consoleOutput = useState(undefined); diff --git a/quadratic-client/src/shared/constants/routes.ts b/quadratic-client/src/shared/constants/routes.ts index acdf14d1f8..9e1567ff2c 100644 --- a/quadratic-client/src/shared/constants/routes.ts +++ b/quadratic-client/src/shared/constants/routes.ts @@ -62,6 +62,12 @@ export const SEARCH_PARAMS = { }; export const AI = { - CHAT: `${apiClient.getApiUrl()}/ai/chat`, - STREAM: `${apiClient.getApiUrl()}/ai/chat/stream`, + OPENAI: { + CHAT: `${apiClient.getApiUrl()}/ai/openai/chat`, + STREAM: `${apiClient.getApiUrl()}/ai/openai/chat/stream`, + }, + ANTHROPIC: { + CHAT: `${apiClient.getApiUrl()}/ai/anthropic/chat`, + STREAM: `${apiClient.getApiUrl()}/ai/anthropic/chat/stream`, + }, }; diff --git a/quadratic-shared/typesAndSchemasAI.ts b/quadratic-shared/typesAndSchemasAI.ts index 42ef507545..a91e84577d 100644 --- a/quadratic-shared/typesAndSchemasAI.ts +++ b/quadratic-shared/typesAndSchemasAI.ts @@ -1,17 +1,31 @@ import { z } from 'zod'; -export const AIMessageSchema = z.object({ +export const OpenAIMessageSchema = z.object({ role: z.enum(['system', 'user', 'assistant']), content: z.string(), - stream: z.boolean().optional(), +}); +export type OpenAIMessage = z.infer; + +export const OpenAIModelSchema = z.enum(['gpt-4o', 'gpt-4o-2024-08-06']).default('gpt-4o'); +export type OpenAIModel = z.infer; + +export const OpenAIAutoCompleteRequestBodySchema = z.object({ + messages: z.array(OpenAIMessageSchema), + model: OpenAIModelSchema, +}); +export type OpenAIAutoCompleteRequestBody = z.infer; + +export const AIMessageSchema = z.object({ + role: z.enum(['user', 'assistant']), + content: z.string(), }); export type AIMessage = z.infer; -export const AIModelSchema = z.enum(['gpt-4o', 'gpt-4o-2024-08-06']).default('gpt-4o'); -export type AIModel = z.infer; +export const AnthropicModelSchema = z.enum(['claude-3-5-sonnet-20240620']).default('claude-3-5-sonnet-20240620'); +export type AnthropicModel = z.infer; -export const AIAutoCompleteRequestBodySchema = z.object({ +export const AnthropicAutoCompleteRequestBodySchema = z.object({ messages: z.array(AIMessageSchema), - model: AIModelSchema, + model: AnthropicModelSchema, }); -export type AIAutoCompleteRequestBody = z.infer; +export type AnthropicAutoCompleteRequestBody = z.infer; From 485cb4e82fee813ffeb315881213e1cd1c06a3a5 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Sat, 7 Sep 2024 21:54:56 +0530 Subject: [PATCH 03/22] improve cell context in prompt --- .../app/ui/menus/CodeEditor/AiAssistant.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 22d9f2b545..64f0234045 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -40,7 +40,7 @@ export const AiAssistant = ({ autoFocus }: { autoFocus?: boolean }) => { const connection = getConnectionInfo(mode); const { data: schemaData } = useConnectionSchemaBrowser({ uuid: connection?.id, type: connection?.kind }); - const schemaJsonForAi = schemaData ? JSON.stringify(schemaData) : ''; + const schemaJsonForAi = useMemo(() => (schemaData ? JSON.stringify(schemaData) : ''), [schemaData]); // TODO: This is only sent with the first message, we should refresh the content with each message. const quadraticContext = useMemo( @@ -59,7 +59,30 @@ If the code was recently run here is the result: ${JSON.stringify(consoleOutput)}\`\`\` This is the documentation for Quadratic: ${QuadraticDocs}`, - [consoleOutput, editorContent, mode, schemaJsonForAi, selectedCell.x, selectedCell.y] + [consoleOutput, editorContent, schemaJsonForAi, selectedCell.x, selectedCell.y, mode] + ); + + const cellContext = useMemo( + () => ({ + role: 'assistant', + content: ` +Hi, I am your AI assistant.\n +I understand the Quadratic spreadsheet application. I will strictly adhere to the Quadratic documentation\n +I understand the cell type is ${getConnectionKind(mode)}.\n +I understand the cell is located at ${selectedCell.x}, ${selectedCell.y}.\n +I understand the code in the cell is: +\`\`\`${getConnectionKind(mode)} +${editorContent} +\`\`\` +I understand the console output is: +\`\`\` +${JSON.stringify(consoleOutput)} +\`\`\` +I will strictly adhere to the cell context. +How can I help you? +`, + }), + [consoleOutput, editorContent, mode, selectedCell.x, selectedCell.y] ); // Focus the input when relevant & the tab comes into focus @@ -98,9 +121,10 @@ ${QuadraticDocs}`, const aiMessage: AIMessage[] = [ { role: 'user', - content: quadraticContext + '\n\n' + updatedMessages[0].content, + content: quadraticContext, }, - ...updatedMessages.slice(1), + cellContext, + ...updatedMessages, ]; await handleAIStream({ model: 'claude-3-5-sonnet-20240620', @@ -114,6 +138,7 @@ ${QuadraticDocs}`, role: 'system', content: quadraticContext, }, + cellContext, ...updatedMessages, ]; handleAIStream({ @@ -126,6 +151,7 @@ ${QuadraticDocs}`, setLoading(false); }, [ + cellContext, controllerRef, handleAIStream, loading, @@ -208,7 +234,7 @@ ${QuadraticDocs}`, setModel(event.target.value as 'claude-3-5-sonnet-20240620' | 'gpt-4o'); }} > - +
From 73790fa082dd86139e99d5a97200d445f79fd67e Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Sat, 7 Sep 2024 22:02:52 +0530 Subject: [PATCH 04/22] fix bottom avatar margin --- quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 64f0234045..f3154bbb49 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -200,6 +200,7 @@ How can I help you? alt={user?.name} style={{ backgroundColor: colors.quadraticSecondary, + marginBottom: '0.5rem', }} > {user?.name} From ad09412689e0b5a1d8e41bc5f2dff68dd4c0a6a5 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Sat, 7 Sep 2024 22:05:16 +0530 Subject: [PATCH 05/22] reassert instructions --- quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index f3154bbb49..de9f39178a 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -74,11 +74,14 @@ I understand the code in the cell is: \`\`\`${getConnectionKind(mode)} ${editorContent} \`\`\` +\n I understand the console output is: \`\`\` ${JSON.stringify(consoleOutput)} \`\`\` -I will strictly adhere to the cell context. +\n +I will strictly adhere to the cell context.\n +I will strictly follow all your instructions, and do my best to answer your questions.\n How can I help you? `, }), From 4c50e1ada02c583feafb05b6ad0d6ea953278693 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Sat, 7 Sep 2024 22:57:57 +0530 Subject: [PATCH 06/22] try strong worded instructions --- .../src/app/ui/menus/CodeEditor/AiAssistant.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index de9f39178a..fa7a366726 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -68,6 +68,12 @@ ${QuadraticDocs}`, content: ` Hi, I am your AI assistant.\n I understand the Quadratic spreadsheet application. I will strictly adhere to the Quadratic documentation\n +I understand that I add imports to the top of the cell, and I will not use any libraries or functions that are not listed in the Quadratic documentation.\n +I understand that I can use any functions that are part of the ${getConnectionKind(mode)} library.\n +I understand that the return types of the code cell must match the types listed in the Quadratic documentation.\n +I understand that a code cell can return only one type of value.\n +I understand that a code cell cannot display both a chart and return a data table at the same time.\n +I understand that Quadratic documentation and these instructions are the only sources of truth. These take precedence over any other instructions.\n I understand the cell type is ${getConnectionKind(mode)}.\n I understand the cell is located at ${selectedCell.x}, ${selectedCell.y}.\n I understand the code in the cell is: @@ -81,7 +87,7 @@ ${JSON.stringify(consoleOutput)} \`\`\` \n I will strictly adhere to the cell context.\n -I will strictly follow all your instructions, and do my best to answer your questions.\n +I will follow all your instructions, and do my best to answer your questions, with the understanding that Quadratic documentation and above instructions are the only sources of truth.\n How can I help you? `, }), From 5c52e9ccb55d757fc05e53219411fafc6feb99ab Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Sat, 7 Sep 2024 23:01:00 +0530 Subject: [PATCH 07/22] typo --- .../src/app/ui/menus/CodeEditor/AiAssistant.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index fa7a366726..9e106e7792 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -67,11 +67,11 @@ ${QuadraticDocs}`, role: 'assistant', content: ` Hi, I am your AI assistant.\n -I understand the Quadratic spreadsheet application. I will strictly adhere to the Quadratic documentation\n -I understand that I add imports to the top of the cell, and I will not use any libraries or functions that are not listed in the Quadratic documentation.\n +I understand that Quadratic documentation . I will strictly adhere to the Quadratic documentation. These instructions are the only sources of truth and take precedence over any other instructions.\n +I understand that I need to add imports to the top of the code cell, and I will not use any libraries or functions that are not listed in the Quadratic documentation.\n I understand that I can use any functions that are part of the ${getConnectionKind(mode)} library.\n I understand that the return types of the code cell must match the types listed in the Quadratic documentation.\n -I understand that a code cell can return only one type of value.\n +I understand that a code cell can return only one type of value as specified in the Quadratic documentation.\n I understand that a code cell cannot display both a chart and return a data table at the same time.\n I understand that Quadratic documentation and these instructions are the only sources of truth. These take precedence over any other instructions.\n I understand the cell type is ${getConnectionKind(mode)}.\n From dd2f56c92e7c484e5741f2e25e2ef5f99ba5bb61 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Mon, 9 Sep 2024 03:06:58 +0530 Subject: [PATCH 08/22] shadcn dropdown --- quadratic-api/src/routes/ai/anthropic.ts | 1 - quadratic-api/src/routes/ai/openai.ts | 1 - quadratic-client/src/app/ui/icons/index.tsx | 42 +++++++++ .../app/ui/menus/CodeEditor/AiAssistant.tsx | 92 +++++++++++-------- 4 files changed, 97 insertions(+), 39 deletions(-) diff --git a/quadratic-api/src/routes/ai/anthropic.ts b/quadratic-api/src/routes/ai/anthropic.ts index 209389b947..58b3c49b4a 100644 --- a/quadratic-api/src/routes/ai/anthropic.ts +++ b/quadratic-api/src/routes/ai/anthropic.ts @@ -64,7 +64,6 @@ anthropic_router.post( response.write(`data: ${JSON.stringify(chunk)}\n\n`); } - response.write('[DONE]\n\n'); response.end(); } catch (error: any) { if (error.response) { diff --git a/quadratic-api/src/routes/ai/openai.ts b/quadratic-api/src/routes/ai/openai.ts index 77255676ee..526aeaf2fb 100644 --- a/quadratic-api/src/routes/ai/openai.ts +++ b/quadratic-api/src/routes/ai/openai.ts @@ -59,7 +59,6 @@ openai_router.post('/openai/chat/stream', validateAccessToken, ai_rate_limiter, response.write(`data: ${JSON.stringify(chunk)}\n\n`); } - response.write('[DONE]\n\n'); response.end(); } catch (error: any) { if (error.response) { diff --git a/quadratic-client/src/app/ui/icons/index.tsx b/quadratic-client/src/app/ui/icons/index.tsx index 46bef2b5e4..c63fbdc236 100644 --- a/quadratic-client/src/app/ui/icons/index.tsx +++ b/quadratic-client/src/app/ui/icons/index.tsx @@ -187,6 +187,48 @@ export const AI = (props: SvgIconProps) => ( ); +export const Anthropic = (props: SvgIconProps) => ( + + + + + {' '} + +); + +export const OpenAI = (props: SvgIconProps) => ( + + + + + + + + + + {' '} + +); + export const JavaScript = (props: SvgIconProps) => ( diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 9e106e7792..567e340c44 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -5,13 +5,19 @@ import { colors } from '@/app/theme/colors'; import ConditionalWrapper from '@/app/ui/components/ConditionalWrapper'; import { TooltipHint } from '@/app/ui/components/TooltipHint'; import { useAI } from '@/app/ui/hooks/useAI'; -import { AI } from '@/app/ui/icons'; +import { AI, Anthropic, OpenAI } from '@/app/ui/icons'; import { CodeBlockParser } from '@/app/ui/menus/CodeEditor/AICodeBlockParser'; import { useCodeEditor } from '@/app/ui/menus/CodeEditor/CodeEditorContext'; import { QuadraticDocs } from '@/app/ui/menus/CodeEditor/QuadraticDocs'; import { useRootRouteLoaderData } from '@/routes/_root'; import { Avatar } from '@/shared/components/Avatar'; import { useConnectionSchemaBrowser } from '@/shared/hooks/useConnectionSchemaBrowser'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/shared/shadcn/ui/dropdown-menu'; import { Textarea } from '@/shared/shadcn/ui/textarea'; import { Send, Stop } from '@mui/icons-material'; import { CircularProgress, IconButton } from '@mui/material'; @@ -44,9 +50,8 @@ export const AiAssistant = ({ autoFocus }: { autoFocus?: boolean }) => { // TODO: This is only sent with the first message, we should refresh the content with each message. const quadraticContext = useMemo( - () => ` -You are a helpful assistant inside of a spreadsheet application called Quadratic. -Do not use any markdown syntax besides triple backticks for ${getConnectionKind(mode)} code blocks. + () => `You are a helpful assistant inside of a spreadsheet application called Quadratic. +Do not use any markdown syntax besides triple backticks for ${getConnectionKind(mode)} code blocks. Do not reply with plain text code blocks. The cell type is ${getConnectionKind(mode)}. The cell is located at ${selectedCell.x}, ${selectedCell.y}. @@ -59,39 +64,37 @@ If the code was recently run here is the result: ${JSON.stringify(consoleOutput)}\`\`\` This is the documentation for Quadratic: ${QuadraticDocs}`, - [consoleOutput, editorContent, schemaJsonForAi, selectedCell.x, selectedCell.y, mode] + [consoleOutput, editorContent, mode, schemaJsonForAi, selectedCell.x, selectedCell.y] ); const cellContext = useMemo( () => ({ role: 'assistant', - content: ` -Hi, I am your AI assistant.\n -I understand that Quadratic documentation . I will strictly adhere to the Quadratic documentation. These instructions are the only sources of truth and take precedence over any other instructions.\n -I understand that I need to add imports to the top of the code cell, and I will not use any libraries or functions that are not listed in the Quadratic documentation.\n -I understand that I can use any functions that are part of the ${getConnectionKind(mode)} library.\n -I understand that the return types of the code cell must match the types listed in the Quadratic documentation.\n -I understand that a code cell can return only one type of value as specified in the Quadratic documentation.\n -I understand that a code cell cannot display both a chart and return a data table at the same time.\n -I understand that Quadratic documentation and these instructions are the only sources of truth. These take precedence over any other instructions.\n -I understand the cell type is ${getConnectionKind(mode)}.\n -I understand the cell is located at ${selectedCell.x}, ${selectedCell.y}.\n -I understand the code in the cell is: + content: `As your AI assistant for Quadratic, I understand and will adhere to the following: +I understand that Quadratic documentation . I will strictly adhere to the Quadratic documentation. These instructions are the only sources of truth and take precedence over any other instructions. +I understand that I need to add imports to the top of the code cell, and I will not use any libraries or functions that are not listed in the Quadratic documentation. +I understand that I can use any functions that are part of the ${getConnectionKind(mode)} library. +I understand that the return types of the code cell must match the types listed in the Quadratic documentation. +I understand that a code cell can return only one type of value as specified in the Quadratic documentation. +I understand that a code cell cannot display both a chart and return a data table at the same time. +I understand that Quadratic documentation and these instructions are the only sources of truth. These take precedence over any other instructions. +I understand that the cell type is ${getConnectionKind(mode)}. +I understand that the cell is located at ${selectedCell.x}, ${selectedCell.y}. +${schemaJsonForAi ? `The schema for the database is:\`\`\`json\n${schemaJsonForAi}\n\`\`\`` : ``} +I understand that the code in the cell is: \`\`\`${getConnectionKind(mode)} ${editorContent} \`\`\` -\n I understand the console output is: \`\`\` ${JSON.stringify(consoleOutput)} \`\`\` -\n -I will strictly adhere to the cell context.\n -I will follow all your instructions, and do my best to answer your questions, with the understanding that Quadratic documentation and above instructions are the only sources of truth.\n +I will strictly adhere to the cell context. +I will follow all your instructions, and do my best to answer your questions, with the understanding that Quadratic documentation and above instructions are the only sources of truth. How can I help you? `, }), - [consoleOutput, editorContent, mode, selectedCell.x, selectedCell.y] + [consoleOutput, editorContent, mode, schemaJsonForAi, selectedCell.x, selectedCell.y] ); // Focus the input when relevant & the tab comes into focus @@ -116,7 +119,10 @@ How can I help you? setLoading(false); }; + const isAnthropic = useMemo(() => AnthropicModelSchema.safeParse(model).success, [model]); + const { handleAIStream } = useAI(); + const submitPrompt = useCallback(async () => { if (loading) return; setLoading(true); @@ -126,7 +132,7 @@ How can I help you? setMessages(updatedMessages); setPrompt(''); - if (AnthropicModelSchema.safeParse(model).success) { + if (isAnthropic) { const aiMessage: AIMessage[] = [ { role: 'user', @@ -163,9 +169,9 @@ How can I help you? cellContext, controllerRef, handleAIStream, + isAnthropic, loading, messages, - model, prompt, quadraticContext, setLoading, @@ -236,19 +242,6 @@ How can I help you? -
- -
- { @@ -286,7 +279,32 @@ How can I help you? />
+
+ + + {isAnthropic ? : } + + + + setModel('claude-3-5-sonnet-20240620')} + className={`${model === 'claude-3-5-sonnet-20240620' ? 'bg-gray-100' : ''}`} + > + claude-3.5-sonnet + + + setModel('gpt-4o')} + className={`${model === 'gpt-4o' ? 'bg-gray-100' : ''}`} + > + gpt-4o + + + +
+ {loading && } + {loading ? ( From 5883d166b51ee2506b1cd95384e65db598cd6025 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Mon, 9 Sep 2024 03:13:37 +0530 Subject: [PATCH 09/22] fix kebab case bug in svg --- quadratic-client/src/app/ui/icons/index.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/quadratic-client/src/app/ui/icons/index.tsx b/quadratic-client/src/app/ui/icons/index.tsx index c63fbdc236..628f581d7e 100644 --- a/quadratic-client/src/app/ui/icons/index.tsx +++ b/quadratic-client/src/app/ui/icons/index.tsx @@ -191,17 +191,17 @@ export const Anthropic = (props: SvgIconProps) => ( {' '} From 2764070c879451b8c757e7be036edbc6a6a95d65 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Tue, 10 Sep 2024 14:18:08 +0530 Subject: [PATCH 10/22] update ui --- quadratic-client/src/app/ui/hooks/useAI.tsx | 26 ++- quadratic-client/src/app/ui/icons/index.tsx | 51 ++--- .../app/ui/menus/CodeEditor/AiAssistant.tsx | 208 ++++++++++++------ .../app/ui/menus/CodeEditor/CodeEditor.tsx | 2 +- .../ui/menus/CodeEditor/CodeEditorContext.tsx | 6 +- quadratic-shared/typesAndSchemasAI.ts | 43 ++-- 6 files changed, 204 insertions(+), 132 deletions(-) diff --git a/quadratic-client/src/app/ui/hooks/useAI.tsx b/quadratic-client/src/app/ui/hooks/useAI.tsx index 990c7065f9..a2a0ae926b 100644 --- a/quadratic-client/src/app/ui/hooks/useAI.tsx +++ b/quadratic-client/src/app/ui/hooks/useAI.tsx @@ -2,33 +2,39 @@ import { authClient } from '@/auth'; import { AI } from '@/shared/constants/routes'; import { AIMessage, + AnthropicMessage, AnthropicModel, AnthropicModelSchema, OpenAIMessage, OpenAIModel, + UserMessage, } from 'quadratic-shared/typesAndSchemasAI'; import { useCallback } from 'react'; type HandleOpenAIPromptProps = { model: OpenAIModel; messages: OpenAIMessage[]; - setMessages: (value: React.SetStateAction) => void; + setMessages: (value: React.SetStateAction<(UserMessage | AIMessage)[]>) => void; signal: AbortSignal; }; type HandleAnthropicAIPromptProps = { model: AnthropicModel; - messages: AIMessage[]; - setMessages: (value: React.SetStateAction) => void; + messages: AnthropicMessage[]; + setMessages: (value: React.SetStateAction<(UserMessage | AIMessage)[]>) => void; signal: AbortSignal; }; export function useAI() { + const isAnthropicModel = useCallback((model: AnthropicModel | OpenAIModel): model is AnthropicModel => { + return AnthropicModelSchema.safeParse(model).success; + }, []); + const parseOpenAIStream = useCallback( async ( reader: ReadableStreamDefaultReader, responseMessage: AIMessage, - setMessages: (value: React.SetStateAction) => void + setMessages: (value: React.SetStateAction<(UserMessage | AIMessage)[]>) => void ): Promise<{ error?: boolean; content: string }> => { const decoder = new TextDecoder(); @@ -68,7 +74,7 @@ export function useAI() { async ( reader: ReadableStreamDefaultReader, responseMessage: AIMessage, - setMessages: (value: React.SetStateAction) => void + setMessages: (value: React.SetStateAction<(UserMessage | AIMessage)[]>) => void ): Promise<{ error?: boolean; content: string }> => { const decoder = new TextDecoder(); while (true) { @@ -114,8 +120,8 @@ export function useAI() { setMessages, signal, }: HandleOpenAIPromptProps | HandleAnthropicAIPromptProps): Promise<{ error?: boolean; content: string }> => { - let responseMessage: AIMessage = { role: 'assistant', content: '' }; - const isAnthropic = AnthropicModelSchema.safeParse(model).success; + let responseMessage: AIMessage = { role: 'assistant', content: '', model }; + const isAnthropic = isAnthropicModel(model); try { const token = await authClient.getTokenOrRedirect(); const endpoint = isAnthropic ? AI.ANTHROPIC.STREAM : AI.OPENAI.STREAM; @@ -131,7 +137,7 @@ export function useAI() { response.status === 429 ? 'You have exceeded the maximum number of requests. Please try again later.' : `Looks like there was a problem. Status Code: ${response.status}`; - setMessages((prev) => [...prev, { role: 'assistant', content: error }]); + setMessages((prev) => [...prev, { role: 'assistant', content: error, model }]); if (response.status !== 429) { console.error(`Error retrieving data from AI API: ${response.status}`); } @@ -159,8 +165,8 @@ export function useAI() { } } }, - [parseAnthropicStream, parseOpenAIStream] + [isAnthropicModel, parseAnthropicStream, parseOpenAIStream] ); - return { handleAIStream }; + return { handleAIStream, isAnthropicModel }; } diff --git a/quadratic-client/src/app/ui/icons/index.tsx b/quadratic-client/src/app/ui/icons/index.tsx index 628f581d7e..9c38b45b85 100644 --- a/quadratic-client/src/app/ui/icons/index.tsx +++ b/quadratic-client/src/app/ui/icons/index.tsx @@ -188,44 +188,31 @@ export const AI = (props: SvgIconProps) => ( ); export const Anthropic = (props: SvgIconProps) => ( - - - - - {' '} + + + + + + + + + + + ); export const OpenAI = (props: SvgIconProps) => ( - - + + - - - - - - - {' '} + ); diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 567e340c44..80d3e1c98f 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -5,7 +5,7 @@ import { colors } from '@/app/theme/colors'; import ConditionalWrapper from '@/app/ui/components/ConditionalWrapper'; import { TooltipHint } from '@/app/ui/components/TooltipHint'; import { useAI } from '@/app/ui/hooks/useAI'; -import { AI, Anthropic, OpenAI } from '@/app/ui/icons'; +import { Anthropic, OpenAI } from '@/app/ui/icons'; import { CodeBlockParser } from '@/app/ui/menus/CodeEditor/AICodeBlockParser'; import { useCodeEditor } from '@/app/ui/menus/CodeEditor/CodeEditorContext'; import { QuadraticDocs } from '@/app/ui/menus/CodeEditor/QuadraticDocs'; @@ -14,15 +14,23 @@ import { Avatar } from '@/shared/components/Avatar'; import { useConnectionSchemaBrowser } from '@/shared/hooks/useConnectionSchemaBrowser'; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, } from '@/shared/shadcn/ui/dropdown-menu'; import { Textarea } from '@/shared/shadcn/ui/textarea'; -import { Send, Stop } from '@mui/icons-material'; +import { ArrowUpward, Stop } from '@mui/icons-material'; import { CircularProgress, IconButton } from '@mui/material'; +import { CaretDownIcon } from '@radix-ui/react-icons'; import mixpanel from 'mixpanel-browser'; -import { AIMessage, AnthropicModelSchema, OpenAIMessage } from 'quadratic-shared/typesAndSchemasAI'; +import { + AIMessage, + AnthropicMessage, + AnthropicModel, + OpenAIMessage, + OpenAIModel, + UserMessage, +} from 'quadratic-shared/typesAndSchemasAI'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useRecoilValue } from 'recoil'; import './AiAssistant.css'; @@ -93,8 +101,9 @@ I will strictly adhere to the cell context. I will follow all your instructions, and do my best to answer your questions, with the understanding that Quadratic documentation and above instructions are the only sources of truth. How can I help you? `, + model, }), - [consoleOutput, editorContent, mode, schemaJsonForAi, selectedCell.x, selectedCell.y] + [consoleOutput, editorContent, mode, schemaJsonForAi, selectedCell.x, selectedCell.y, model] ); // Focus the input when relevant & the tab comes into focus @@ -119,46 +128,55 @@ How can I help you? setLoading(false); }; - const isAnthropic = useMemo(() => AnthropicModelSchema.safeParse(model).success, [model]); - - const { handleAIStream } = useAI(); + const { handleAIStream, isAnthropicModel } = useAI(); const submitPrompt = useCallback(async () => { if (loading) return; setLoading(true); controllerRef.current = new AbortController(); - - const updatedMessages: AIMessage[] = [...messages, { role: 'user', content: prompt }]; + const updatedMessages: (UserMessage | AIMessage)[] = [...messages, { role: 'user', content: prompt }]; setMessages(updatedMessages); setPrompt(''); + const messagesToSend = [ + { + role: cellContext.role, + content: cellContext.content, + }, + ...updatedMessages.map((message) => ({ + role: message.role, + content: message.content, + })), + ]; + + const isAnthropic = isAnthropicModel(model); if (isAnthropic) { - const aiMessage: AIMessage[] = [ + const aiMessages: AnthropicMessage[] = [ { role: 'user', content: quadraticContext, }, - cellContext, - ...updatedMessages, + ...messagesToSend, ]; + await handleAIStream({ - model: 'claude-3-5-sonnet-20240620', - messages: aiMessage, + model, + messages: aiMessages, setMessages, signal: controllerRef.current.signal, }); } else { - const aiMessage: OpenAIMessage[] = [ + const aiMessages: OpenAIMessage[] = [ { role: 'system', content: quadraticContext, }, - cellContext, - ...updatedMessages, + ...messagesToSend, ]; - handleAIStream({ - model: 'gpt-4o', - messages: aiMessage, + + await handleAIStream({ + model, + messages: aiMessages, setMessages, signal: controllerRef.current.signal, }); @@ -166,12 +184,14 @@ How can I help you? setLoading(false); }, [ - cellContext, + cellContext.content, + cellContext.role, controllerRef, handleAIStream, - isAnthropic, + isAnthropicModel, loading, messages, + model, prompt, quadraticContext, setLoading, @@ -231,7 +251,7 @@ How can I help you? marginBottom: '0.5rem', }} > - + {isAnthropicModel(message.model) ? : } @@ -243,7 +263,7 @@ How can I help you?
{ e.preventDefault(); }} @@ -252,6 +272,13 @@ How can I help you? ref={textareaRef} id="prompt-input" value={prompt} + style={{ + border: 'none', + boxShadow: 'none', + borderTop: `1px solid #E1E7EF`, + paddingLeft: '4px', + paddingRight: '4px', + }} onChange={(event) => { setPrompt(event.target.value); }} @@ -278,61 +305,96 @@ How can I help you? maxHeight="120px" /> -
-
- - - {isAnthropic ? : } - - - - setModel('claude-3-5-sonnet-20240620')} - className={`${model === 'claude-3-5-sonnet-20240620' ? 'bg-gray-100' : ''}`} - > - claude-3.5-sonnet - - - setModel('gpt-4o')} - className={`${model === 'gpt-4o' ? 'bg-gray-100' : ''}`} - > - gpt-4o - - - -
- - {loading && } +
+ {loading ? ( - - - - - +
+ + + + + + +
) : ( - ( - - {children as React.ReactElement} - - )} - > - + {KeyboardSymbols.Command}↵ new line + ↵ submit + ( + + {children as React.ReactElement} + + )} > - - - + + + + +
)}
); }; + +function SelectAIModelDropdownMenu({ + loading, + isAnthropic, + setModel, +}: { + loading: boolean; + isAnthropic: boolean; + setModel: React.Dispatch>; +}) { + return ( + + {children as React.ReactElement}} + > + +
+ {isAnthropic ? ( + <> + + Anthropic: claude-3.5-sonnet + + ) : ( + <> + + OpenAI: gpt-4o + + )} + +
+
+
+ + + setModel('claude-3-5-sonnet-20240620')}> +
+ Anthropic: claude-3.5-sonnet + +
+
+ + setModel('gpt-4o')}> +
+ OpenAI: gpt-4o + +
+
+
+
+ ); +} diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditor.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditor.tsx index ae76b60d62..2771f60606 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditor.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditor.tsx @@ -69,7 +69,7 @@ export const CodeEditor = () => { // Trigger vanilla changes to code editor useEffect(() => { events.emit('codeEditor'); - setPanelBottomActiveTab(mode === 'Connection' ? 'data-browser' : 'console'); + setPanelBottomActiveTab(mode === 'Connection' ? 'data-browser' : 'ai-assistant'); setAiMessages([]); }, [ showCodeEditor, diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx index 49de436924..5f03316d31 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/CodeEditorContext.tsx @@ -3,7 +3,7 @@ import { CodeEditor } from '@/app/ui/menus/CodeEditor/CodeEditor'; import { EvaluationResult } from '@/app/web-workers/pythonWebWorker/pythonTypes'; import { Monaco } from '@monaco-editor/react'; import monaco from 'monaco-editor'; -import { AIMessage, AnthropicModel, OpenAIModel } from 'quadratic-shared/typesAndSchemasAI'; +import { AIMessage, AnthropicModel, OpenAIModel, UserMessage } from 'quadratic-shared/typesAndSchemasAI'; import React, { createContext, useContext, useRef, useState } from 'react'; import { PanelTab } from './panels//CodeEditorPanelBottom'; @@ -11,7 +11,7 @@ type Context = { aiAssistant: { controllerRef: React.MutableRefObject; loading: [boolean, React.Dispatch>]; - messages: [AIMessage[], React.Dispatch>]; + messages: [(UserMessage | AIMessage)[], React.Dispatch>]; prompt: [string, React.Dispatch>]; model: [AnthropicModel | OpenAIModel, React.Dispatch>]; }; @@ -66,7 +66,7 @@ export const CodeEditorProvider = () => { const editorRef = useRef(null); const evaluationResult = useState(undefined); const monacoRef = useRef(null); - const panelBottomActiveTab = useState('console'); + const panelBottomActiveTab = useState('ai-assistant'); const showSnippetsPopover = useState(false); const spillError = useState(undefined); diff --git a/quadratic-shared/typesAndSchemasAI.ts b/quadratic-shared/typesAndSchemasAI.ts index a91e84577d..cee7547384 100644 --- a/quadratic-shared/typesAndSchemasAI.ts +++ b/quadratic-shared/typesAndSchemasAI.ts @@ -1,31 +1,48 @@ import { z } from 'zod'; -export const OpenAIMessageSchema = z.object({ - role: z.enum(['system', 'user', 'assistant']), - content: z.string(), -}); -export type OpenAIMessage = z.infer; +export const AnthropicModelSchema = z.enum(['claude-3-5-sonnet-20240620']).default('claude-3-5-sonnet-20240620'); +export type AnthropicModel = z.infer; export const OpenAIModelSchema = z.enum(['gpt-4o', 'gpt-4o-2024-08-06']).default('gpt-4o'); export type OpenAIModel = z.infer; -export const OpenAIAutoCompleteRequestBodySchema = z.object({ - messages: z.array(OpenAIMessageSchema), - model: OpenAIModelSchema, +export const SystemMessageSchema = z.object({ + role: z.enum(['system']), + content: z.string(), }); -export type OpenAIAutoCompleteRequestBody = z.infer; +export type SystemMessage = z.infer; + +export const UserMessageSchema = z.object({ + role: z.enum(['user']), + content: z.string(), +}); +export type UserMessage = z.infer; export const AIMessageSchema = z.object({ - role: z.enum(['user', 'assistant']), + role: z.enum(['assistant']), content: z.string(), + model: AnthropicModelSchema.or(OpenAIModelSchema), }); export type AIMessage = z.infer; -export const AnthropicModelSchema = z.enum(['claude-3-5-sonnet-20240620']).default('claude-3-5-sonnet-20240620'); -export type AnthropicModel = z.infer; +export const AnthropicMessageSchema = z.union([UserMessageSchema, AIMessageSchema.omit({ model: true })]); +export type AnthropicMessage = z.infer; + +export const OpenAIMessageSchema = z.union([ + SystemMessageSchema, + UserMessageSchema, + AIMessageSchema.omit({ model: true }), +]); +export type OpenAIMessage = z.infer; export const AnthropicAutoCompleteRequestBodySchema = z.object({ - messages: z.array(AIMessageSchema), + messages: z.array(AnthropicMessageSchema), model: AnthropicModelSchema, }); export type AnthropicAutoCompleteRequestBody = z.infer; + +export const OpenAIAutoCompleteRequestBodySchema = z.object({ + messages: z.array(OpenAIMessageSchema), + model: OpenAIModelSchema, +}); +export type OpenAIAutoCompleteRequestBody = z.infer; From 4c13f8e9ecac1cacca8480aaa0e7ddb01a79488b Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Tue, 10 Sep 2024 15:57:08 +0530 Subject: [PATCH 11/22] fix: bug --- .../src/app/ui/menus/CodeEditor/AiAssistant.tsx | 8 +------- quadratic-client/src/shared/shadcn/ui/textarea.tsx | 3 ++- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 80d3e1c98f..f325300f4e 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -272,13 +272,7 @@ How can I help you? ref={textareaRef} id="prompt-input" value={prompt} - style={{ - border: 'none', - boxShadow: 'none', - borderTop: `1px solid #E1E7EF`, - paddingLeft: '4px', - paddingRight: '4px', - }} + className="border-t-lightGray border-b-0 border-l-0 border-r-0 border-t pl-1 pr-1 shadow-none focus-visible:ring-0" onChange={(event) => { setPrompt(event.target.value); }} diff --git a/quadratic-client/src/shared/shadcn/ui/textarea.tsx b/quadratic-client/src/shared/shadcn/ui/textarea.tsx index d19ff4500c..fe0d4a4488 100644 --- a/quadratic-client/src/shared/shadcn/ui/textarea.tsx +++ b/quadratic-client/src/shared/shadcn/ui/textarea.tsx @@ -8,7 +8,7 @@ export interface TextareaProps extends React.TextareaHTMLAttributes( - ({ className, autoHeight, maxHeight, onChange, onKeyDown, ...props }, ref) => { + ({ className, autoHeight, maxHeight, onChange, onKeyDown, style, ...props }, ref) => { const textareaRef = React.useRef(null); React.useImperativeHandle(ref, () => textareaRef.current!); @@ -32,6 +32,7 @@ const Textarea = React.forwardRef( )} style={{ maxHeight, + ...style, }} onChange={(event: React.ChangeEvent) => { onChange?.(event); From 55991559c9d1dba48515bf2c0c6d3676635bb9c7 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Tue, 10 Sep 2024 20:34:46 +0530 Subject: [PATCH 12/22] new line shortcut --- quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index f325300f4e..32fdbd3af0 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -313,7 +313,7 @@ How can I help you? ) : (
- {KeyboardSymbols.Command}↵ new line + {KeyboardSymbols.Shift}↵ new line ↵ submit Date: Tue, 10 Sep 2024 21:41:44 +0530 Subject: [PATCH 13/22] jim feedback --- .../app/ui/menus/CodeEditor/AiAssistant.tsx | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 32fdbd3af0..d4fa2910e4 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -312,13 +312,16 @@ How can I help you?
) : ( -
- {KeyboardSymbols.Shift}↵ new line - ↵ submit +
+ + {KeyboardSymbols.Shift} + {KeyboardSymbols.Enter} new line + + {KeyboardSymbols.Enter} submit ( - + {children as React.ReactElement} )} @@ -352,27 +355,22 @@ function SelectAIModelDropdownMenu({ }) { return ( - {children as React.ReactElement}} - > - -
- {isAnthropic ? ( - <> - - Anthropic: claude-3.5-sonnet - - ) : ( - <> - - OpenAI: gpt-4o - - )} - -
-
-
+ +
+ {isAnthropic ? ( + <> + + Anthropic: claude-3.5-sonnet + + ) : ( + <> + + OpenAI: gpt-4o + + )} + +
+
setModel('claude-3-5-sonnet-20240620')}> From 8bc2ddb2fb3f855cdb4f3c06bfccb23880eb0a6b Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Tue, 10 Sep 2024 21:55:48 +0530 Subject: [PATCH 14/22] textarea border radius and height change --- quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx | 2 +- quadratic-client/src/shared/shadcn/ui/textarea.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index d4fa2910e4..193564108e 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -272,7 +272,7 @@ How can I help you? ref={textareaRef} id="prompt-input" value={prompt} - className="border-t-lightGray border-b-0 border-l-0 border-r-0 border-t pl-1 pr-1 shadow-none focus-visible:ring-0" + className="border-t-lightGray rounded-none border-b-0 border-l-0 border-r-0 border-t pl-1 pr-1 shadow-none focus-visible:ring-0" onChange={(event) => { setPrompt(event.target.value); }} diff --git a/quadratic-client/src/shared/shadcn/ui/textarea.tsx b/quadratic-client/src/shared/shadcn/ui/textarea.tsx index fe0d4a4488..bddc285acf 100644 --- a/quadratic-client/src/shared/shadcn/ui/textarea.tsx +++ b/quadratic-client/src/shared/shadcn/ui/textarea.tsx @@ -18,7 +18,7 @@ const Textarea = React.forwardRef( const textarea = textareaRef.current; if (textarea) { textarea.style.height = ''; - textarea.style.height = `${textarea.scrollHeight + 2}px`; + textarea.style.height = `${textarea.scrollHeight + 1}px`; } }); }, [textareaRef]); From 88d1cd4994eb10ba5aff2539722c394a04d39128 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Wed, 11 Sep 2024 00:45:58 +0530 Subject: [PATCH 15/22] update text area --- .../app/ui/menus/CodeEditor/AiAssistant.tsx | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 193564108e..885bab50cf 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -263,7 +263,7 @@ How can I help you?
{ e.preventDefault(); }} @@ -272,7 +272,7 @@ How can I help you? ref={textareaRef} id="prompt-input" value={prompt} - className="border-t-lightGray rounded-none border-b-0 border-l-0 border-r-0 border-t pl-1 pr-1 shadow-none focus-visible:ring-0" + className="min-h-14 rounded-none border-none p-2 shadow-none focus-visible:ring-0" onChange={(event) => { setPrompt(event.target.value); }} @@ -294,20 +294,33 @@ How can I help you? } }} autoComplete="off" - placeholder="Ask a question" + placeholder="Ask a question..." autoHeight={true} maxHeight="120px" /> -
+
{ + textareaRef.current?.focus(); + }} + > {loading ? (
- - + { + e.stopPropagation(); + abortPrompt(); + }} + edge="end" + > +
@@ -328,12 +341,22 @@ How can I help you? > { + e.stopPropagation(); + submitPrompt(); + }} edge="end" - {...(prompt.length === 0 ? { disabled: true } : {})} + disabled={prompt.length === 0} > - +
From 289f906c06314d4ddf21ab77e52a3fa6b75b1c71 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Wed, 11 Sep 2024 16:44:40 +0530 Subject: [PATCH 16/22] shift AI Assistant tab --- .../app/ui/menus/CodeEditor/panels/CodeEditorPanelBottom.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/panels/CodeEditorPanelBottom.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/panels/CodeEditorPanelBottom.tsx index 44b633ab56..1cd86f3861 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/panels/CodeEditorPanelBottom.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/panels/CodeEditorPanelBottom.tsx @@ -49,6 +49,8 @@ export function CodeEditorPanelBottom({ /> + {schemaBrowser && Schema} + {showAiAssistant && AI Assistant} Console - {schemaBrowser && Schema} - {showAiAssistant && AI Assistant}
From 5ae2c8c1a6709620af0a863063a53f40abb1e8c0 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Wed, 11 Sep 2024 21:11:40 +0530 Subject: [PATCH 17/22] hide shortcuts using container query --- .../src/app/ui/menus/CodeEditor/AiAssistant.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 885bab50cf..446e2d198b 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -300,7 +300,7 @@ How can I help you? />
{ textareaRef.current?.focus(); }} @@ -326,11 +326,11 @@ How can I help you?
) : (
- + {KeyboardSymbols.Shift} {KeyboardSymbols.Enter} new line - {KeyboardSymbols.Enter} submit + {KeyboardSymbols.Enter} submit ( From f4c477c15c140a9b394e13b04aa71e3e53238c29 Mon Sep 17 00:00:00 2001 From: Jim Nielsen Date: Wed, 11 Sep 2024 09:41:54 -0600 Subject: [PATCH 18/22] move to shadcn components --- .../app/ui/menus/CodeEditor/AiAssistant.tsx | 44 +++++++------------ .../src/shared/shadcn/ui/tooltip.tsx | 14 +++++- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 885bab50cf..f7199a272d 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -3,7 +3,6 @@ import { getConnectionInfo, getConnectionKind } from '@/app/helpers/codeCellLang import { KeyboardSymbols } from '@/app/helpers/keyboardSymbols'; import { colors } from '@/app/theme/colors'; import ConditionalWrapper from '@/app/ui/components/ConditionalWrapper'; -import { TooltipHint } from '@/app/ui/components/TooltipHint'; import { useAI } from '@/app/ui/hooks/useAI'; import { Anthropic, OpenAI } from '@/app/ui/icons'; import { CodeBlockParser } from '@/app/ui/menus/CodeEditor/AICodeBlockParser'; @@ -12,6 +11,7 @@ import { QuadraticDocs } from '@/app/ui/menus/CodeEditor/QuadraticDocs'; import { useRootRouteLoaderData } from '@/routes/_root'; import { Avatar } from '@/shared/components/Avatar'; import { useConnectionSchemaBrowser } from '@/shared/hooks/useConnectionSchemaBrowser'; +import { Button } from '@/shared/shadcn/ui/button'; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -19,8 +19,9 @@ import { DropdownMenuTrigger, } from '@/shared/shadcn/ui/dropdown-menu'; import { Textarea } from '@/shared/shadcn/ui/textarea'; +import { TooltipPopover } from '@/shared/shadcn/ui/tooltip'; import { ArrowUpward, Stop } from '@mui/icons-material'; -import { CircularProgress, IconButton } from '@mui/material'; +import { CircularProgress } from '@mui/material'; import { CaretDownIcon } from '@radix-ui/react-icons'; import mixpanel from 'mixpanel-browser'; import { @@ -300,7 +301,7 @@ How can I help you? />
{ textareaRef.current?.focus(); }} @@ -309,20 +310,18 @@ How can I help you? {loading ? (
- - - + + +
) : (
@@ -334,30 +333,21 @@ How can I help you? ( - + {children as React.ReactElement} - + )} > - { e.stopPropagation(); submitPrompt(); }} - edge="end" disabled={prompt.length === 0} > - - + +
)} diff --git a/quadratic-client/src/shared/shadcn/ui/tooltip.tsx b/quadratic-client/src/shared/shadcn/ui/tooltip.tsx index 66a6e3ad7c..b13284e313 100644 --- a/quadratic-client/src/shared/shadcn/ui/tooltip.tsx +++ b/quadratic-client/src/shared/shadcn/ui/tooltip.tsx @@ -33,14 +33,24 @@ TooltipContent.displayName = TooltipPrimitive.Content.displayName; * * */ -const TooltipPopover = ({ label, children }: { label: string; children: React.ReactNode }) => { +const TooltipPopover = ({ + label, + children, + shortcut, +}: { + label: string; + children: React.ReactNode; + shortcut?: string; +}) => { return ( {children} -

{label}

+

+ {label} {shortcut && ({shortcut})} +

From 470fd145ace84ce2ad74fc6bc94f3a43c8c32fac Mon Sep 17 00:00:00 2001 From: Jim Nielsen Date: Wed, 11 Sep 2024 09:52:02 -0600 Subject: [PATCH 19/22] align margins/padding --- quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx | 4 ++-- quadratic-client/src/app/ui/menus/CodeEditor/Console.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index 7375fcf334..c98dcf492f 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -205,7 +205,7 @@ How can I help you?
{ if (((e.metaKey || e.ctrlKey) && e.key === 'a') || ((e.metaKey || e.ctrlKey) && e.key === 'c')) { @@ -264,7 +264,7 @@ How can I help you?
{ e.preventDefault(); }} diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/Console.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/Console.tsx index d64bc8b43f..c02ff983c9 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/Console.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/Console.tsx @@ -27,7 +27,7 @@ export function Console() { e.preventDefault(); } }} - className="h-full overflow-y-auto whitespace-pre-wrap pl-3 pr-4 outline-none" + className="h-full overflow-y-auto whitespace-pre-wrap pl-3 pr-3 outline-none" style={codeEditorBaseStyles} // Disable Grammarly data-gramm="false" From ebfdbf49fa0df255f2889d19c4cd62b00cf205ee Mon Sep 17 00:00:00 2001 From: Jim Nielsen Date: Wed, 11 Sep 2024 09:56:19 -0600 Subject: [PATCH 20/22] remove bottom padding to bring it closer --- quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx index c98dcf492f..824d62e962 100644 --- a/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx +++ b/quadratic-client/src/app/ui/menus/CodeEditor/AiAssistant.tsx @@ -273,7 +273,7 @@ How can I help you? ref={textareaRef} id="prompt-input" value={prompt} - className="min-h-14 rounded-none border-none p-2 shadow-none focus-visible:ring-0" + className="min-h-14 rounded-none border-none p-2 pb-0 shadow-none focus-visible:ring-0" onChange={(event) => { setPrompt(event.target.value); }} From 9c01157ae0ec16c97b54b8d5f0e7670b63baca9d Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Wed, 11 Sep 2024 21:28:14 +0530 Subject: [PATCH 21/22] fix textarea resize --- quadratic-client/src/shared/shadcn/ui/textarea.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quadratic-client/src/shared/shadcn/ui/textarea.tsx b/quadratic-client/src/shared/shadcn/ui/textarea.tsx index bddc285acf..01e4c683ee 100644 --- a/quadratic-client/src/shared/shadcn/ui/textarea.tsx +++ b/quadratic-client/src/shared/shadcn/ui/textarea.tsx @@ -18,7 +18,7 @@ const Textarea = React.forwardRef( const textarea = textareaRef.current; if (textarea) { textarea.style.height = ''; - textarea.style.height = `${textarea.scrollHeight + 1}px`; + textarea.style.height = `${textarea.scrollHeight}px`; } }); }, [textareaRef]); From 514b0007d839f87a890e8baa4e7eb838305acbf2 Mon Sep 17 00:00:00 2001 From: AyushAgrawal-A2 Date: Thu, 12 Sep 2024 02:46:46 +0530 Subject: [PATCH 22/22] fix lock file --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 45910c0a1a..f3b3d4b35f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27375,7 +27375,7 @@ }, "quadratic-rust-client": {}, "quadratic-shared": { - "version": "1.0.0", + "version": "0.5.1", "license": "ISC", "dependencies": { "zod": "^3.23.8"