diff --git a/backend/src/app.ts b/backend/src/app.ts index be7a4f96..97082fac 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -32,24 +32,85 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { }); // OpenAI-compatible passthrough. The frontend's ai-sdk client posts here; we - // only inject the server-held API key and stream the upstream response back. + // only inject the server-held API key and relay the upstream response back. + // We log every request/response so empty or truncated replies are visible + // instead of silently surfacing as a 200 with no body. app.post('/api/openai/chat/completions', async (c) => { const body = await c.req.text(); - const upstream = await fetch(OPENAI_URL, { - method: 'POST', - headers: { - Authorization: `Bearer ${openaiApiKey()}`, - 'Content-Type': 'application/json', + // Non-streaming requests (e.g. the my-words generateText path) omit + // `stream:true`; those we buffer fully so a dropped upstream connection + // throws here rather than yielding an empty 200. Streaming requests + // (streamText pages) are relayed through with a byte counter. + const wantsStream = (() => { + try { + return Boolean((JSON.parse(body) as { stream?: unknown }).stream); + } catch { + return false; + } + })(); + + const started = Date.now(); + let upstream: Response; + try { + upstream = await fetch(OPENAI_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${openaiApiKey()}`, + 'Content-Type': 'application/json', + }, + body, + }); + } catch (e) { + console.error('[openai-proxy] upstream fetch failed:', (e as Error).message); + throw e; // -> onError -> 500 JSON, instead of a silent empty response + } + + const contentType = + upstream.headers.get('content-type') ?? + (wantsStream ? 'text/event-stream' : 'application/json'); + + const log = (bytes: number) => { + const line = `[openai-proxy] ${upstream.status} ${ + wantsStream ? 'stream' : 'json' + } ${contentType.split(';')[0]} ${bytes}B ${Date.now() - started}ms`; + if (!upstream.ok || bytes === 0) console.warn(`${line} ⚠️ EMPTY/ERROR`); + else console.log(line); + }; + + if (!wantsStream) { + let buf: ArrayBuffer; + try { + buf = await upstream.arrayBuffer(); + } catch (e) { + console.error( + '[openai-proxy] upstream body read failed:', + (e as Error).message, + ); + throw e; + } + log(buf.byteLength); + return new Response(buf, { + status: upstream.status, + headers: { 'Content-Type': contentType }, + }); + } + + // Streaming: relay the body through a pass-through that tallies bytes so + // we can log the total (and flag an empty stream) once it completes. + let bytes = 0; + const counter = new TransformStream({ + transform(chunk, ctrl) { + bytes += chunk.byteLength; + ctrl.enqueue(chunk); + }, + flush() { + log(bytes); }, - body, }); - return new Response(upstream.body, { + return new Response(upstream.body?.pipeThrough(counter) ?? null, { status: upstream.status, - headers: { - 'Content-Type': - upstream.headers.get('content-type') ?? 'text/event-stream', - }, + headers: { 'Content-Type': contentType }, }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c5f49d75..3360380a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,13 +9,15 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@ai-sdk/openai": "^2.0.0", + "@ai-sdk/openai": "^2.0.109", + "@ai-sdk/otel": "^1.0.0", + "@ai-sdk/react": "^4.0.0", "@auth0/auth0-react": "^2.2.4", "@lexical/react": "^0.16.1", "@posthog/react": "^1.9.0", "@react-hook/window-size": "^3.1.1", "@types/node": "^24.6.2", - "ai": "^5.0.0", + "ai": "^7.0.0", "jotai": "^2.12.5", "lexical": "^0.16.1", "posthog-js": "^1.388.1", @@ -23,7 +25,8 @@ "react-dom": "^18.2.0", "react-icons": "^5.2.1", "react-remark": "^2.1.0", - "reshaped": "^3.5.3" + "reshaped": "^3.5.3", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "2.0.6", @@ -71,30 +74,107 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "2.0.98", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-2.0.98.tgz", - "integrity": "sha512-JNMc5Fbz8AwiLIR3Ar/lV2egbLFE+A5nfwbRKrdfgusoVN2VjgMX2U2KCLux5iWD/Q9+rg9+njHPZNw4HmzBJQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.0.tgz", + "integrity": "sha512-rcKukspbM4h511ot2E8TsPl7rXjRK1zHKrMCP7w4+XF55UKqQHaDzo2kKbGv5rp8Bjb1yQatIHJZE1E2yrOOMw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.3", - "@ai-sdk/provider-utils": "3.0.25", - "@vercel/oidc": "3.1.0" + "@ai-sdk/provider": "4.0.0", + "@ai-sdk/provider-utils": "5.0.0", + "@vercel/oidc": "3.2.0" }, "engines": { - "node": ">=18" + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz", + "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz", + "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.0", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mcp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/mcp/-/mcp-2.0.0.tgz", + "integrity": "sha512-+N6gJ1AbcDk3+6asoEsdIojVmgEReKNcvWIT716pDL3AepGI6j7RJVdaRyhQLltwzTj0arwpwU4BBzvhOiCd/g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.0", + "@ai-sdk/provider-utils": "5.0.0", + "pkce-challenge": "^5.0.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/mcp/node_modules/@ai-sdk/provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz", + "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/mcp/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz", + "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.0", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/@ai-sdk/openai": { - "version": "2.0.106", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.106.tgz", - "integrity": "sha512-EFC0rpo1wfe4HIz5KZCE72edP2J7fOeR7wPXzjCDljaTRB1wectKDIKRLowpU4F0mbcJ+XScAsoYNPK/Z20aVQ==", + "version": "2.0.109", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.109.tgz", + "integrity": "sha512-i2no65RS/08qjB+m3zmAej5AqO4JtTTxdfpWusgA9p73K6TYn7t15h76MZPQVT8tYv5hDR1nHRELuAWaXZyb9g==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.3", - "@ai-sdk/provider-utils": "3.0.25" + "@ai-sdk/provider-utils": "3.0.27" }, "engines": { "node": ">=18" @@ -103,6 +183,41 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/otel": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/otel/-/otel-1.0.0.tgz", + "integrity": "sha512-XRTAFrerQ042ITLc4Ew5dR+6fiaOntOVBKfIG+H9Vtwpf+HzEhkmpacBhUo/WuSYIxzJTMdlqveZC4gxH5Xocw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.0", + "@opentelemetry/api": "1.9.1", + "ai": "7.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/otel/node_modules/@ai-sdk/provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz", + "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/otel/node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@ai-sdk/provider": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.3.tgz", @@ -116,9 +231,9 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.25", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.25.tgz", - "integrity": "sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA==", + "version": "3.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.27.tgz", + "integrity": "sha512-JFhJK5ynprll2FR3e+sHagJJIwvIagsNA0FLbLPq2Os4yLUK2/eiaCU0jXsADik73/hhvcPPLmD+Uo8eu5kFaQ==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.3", @@ -132,6 +247,56 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/react": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-4.0.0.tgz", + "integrity": "sha512-B8QCCzSr/neXnJebBPQpS/rO+I++xhhs81hcaiKD1gU+C7GKg5gZSHLb5iP+AUqrx6VP8QWwgNpHmLlfrlV2rg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/mcp": "2.0.0", + "@ai-sdk/provider": "4.0.0", + "@ai-sdk/provider-utils": "5.0.0", + "ai": "7.0.0", + "swr": "^2.4.1", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz", + "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/react/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz", + "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.0", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -5153,15 +5318,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -7173,9 +7329,9 @@ "license": "ISC" }, "node_modules/@vercel/oidc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", - "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", "license": "Apache-2.0", "engines": { "node": ">= 20" @@ -7305,6 +7461,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -7399,18 +7561,47 @@ } }, "node_modules/ai": { - "version": "5.0.197", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.197.tgz", - "integrity": "sha512-iUzFb2M3ZUL/Bbmfonh75DIZ354svWO5xh8VPC2wYNR6zzEMFghPOlJG5rtEpqRa037lHfdcjt0qmzg3em/WDw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.0.tgz", + "integrity": "sha512-hncs+jamJh8r36K6G8xky7oF4Ai/RLU5TF85FMzI2vElyMJGGnLoHihpdmuDiuY2BsktDWHevKaJM1l0VcRLGw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "2.0.98", - "@ai-sdk/provider": "2.0.3", - "@ai-sdk/provider-utils": "3.0.25", - "@opentelemetry/api": "1.9.0" + "@ai-sdk/gateway": "4.0.0", + "@ai-sdk/provider": "4.0.0", + "@ai-sdk/provider-utils": "5.0.0" }, "engines": { - "node": ">=18" + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.0.tgz", + "integrity": "sha512-fr9Gs89prDWiuox/T+kCA+i2cJkHpxU5S+tr4megjTzRC27ZsvFhwjU/+XrqqMbvBUlfmXxTOYWy8ng45dsjIg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.0.tgz", + "integrity": "sha512-zj66M02jc6ASYwIgWZowsooDUwaVngeNZQ3H10GwcPMZ+KR6gHMhcUuKl6tkai+JPXTKDyHY1pnszuxRtw2D4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.0", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" @@ -9291,6 +9482,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -16626,9 +16826,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.20.0" } @@ -19176,6 +19374,19 @@ "node": ">= 6" } }, + "node_modules/swr": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/synckit": { "version": "0.11.11", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", @@ -19342,6 +19553,18 @@ "dev": true, "license": "MIT" }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -20017,6 +20240,15 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", @@ -20337,106 +20569,618 @@ } } }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.8", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/vitest/node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, + "optional": true, + "os": [ + "aix" + ], + "peer": true, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "node": ">=18" } }, - "node_modules/vitest/node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ - "arm64" + "arm" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ "android" ], + "peer": true, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=18" } }, - "node_modules/vitest/node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], "dev": true, - "license": "MPL-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vitest/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" ], "engines": { "node": ">= 12.0.0" @@ -21274,7 +22018,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index ab69c382..2045c7f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,13 +46,15 @@ "test:dev-server": "node test-dev-server.mjs" }, "dependencies": { - "@ai-sdk/openai": "^2.0.0", + "@ai-sdk/openai": "^2.0.109", + "@ai-sdk/otel": "^1.0.0", + "@ai-sdk/react": "^4.0.0", "@auth0/auth0-react": "^2.2.4", "@lexical/react": "^0.16.1", "@posthog/react": "^1.9.0", "@react-hook/window-size": "^3.1.1", "@types/node": "^24.6.2", - "ai": "^5.0.0", + "ai": "^7.0.0", "jotai": "^2.12.5", "lexical": "^0.16.1", "posthog-js": "^1.388.1", @@ -60,7 +62,8 @@ "react-dom": "^18.2.0", "react-icons": "^5.2.1", "react-remark": "^2.1.0", - "reshaped": "^3.5.3" + "reshaped": "^3.5.3", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "2.0.6", diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 88573f2a..6d1d1629 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -231,6 +231,28 @@ export const googleDocsEditorAPI: EditorAPI = { throw new Error('Phrase not found'); } }, + + /** Full document text, used for the corpus and the `view` tool. */ + async getDocText(): Promise { + const ctx = await window.GoogleAppsScript.getDocContext(); + return `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`; + }, + + /** Paragraphs in order — the coordinate system for `view` and inserts. */ + async getParagraphs(): Promise { + const ctx = await window.GoogleAppsScript.getDocContext(); + const text = `${ctx.beforeCursor || ''}${ctx.selectedText || ''}${ctx.afterCursor || ''}`; + return text.split('\n'); + }, + + // TODO(my-words): bridge to Apps Script (selectPhrase + replaceSelection for + // str_replace; insertTextAtCursor for insert). The GDocs multi-tab corpus + // (getAllTabs) is the exciting follow-up. Deferred — v1 targets standalone. + applyEdit(_edit: DocEdit): Promise { + return Promise.reject( + new Error('applyEdit is not implemented for Google Docs yet'), + ); + }, }; /** diff --git a/frontend/src/api/openai.ts b/frontend/src/api/openai.ts index d5f236bd..353a1982 100644 --- a/frontend/src/api/openai.ts +++ b/frontend/src/api/openai.ts @@ -6,4 +6,4 @@ export const openai = createOpenAI({ apiKey: "unused", }); -export const OPENAI_MODEL = "gpt-4o"; +export const OPENAI_MODEL = "gpt-5.5"; diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index 81202381..db90d0a4 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -212,4 +212,107 @@ export const wordEditorAPI: EditorAPI = { } }); }, + + /** Full document text, used for the corpus and the `view` tool. */ + async getDocText(): Promise { + return Word.run(async (context: Word.RequestContext) => { + const body = context.document.body; + context.load(body, 'text'); + await context.sync(); + return body.text.replace(/\r/g, '\n'); + }); + }, + + /** Paragraphs in order — the coordinate system for `view` and inserts. */ + async getParagraphs(): Promise { + return Word.run(async (context: Word.RequestContext) => { + const paragraphs = context.document.body.paragraphs; + context.load(paragraphs, 'items/text'); + await context.sync(); + return paragraphs.items.map((p) => p.text.replace(/\r/g, '\n')); + }); + }, + + /** + * Apply a validated edit to the Word document. If the user has Track Changes + * on (Review ribbon → changeTrackingMode = TrackAll), these edits are + * recorded as revisions they can accept or reject — no extra work here. + * + * Note: Word's body.search() is limited to ~255 characters and does not match + * across paragraph breaks, so this supports sentence/phrase-level edits (how + * the AI already works), not multi-paragraph spans. + */ + applyEdit(edit: DocEdit): Promise { + return Word.run(async (context: Word.RequestContext) => { + const body = context.document.body; + const searchOptions: Word.SearchOptions | object = { + matchCase: false, + matchWildcards: false, + ignorePunct: false, + ignoreSpace: false, + }; + + if (edit.type === 'str_replace') { + const results = body.search(edit.oldStr, searchOptions); + context.load(results, 'items'); + await context.sync(); + if (results.items.length === 0) { + throw new Error( + `Could not find the text to replace: "${edit.oldStr}"`, + ); + } + results.items[0].insertText( + edit.newStr, + Word.InsertLocation.replace, + ); + await context.sync(); + return; + } + + // insert — by paragraph number (robust; avoids the search limit) + if (edit.paragraph !== undefined) { + const paragraphs = body.paragraphs; + context.load(paragraphs, 'items'); + await context.sync(); + if ( + edit.paragraph < 1 || + edit.paragraph > paragraphs.items.length + ) { + throw new Error( + `Paragraph ${edit.paragraph} is out of range (1–${paragraphs.items.length}).`, + ); + } + paragraphs.items[edit.paragraph - 1].insertParagraph( + edit.text, + edit.position === 'before' + ? Word.InsertLocation.before + : Word.InsertLocation.after, + ); + await context.sync(); + return; + } + + // insert — after an anchor string (within a paragraph) + if (edit.after !== undefined && edit.after !== '') { + const results = body.search(edit.after, searchOptions); + context.load(results, 'items'); + await context.sync(); + if (results.items.length === 0) { + throw new Error( + `Could not find the anchor text: "${edit.after}"`, + ); + } + results.items[0].insertText( + edit.text, + Word.InsertLocation.after, + ); + } else { + // No anchor: insert at the current cursor / replace the selection. + context.document + .getSelection() + .insertText(edit.text, Word.InsertLocation.replace); + } + await context.sync(); + }); + }, }; diff --git a/frontend/src/components/navbar/index.tsx b/frontend/src/components/navbar/index.tsx index 05147e89..660e726c 100644 --- a/frontend/src/components/navbar/index.tsx +++ b/frontend/src/components/navbar/index.tsx @@ -24,6 +24,7 @@ const pageNames: Page[] = [ { name: PageName.Draft, title: 'Draft', hint: 'Generate suggestions' }, { name: PageName.Revise, title: 'Revise', hint: 'Improve your text' }, { name: PageName.Chat, title: 'Chat', hint: 'Ask about your doc' }, + { name: PageName.MyWords, title: 'My Words', hint: 'Shape your own words' }, ]; export default function Navbar() { diff --git a/frontend/src/contexts/editorContext.tsx b/frontend/src/contexts/editorContext.tsx index 5fc98452..52e4d47d 100644 --- a/frontend/src/contexts/editorContext.tsx +++ b/frontend/src/contexts/editorContext.tsx @@ -18,4 +18,10 @@ export const EditorContext = createContext({ console.warn('selectPhrase is not implemented yet'); return new Promise((resolve) => resolve()); }, + getDocText: () => Promise.resolve(''), + getParagraphs: () => Promise.resolve([]), + applyEdit: () => { + console.warn('applyEdit is not implemented yet'); + return Promise.resolve(); + }, }); diff --git a/frontend/src/contexts/pageContext.tsx b/frontend/src/contexts/pageContext.tsx index 0c9f902e..bce6bcb6 100644 --- a/frontend/src/contexts/pageContext.tsx +++ b/frontend/src/contexts/pageContext.tsx @@ -5,6 +5,7 @@ export enum PageName { Chat = 'chat', Draft = 'draft', TagLinker = 'tag-linker', + MyWords = 'my-words', } export enum OverallMode { diff --git a/frontend/src/editor/editor.tsx b/frontend/src/editor/editor.tsx index ffee53b5..65bb6aa0 100644 --- a/frontend/src/editor/editor.tsx +++ b/frontend/src/editor/editor.tsx @@ -6,21 +6,133 @@ import { type InitialEditorStateType, LexicalComposer, } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { + $createParagraphNode, + $createRangeSelection, + $createTextNode, $getRoot, $getSelection, $isRangeSelection, + $isTextNode, + $setSelection, type ElementNode, type LexicalNode, + type TextNode, } from 'lexical'; +import { useEffect } from 'react'; import classes from './editor.module.css'; +/** + * Imperative handle the "My Words" page uses to read and edit the standalone + * Lexical document. Mirrors the host-agnostic operations on EditorAPI; Word and + * Google Docs implement the same shape with their native APIs. + */ +export interface EditorControls { + getText: () => string; + /** Top-level paragraphs in order — the coordinate system for `view`. */ + getParagraphs: () => string[]; + /** Replace the whole document with plain text (paragraphs split on \n). */ + setText: (text: string) => void; + /** Select the first occurrence of `phrase` within a single paragraph. */ + selectPhrase: (phrase: string) => boolean; +} + +/** + * Lives inside LexicalComposer so it can grab the editor instance and hand a + * small imperative control surface back up to the EditorScreen. + */ +function ControlsPlugin({ + onReady, +}: { + onReady?: (controls: EditorControls) => void; +}) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!onReady) return; + + const controls: EditorControls = { + getText: () => + editor + .getEditorState() + .read(() => $getRoot().getTextContent()), + + getParagraphs: () => + editor + .getEditorState() + .read(() => + $getRoot() + .getChildren() + .map((node) => node.getTextContent()), + ), + + setText: (text: string) => { + editor.update(() => { + const root = $getRoot(); + root.clear(); + for (const line of text.split('\n')) { + const paragraph = $createParagraphNode(); + if (line.length > 0) { + paragraph.append($createTextNode(line)); + } + root.append(paragraph); + } + }); + }, + + selectPhrase: (phrase: string) => { + let found = false; + editor.update(() => { + const textNodes: TextNode[] = []; + const collect = (node: LexicalNode) => { + if ($isTextNode(node)) { + textNodes.push(node); + } else if ('getChildren' in node) { + for (const child of ( + node as ElementNode + ).getChildren()) { + collect(child); + } + } + }; + collect($getRoot()); + + const needle = phrase.toLowerCase(); + for (const node of textNodes) { + const idx = node + .getTextContent() + .toLowerCase() + .indexOf(needle); + if (idx === -1) continue; + const selection = $createRangeSelection(); + selection.anchor.set(node.getKey(), idx, 'text'); + selection.focus.set( + node.getKey(), + idx + phrase.length, + 'text', + ); + $setSelection(selection); + found = true; + return; + } + }); + return found; + }, + }; + + onReady(controls); + }, [editor, onReady]); + + return null; +} + function $getDocContext(): DocContext { // Initialize default empty context const docContext: DocContext = { @@ -156,11 +268,13 @@ function LexicalEditor({ initialState, storageKey = 'doc', preamble, + onReady, }: { updateDocContext: (docContext: DocContext) => void; initialState: InitialEditorStateType | null; storageKey?: string; preamble?: JSX.Element; + onReady?: (controls: EditorControls) => void; }) { return ( + + diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 1ce35295..bb5f07c8 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -1,11 +1,11 @@ -import { useRef, useState, StrictMode, useMemo } from 'react'; +import { useCallback, useRef, useState, StrictMode, useMemo } from 'react'; import { createRoot } from 'react-dom/client'; import { OverallMode, overallModeAtom } from '@/contexts/pageContext'; import * as SidebarInner from '@/pages/app'; import type { Auth0ContextInterface } from '@auth0/auth0-react'; import { useAtomValue, useSetAtom } from 'jotai'; -import LexicalEditor from './editor'; +import LexicalEditor, { type EditorControls } from './editor'; import './styles.css'; import classes from './styles.module.css'; import { EditorContext } from '@/contexts/editorContext'; @@ -33,6 +33,12 @@ export function EditorScreen({ afterCursor: '', }); + // Imperative handle into the Lexical document, populated once it mounts. + const controlsRef = useRef(null); + const handleEditorReady = useCallback((controls: EditorControls) => { + controlsRef.current = controls; + }, []); + // Since this is a list, a useState would have worked as well const selectionChangeHandlers = useRef<(() => void)[]>([]); @@ -78,9 +84,68 @@ export function EditorScreen({ else console.warn('Handler not found'); }, - selectPhrase(_text) { - console.warn('selectPhrase is not implemented yet'); - return new Promise((resolve) => resolve()); + selectPhrase(text) { + const found = controlsRef.current?.selectPhrase(text) ?? false; + return found + ? Promise.resolve() + : Promise.reject(new Error('Phrase not found')); + }, + getDocText: (): Promise => { + return Promise.resolve(controlsRef.current?.getText() ?? ''); + }, + getParagraphs: (): Promise => { + return Promise.resolve(controlsRef.current?.getParagraphs() ?? []); + }, + applyEdit: (edit: DocEdit): Promise => { + const controls = controlsRef.current; + if (!controls) { + return Promise.reject(new Error('Editor is not ready yet')); + } + const current = controls.getText(); + + let next: string; + if (edit.type === 'str_replace') { + const idx = current.indexOf(edit.oldStr); + if (idx === -1) { + throw new Error( + `Could not find the text to replace: "${edit.oldStr}"`, + ); + } + next = + current.slice(0, idx) + + edit.newStr + + current.slice(idx + edit.oldStr.length); + } else if (edit.paragraph !== undefined) { + const paras = controls.getParagraphs(); + if (edit.paragraph < 1 || edit.paragraph > paras.length) { + throw new Error( + `Paragraph ${edit.paragraph} is out of range (1–${paras.length}).`, + ); + } + const spliceAt = + edit.position === 'before' + ? edit.paragraph - 1 + : edit.paragraph; + paras.splice(spliceAt, 0, edit.text); + next = paras.join('\n'); + } else if (edit.after !== undefined && edit.after !== '') { + const idx = current.indexOf(edit.after); + if (idx === -1) { + throw new Error( + `Could not find the anchor text: "${edit.after}"`, + ); + } + const at = idx + edit.after.length; + next = current.slice(0, at) + edit.text + current.slice(at); + } else { + // No anchor: insert at the current cursor / after the selection. + const { beforeCursor, selectedText, afterCursor } = + docContextRef.current; + next = beforeCursor + selectedText + edit.text + afterCursor; + } + + controls.setText(next); + return Promise.resolve(); }, }), []); @@ -131,6 +196,7 @@ export function EditorScreen({ updateDocContext={docUpdated} storageKey={getStorageKey()} preamble={editorPreamble} + onReady={handleEditorReady} /> {isDemo ? (
diff --git a/frontend/src/pages/app/index.tsx b/frontend/src/pages/app/index.tsx index 64f1ffe4..1120cac5 100644 --- a/frontend/src/pages/app/index.tsx +++ b/frontend/src/pages/app/index.tsx @@ -20,6 +20,7 @@ import { import { OnboardingCarousel } from '../carousel/OnboardingCarousel'; import Chat from '../chat'; import Draft from '../draft'; +import MyWords from '../my-words'; import Revise from '../revise'; import classes from './styles.module.css'; import Navbar from '@/components/navbar'; @@ -317,6 +318,8 @@ function AppInner() { return ; case PageName.Draft: return ; + case PageName.MyWords: + return ; } return null; } diff --git a/frontend/src/pages/my-words/__tests__/corpus.test.ts b/frontend/src/pages/my-words/__tests__/corpus.test.ts new file mode 100644 index 00000000..adb7a23d --- /dev/null +++ b/frontend/src/pages/my-words/__tests__/corpus.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCorpus, GLUE_WORDS, validateText } from '../corpus'; + +const corpusOf = (text: string) => buildCorpus({ docText: text }); + +describe('buildCorpus', () => { + it('collects words from all sources, dropping punctuation', () => { + const corpus = buildCorpus({ + docText: 'The cat sat.', + scratchpad: 'a quiet morning', + userMessages: ['I like dogs too'], + }); + expect(corpus.wordSet.has('cat')).toBe(true); + expect(corpus.wordSet.has('morning')).toBe(true); + expect(corpus.wordSet.has('dogs')).toBe(true); + expect(corpus.wordSet.has('.')).toBe(false); + }); + + it('does not let phrases span across separate sources', () => { + // "sat" ends docText and "dog" begins scratchpad; their adjacency is an + // artifact of concatenation and must not count as a corpus phrase. + const corpus = buildCorpus({ docText: 'cat sat', scratchpad: 'dog run' }); + expect(validateText('cat sat', corpus).ok).toBe(true); + expect(validateText('sat dog', corpus).ok).toBe(false); + }); +}); + +describe('validateText — phrase-level rule', () => { + it('accepts a verbatim phrase lifted from the corpus', () => { + const corpus = corpusOf('The quick brown fox jumped over the lazy dog.'); + expect(validateText('the quick brown fox', corpus).ok).toBe(true); + expect(validateText('lazy dog', corpus).ok).toBe(true); + }); + + it('accepts two corpus phrases bridged by a glue word', () => { + const corpus = corpusOf('I value honesty. I also value hard work.'); + // "honesty" and "hard work" are both in the corpus, bridged by "and". + expect(validateText('honesty and hard work', corpus).ok).toBe(true); + }); + + it('accepts punctuation inserted freely between lifted content', () => { + const corpus = corpusOf('honesty hard work'); + expect(validateText('honesty, hard work', corpus).ok).toBe(true); + expect(validateText('honesty: hard work!', corpus).ok).toBe(true); + }); + + it('rejects a new adjacency of two corpus words with no bridge', () => { + // "big" and "dog" both appear, but never adjacent and not glue-bridged. + const corpus = corpusOf('the big cat and the small dog'); + expect(validateText('big dog', corpus).ok).toBe(false); + // The glue-bridged version is allowed. + expect(validateText('big and dog', corpus).ok).toBe(true); + }); + + it('rejects a multi-word run that is not contiguous in the corpus', () => { + // Each bigram exists ("a b", "b c") but the trigram "a b c" never does. + const corpus = corpusOf('alpha beta. beta gamma.'); + expect(validateText('alpha beta', corpus).ok).toBe(true); + expect(validateText('beta gamma', corpus).ok).toBe(true); + expect(validateText('alpha beta gamma', corpus).ok).toBe(false); + }); + + it('rejects a novel content word the writer never used', () => { + const corpus = corpusOf('I enjoy writing essays.'); + const result = validateText('I enjoy painting', corpus); + expect(result.ok).toBe(false); + expect(result.offending).toContain('painting'); + }); + + it('allows a punctuation-only / glue-only edit', () => { + const corpus = corpusOf('anything at all'); + expect(validateText('.', corpus).ok).toBe(true); + expect(validateText('and', corpus).ok).toBe(true); + expect(validateText(' , ; — ', corpus).ok).toBe(true); + }); + + it('is case-insensitive when matching', () => { + const corpus = corpusOf('Reproducible Research Matters'); + expect(validateText('reproducible research', corpus).ok).toBe(true); + expect(validateText('REPRODUCIBLE RESEARCH', corpus).ok).toBe(true); + }); + + it('matches words with internal apostrophes and straightens curly quotes', () => { + const corpus = corpusOf("I don't think that's wise"); + expect(validateText("don't", corpus).ok).toBe(true); + // curly apostrophe in the proposed text should still match. + expect(validateText('don’t', corpus).ok).toBe(true); + }); + + it('reports a segmentation that labels lifted / glue / punct parts', () => { + const corpus = corpusOf('honesty hard work'); + const result = validateText('honesty and hard work, please', corpus); + // "please" is novel content => not ok. + expect(result.ok).toBe(false); + const kinds = result.segments.map((s) => s.kind); + expect(kinds).toContain('lifted'); + expect(kinds).toContain('glue'); + expect(kinds).toContain('punct'); + }); + + it('treats an empty proposal as trivially valid', () => { + const corpus = corpusOf('whatever'); + expect(validateText('', corpus).ok).toBe(true); + }); +}); + +describe('GLUE_WORDS', () => { + it('includes basic articles/conjunctions but excludes content connectives', () => { + expect(GLUE_WORDS.has('and')).toBe(true); + expect(GLUE_WORDS.has('the')).toBe(true); + expect(GLUE_WORDS.has('because')).toBe(false); + expect(GLUE_WORDS.has('however')).toBe(false); + }); +}); diff --git a/frontend/src/pages/my-words/corpus.ts b/frontend/src/pages/my-words/corpus.ts new file mode 100644 index 00000000..ab1429e2 Binary files /dev/null and b/frontend/src/pages/my-words/corpus.ts differ diff --git a/frontend/src/pages/my-words/index.tsx b/frontend/src/pages/my-words/index.tsx new file mode 100644 index 00000000..27b158bb --- /dev/null +++ b/frontend/src/pages/my-words/index.tsx @@ -0,0 +1,396 @@ +import { + generateText, + type ModelMessage, + isStepCount, + tool, +} from 'ai'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { AiOutlineSend } from 'react-icons/ai'; +import { z } from 'zod'; + +import { OPENAI_MODEL, openai } from '@/api/openai'; +import { EditorContext } from '@/contexts/editorContext'; +import { buildCorpus, validateText } from './corpus'; +import classes from './styles.module.css'; + +const SYSTEM_PROMPT = `You are a writing tutor helping a writer develop their OWN writing. Two things define your role. + +1) You never contribute words. Every word you place in the document must come from the writer's own corpus — the document, their scratchpad, and their messages to you — joined only by punctuation and a small closed set of glue words (a, an, the, and, or, of, to, in, on, ...). Treat all three as a single word bank you may quote from freely: every line of the scratchpad, and everything the writer has typed or said to you in this conversation, is fair game to lift, exactly like the document text itself. (Their messages are a source of words, not just instructions.) The harness enforces this: any edit whose text is not lifted from the corpus is REJECTED. Your edits only ever rearrange, tighten, or connect the writer's existing words; the ideas and the language stay theirs. + +2) You are non-directive. Lead with curiosity, like a good tutor in a writing conference. Ask open questions about what the writer means, what they most want to say, how two ideas connect, what matters most here. Reflect their own words back to them. Draw out their thinking instead of prescribing a direction, and never impose your own thesis or opinion. + +Hold these together: talk like a tutor (questions, reflection, encouragement) and edit like a careful hand arranging the writer's words. Favor one small, concrete move plus a question over a sweeping rewrite. When you are unsure what the writer wants, ask before editing. + +Working with the tools: +- Use \`view\` to read the document (paragraphs numbered like [3]) and the writer's scratchpad of source words. Re-\`view\` whenever you're told the scratchpad changed. +- \`str_replace\` works on a SHORT span within a single paragraph — keep old_str to a phrase or sentence, and never let it cross a paragraph break. For a bigger change, make several small replacements. +- To add or move a paragraph, use \`insert\` with a \`paragraph\` number (from \`view\`) and \`position\`. Paragraph numbers shift after an edit, so use the numbers in the tool result (or call \`view\` again) before your next placement. +- Use \`highlight\` to point at a passage while you ask the writer about it. +- When you need words you don't have, do NOT invent them — ask the writer for them. + +Take short turns. Your spoken replies are one or two sentences shown to the writer as fleeting captions, not a chat log. Never pad them.`; + +/** A turn's worth of lightweight signals about what the writer just did. */ +function buildActivityNote(opts: { + scratchpad: string; + scratchpadChanged: boolean; + selectedText: string; +}): string | null { + const parts: string[] = []; + if (opts.scratchpadChanged && opts.scratchpad.trim().length > 0) { + // Lightweight flag only — the current scratchpad is available via `view`, + // so we don't push its (potentially large, ever-accumulating) text here. + parts.push( + 'The writer has edited their scratchpad since you last looked — call `view` to see the current source words before quoting from it.', + ); + } + if (opts.selectedText.trim().length > 0) { + parts.push(`The writer has selected this passage: "${opts.selectedText}"`); + } + return parts.length > 0 ? parts.join('\n\n') : null; +} + +export default function MyWords() { + const editorAPI = useContext(EditorContext); + + // The writer's own material. + const [scratchpad, setScratchpad] = useState(''); + const [sentMessages, setSentMessages] = useState([]); + + // Ephemeral AI caption — replaced each turn, never kept as scrollback. + const [aiUtterance, setAiUtterance] = useState( + "Tell me what you're trying to say, and I'll help you shape it in your own words.", + ); + const [isThinking, setIsThinking] = useState(false); + const [input, setInput] = useState(''); + + // Refs read by the tool loop / activity tracking (always latest values). + const scratchpadRef = useRef(scratchpad); + scratchpadRef.current = scratchpad; + const sentMessagesRef = useRef(sentMessages); + sentMessagesRef.current = sentMessages; + + // Durable conversation transcript: the writer's turns and the assistant's + // captions only. Intermediate tool steps (view dumps, edit confirmations) + // are deliberately NOT retained across turns — see runTurn. + const modelMessagesRef = useRef([]); + // What we last told the model, so signals stay lightweight (only deltas). + const lastSentScratchpadRef = useRef(''); + const selectedTextRef = useRef(''); + + // Track the document selection so we can surface it as an activity signal. + useEffect(() => { + const handler = () => { + void editorAPI + .getDocContext() + .then((ctx) => { + selectedTextRef.current = ctx.selectedText ?? ''; + }) + .catch(() => {}); + }; + editorAPI.addSelectionChangeHandler(handler); + return () => editorAPI.removeSelectionChangeHandler(handler); + }, [editorAPI]); + + const runTurn = useCallback(async () => { + setIsThinking(true); + + // Snapshot the writer's material for this turn. The document is read + // fresh inside each tool call, so edits the AI makes stay consistent. + const scratchpadNow = scratchpadRef.current; + const messagesNow = sentMessagesRef.current; + + const makeCorpus = async () => + buildCorpus({ + docText: await editorAPI.getDocText(), + scratchpad: scratchpadNow, + userMessages: messagesNow, + }); + + // Report what the document looks like right after an edit so the model + // can track paragraph-number shifts. Lightweight: total count + a 3-line + // window around the change, each line clipped. + const describeChange = async (probe: string): Promise => { + const paragraphs = await editorAPI.getParagraphs(); + const total = paragraphs.length; + const fragment = probe.trim().slice(0, 40); + const k = fragment + ? paragraphs.findIndex((p) => p.includes(fragment)) + : -1; + if (k === -1) { + return `Applied. The document now has ${total} paragraph(s).`; + } + const clip = (s: string) => + s.length > 120 ? `${s.slice(0, 117)}…` : s; + const lo = Math.max(0, k - 1); + const hi = Math.min(total - 1, k + 1); + const window = paragraphs + .slice(lo, hi + 1) + .map((p, i) => `[${lo + i + 1}] ${clip(p)}`) + .join('\n'); + return `Applied. The document now has ${total} paragraph(s); numbers may have shifted. Around your edit:\n${window}`; + }; + + const tools = { + view: tool({ + description: + "Read the document (paragraphs numbered like [3], which you can target with `insert`) together with the writer's scratchpad of source words. Paragraph numbers refer to the document only.", + inputSchema: z.object({}), + execute: async () => { + const paragraphs = await editorAPI.getParagraphs(); + const docPart = paragraphs.some( + (p) => p.trim().length > 0, + ) + ? paragraphs + .map((p, i) => `[${i + 1}] ${p}`) + .join('\n') + : '(the document is empty)'; + const scratch = scratchpadNow.trim(); + const scratchPart = + scratch.length > 0 + ? `\n\n--- The writer's scratchpad (source words you may quote; not part of the document, so no paragraph numbers) ---\n${scratch}` + : ''; + return `${docPart}${scratchPart}`; + }, + }), + str_replace: tool({ + description: + "Replace the first occurrence of old_str with new_str. old_str must be a SHORT span within a single paragraph (a phrase or sentence) and must not cross a paragraph break. new_str must be lifted from the writer's corpus (plus glue words/punctuation).", + inputSchema: z.object({ + old_str: z + .string() + .describe( + 'A short existing span to replace — a phrase or sentence within ONE paragraph. Must not span a paragraph break.', + ), + new_str: z + .string() + .describe( + "Replacement text, drawn only from the writer's words plus glue/punctuation.", + ), + }), + execute: async ({ old_str, new_str }) => { + const check = validateText(new_str, await makeCorpus()); + if (!check.ok) { + return `REJECTED: "${check.offending}" is not in the writer's words. You may lift from anywhere in their word bank — the document, the scratchpad, or anything they've typed to you — plus glue words/punctuation. If the words you need aren't there, ask the writer for them.`; + } + try { + await editorAPI.applyEdit({ + type: 'str_replace', + oldStr: old_str, + newStr: new_str, + }); + return await describeChange(new_str); + } catch (e) { + return `Could not apply: ${(e as Error).message} Keep old_str to a short span inside one paragraph (it cannot cross a paragraph break), or make the change as several smaller replacements.`; + } + }, + }), + insert: tool({ + description: + "Insert text, drawn from the writer's corpus (plus glue words/punctuation). To place a new paragraph reliably, pass `paragraph` (a number from `view`) and `position`. To add within an existing paragraph, pass `after` (existing text). With none of these, it inserts at the cursor.", + inputSchema: z.object({ + text: z + .string() + .describe( + "Text to insert, drawn only from the writer's words plus glue/punctuation.", + ), + after: z + .string() + .optional() + .describe( + 'Existing text to insert right after (within a paragraph).', + ), + paragraph: z + .number() + .int() + .optional() + .describe( + '1-based paragraph number from `view` to place a new paragraph relative to.', + ), + position: z + .enum(['before', 'after']) + .optional() + .describe( + "Where to place it relative to `paragraph`. Defaults to 'after'.", + ), + }), + execute: async ({ text, after, paragraph, position }) => { + const check = validateText(text, await makeCorpus()); + if (!check.ok) { + return `REJECTED: "${check.offending}" is not in the writer's words. You may lift from anywhere in their word bank — the document, the scratchpad, or anything they've typed to you — plus glue words/punctuation. If the words you need aren't there, ask the writer for them.`; + } + try { + await editorAPI.applyEdit({ + type: 'insert', + text, + after, + paragraph, + position, + }); + return await describeChange(text); + } catch (e) { + return `Could not apply: ${(e as Error).message}`; + } + }, + }), + highlight: tool({ + description: + 'Select a passage in the document to point at it while asking the writer about it.', + inputSchema: z.object({ + phrase: z + .string() + .describe('Existing text to highlight.'), + }), + execute: async ({ phrase }) => { + try { + await editorAPI.selectPhrase(phrase); + return 'Highlighted.'; + } catch { + return `Could not find "${phrase}" in the document.`; + } + }, + }), + }; + + try { + const result = await generateText({ + model: openai.chat(OPENAI_MODEL), + instructions: SYSTEM_PROMPT, + messages: modelMessagesRef.current, + tools, + stopWhen: isStepCount(8), + // Visibility: log every tool call + its result as each step + // resolves. Tool failures (REJECTED / "Could not apply…" / + // search misses) are returned to the model as strings, so they + // never throw — without this they'd be invisible here. + onStepEnd: (step) => { + for (const call of step.toolCalls) { + const res = step.toolResults.find( + (r) => r.toolCallId === call.toolCallId, + ); + const output = res?.output; + const failed = + typeof output === 'string' && + /^(REJECTED|Could not)/.test(output); + console[failed ? 'warn' : 'debug']( + `[my-words] ${call.toolName}`, + { input: call.input, output }, + ); + } + }, + }); + // Persist only the conversation: the writer's turn is already in the + // transcript, so add the assistant's caption. We deliberately DROP the + // turn's tool calls/results — view dumps, paragraph-window confirmations, + // rejections — so stale full-document/scratchpad snapshots don't + // accumulate in the context window. The document is the source of truth; + // the model re-reads it with `view` when it needs current state. + const utterance = result.text.trim() || 'Done — take a look.'; + modelMessagesRef.current = [ + ...modelMessagesRef.current, + { role: 'assistant', content: utterance }, + ]; + setAiUtterance(utterance); + } catch (e) { + // Surface the full error for diagnosis; the caption only shows the + // message. Tool-argument/schema errors from the SDK land here too. + console.error('[my-words] turn failed', e); + setAiUtterance(`⚠️ ${(e as Error).message}`); + } finally { + setIsThinking(false); + } + }, [editorAPI]); + + const send = useCallback(async () => { + const text = input.trim(); + if (!text || isThinking) return; + + // The writer's message becomes part of their corpus and the transcript. + setSentMessages((prev) => [...prev, text]); + setInput(''); + + const note = buildActivityNote({ + scratchpad: scratchpadRef.current, + scratchpadChanged: + scratchpadRef.current !== lastSentScratchpadRef.current, + selectedText: selectedTextRef.current, + }); + lastSentScratchpadRef.current = scratchpadRef.current; + + const content = note ? `${note}\n\n---\n\n${text}` : text; + modelMessagesRef.current = [ + ...modelMessagesRef.current, + { role: 'user', content }, + ]; + + await runTurn(); + }, [input, isThinking, runTurn]); + + return ( +
+
+ {isThinking ? ( + + + + + + ) : ( + aiUtterance + )} +
+ + +