diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..2414762 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,5 @@ +# Copy to `.dev.vars` for local `wrangler dev`. Never commit `.dev.vars`. +# Generate with: openssl rand -base64 32 +API_KEY_COOKIE_SECRET= +# Optional, for secret rotation: +# API_KEY_COOKIE_SECRET_PREVIOUS= diff --git a/.gitignore b/.gitignore index d980113..592b177 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +.dev.vars +.wrangler/ # Editor directories and files .vscode/* diff --git a/AGENTS.md b/AGENTS.md index 6d6bd1d..2b7e086 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -849,12 +849,15 @@ Close the session when done: `pw close` (optionally `pw delete-data`). - Hide `ConversationHint` during TEF Ad Persuasion and TEF Ad Questioning practice only; keep hints for role-play scenario practice and free conversation. - TEF in-session practice guide: per-topic accordions; on start/restart auto-attach the latest topic archive for the current ad (`latest_auto`). - In LLM system prompts, prefer short behavioral rules over hardcoded lists of French verbs or phrases that may be appropriate in other conversational contexts. -- When rebuilding conversation context for the LLM (including regenerating an AI reply), send every user turn as the original audio recording, not transcript text—transcripts are often inaccurate. Match the TEF/review audio-first pattern. +- When rebuilding conversation context for the LLM (including regenerating an AI reply and BFF `POST /api/chat`), send every user turn as the original audio recording, not transcript text—transcripts are often inaccurate. Existing text-history reconstruction may remain for text-only paths; do not add new transcript-only substitutes for audio-history flows (chat, reviews). ## Learned Workspace Facts -- Continual-learning transcript processing for this project uses an index file under the main checkout: `01-projects/parle/.cursor/hooks/state/continual-learning-index.json`. `AGENTS.md` may be edited from a Cursor worktree (e.g. `worktrees/parle//AGENTS.md`), so hook state and agent memory paths are not always the same directory. +- Continual-learning indexes can live in the main checkout (`01-projects/parle/.cursor/hooks/state/continual-learning-index.json`) or a worktree (`.cursor/hooks/state/continual-learning-index.json`); `AGENTS.md` may be edited from either, so hook state and memory paths are not always the same directory. - Approved UI reference mockups for Parle may be extracted under `.mockup-ref/` (e.g. `TopicHistoryV2Demo.tsx`); treat as implementation reference only, not production dependencies. +- API keys are stored in an HttpOnly cookie sealed by a Cloudflare Worker BFF (`worker/`); `/api/*` runs Worker-first. Instantiate `@google/genai` per request in the Worker; do not bundle LangChain there—OpenAI planning uses fetch plus shared Zod. +- Stateless chat and related AI routes validate model JSON fail-closed on the Worker with the shared Zod schemas in `shared/chatSchemas.ts`, even if the client omits the schema. +- `.wrangler/` is gitignored Miniflare local state; keep `wrangler.jsonc`, `worker/`, and `worker-configuration.d.ts` in git. Local full-stack is `npm run dev:full` (Vite :3000 + wrangler :8787). --- diff --git a/App.tsx b/App.tsx index 9814fd9..fcc1aef 100644 --- a/App.tsx +++ b/App.tsx @@ -6,7 +6,12 @@ import { useConversationTimer } from './hooks/useConversationTimer'; import { initializeSession, sendVoiceMessage, resetSession, resetSessionWithUserAudioHistory, setScenario, transcribeAndCleanupAudio, generateCharacterSpeech, PIPELINE_MAX_MS } from './services/geminiService'; import { processScenarioDescriptionOpenAI } from './services/openaiService'; import { clearHistory, getConversationHistory, setHistory } from './services/conversationHistory'; -import { hasApiKeyOrEnv } from './services/apiKeyService'; +import { BffError } from './services/bffClient'; +import { + clearLegacyLocalStorageKeys, + hasApiKeyOrEnv, + refreshSessionStatus, +} from './services/apiKeyService'; import { assignVoicesToCharacters } from './services/voiceService'; import { generateId, @@ -600,6 +605,8 @@ const App: React.FC = () => { // API Key management state const [showApiKeyModal, setShowApiKeyModal] = useState(false); + const [apiKeyModalError, setApiKeyModalError] = useState(null); + const [sessionEpoch, setSessionEpoch] = useState(0); const [apiKeyCheckDone, setApiKeyCheckDone] = useState(false); // Ref to track if we're recording for scenario description @@ -633,6 +640,7 @@ const App: React.FC = () => { const [chatProcessingErrorMessage, setChatProcessingErrorMessage] = useState(''); const hasMessages = messages.length > 0; + void sessionEpoch; const geminiKeyMissing = apiKeyCheckDone && !hasApiKeyOrEnv('gemini'); /** @@ -669,6 +677,9 @@ const App: React.FC = () => { } catch (error) { console.error('Failed to initialize durable local data:', error); } + clearLegacyLocalStorageKeys(); + await refreshSessionStatus(); + setSessionEpoch((value) => value + 1); setApiKeyCheckDone(true); if (hasApiKeyOrEnv('gemini')) { try { @@ -683,7 +694,7 @@ const App: React.FC = () => { // Handle API key save - re-initialize services if needed const handleApiKeySave = async () => { - // Re-initialize Gemini session if Gemini key is now available + setSessionEpoch((value) => value + 1); if (hasApiKeyOrEnv('gemini')) { try { await initializeSession(); @@ -694,10 +705,15 @@ const App: React.FC = () => { setApiKeyCheckDone(true); }; + const openApiKeyModal = (message?: string) => { + setApiKeyModalError(message ?? null); + setShowApiKeyModal(true); + }; + // Handle API key modal close const handleApiKeyModalClose = () => { setShowApiKeyModal(false); - // Mark that user has been offered the chance to enter keys + setApiKeyModalError(null); setApiKeyCheckDone(true); }; @@ -761,7 +777,7 @@ const App: React.FC = () => { const handleStartRecording = async () => { if (!hasApiKeyOrEnv('gemini')) { - setShowApiKeyModal(true); + openApiKeyModal(); return; } if (!hasStarted) await handleStartInteraction(); @@ -883,6 +899,13 @@ const App: React.FC = () => { const { base64, mimeType } = audioData; + const priorMessages = isRegenerate + ? (() => { + const turn = findLastAssistantTurn(messagesRef.current); + return turn ? messagesRef.current.slice(0, turn.lastUserIndex) : []; + })() + : messagesRef.current; + // Build phase-based per-turn context for TEF Ad practice // Skip context injection for the very first message (greeting turn) let phaseContextText: string | undefined; @@ -904,7 +927,8 @@ const App: React.FC = () => { base64, mimeType, pipelineSignal, - phaseContextText + phaseContextText, + priorMessages ); // Check if user aborted or a newer request has started (stale response) @@ -1194,6 +1218,14 @@ const App: React.FC = () => { } console.error("Interaction failed", error); + if (error instanceof BffError && error.code === 'UPSTREAM_AUTH_FAILED') { + openApiKeyModal(error.message); + setChatProcessingErrorMessage(error.message); + setCanRetryChatAudio(true); + setAppState(AppState.ERROR); + showErrorFlash(error.message); + return; + } setChatProcessingErrorMessage(defaultMsg); setCanRetryChatAudio(true); setAppState(AppState.ERROR); @@ -1435,7 +1467,7 @@ const App: React.FC = () => { const handleStartRecordingDescription = async () => { // Scenario creation requires both Gemini (transcription) and OpenAI (planning) if (!hasApiKeyOrEnv('gemini') || !hasApiKeyOrEnv('openai')) { - setShowApiKeyModal(true); + openApiKeyModal(); return; } try { @@ -1686,7 +1718,7 @@ const App: React.FC = () => { const handleSubmitScenarioDescription = async (description: string, name: string) => { // Scenario creation requires both Gemini (transcription) and OpenAI (planning) if (!hasApiKeyOrEnv('gemini') || !hasApiKeyOrEnv('openai')) { - setShowApiKeyModal(true); + openApiKeyModal(); return; } await processScenarioDescriptionAndPopulate(description); @@ -1700,7 +1732,7 @@ const App: React.FC = () => { // existing id so "Start Practice" from there updates it in place. const handleRegenerateRoadmapForScenario = async (scenario: Scenario) => { if (!hasApiKeyOrEnv('gemini') || !hasApiKeyOrEnv('openai')) { - setShowApiKeyModal(true); + openApiKeyModal(); return; } setRegeneratingScenario(scenario); @@ -1937,7 +1969,7 @@ const App: React.FC = () => { existingAdId?: string ) => { if (!hasApiKeyOrEnv('gemini')) { - setShowApiKeyModal(true); + openApiKeyModal(); return; } @@ -2183,7 +2215,7 @@ const App: React.FC = () => { existingAdId?: string ) => { if (!hasApiKeyOrEnv('gemini')) { - setShowApiKeyModal(true); + openApiKeyModal(); return; } @@ -2516,7 +2548,7 @@ const App: React.FC = () => { setShowApiKeyModal(true)} + onOpenSettings={() => openApiKeyModal()} disabledModes={navDisabledModes} rightSlot={ <> @@ -2781,7 +2813,7 @@ const App: React.FC = () => { recentAdsRefreshToken={recentAdsRefreshToken} onClose={handleCloseTefAdSetup} geminiKeyMissing={geminiKeyMissing} - onOpenApiKeyModal={() => setShowApiKeyModal(true)} + onOpenApiKeyModal={() => openApiKeyModal()} /> )} @@ -2795,7 +2827,7 @@ const App: React.FC = () => { recentAdsRefreshToken={recentAdsRefreshToken} onClose={() => setTefQuestioningMode('none')} geminiKeyMissing={geminiKeyMissing} - onOpenApiKeyModal={() => setShowApiKeyModal(true)} + onOpenApiKeyModal={() => openApiKeyModal()} /> )} @@ -2914,7 +2946,7 @@ const App: React.FC = () => { {scenarioMode === 'setup' && ( setShowApiKeyModal(true)} + onOpenApiKeyModal={() => openApiKeyModal()} onClose={handleCloseScenarioSetup} isRecordingDescription={isRecordingDescription} isTranscribingDescription={isTranscribingDescription} @@ -2952,6 +2984,7 @@ const App: React.FC = () => { onClose={handleApiKeyModalClose} onSave={handleApiKeySave} onImported={() => setRecentAdsRefreshToken((token) => token + 1)} + initialError={apiKeyModalError} /> )} diff --git a/README.md b/README.md index aeddf0b..28f47e6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ## What is Parle? -Parle is a web app for practicing French conversation using your microphone and AI. You speak, the app transcribes and replies in French (with optional English), and you hear the reply via text-to-speech. No account required to try it; API keys are configured in the app or via environment variables. +Parle is a web app for practicing French conversation using your microphone and AI. You speak, the app transcribes and replies in French (with optional English), and you hear the reply via text-to-speech. No account required to try it; paste your API keys in Settings. The browser never stores readable keys — they are sealed in an HttpOnly cookie by the Cloudflare Worker BFF. ### Features @@ -26,7 +26,7 @@ and (where applicable) timers and summaries are shown in the UI. ## Tech stack - **Frontend:** React 19, Vite 7, TypeScript, Tailwind CSS (French-flag-inspired blue/white/red design tokens; responsive at `tablet` 760px / `desktop` 1200px breakpoints) -- **AI:** Google Gemini (transcription, chat, TTS); OpenAI optional for scenario creation from a description +- **BFF:** Cloudflare Worker (`worker/`) with static assets + `/api/*` routes; Gemini via `@google/genai` on the Worker; OpenAI scenario planning via `fetch` - **Tests:** Vitest (unit), Playwright (e2e) --- @@ -34,7 +34,8 @@ and (where applicable) timers and summaries are shown in the UI. ## Prerequisites - **Node.js** (LTS recommended) -- **API keys:** +- **Wrangler** (installed with `npm install`) for the local BFF Worker +- **API keys** (pasted in Settings; sealed into an HttpOnly cookie): - **Gemini** — required for voice conversation, scenario practice, and TEF modes (transcription, chat, TTS). - **OpenAI** — optional; used only when creating a scenario from a spoken/typed description (scenario planning). @@ -47,27 +48,27 @@ and (where applicable) timers and summaries are shown in the UI. npm install ``` -2. **Configure API keys** (pick one approach) - - **Option A — Environment (recommended for development)** - Create `.env.local` in the project root: - ```env - GEMINI_API_KEY=your_gemini_key - OPENAI_API_KEY=your_openai_key # optional, for scenario-from-description - ``` - - **Option B — In-app** - Run the app; if no keys are found, you’ll be prompted to enter them. Keys are stored in the browser and override env vars. - -3. **Start the app** +2. **Configure the Worker cookie secret** ```bash - npm run dev + cp .dev.vars.example .dev.vars + # Set API_KEY_COOKIE_SECRET to a high-entropy value, e.g. `openssl rand -base64 32` ``` - Open the URL shown in the terminal (e.g. `http://localhost:5173`). + +3. **Start Vite and the Worker together** + ```bash + npm run dev:full + ``` + Or run `npm run dev` (http://localhost:3000) and `npm run dev:worker` (http://localhost:8787) in two terminals. Vite proxies `/api` to the Worker. + +4. **Paste API keys in Settings** (gear icon). Empty fields leave a saved key unchanged. Use Remove to delete a key. ### Other commands | Command | Purpose | |---------|--------| -| `npm run build` | Production build | +| `npm run build` | Production frontend build | +| `npm run deploy` | Build + `wrangler deploy` | +| `npm run types` | Regenerate `worker-configuration.d.ts` from `wrangler.jsonc` | | `npm run preview` | Preview production build locally | | `npm test` | Run unit tests (Vitest) | | `npm run test:e2e` | Run E2E tests (Playwright; run `npm run test:e2e:install` once to install browsers) | @@ -80,7 +81,9 @@ and (where applicable) timers and summaries are shown in the UI. |------|----------| | `App.tsx` | Main UI and mode orchestration (free chat, scenario, TEF Ad persuasion/questioning) | | `components/` | UI (Orb, Controls, conversation history, setup flows, timers, summaries); app shell (`NavRail`, `TopBar`) and `ScenarioRoadmap` (scenario step progress outline) | -| `services/` | Gemini (session, voice message, TTS), OpenAI (scenario planning), scenario/voice/API-key helpers, IndexedDB archives, `.parle` backup export/import | +| `services/` | Client BFF calls (`geminiService`, scenario planning, reviews); IndexedDB archives, `.parle` backup | +| `worker/` | Cloudflare Worker: session cookie seal/CSRF, typed `/api/*` AI routes | +| `shared/` | Prompts and Zod chat schemas used by Worker and client | | `hooks/` | Audio, conversation timer, document head | | `utils/` | Abort signal combiner, abort error helper, time helpers | | `__tests__/` | Unit tests | diff --git a/__tests__/adPersuasionCredentials.test.ts b/__tests__/adPersuasionCredentials.test.ts index 5d47292..0384ad5 100644 --- a/__tests__/adPersuasionCredentials.test.ts +++ b/__tests__/adPersuasionCredentials.test.ts @@ -60,14 +60,15 @@ function selectFile(file: File) { // --------------------------------------------------------------------------- describe('apiKeyService · hasApiKeyOrEnv', () => { - beforeEach(() => { + beforeEach(async () => { localStorage.clear(); + const { hydrateSessionStatus } = await import('../services/apiKeyService'); + hydrateSessionStatus({ hasGemini: false, hasOpenai: false, hasApiKey: false }); }); afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); - delete process.env.GEMINI_API_KEY; }); it('is importable from the apiKeyService module', async () => { @@ -75,37 +76,34 @@ describe('apiKeyService · hasApiKeyOrEnv', () => { expect(typeof mod.hasApiKeyOrEnv).toBe('function'); }); - it('returns false for "gemini" when localStorage is empty and env var is absent', async () => { + it('returns false for "gemini" when no BFF session is cached', async () => { const { hasApiKeyOrEnv } = await import('../services/apiKeyService'); - delete process.env.GEMINI_API_KEY; expect(hasApiKeyOrEnv('gemini')).toBe(false); }); - it('returns true for "gemini" when a key is stored in localStorage', async () => { + it('returns true for "gemini" after the session cache is hydrated', async () => { const { hasApiKeyOrEnv, setApiKey } = await import('../services/apiKeyService'); setApiKey('gemini', 'test-gemini-key-123'); expect(hasApiKeyOrEnv('gemini')).toBe(true); }); - it('returns true for "gemini" when GEMINI_API_KEY env var is set', async () => { - const { hasApiKeyOrEnv } = await import('../services/apiKeyService'); + it('does not treat a Vite/env GEMINI_API_KEY as a client-readable key', async () => { + const { hasApiKeyOrEnv, getApiKeyOrEnv } = await import('../services/apiKeyService'); process.env.GEMINI_API_KEY = 'env-gemini-key'; - expect(hasApiKeyOrEnv('gemini')).toBe(true); + expect(hasApiKeyOrEnv('gemini')).toBe(false); + expect(getApiKeyOrEnv('gemini')).toBeNull(); delete process.env.GEMINI_API_KEY; }); - it('localStorage key takes precedence over env variable', async () => { + it('never returns the raw key to JavaScript', async () => { const { hasApiKeyOrEnv, setApiKey, getApiKeyOrEnv } = await import('../services/apiKeyService'); - process.env.GEMINI_API_KEY = 'env-key'; setApiKey('gemini', 'stored-key'); - expect(getApiKeyOrEnv('gemini')).toBe('stored-key'); + expect(getApiKeyOrEnv('gemini')).toBeNull(); expect(hasApiKeyOrEnv('gemini')).toBe(true); - delete process.env.GEMINI_API_KEY; }); - it('removing key from localStorage causes hasApiKeyOrEnv to return false (when no env var)', async () => { + it('clearing the cached Gemini flag makes hasApiKeyOrEnv return false', async () => { const { hasApiKeyOrEnv, setApiKey } = await import('../services/apiKeyService'); - delete process.env.GEMINI_API_KEY; setApiKey('gemini', 'temp-key'); expect(hasApiKeyOrEnv('gemini')).toBe(true); setApiKey('gemini', ''); diff --git a/__tests__/helpers/mockParleBff.ts b/__tests__/helpers/mockParleBff.ts new file mode 100644 index 0000000..dd7309e --- /dev/null +++ b/__tests__/helpers/mockParleBff.ts @@ -0,0 +1,38 @@ +import { vi } from 'vitest'; + +export function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export function mockParleBff(options?: { + transcription?: string; + modelJson?: unknown; + onChat?: (body: Record) => void; +}) { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/transcribe')) { + return jsonResponse({ text: options?.transcription ?? 'Bonjour.' }); + } + if (url.includes('/api/chat')) { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + options?.onChat?.(body); + return jsonResponse({ + modelJson: options?.modelJson ?? { + french: 'Bonjour!', + english: 'Hello!', + hint: 'Continue', + }, + }); + } + if (url.includes('/api/tts')) { + return jsonResponse({ audioBase64: 'ZmFrZQ==', mimeType: 'audio/pcm' }); + } + return jsonResponse({ error: 'NOT_FOUND' }, 404); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} diff --git a/__tests__/openaiService.roadmapSteps.test.ts b/__tests__/openaiService.roadmapSteps.test.ts index 1f00337..9f272a5 100644 --- a/__tests__/openaiService.roadmapSteps.test.ts +++ b/__tests__/openaiService.roadmapSteps.test.ts @@ -1,91 +1,37 @@ /** - * TDD tests for AI-generated scenario roadmap steps. - * - * Replaces the sentence-split heuristic (`seedRoadmapStepsFromSummary`) as the - * PRIMARY source of roadmap-editor steps: the existing OpenAI scenario-planning - * call (`processScenarioDescriptionOpenAI`, which already returns `summary` and - * `characters` via LangChain structured output) is extended to also return a - * `steps` array in the same call — no extra request, same latency/cost as today. - * The heuristic remains as a defensive fallback only (non-JSON legacy response, - * or the model omitting/returning an unusably short `steps` array). - * - * Contract this file pins down for the implementation (services/openaiService.ts): - * - The Zod schema passed to `model.withStructuredOutput(...)` must accept a - * `steps` field: an array of at least 2 short strings (roadmap step text). - * A payload missing `steps` (or with fewer than 2) must fail schema validation, - * the same way a payload missing `summary` or `characters` already does. - * - `processScenarioDescriptionOpenAI(description)` must return a JSON string - * whose parsed `steps` field is exactly the array the structured-output call - * resolved with. - * - On error (the try/catch fallback path), the returned JSON must include - * `steps: []` alongside the existing fallback `summary`/`characters`, so - * callers can rely on `steps` always being present (possibly empty) and - * never `undefined`. - * - * Tests FAIL before the implementation is in place. + * Scenario planning now goes through POST /api/scenario-plan. + * The Worker validates with ScenarioSummarySchema (2–8 steps). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -let capturedSchema: any = null; -let mockInvoke = vi.fn(); - -vi.mock('@langchain/openai', () => { - return { - // Must be a real function (not an arrow function) so `new ChatOpenAI(...)` - // in the implementation under test can construct it. - ChatOpenAI: vi.fn().mockImplementation(function ChatOpenAIMock() { - return { - withStructuredOutput: vi.fn().mockImplementation((schema: any) => { - capturedSchema = schema; - return { invoke: mockInvoke }; - }), - }; - }), - }; -}); +import { ScenarioSummarySchema } from '../shared/chatSchemas'; beforeEach(() => { - localStorage.setItem('parle_api_key_openai', 'test-key-roadmap-steps'); - capturedSchema = null; - mockInvoke = vi.fn(); + vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.resetModules(); }); describe('AI-generated scenario roadmap steps (services/openaiService.ts)', () => { - it('requires a `steps` array of at least 2 entries in the structured-output schema', async () => { - const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); - mockInvoke.mockResolvedValue({ - summary: 'A trip to the bakery.', - characters: [{ name: 'Baker', role: 'baker' }], - steps: ['Greet the baker', 'Order a baguette', 'Pay and leave'], - }); - - await processScenarioDescriptionOpenAI('I went to a bakery'); - - expect(capturedSchema).toBeTruthy(); - // Missing `steps` entirely must fail validation. - const withoutSteps = capturedSchema.safeParse({ + it('requires a `steps` array of at least 2 entries in the shared schema', () => { + const withoutSteps = ScenarioSummarySchema.safeParse({ summary: 'A trip to the bakery.', characters: [{ name: 'Baker', role: 'baker' }], }); expect(withoutSteps.success).toBe(false); - // Fewer than 2 steps must fail validation. - const tooFewSteps = capturedSchema.safeParse({ + const tooFewSteps = ScenarioSummarySchema.safeParse({ summary: 'A trip to the bakery.', characters: [{ name: 'Baker', role: 'baker' }], steps: ['Only one step'], }); expect(tooFewSteps.success).toBe(false); - // 2+ steps must pass validation. - const validSteps = capturedSchema.safeParse({ + const validSteps = ScenarioSummarySchema.safeParse({ summary: 'A trip to the bakery.', characters: [{ name: 'Baker', role: 'baker' }], steps: ['Greet the baker', 'Order a baguette'], @@ -94,30 +40,36 @@ describe('AI-generated scenario roadmap steps (services/openaiService.ts)', () = }); it('returns the AI-generated steps in the parsed JSON result', async () => { - const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); const aiSteps = ['Greet the baker', 'Ask for a baguette', 'Order two croissants', 'Pay the total']; - mockInvoke.mockResolvedValue({ - summary: 'A trip to the bakery.', - characters: [{ name: 'Baker', role: 'baker' }], - steps: aiSteps, - }); + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ + result: JSON.stringify({ + summary: 'A trip to the bakery.', + characters: [{ name: 'Baker', role: 'baker' }], + steps: aiSteps, + }), + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); + const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); const result = await processScenarioDescriptionOpenAI('I went to a bakery and bought bread'); const parsed = JSON.parse(result); - expect(parsed.steps).toEqual(aiSteps); }); - it('falls back to an empty steps array (not undefined) when the OpenAI call fails', async () => { - const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); - mockInvoke.mockRejectedValue(new Error('network error')); - - const result = await processScenarioDescriptionOpenAI('a scenario description'); - const parsed = JSON.parse(result); + it('propagates a 502 UPSTREAM_ERROR instead of returning empty characters and steps', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ error: 'UPSTREAM_ERROR' }), { + status: 502, + headers: { 'Content-Type': 'application/json' }, + }) + ); - expect(parsed.steps).toEqual([]); - // Existing fallback fields must still be present. - expect(typeof parsed.summary).toBe('string'); - expect(parsed.characters).toEqual([]); + const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); + await expect(processScenarioDescriptionOpenAI('a scenario description')).rejects.toMatchObject({ + name: 'BffError', + code: 'UPSTREAM_ERROR', + httpStatus: 502, + }); }); }); diff --git a/__tests__/openaiService.scenarioDescriptionAbort.test.ts b/__tests__/openaiService.scenarioDescriptionAbort.test.ts index d57a7fe..f6cdba3 100644 --- a/__tests__/openaiService.scenarioDescriptionAbort.test.ts +++ b/__tests__/openaiService.scenarioDescriptionAbort.test.ts @@ -1,97 +1,67 @@ /** - * TDD tests for a real race condition found in live usage: clicking "Start" - * on a saved scenario without a roadmap kicks off an AI planning request; if - * the user navigates back and clicks "Start" again (same or a different - * scenario) before the first request resolves, BOTH requests run - * concurrently, and whichever settles last wins — even if it's the - * abandoned one. Observed: the first (stale) request could resolve after - * the second (current) one and silently overwrite its data. - * - * Fix (this file): `processScenarioDescriptionOpenAI` accepts an optional - * `AbortSignal` and passes it through to the LangChain call via - * `RunnableConfig.signal`, so an aborted request's underlying call is - * actually cancelled rather than left to run to completion. An abort-like - * error (per the existing `isAbortLikeError` convention) is RE-THROWN - * rather than swallowed into the generic fallback response, so callers can - * tell an intentional cancel apart from a real failure. - * - * The App.tsx-side request-token guarding (which supersedes/discards stale - * requests regardless of settle order) is covered separately by - * `scenarioDescriptionAbort.source.test.ts`. - * - * Tests FAIL before the implementation exists. + * processScenarioDescriptionOpenAI forwards AbortSignal to fetch('/api/scenario-plan') + * and rethrows abort-like errors instead of returning the fallback JSON. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -let capturedInvokeArgs: unknown[] = []; -let mockInvoke = vi.fn(); - -vi.mock('@langchain/openai', () => { - return { - ChatOpenAI: vi.fn().mockImplementation(function ChatOpenAIMock() { - return { - withStructuredOutput: vi.fn().mockImplementation(() => ({ - invoke: (...args: unknown[]) => { - capturedInvokeArgs = args; - return mockInvoke(...args); - }, - })), - }; - }), - }; -}); - beforeEach(() => { - localStorage.setItem('parle_api_key_openai', 'test-key-abort'); - capturedInvokeArgs = []; - mockInvoke = vi.fn(); + vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.resetModules(); }); describe('processScenarioDescriptionOpenAI: AbortSignal threading', () => { - it('passes the provided signal through to the LangChain invoke call config', async () => { + it('passes the provided signal through to fetch', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ + result: JSON.stringify({ summary: 'ok', characters: [], steps: ['a', 'b'] }), + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); - mockInvoke.mockResolvedValue({ summary: 'ok', characters: [], steps: ['a', 'b'] }); - const controller = new AbortController(); await processScenarioDescriptionOpenAI('a description', controller.signal); - expect(capturedInvokeArgs.length).toBeGreaterThanOrEqual(2); - const config = capturedInvokeArgs[1] as { signal?: AbortSignal }; - expect(config?.signal).toBe(controller.signal); + expect(fetch).toHaveBeenCalledWith( + '/api/scenario-plan', + expect.objectContaining({ + method: 'POST', + signal: controller.signal, + }) + ); }); it('works without a signal (backward compatible, signal is optional)', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ + result: JSON.stringify({ summary: 'ok', characters: [], steps: ['a', 'b'] }), + }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + ); const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); - mockInvoke.mockResolvedValue({ summary: 'ok', characters: [], steps: ['a', 'b'] }); - const result = await processScenarioDescriptionOpenAI('a description'); expect(JSON.parse(result).summary).toBe('ok'); }); it('re-throws an abort-like error instead of swallowing it into a fallback response', async () => { - const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); const abortError = new Error('signal is aborted without reason'); abortError.name = 'AbortError'; - mockInvoke.mockRejectedValue(abortError); - + vi.mocked(fetch).mockRejectedValue(abortError); + const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); const controller = new AbortController(); await expect(processScenarioDescriptionOpenAI('a description', controller.signal)).rejects.toThrow(); }); - it('still returns the fallback response for a genuine (non-abort) error', async () => { + it('propagates a genuine (non-abort) error instead of a fallback JSON payload', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('network error')); const { processScenarioDescriptionOpenAI } = await import('../services/openaiService'); - mockInvoke.mockRejectedValue(new Error('network error')); - - const result = await processScenarioDescriptionOpenAI('a description'); - const parsed = JSON.parse(result); - expect(parsed.steps).toEqual([]); - expect(typeof parsed.summary).toBe('string'); + await expect(processScenarioDescriptionOpenAI('a description')).rejects.toMatchObject({ + name: 'BffError', + code: 'UPSTREAM_ERROR', + httpStatus: 502, + }); }); }); diff --git a/__tests__/persuasionFirstMessage.test.ts b/__tests__/persuasionFirstMessage.test.ts index dd7c194..cada6f8 100644 --- a/__tests__/persuasionFirstMessage.test.ts +++ b/__tests__/persuasionFirstMessage.test.ts @@ -17,16 +17,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - // --------------------------------------------------------------------------- // Source-text specs: verify the fix in App.tsx // --------------------------------------------------------------------------- @@ -87,97 +77,49 @@ describe('persuasionFirstMessage · App.tsx source-text specs', () => { describe('persuasionFirstMessage · sendVoiceMessage receives no context on first turn', () => { const FAKE_AUDIO = 'ZmFrZWF1ZGlv'; const FAKE_MIME = 'audio/webm'; - const FAKE_MODEL_RESPONSE = JSON.stringify({ - french: 'Bonjour!', - english: 'Hello!', - hint: 'Introduce the ad', - }); - - function buildMockAi() { - const mockSendMessage = vi.fn().mockResolvedValue({ text: FAKE_MODEL_RESPONSE }); - const mockChatSession = { sendMessage: mockSendMessage }; - const mockGenerateContent = vi.fn() - .mockResolvedValue({ text: 'Bonjour mon ami.' }); - - const mockAi = { - models: { generateContent: mockGenerateContent }, - chats: { create: vi.fn().mockReturnValue(mockChatSession) }, - }; - - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - return { mockSendMessage }; - } - - beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-persuasion-first'); - }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.resetModules(); }); it('sendVoiceMessage 4th argument (contextText) must be undefined/absent for first turn', async () => { - /** - * This test verifies the sendVoiceMessage module export directly: - * when called with no contextText, no text part is prepended. - * That matches the contract: first-turn in App.tsx must NOT pass contextText. - */ - const { mockSendMessage } = buildMockAi(); + const chatBodies: Array> = []; + const { mockParleBff } = await import('./helpers/mockParleBff'); + mockParleBff({ onChat: (body) => chatBodies.push(body) }); const { sendVoiceMessage, initializeSession, setScenario } = await import('../services/geminiService'); - const tefScenario = { + setScenario({ id: 'tef-persuasion', name: 'TEF Ad Persuasion', description: 'You are a skeptical friend.', createdAt: Date.now(), isActive: true, characters: [{ id: 'friend', name: 'Friend', role: 'friend', voiceName: 'aoede' }], - }; - - setScenario(tefScenario as Parameters[0]); + }); await initializeSession(); - - // Simulate first turn: call without contextText (as App.tsx should on first message) await sendVoiceMessage(FAKE_AUDIO, FAKE_MIME, undefined, undefined); - - expect(mockSendMessage).toHaveBeenCalledTimes(1); - const callArg = mockSendMessage.mock.calls[0][0]; - const parts = callArg?.message ?? []; - const textParts = parts.filter((p: Record) => typeof p.text === 'string'); - // No context text part on the first turn - expect(textParts).toHaveLength(0); + expect(chatBodies[0]?.contextText).toBeUndefined(); }); it('sendVoiceMessage with contextText passes a text part — confirming second-turn behaviour', async () => { - const { mockSendMessage } = buildMockAi(); + const chatBodies: Array> = []; + const { mockParleBff } = await import('./helpers/mockParleBff'); + mockParleBff({ onChat: (body) => chatBodies.push(body) }); const { sendVoiceMessage, initializeSession, setScenario } = await import('../services/geminiService'); - const tefScenario = { + setScenario({ id: 'tef-persuasion-2', name: 'TEF Ad Persuasion', description: 'You are a skeptical friend.', createdAt: Date.now(), isActive: true, characters: [{ id: 'friend', name: 'Friend', role: 'friend', voiceName: 'aoede' }], - }; - - setScenario(tefScenario as Parameters[0]); + }); await initializeSession(); - const contextText = '[Per-turn context: early phase — encourage the user to introduce and present the advertisement clearly.]'; await sendVoiceMessage(FAKE_AUDIO, FAKE_MIME, undefined, contextText); - - expect(mockSendMessage).toHaveBeenCalledTimes(1); - const callArg = mockSendMessage.mock.calls[0][0]; - const parts = callArg?.message ?? []; - const textParts = parts.filter((p: Record) => typeof p.text === 'string'); - // Context text part IS present on the second turn - expect(textParts.length).toBeGreaterThan(0); - const hasContext = textParts.some( - (p: { text: string }) => p.text.includes('[Per-turn context:') - ); - expect(hasContext).toBe(true); + expect(String(chatBodies[0]?.contextText ?? '')).toContain('[Per-turn context:'); }); }); diff --git a/__tests__/regenerateAudioHistoryAbort.source.test.ts b/__tests__/regenerateAudioHistoryAbort.source.test.ts index 24e07cd..033bb22 100644 --- a/__tests__/regenerateAudioHistoryAbort.source.test.ts +++ b/__tests__/regenerateAudioHistoryAbort.source.test.ts @@ -24,13 +24,12 @@ describe('regenerate restore / abort guards (source-text)', () => { ); }); - it('resetSessionWithUserAudioHistory throws AbortError before chats.create when aborted', async () => { + it('resetSessionWithUserAudioHistory throws AbortError when aborted', async () => { const src = (await import('../services/geminiService?raw')).default as string; const fnStart = src.indexOf('export const resetSessionWithUserAudioHistory'); expect(fnStart).toBeGreaterThan(-1); - const fnSlice = src.slice(fnStart, src.indexOf('function ensureAiInitialized', fnStart)); - expect(fnSlice).toMatch( - /if\s*\(\s*signal\?\.aborted\s*\)\s*\{[\s\S]*?AbortError[\s\S]*?chatSession\s*=\s*ai\.chats\.create/ - ); + const fnSlice = src.slice(fnStart, fnStart + 800); + expect(fnSlice).toMatch(/if\s*\(\s*signal\?\.aborted\s*\)/); + expect(fnSlice).toMatch(/AbortError/); }); }); diff --git a/__tests__/roadmapMultiCharacterSchema.test.ts b/__tests__/roadmapMultiCharacterSchema.test.ts index 1c92930..b8d5803 100644 --- a/__tests__/roadmapMultiCharacterSchema.test.ts +++ b/__tests__/roadmapMultiCharacterSchema.test.ts @@ -1,64 +1,5 @@ -/** - * TDD tests for a real bug found in live usage: the roadmap auto-advance - * schema field (`currentStepIndex`) was only ever wired into the - * single-character schema branch. Multi-character scenarios (e.g. a bakery - * visit with a Baker + Cashier — the exact example scenario used throughout - * this feature's own design mockups) take the `characters.length > 1` branch - * in `createChatSession()`/`sendVoiceMessage()`, which is checked BEFORE the - * roadmap-steps check, so `currentStepIndex` was silently dropped for any - * scenario with more than one character — the roadmap sidebar would render - * but never advance past step 1. - * - * Mirrors `roadmapSchemaSelection.test.ts`'s structure/mocking approach, but - * exercises the multi-character path (`characters.length > 1`) specifically. - * - * Contract this file pins down for the fix (services/geminiService.ts): - * - When `activeScenario.characters.length > 1` AND `activeScenario.steps` - * is a non-empty array, the multi-character response schema must ALSO - * include a "currentStepIndex" property (in addition to the existing - * `characterResponses`/`hint` fields). - * - When `activeScenario.characters.length > 1` and `steps` is empty/absent, - * the multi-character schema must NOT contain "currentStepIndex" (existing - * behavior, must not regress). - * - * Tests FAIL before the fix (schema omits "currentStepIndex" for the - * multi-character + roadmap-steps combination). - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - -beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-roadmap-multichar-schema'); -}); - -afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - vi.resetModules(); -}); - -function buildMockAiCapturingChatCreate() { - const mockSendMessage = vi.fn().mockResolvedValue({ text: '{}' }); - const mockChatSession = { sendMessage: mockSendMessage }; - const createSpy = vi.fn().mockReturnValue(mockChatSession); - const mockGenerateContent = vi.fn().mockResolvedValue({ text: 'transcribed text' }); - const mockAi = { - models: { generateContent: mockGenerateContent }, - chats: { create: createSpy }, - }; - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - return { createSpy }; -} +import { describe, it, expect } from 'vitest'; +import { createMultiCharacterSchema, selectGeminiResponseSchema } from '../shared/chatSchemas'; const multiCharacterRoadmapScenario = { id: 'bakery-multichar-1', @@ -77,38 +18,39 @@ const multiCharacterRoadmapScenario = { ], }; -describe('createChatSession · multi-character scenario with roadmap steps', () => { - it('includes "currentStepIndex" in the multi-character response schema', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - const { initializeSession, setScenario } = await import('../services/geminiService'); - - setScenario(multiCharacterRoadmapScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - const schemaStr = JSON.stringify(schema); - - // The multi-character shape must still be present (not replaced). +describe('Worker chat schema · multi-character scenario with roadmap steps', () => { + it('includes "currentStepIndex" in the multi-character response schema', () => { + const schemaStr = JSON.stringify(selectGeminiResponseSchema(multiCharacterRoadmapScenario)); expect(schemaStr).toMatch(/characterResponses/i); - // And now also carry the roadmap field. expect(schemaStr).toMatch(/currentStepIndex/i); }); - it('omits "currentStepIndex" for a multi-character scenario without roadmap steps (no regression)', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const noRoadmapScenario = { ...multiCharacterRoadmapScenario, id: 'bakery-multichar-2', steps: [] }; - setScenario(noRoadmapScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - const schemaStr = JSON.stringify(schema); + it('constrains characterName to the fixed Character N labels', () => { + const schema = selectGeminiResponseSchema(multiCharacterRoadmapScenario); + expect(JSON.stringify(schema)).toMatch(/"enum":\["Character 1","Character 2"\]/); + + const zodSchema = createMultiCharacterSchema(multiCharacterRoadmapScenario); + const valid = zodSchema.safeParse({ + characterResponses: [ + { characterName: 'Character 1', french: 'Bonjour', english: 'Hello' }, + ], + currentStepIndex: 0, + }); + expect(valid.success).toBe(true); + const invalid = zodSchema.safeParse({ + characterResponses: [ + { characterName: 'Baker', french: 'Bonjour', english: 'Hello' }, + ], + }); + expect(invalid.success).toBe(false); + }); + it('omits "currentStepIndex" for a multi-character scenario without roadmap steps (no regression)', () => { + const schemaStr = JSON.stringify(selectGeminiResponseSchema({ + ...multiCharacterRoadmapScenario, + id: 'bakery-multichar-2', + steps: [], + })); expect(schemaStr).toMatch(/characterResponses/i); expect(schemaStr).not.toMatch(/currentStepIndex/i); }); diff --git a/__tests__/roadmapSchemaSelection.test.ts b/__tests__/roadmapSchemaSelection.test.ts index aaf4729..bd1da8e 100644 --- a/__tests__/roadmapSchemaSelection.test.ts +++ b/__tests__/roadmapSchemaSelection.test.ts @@ -1,80 +1,9 @@ -/** - * TDD tests for conditional response-schema selection when a scenario carries - * roadmap steps (the new scenario-roadmap feature). - * - * Mirrors the existing `isTefQuestioning` schema-selection convention documented - * in AGENTS.md ("TEF Ad Questioning Mode: Schema Selection") and tested in - * `__tests__/tefQuestioningSchema.test.ts`. Just like `isRepeat`/`conceptLabels` - * are only present in the questioning schema, the new roadmap step-index field - * must ONLY appear in the response schema when the active scenario has a - * non-empty `steps` array — it must be completely absent otherwise. This must be - * a separate conditional schema branch, not folded into a single always-present - * optional field, so that the field is never present/required for scenarios that - * don't have a roadmap. - * - * Contract this test file pins down for the builder (services/geminiService.ts): - * - `createChatSession()` (invoked via `initializeSession()` / `setScenario()`) - * must pick a response schema that includes a "currentStepIndex" property - * when `activeScenario.steps` is a non-empty array. - * - The field must be named exactly "currentStepIndex" (0-based index into - * `scenario.steps` of the step the AI infers is currently being addressed). - * - When `activeScenario.steps` is undefined OR an empty array, the schema - * must NOT contain "currentStepIndex" at all. - * - This test only exercises the single-character, non-TEF-Questioning path - * (one character, `isTefQuestioning` unset) — multi-character and TEF - * Questioning interaction with roadmap steps is intentionally left to the - * builder's discretion and is not pinned down here (see summary notes). - * - * Tests FAIL before the implementation is in place (schema will not yet contain - * "currentStepIndex" for a scenario with steps). - */ +import { describe, it, expect } from 'vitest'; +import { selectGeminiResponseSchema } from '../shared/chatSchemas'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - -beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-roadmap-schema'); -}); - -afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - vi.resetModules(); -}); - -function buildMockAiCapturingChatCreate() { - const mockSendMessage = vi.fn().mockResolvedValue({ text: '{}' }); - const mockChatSession = { sendMessage: mockSendMessage }; - - const createSpy = vi.fn().mockReturnValue(mockChatSession); - - const mockGenerateContent = vi.fn().mockResolvedValue({ text: 'transcribed text' }); - - const mockAi = { - models: { generateContent: mockGenerateContent }, - chats: { create: createSpy }, - }; - - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - return { createSpy }; -} - -describe('createChatSession · scenario with non-empty steps uses a roadmap-aware schema', () => { - it('includes a "currentStepIndex" property in the response schema', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const roadmapScenario = { +describe('Worker chat schema · scenario with non-empty steps uses a roadmap-aware schema', () => { + it('includes a "currentStepIndex" property in the response schema', () => { + const schema = selectGeminiResponseSchema({ id: 'roadmap-1', name: 'Bakery Visit', description: 'Visit a bakery and buy bread', @@ -86,55 +15,26 @@ describe('createChatSession · scenario with non-empty steps uses a roadmap-awar { id: 'step-2', text: 'Ask for a baguette' }, { id: 'step-3', text: 'Pay the total' }, ], - }; - - setScenario(roadmapScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).toMatch(/currentStepIndex/i); + }); + expect(JSON.stringify(schema)).toMatch(/currentStepIndex/i); }); }); -describe('createChatSession · scenario without steps does NOT use the roadmap-aware schema', () => { - it('omits "currentStepIndex" when the scenario has no steps field at all', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const plainScenario = { +describe('Worker chat schema · scenario without steps does NOT use the roadmap-aware schema', () => { + it('omits "currentStepIndex" when the scenario has no steps field at all', () => { + const schema = selectGeminiResponseSchema({ id: 'plain-1', name: 'Role Play', description: 'Regular role-play scenario, no roadmap', createdAt: Date.now(), isActive: true, characters: [{ id: 'char1', name: 'Waiter', role: 'waiter', voiceName: 'aoede' }], - // steps intentionally omitted - }; - - setScenario(plainScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).not.toMatch(/currentStepIndex/i); + }); + expect(JSON.stringify(schema)).not.toMatch(/currentStepIndex/i); }); - it('omits "currentStepIndex" when the scenario has an empty steps array', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const emptyStepsScenario = { + it('omits "currentStepIndex" when the scenario has an empty steps array', () => { + const schema = selectGeminiResponseSchema({ id: 'empty-steps-1', name: 'Role Play', description: 'Scenario created before roadmap steps were added, or with steps removed', @@ -142,34 +42,12 @@ describe('createChatSession · scenario without steps does NOT use the roadmap-a isActive: true, characters: [{ id: 'char1', name: 'Waiter', role: 'waiter', voiceName: 'aoede' }], steps: [], - }; - - setScenario(emptyStepsScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).not.toMatch(/currentStepIndex/i); + }); + expect(JSON.stringify(schema)).not.toMatch(/currentStepIndex/i); }); - it('omits "currentStepIndex" for free conversation (no active scenario at all)', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - setScenario(null); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).not.toMatch(/currentStepIndex/i); + it('omits "currentStepIndex" for free conversation (no active scenario at all)', () => { + const schema = selectGeminiResponseSchema(null); + expect(JSON.stringify(schema)).not.toMatch(/currentStepIndex/i); }); }); diff --git a/__tests__/scenarioDescriptionRecordingAbortDiscard.test.tsx b/__tests__/scenarioDescriptionRecordingAbortDiscard.test.tsx index 2a9bbd4..a20322d 100644 --- a/__tests__/scenarioDescriptionRecordingAbortDiscard.test.tsx +++ b/__tests__/scenarioDescriptionRecordingAbortDiscard.test.tsx @@ -17,8 +17,10 @@ * Tests FAIL before the abort/discard fix is implemented. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'; +import React from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import { jsonResponse } from './helpers/mockParleBff'; type Deferred = { promise: Promise; @@ -39,57 +41,13 @@ const createDeferred = (): Deferred => { const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; const FAKE_MIME_TYPE = 'audio/webm'; -// --------------------------------------------------------------------------- -// Mock @google/genai so Gemini transcription calls are fully controlled -// --------------------------------------------------------------------------- -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - let transcriptionCalls: Array<{ - deferred: Deferred<{ text: string }>; + deferred: Deferred; abortSignal?: AbortSignal; }> = []; -// Some tests need to simulate "late resolve even after abort" to verify -// request-id based discard works on close+reopen races. let rejectOnAbort = true; -const mockGenerateContent = vi.fn().mockImplementation((request: any) => { - const abortSignal: AbortSignal | undefined = request?.config?.abortSignal; - const deferred = createDeferred<{ text: string }>(); - - transcriptionCalls.push({ deferred, abortSignal }); - - // If the app wires abortSignal through to Gemini, we reject when aborted. - if (abortSignal) { - abortSignal.addEventListener('abort', () => { - if (rejectOnAbort) { - deferred.reject(new DOMException('Request aborted', 'AbortError')); - } - }); - } - - return deferred.promise; -}); - -const mockAi = { - models: { - generateContent: mockGenerateContent, - }, - chats: { - create: vi.fn().mockReturnValue({ sendMessage: vi.fn() }), - }, -}; - -vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - // --------------------------------------------------------------------------- // Mock Vaul so PracticeModeSheet can be imported without the real library. // (Some environments don't resolve optional deps during Vitest transforms.) @@ -193,19 +151,38 @@ beforeAll(() => { }); describe('ScenarioSetup · describe by voice abort + discard', () => { - beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-scenario-abort'); - localStorage.setItem('parle_api_key_openai', 'test-key-openai'); + beforeEach(async () => { transcriptionCalls = []; rejectOnAbort = true; - mockGenerateContent.mockClear(); + const { hydrateSessionStatus } = await import('../services/apiKeyService'); + hydrateSessionStatus({ hasGemini: true, hasOpenai: true, hasApiKey: true }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/api/session/status')) { + return jsonResponse({ hasGemini: true, hasOpenai: true, hasApiKey: true }); + } + if (url.includes('/api/transcribe')) { + const abortSignal = init?.signal; + const deferred = createDeferred(); + transcriptionCalls.push({ deferred, abortSignal }); + if (abortSignal) { + abortSignal.addEventListener('abort', () => { + if (rejectOnAbort) { + deferred.reject(new DOMException('Request aborted', 'AbortError')); + } + }); + } + return deferred.promise; + } + return jsonResponse({ error: 'NOT_FOUND' }, 404); + })); mockStartRecording.mockClear(); mockStopRecording.mockClear(); mockCancelRecording.mockClear(); }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -260,9 +237,7 @@ describe('ScenarioSetup · describe by voice abort + discard', () => { // If stale results are not discarded, the UI will switch away from the // second transcription spinner and show transcript1 instead. await act(async () => { - call1.deferred.resolve({ - text: JSON.stringify({ rawTranscript: 'RAW_ONE', cleanedTranscript: 'CLEAN_ONE' }), - }); + call1.deferred.resolve(jsonResponse({ rawTranscript: 'RAW_ONE', cleanedTranscript: 'CLEAN_ONE' })); }); // Second transcription must still be in-flight and must not be overwritten. @@ -273,9 +248,7 @@ describe('ScenarioSetup · describe by voice abort + discard', () => { // Resolve the second transcription and ensure only attempt #2 appears. await act(async () => { - call2.deferred.resolve({ - text: JSON.stringify({ rawTranscript: 'RAW_TWO', cleanedTranscript: 'CLEAN_TWO' }), - }); + call2.deferred.resolve(jsonResponse({ rawTranscript: 'RAW_TWO', cleanedTranscript: 'CLEAN_TWO' })); }); expect(await screen.findByText('RAW_TWO')).toBeInTheDocument(); @@ -321,9 +294,7 @@ describe('ScenarioSetup · describe by voice abort + discard', () => { // Late resolve of the first transcription should not overwrite the reopened modal. await act(async () => { - call1.deferred.resolve({ - text: JSON.stringify({ rawTranscript: 'RAW_ONE', cleanedTranscript: 'CLEAN_ONE' }), - }); + call1.deferred.resolve(jsonResponse({ rawTranscript: 'RAW_ONE', cleanedTranscript: 'CLEAN_ONE' })); }); expect(screen.queryByText('Transcribing...')).not.toBeInTheDocument(); diff --git a/__tests__/scenarioStandardizationReviewService.test.ts b/__tests__/scenarioStandardizationReviewService.test.ts index a9b5167..5022b28 100644 --- a/__tests__/scenarioStandardizationReviewService.test.ts +++ b/__tests__/scenarioStandardizationReviewService.test.ts @@ -1,34 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { GoogleGenAI, Type } from '@google/genai'; - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(function GoogleGenAIMock() {}), - }; -}); - -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - import { generateScenarioStandardizationReview } from '../services/scenarioStandardizationReviewService'; import type { Message, ScenarioStandardizationReview } from '../types'; - -let mockGenerateContent = vi.fn(); - -const mockAi = { - models: { - get generateContent() { - return mockGenerateContent; - }, - }, - chats: { create: vi.fn() }, -}; - -vi.mocked(GoogleGenAI).mockImplementation(function GoogleGenAIConstructorMock() { - return mockAi as unknown as GoogleGenAI; -}); +import { jsonResponse } from './helpers/mockParleBff'; const SAMPLE_REVIEW: ScenarioStandardizationReview = { items: [ @@ -42,47 +15,39 @@ const SAMPLE_REVIEW: ScenarioStandardizationReview = { const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; const FAKE_MIME_TYPE = 'audio/webm'; -function setupSuccessfulAudioFetch() { - const fakeBlob = new Blob([Buffer.from(FAKE_AUDIO_BASE64, 'base64')], { type: FAKE_MIME_TYPE }); - mockFetch.mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(fakeBlob), - }); -} - function makeUserMessage(text: string, audioUrl?: string): Message { - return { - role: 'user', - text, - timestamp: Date.now(), - audioUrl, - }; + return { role: 'user', text, timestamp: Date.now(), audioUrl }; } function makeModelMessage(text: string): Message { - return { - role: 'model', - text, - timestamp: Date.now(), - }; + return { role: 'model', text, timestamp: Date.now() }; } +let lastReviewBody: Record | null = null; + beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-scenario-review'); - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(SAMPLE_REVIEW), - }); - setupSuccessfulAudioFetch(); + lastReviewBody = null; + const fakeBlob = new Blob([Buffer.from(FAKE_AUDIO_BASE64, 'base64')], { type: FAKE_MIME_TYPE }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith('blob:')) { + return { ok: true, blob: async () => fakeBlob } as Response; + } + if (url.includes('/api/scenario-review')) { + lastReviewBody = JSON.parse(String(init?.body ?? '{}')) as Record; + return jsonResponse(SAMPLE_REVIEW); + } + return jsonResponse({ error: 'NOT_FOUND' }, 404); + })); }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); - mockFetch.mockReset(); }); describe('generateScenarioStandardizationReview', () => { - it('uses user audio as inlineData and agent text only as context', async () => { + it('sends user audio as inlineData turns and agent text only as context', async () => { const messages: Message[] = [ makeModelMessage('Bonjour, vous désirez ?'), makeUserMessage('je cherche pour acheter un billet', 'blob:http://localhost/user-audio-1'), @@ -96,36 +61,38 @@ describe('generateScenarioStandardizationReview', () => { }); expect(result).toEqual(SAMPLE_REVIEW); - expect(mockGenerateContent).toHaveBeenCalledTimes(1); - - const request = mockGenerateContent.mock.calls[0][0]; - expect(request.config.responseMimeType).toBe('application/json'); - expect(request.config.responseSchema.properties.items.type).toBe(Type.ARRAY); - - const parts = request.contents[0].parts as Array<{ text?: string; inlineData?: { data: string; mimeType: string } }>; - expect(parts.some((part) => part.inlineData?.data === FAKE_AUDIO_BASE64)).toBe(true); - expect(parts.some((part) => part.text?.includes('[Agent said: Bonjour, vous désirez ?]'))).toBe(true); - expect(parts.some((part) => part.text?.includes('[User said (transcript fallback only): je cherche pour acheter un billet]'))).toBe(false); + const turns = lastReviewBody?.turns as Array>; + expect(turns.some((turn) => turn.audioBase64 === FAKE_AUDIO_BASE64)).toBe(true); + expect(turns.some((turn) => turn.role === 'model' && String(turn.text).includes('Bonjour, vous désirez ?'))).toBe(true); }); it('falls back to transcript text when user audio cannot be fetched', async () => { - mockFetch.mockRejectedValue(new Error('blob fetch failed')); + vi.mocked(fetch).mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith('blob:')) { + throw new Error('blob fetch failed'); + } + if (url.includes('/api/scenario-review')) { + lastReviewBody = JSON.parse(String(init?.body ?? '{}')) as Record; + return jsonResponse(SAMPLE_REVIEW); + } + return jsonResponse({ error: 'NOT_FOUND' }, 404); + }); await generateScenarioStandardizationReview({ messages: [makeUserMessage('je cherche pour acheter un billet', 'blob:http://localhost/user-audio-1')], }); - const request = mockGenerateContent.mock.calls[0][0]; - const parts = request.contents[0].parts as Array<{ text?: string; inlineData?: { data: string; mimeType: string } }>; - expect(parts.some((part) => part.text?.includes('[User said (transcript fallback only): je cherche pour acheter un billet]'))).toBe(true); + const turns = lastReviewBody?.turns as Array>; + expect(turns[0]?.text).toContain('je cherche pour acheter un billet'); + expect(turns[0]?.audioBase64).toBeUndefined(); }); - it('returns an empty review without calling the model when there are no user messages', async () => { + it('returns an empty review without calling the BFF when there are no user messages', async () => { const result = await generateScenarioStandardizationReview({ messages: [makeModelMessage('Bonjour')], }); - expect(result).toEqual({ items: [] }); - expect(mockGenerateContent).not.toHaveBeenCalled(); + expect(lastReviewBody).toBeNull(); }); }); diff --git a/__tests__/sendVoiceMessage.audioHistory.test.ts b/__tests__/sendVoiceMessage.audioHistory.test.ts new file mode 100644 index 0000000..d11bf76 --- /dev/null +++ b/__tests__/sendVoiceMessage.audioHistory.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { jsonResponse } from './helpers/mockParleBff'; +import type { Message } from '../types'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe('sendVoiceMessage · audio-first history', () => { + it('posts prior user recordings as audioBase64 instead of transcripts', async () => { + const chatBodies: Array> = []; + const fakeBlob = new Blob([Uint8Array.from([1, 2, 3])], { type: 'audio/webm' }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith('blob:')) { + return { ok: true, blob: async () => fakeBlob } as Response; + } + if (url.includes('/api/transcribe')) { + return jsonResponse({ text: 'Bonjour.' }); + } + if (url.includes('/api/chat')) { + chatBodies.push(JSON.parse(String(init?.body ?? '{}')) as Record); + return jsonResponse({ + modelJson: { french: 'Bonjour!', english: 'Hello!', hint: 'Continue' }, + }); + } + if (url.includes('/api/tts')) { + return jsonResponse({ audioBase64: 'ZmFrZQ==', mimeType: 'audio/pcm' }); + } + return jsonResponse({ error: 'NOT_FOUND' }, 404); + })); + + const { sendVoiceMessage, initializeSession } = await import('../services/geminiService'); + await initializeSession(); + const prior: Message[] = [ + { role: 'user', text: 'bonjour transcript', timestamp: 1, audioUrl: 'blob:http://localhost/user-1' }, + { role: 'model', text: 'Bonjour!', frenchText: 'Bonjour!', timestamp: 2 }, + ]; + await sendVoiceMessage('Y3VycmVudA==', 'audio/webm', undefined, undefined, prior); + const history = chatBodies[0]?.history as Array>; + expect(history[0]?.role).toBe('user'); + expect(history[0]?.audioBase64).toBeTruthy(); + expect(history[0]?.text).toBeUndefined(); + expect(history[1]).toMatchObject({ role: 'model', frenchText: 'Bonjour!' }); + }); +}); diff --git a/__tests__/sendVoiceMessageContext.test.ts b/__tests__/sendVoiceMessageContext.test.ts index 6696b89..b31ba86 100644 --- a/__tests__/sendVoiceMessageContext.test.ts +++ b/__tests__/sendVoiceMessageContext.test.ts @@ -1,212 +1,81 @@ -/** - * TDD tests for the optional contextText parameter added to sendVoiceMessage. - * - * New signature: sendVoiceMessage(audioBase64, mimeType, signal?, contextText?) - * - * When contextText is provided it must be sent as an additional text part alongside - * the audio in the chat session message. When omitted, no extra part is added. - * - * Tests FAIL before the implementation is in place. - */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mockParleBff } from './helpers/mockParleBff'; -// --------------------------------------------------------------------------- -// Mock @google/genai at module level -// --------------------------------------------------------------------------- - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; // "fakeaudio" in base64 +const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; const FAKE_MIME_TYPE = 'audio/webm'; -// A minimal valid JSON response for a single-character TEF Ad scenario -const FAKE_MODEL_RESPONSE = JSON.stringify({ - french: 'Bonjour! Ça va?', - english: 'Hello! How are you?', - hint: 'Introduce the ad', -}); - -const FAKE_TRANSCRIPTION = 'Bonjour mon ami.'; - -/** - * Build a mock GoogleGenAI instance that: - * - models.generateContent: handles transcription (returns FAKE_TRANSCRIPTION) and TTS - * - chats.create: returns a mock chat session whose sendMessage returns FAKE_MODEL_RESPONSE - * - * Returns the mockSendMessage spy so callers can assert against it. - */ -function buildMockAi() { - const mockSendMessage = vi.fn().mockResolvedValue({ text: FAKE_MODEL_RESPONSE }); - - const mockChatSession = { - sendMessage: mockSendMessage, - }; - - const mockGenerateContent = vi.fn() - .mockResolvedValueOnce({ text: FAKE_TRANSCRIPTION }) // first call = transcription - .mockResolvedValue({ // subsequent calls = TTS (fallback) - candidates: [{ - content: { - parts: [{ inlineData: { data: 'ZmFrZWF1ZGlv', mimeType: 'audio/wav' } }] - } - }] - }); - - const mockAi = { - models: { - generateContent: mockGenerateContent, - }, - chats: { - create: vi.fn().mockReturnValue(mockChatSession), - }, - }; - - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - - return { mockAi, mockSendMessage, mockGenerateContent }; -} - -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- - beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-send-voice-context'); + vi.unstubAllGlobals(); }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); - // Reset module state between tests so each test gets a fresh session vi.resetModules(); }); -// --------------------------------------------------------------------------- -// Existence check -// --------------------------------------------------------------------------- - describe('sendVoiceMessage · contextText parameter — existence', () => { it('sendVoiceMessage accepts a 4th contextText parameter without throwing a type error', async () => { - // If the implementation still only accepts 3 parameters this test will - // catch runtime issues (TypeScript won't be enforced at vitest runtime - // but the call should not blow up with "too many arguments"). const { sendVoiceMessage } = await import('../services/geminiService'); - // We just verify the function is callable — a detailed behavioural check - // follows in the next describe blocks. expect(typeof sendVoiceMessage).toBe('function'); - expect(sendVoiceMessage.length).toBeGreaterThanOrEqual(2); // at minimum audioBase64 + mimeType + expect(sendVoiceMessage.length).toBeGreaterThanOrEqual(2); }); }); -// --------------------------------------------------------------------------- -// contextText provided — must be injected as a text part in the chat message -// --------------------------------------------------------------------------- - describe('sendVoiceMessage · contextText provided', () => { - it('includes a text part with the contextText in the sendMessage call', async () => { - const { mockSendMessage } = buildMockAi(); + it('includes contextText in the POST /api/chat body', async () => { + const chatBodies: Array> = []; + mockParleBff({ + transcription: 'Bonjour mon ami.', + onChat: (body) => chatBodies.push(body), + }); const { sendVoiceMessage, initializeSession } = await import('../services/geminiService'); - await initializeSession(); - const contextText = '[Turn context: Direction 1/5 · Round 1/3. Raise objection about price.]'; - await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE, undefined, contextText); - - expect(mockSendMessage.mock.calls.length).toBeGreaterThan(0); - const callArg = mockSendMessage.mock.calls[0][0]; - const parts = callArg?.message ?? []; - const textParts = parts.filter((p: Record) => typeof p.text === 'string'); - const hasContextText = textParts.some( - (p: { text: string }) => p.text.includes(contextText) || p.text === contextText - ); - expect(hasContextText).toBe(true); + expect(chatBodies[0]?.contextText).toBe(contextText); + expect(chatBodies[0]?.audioBase64).toBe(FAKE_AUDIO_BASE64); }); - it('sends the audio inlineData part alongside the contextText part', async () => { - const { mockSendMessage } = buildMockAi(); + it('sends the current user audio alongside the contextText', async () => { + const chatBodies: Array> = []; + mockParleBff({ onChat: (body) => chatBodies.push(body) }); const { sendVoiceMessage, initializeSession } = await import('../services/geminiService'); await initializeSession(); - - const contextText = '[Turn context: Direction 2/5 · Round 3/3.]'; - - await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE, undefined, contextText); - - expect(mockSendMessage.mock.calls.length).toBeGreaterThan(0); - const callArg = mockSendMessage.mock.calls[0][0]; - const parts = callArg?.message ?? []; - const hasInlineData = parts.some( - (p: Record) => p.inlineData !== undefined - ); - expect(hasInlineData).toBe(true); + await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE, undefined, '[Turn context: Direction 2/5 · Round 3/3.]'); + expect(chatBodies[0]?.audioBase64).toBe(FAKE_AUDIO_BASE64); + expect(chatBodies[0]?.mimeType).toBe(FAKE_MIME_TYPE); }); }); -// --------------------------------------------------------------------------- -// contextText omitted — no extra text part -// --------------------------------------------------------------------------- - describe('sendVoiceMessage · contextText omitted', () => { - it('does NOT add a text part when contextText is undefined', async () => { - const { mockSendMessage } = buildMockAi(); + it('does NOT add contextText when it is undefined', async () => { + const chatBodies: Array> = []; + mockParleBff({ onChat: (body) => chatBodies.push(body) }); const { sendVoiceMessage, initializeSession } = await import('../services/geminiService'); await initializeSession(); - - // Call without the 4th argument await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE, undefined, undefined); - - expect(mockSendMessage.mock.calls.length).toBeGreaterThan(0); - const callArg = mockSendMessage.mock.calls[0][0]; - const parts = callArg?.message ?? []; - const textParts = parts.filter((p: Record) => typeof p.text === 'string'); - // No text part should be present when contextText is omitted - expect(textParts).toHaveLength(0); + expect(chatBodies[0]?.contextText).toBeUndefined(); }); - it('does NOT add a text part when contextText is an empty string', async () => { - const { mockSendMessage } = buildMockAi(); + it('does NOT add contextText when it is an empty string', async () => { + const chatBodies: Array> = []; + mockParleBff({ onChat: (body) => chatBodies.push(body) }); const { sendVoiceMessage, initializeSession } = await import('../services/geminiService'); await initializeSession(); - await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE, undefined, ''); - - expect(mockSendMessage.mock.calls.length).toBeGreaterThan(0); - const callArg = mockSendMessage.mock.calls[0][0]; - const parts = callArg?.message ?? []; - const textParts = parts.filter( - (p: Record) => typeof p.text === 'string' && (p.text as string).length > 0 - ); - expect(textParts).toHaveLength(0); + expect(chatBodies[0]?.contextText).toBeUndefined(); }); }); -// --------------------------------------------------------------------------- -// Source-text spec: verify contextText is wired in the implementation -// --------------------------------------------------------------------------- - describe('sendVoiceMessage · source-text spec for contextText parameter', () => { it('geminiService source declares a 4th parameter (contextText) on sendVoiceMessage', async () => { const src = await import('../services/geminiService?raw'); - // The function signature must mention contextText as the 4th param expect((src as { default: string }).default).toMatch(/sendVoiceMessage\s*=\s*async\s*\([^)]*contextText/); }); it('geminiService source uses contextText when building the chat message parts', async () => { const src = await import('../services/geminiService?raw'); - // The source should reference contextText when constructing the message expect((src as { default: string }).default).toMatch(/contextText/); }); }); diff --git a/__tests__/sendVoiceMessageQuestioning.test.ts b/__tests__/sendVoiceMessageQuestioning.test.ts index 90893be..79b7e9b 100644 --- a/__tests__/sendVoiceMessageQuestioning.test.ts +++ b/__tests__/sendVoiceMessageQuestioning.test.ts @@ -1,100 +1,29 @@ -/** - * TDD tests for isRepeat propagation through sendVoiceMessage. - * - * When activeScenario.isTefQuestioning = true: - * - Returned VoiceResponse.isRepeat reflects the isRepeat field in the AI response - * When activeScenario.isTefQuestioning is falsy: - * - Standard single-character path is unchanged; no isRepeat on VoiceResponse - * - * Tests FAIL before the implementation is in place. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mockParleBff } from './helpers/mockParleBff'; const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; const FAKE_MIME_TYPE = 'audio/webm'; -const FAKE_TRANSCRIPTION = 'Bonjour, je voudrais savoir le prix.'; - -// --------------------------------------------------------------------------- -// Mock builder helpers -// --------------------------------------------------------------------------- - -/** - * Build a mock GoogleGenAI instance that returns the given model response JSON - * for the chat sendMessage call. - */ -function buildMockAiWithResponse(modelResponseJson: object) { - const mockSendMessage = vi.fn().mockResolvedValue({ - text: JSON.stringify(modelResponseJson), - }); - - const mockChatSession = { sendMessage: mockSendMessage }; - - const mockGenerateContent = vi.fn() - .mockResolvedValueOnce({ text: FAKE_TRANSCRIPTION }) // transcription - .mockResolvedValue({ // TTS fallback - candidates: [{ - content: { - parts: [{ inlineData: { data: 'ZmFrZWF1ZGlv', mimeType: 'audio/wav' } }], - }, - }], - }); - - const mockAi = { - models: { generateContent: mockGenerateContent }, - chats: { create: vi.fn().mockReturnValue(mockChatSession) }, - }; - - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - return { mockAi, mockSendMessage }; -} - -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- - -beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-questioning'); -}); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.resetModules(); }); -// --------------------------------------------------------------------------- -// isTefQuestioning = true, isRepeat = true in response -// --------------------------------------------------------------------------- - describe('sendVoiceMessage · isTefQuestioning=true, isRepeat=true in response', () => { it('returns VoiceResponse with isRepeat = true', async () => { - buildMockAiWithResponse({ - french: 'Bonjour, comme je vous ai dit, notre plan coûte 29 euros par mois.', - english: 'Hello, as I told you, our plan costs 29 euros per month.', - hint: 'Ask about the installation fee', - isRepeat: true, - conceptLabels: ['pricing'], + mockParleBff({ + transcription: 'Bonjour, je voudrais savoir le prix.', + modelJson: { + french: 'Bonjour, comme je vous ai dit, notre plan coûte 29 euros par mois.', + english: 'Hello, as I told you, our plan costs 29 euros per month.', + hint: 'Ask about the installation fee', + isRepeat: true, + conceptLabels: ['pricing'], + }, }); - const { sendVoiceMessage, initializeSession, setScenario } = await import('../services/geminiService'); - - // Set up a TEF Questioning scenario - const questioningScenario = { + setScenario({ id: 'test-questioning', name: 'TEF Questioning', description: 'Customer service call practice', @@ -102,35 +31,27 @@ describe('sendVoiceMessage · isTefQuestioning=true, isRepeat=true in response', isActive: true, isTefQuestioning: true, characters: [{ id: 'agent', name: 'Agent', role: 'agent', voiceName: 'puck' }], - }; - - setScenario(questioningScenario as Parameters[0]); + }); await initializeSession(); - const response = await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE); - expect(response.isRepeat).toBe(true); expect(response.conceptLabels).toEqual(['pricing']); }); }); -// --------------------------------------------------------------------------- -// isTefQuestioning = true, isRepeat = false in response -// --------------------------------------------------------------------------- - describe('sendVoiceMessage · isTefQuestioning=true, isRepeat=false in response', () => { it('returns VoiceResponse with isRepeat = false or undefined (not true)', async () => { - buildMockAiWithResponse({ - french: 'Bien sûr, nos contrats sont disponibles en 12 ou 24 mois.', - english: 'Of course, our contracts are available in 12 or 24 months.', - hint: 'Ask about the cancellation policy', - isRepeat: false, - conceptLabels: ['contract duration'], + mockParleBff({ + modelJson: { + french: 'Bien sûr, nos contrats sont disponibles en 12 ou 24 mois.', + english: 'Of course, our contracts are available in 12 or 24 months.', + hint: 'Ask about the cancellation policy', + isRepeat: false, + conceptLabels: ['contract duration'], + }, }); - const { sendVoiceMessage, initializeSession, setScenario } = await import('../services/geminiService'); - - const questioningScenario = { + setScenario({ id: 'test-questioning-2', name: 'TEF Questioning', description: 'Customer service call practice', @@ -138,65 +59,48 @@ describe('sendVoiceMessage · isTefQuestioning=true, isRepeat=false in response' isActive: true, isTefQuestioning: true, characters: [{ id: 'agent', name: 'Agent', role: 'agent', voiceName: 'puck' }], - }; - - setScenario(questioningScenario as Parameters[0]); + }); await initializeSession(); - const response = await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE); - - // isRepeat should be false or absent — NOT true expect(response.isRepeat).not.toBe(true); expect(response.conceptLabels).toEqual(['contract duration']); }); }); -// --------------------------------------------------------------------------- -// isTefQuestioning falsy — standard path, no isRepeat -// --------------------------------------------------------------------------- - describe('sendVoiceMessage · isTefQuestioning falsy, standard single-character path', () => { it('does not set isRepeat on VoiceResponse when scenario is a regular TEF Ad scenario', async () => { - buildMockAiWithResponse({ - french: "Hmm, je ne sais pas... c'est assez cher, non?", - english: "Hmm, I don't know... it's quite expensive, isn't it?", - hint: 'Explain the value for money', + mockParleBff({ + modelJson: { + french: "Hmm, je ne sais pas... c'est assez cher, non?", + english: "Hmm, I don't know... it's quite expensive, isn't it?", + hint: 'Explain the value for money', + }, }); - const { sendVoiceMessage, initializeSession, setScenario } = await import('../services/geminiService'); - - // Regular (non-questioning) TEF scenario — no isTefQuestioning flag - const regularScenario = { + setScenario({ id: 'test-ad-persuasion', name: 'TEF Ad Persuasion', description: 'Ad persuasion practice', createdAt: Date.now(), isActive: true, characters: [{ id: 'friend', name: 'Friend', role: 'friend', voiceName: 'aoede' }], - }; - - setScenario(regularScenario as Parameters[0]); + }); await initializeSession(); - const response = await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE); - - // isRepeat should not be set to true on standard single-character path expect(response.isRepeat).toBeUndefined(); }); it('does not set isRepeat when no scenario is active (free conversation mode)', async () => { - buildMockAiWithResponse({ - french: 'Bonjour! Comment puis-je vous aider?', - english: 'Hello! How can I help you?', + mockParleBff({ + modelJson: { + french: 'Bonjour! Comment puis-je vous aider?', + english: 'Hello! How can I help you?', + }, }); - const { sendVoiceMessage, initializeSession, setScenario } = await import('../services/geminiService'); - setScenario(null); await initializeSession(); - const response = await sendVoiceMessage(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE); - expect(response.isRepeat).toBeUndefined(); }); }); diff --git a/__tests__/tefQuestioningRepeatedConcepts.test.ts b/__tests__/tefQuestioningRepeatedConcepts.test.ts index 2b68882..92b1322 100644 --- a/__tests__/tefQuestioningRepeatedConcepts.test.ts +++ b/__tests__/tefQuestioningRepeatedConcepts.test.ts @@ -16,51 +16,11 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// --------------------------------------------------------------------------- -// Group 1: Schema — TefQuestioningSchema includes conceptLabels -// --------------------------------------------------------------------------- - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - -function buildMockAiCapturingChatCreate() { - const mockSendMessage = vi.fn().mockResolvedValue({ text: '{}' }); - const mockChatSession = { sendMessage: mockSendMessage }; - const createSpy = vi.fn().mockReturnValue(mockChatSession); - const mockGenerateContent = vi.fn().mockResolvedValue({ text: 'transcribed text' }); - const mockAi = { - models: { generateContent: mockGenerateContent }, - chats: { create: createSpy }, - }; - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - return { createSpy }; -} - -beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-schema'); -}); - -afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - vi.resetModules(); -}); +import { selectGeminiResponseSchema } from '../shared/chatSchemas'; describe('TefQuestioningSchema · includes conceptLabels when isTefQuestioning=true', () => { - it('passes a schema containing "conceptLabels" to chats.create', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const questioningScenario = { + it('uses a schema containing "conceptLabels"', () => { + const schema = selectGeminiResponseSchema({ id: 'qs-1', name: 'TEF Questioning', description: 'Customer service call', @@ -68,46 +28,22 @@ describe('TefQuestioningSchema · includes conceptLabels when isTefQuestioning=t isActive: true, isTefQuestioning: true, characters: [{ id: 'agent', name: 'Agent', role: 'agent', voiceName: 'puck' }], - }; - - setScenario(questioningScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).toMatch(/conceptLabels/i); + }); + expect(JSON.stringify(schema)).toMatch(/conceptLabels/i); }); }); describe('TefQuestioningSchema · standard scenario does NOT include conceptLabels', () => { - it('does not include "conceptLabels" in the standard schema', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const regularScenario = { + it('does not include "conceptLabels" in the standard schema', () => { + const schema = selectGeminiResponseSchema({ id: 'reg-1', name: 'Role Play', description: 'Regular role-play scenario', createdAt: Date.now(), isActive: true, characters: [{ id: 'char1', name: 'Baker', role: 'baker', voiceName: 'aoede' }], - }; - - setScenario(regularScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).not.toMatch(/conceptLabels/i); + }); + expect(JSON.stringify(schema)).not.toMatch(/conceptLabels/i); }); }); diff --git a/__tests__/tefQuestioningReviewFixes.test.ts b/__tests__/tefQuestioningReviewFixes.test.ts index 282dcf1..0ae56e2 100644 --- a/__tests__/tefQuestioningReviewFixes.test.ts +++ b/__tests__/tefQuestioningReviewFixes.test.ts @@ -15,41 +15,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { AppState } from '../types'; import { useConversationTimer } from '../hooks/useConversationTimer'; - -// --------------------------------------------------------------------------- -// B4 mock setup — must be hoisted above imports of geminiService -// --------------------------------------------------------------------------- - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; +import { jsonResponse } from './helpers/mockParleBff'; import { confirmTefAdImageForQuestioning } from '../services/geminiService'; -// --------------------------------------------------------------------------- -// Shared mock for B4 — a single ai singleton is created per module lifecycle, -// so all B4 tests share the same mockAi object and configure its generateContent -// spy via the module-level variable below. -// --------------------------------------------------------------------------- - -let b4MockGenerateContent = vi.fn(); - -const b4MockAi = { - models: { - get generateContent() { - return b4MockGenerateContent; - }, - }, - chats: { create: vi.fn() }, -}; - -vi.mocked(GoogleGenAI).mockReturnValue(b4MockAi as unknown as GoogleGenAI); - // B1 hint visibility is covered by conversationHintVisibility.test.ts // --------------------------------------------------------------------------- @@ -123,64 +91,39 @@ describe('B3 · abort in-flight requests on exit and timer expiry (App.tsx sourc describe('B4 · confirmTefAdImageForQuestioning throws on invalid summary field', () => { beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-b4'); - // Reset the shared spy to a default (overridden per test as needed) - b4MockGenerateContent = vi.fn().mockResolvedValue({ text: '' }); + vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it('throws when the API returns a response with summary missing entirely', async () => { - b4MockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify({ roleSummary: 'I am ready.' }), - }); - - await expect( - confirmTefAdImageForQuestioning('base64data', 'image/jpeg') - ).rejects.toThrow(); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ roleSummary: 'I am ready.' })); + await expect(confirmTefAdImageForQuestioning('base64data', 'image/jpeg')).rejects.toThrow(); }); it('throws when the API returns a response with a non-string summary (number)', async () => { - b4MockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify({ summary: 42, roleSummary: 'I am ready.' }), - }); - - await expect( - confirmTefAdImageForQuestioning('base64data', 'image/jpeg') - ).rejects.toThrow(); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ summary: 42, roleSummary: 'I am ready.' })); + await expect(confirmTefAdImageForQuestioning('base64data', 'image/jpeg')).rejects.toThrow(); }); it('throws when the API returns a response with an empty-string summary', async () => { - b4MockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify({ summary: '', roleSummary: 'I am ready.' }), - }); - - await expect( - confirmTefAdImageForQuestioning('base64data', 'image/jpeg') - ).rejects.toThrow(); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ summary: '', roleSummary: 'I am ready.' })); + await expect(confirmTefAdImageForQuestioning('base64data', 'image/jpeg')).rejects.toThrow(); }); it('throws when the API returns a response with roleSummary missing entirely', async () => { - b4MockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify({ summary: 'A car ad.' }), - }); - - await expect( - confirmTefAdImageForQuestioning('base64data', 'image/jpeg') - ).rejects.toThrow(); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ summary: 'A car ad.' })); + await expect(confirmTefAdImageForQuestioning('base64data', 'image/jpeg')).rejects.toThrow(); }); it('returns normally when both summary and roleSummary are valid non-empty strings', async () => { - b4MockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify({ - summary: 'A car advertisement.', - roleSummary: 'I understand the ad.', - }), - }); - + vi.mocked(fetch).mockResolvedValue(jsonResponse({ + summary: 'A car advertisement.', + roleSummary: 'I understand the ad.', + })); const result = await confirmTefAdImageForQuestioning('base64data', 'image/jpeg'); expect(result.summary).toBe('A car advertisement.'); expect(result.roleSummary).toBe('I understand the ad.'); diff --git a/__tests__/tefQuestioningSchema.test.ts b/__tests__/tefQuestioningSchema.test.ts index 539e807..361b8e4 100644 --- a/__tests__/tefQuestioningSchema.test.ts +++ b/__tests__/tefQuestioningSchema.test.ts @@ -1,72 +1,9 @@ -/** - * TDD tests for schema selection in createChatSession. - * - * When activeScenario.isTefQuestioning = true: - * - The chat session is created with a schema that includes an "isRepeat" field - * When scenario does not have isTefQuestioning: - * - The standard schema is used (no "isRepeat" field) - * - * Tests FAIL before the implementation is in place. - */ +import { describe, it, expect } from 'vitest'; +import { selectGeminiResponseSchema } from '../shared/chatSchemas'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- - -beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-schema'); -}); - -afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - vi.resetModules(); -}); - -// --------------------------------------------------------------------------- -// Helper to capture the chats.create call arguments -// --------------------------------------------------------------------------- - -function buildMockAiCapturingChatCreate() { - const mockSendMessage = vi.fn().mockResolvedValue({ text: '{}' }); - const mockChatSession = { sendMessage: mockSendMessage }; - - const createSpy = vi.fn().mockReturnValue(mockChatSession); - - const mockGenerateContent = vi.fn().mockResolvedValue({ text: 'transcribed text' }); - - const mockAi = { - models: { generateContent: mockGenerateContent }, - chats: { create: createSpy }, - }; - - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - return { createSpy }; -} - -// --------------------------------------------------------------------------- -// isTefQuestioning = true: schema must include isRepeat -// --------------------------------------------------------------------------- - -describe('createChatSession · isTefQuestioning=true uses TEF_QUESTIONING_RESPONSE_SCHEMA', () => { - it('creates the chat session with a schema that includes an "isRepeat" property', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const questioningScenario = { +describe('Worker chat schema · isTefQuestioning=true uses TEF_QUESTIONING_RESPONSE_SCHEMA', () => { + it('includes an "isRepeat" property', () => { + const schema = selectGeminiResponseSchema({ id: 'qs-1', name: 'TEF Questioning', description: 'Customer service call', @@ -74,63 +11,26 @@ describe('createChatSession · isTefQuestioning=true uses TEF_QUESTIONING_RESPON isActive: true, isTefQuestioning: true, characters: [{ id: 'agent', name: 'Agent', role: 'agent', voiceName: 'puck' }], - }; - - setScenario(questioningScenario as Parameters[0]); - await initializeSession(); - - // The most recent chats.create call should have passed a schema - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - // The schema must exist and must contain "isRepeat" somewhere in its properties - expect(schema).toBeDefined(); - - // Walk the schema object to find an "isRepeat" property - const schemaStr = JSON.stringify(schema); - expect(schemaStr).toMatch(/isRepeat/i); + }); + expect(JSON.stringify(schema)).toMatch(/isRepeat/i); }); }); -// --------------------------------------------------------------------------- -// Standard (non-questioning) scenario: schema must NOT include isRepeat -// --------------------------------------------------------------------------- - -describe('createChatSession · standard scenario uses SINGLE_CHARACTER_RESPONSE_SCHEMA', () => { - it('creates the chat session with a schema that does NOT include "isRepeat"', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const regularScenario = { +describe('Worker chat schema · standard scenario uses SINGLE_CHARACTER_RESPONSE_SCHEMA', () => { + it('does NOT include "isRepeat"', () => { + const schema = selectGeminiResponseSchema({ id: 'reg-1', name: 'Role Play', description: 'Regular role-play scenario', createdAt: Date.now(), isActive: true, - // isTefQuestioning intentionally omitted characters: [{ id: 'char1', name: 'Baker', role: 'baker', voiceName: 'aoede' }], - }; - - setScenario(regularScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).not.toMatch(/isRepeat/i); + }); + expect(JSON.stringify(schema)).not.toMatch(/isRepeat/i); }); - it('uses the standard schema (no isRepeat) when isTefQuestioning is explicitly false', async () => { - const { createSpy } = buildMockAiCapturingChatCreate(); - - const { initializeSession, setScenario } = await import('../services/geminiService'); - - const nonQuestioningScenario = { + it('omits isRepeat when isTefQuestioning is explicitly false', () => { + const schema = selectGeminiResponseSchema({ id: 'non-qs-1', name: 'TEF Ad Persuasion', description: 'Ad persuasion practice', @@ -138,17 +38,7 @@ describe('createChatSession · standard scenario uses SINGLE_CHARACTER_RESPONSE_ isActive: true, isTefQuestioning: false, characters: [{ id: 'friend', name: 'Friend', role: 'friend', voiceName: 'aoede' }], - }; - - setScenario(nonQuestioningScenario as Parameters[0]); - await initializeSession(); - - expect(createSpy).toHaveBeenCalled(); - const callArg = createSpy.mock.calls[createSpy.mock.calls.length - 1][0]; - const schema = callArg?.config?.responseSchema; - - expect(schema).toBeDefined(); - const schemaStr = JSON.stringify(schema); - expect(schemaStr).not.toMatch(/isRepeat/i); + }); + expect(JSON.stringify(schema)).not.toMatch(/isRepeat/i); }); }); diff --git a/__tests__/tefReviewService.test.ts b/__tests__/tefReviewService.test.ts index bb3da39..dc30198 100644 --- a/__tests__/tefReviewService.test.ts +++ b/__tests__/tefReviewService.test.ts @@ -1,1440 +1,104 @@ -/** - * TDD tests for generateTefReview() in services/tefReviewService.ts. - * - * The function does not exist yet — all tests are expected to FAIL until - * the implementation is written. - * - * Covers: - * - Happy path: valid response with all required TefReview fields - * - Prompt construction: questioning vs persuasion exercise types - * - Audio fetching: success, failure (falls back to transcript) - * - Empty message array (no user speech) - * - Persuasion-specific: objection state context included in prompt - * - Guide content included in the prompt - * - Missing / malformed response fields → throws - * - Parse errors → throws - */ - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Type } from '@google/genai'; - -// --------------------------------------------------------------------------- -// Module-level mocks — must be hoisted before the subject-under-test import -// --------------------------------------------------------------------------- - -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; -}); - -import { GoogleGenAI } from '@google/genai'; - -// Mock fetch globally so audio blob URL fetches are controlled in tests -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -// The function under test — does not exist yet import { generateTefReview } from '../services/tefReviewService'; - import type { Message, TefReview } from '../types'; - - -// --------------------------------------------------------------------------- -// Shared mock Gemini client -// --------------------------------------------------------------------------- - -let mockGenerateContent = vi.fn(); - -const mockAi = { - models: { - get generateContent() { - return mockGenerateContent; - }, - }, - chats: { create: vi.fn() }, -}; - -vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- +import { jsonResponse } from './helpers/mockParleBff'; const SAMPLE_REVIEW: TefReview = { cefrLevel: 'B2', cefrJustification: 'The speaker demonstrated solid grammar with occasional errors.', wentWell: ['Good use of connectors', 'Clear pronunciation'], - mistakes: [ - { - original: 'je suis allé hier', - correction: 'je suis allé hier soir', - explanation: 'Missing time qualifier makes the sentence ambiguous.', - }, - ], - vocabularySuggestions: [ - { used: 'bon', better: 'excellent', reason: '"Excellent" is more precise and registers C1 vocabulary.' }, - { used: 'beaucoup', better: 'considérablement', reason: 'More formal and academic register.' }, - { used: 'grand', better: 'considérable', reason: 'Stronger academic adjective.' }, - { used: 'faire', better: 'effectuer', reason: 'Formal verb preferred in professional contexts.' }, - { used: 'voir', better: 'constater', reason: 'More precise observation verb in formal French.' }, - ], - topicSuggestions: [ - { - topic: 'Conditions de paiement', - examples: [ - { - french: 'Peut-on payer en plusieurs fois ?', - english: 'Can we pay in installments?', - }, - { - french: 'Y a-t-il des frais pour le paiement en ligne ?', - english: 'Are there fees for paying online?', - }, - ], - }, - { - topic: 'Garanties incluses', - examples: [ - { - french: 'Quelle garantie est incluse avec ce service ?', - english: 'What warranty is included with this service?', - }, - { - french: 'La garantie couvre-t-elle les pannes majeures ?', - english: 'Does the warranty cover major breakdowns?', - }, - ], - }, - { - topic: 'Frais supplementaires', - examples: [ - { - french: 'Y a-t-il des couts caches a prevoir ?', - english: 'Are there hidden costs to expect?', - }, - { - french: 'Le prix final inclut-il tous les frais ?', - english: 'Does the final price include all fees?', - }, - ], - }, - { - topic: 'Comparaison avec la concurrence', - examples: [ - { - french: 'En quoi cette offre est-elle meilleure que les autres ?', - english: 'How is this offer better than the others?', - }, - { - french: 'Quels avantages concrets avez-vous par rapport aux concurrents ?', - english: 'What concrete advantages do you have over competitors?', - }, - ], - }, - { - topic: 'Flexibilite des horaires', - examples: [ - { - french: 'Les horaires sont-ils flexibles en semaine ?', - english: 'Are schedules flexible during the week?', - }, - { - french: 'Peut-on modifier l horaire apres reservation ?', - english: 'Can we change the schedule after booking?', - }, - ], - }, - ], - // criteriaEvaluation is included so this fixture is valid for persuasion-type calls - criteriaEvaluation: [ - { criterion: 'Clear & interesting presentation', met: true, evidence: 'User introduced the ad clearly.' }, - { criterion: 'Argumentation vocabulary', met: true, evidence: 'Good use of linking words.' }, - { criterion: '3+ distinct arguments', met: true, evidence: 'Three distinct points raised.' }, - { criterion: 'Arguments developed with examples', met: false, evidence: 'Some bare assertions.' }, - { criterion: 'Handled counter-arguments / nuance', met: true, evidence: 'Acknowledged objections.' }, - ], + topicSuggestions: Array.from({ length: 5 }, (_, i) => ({ + topic: `Topic ${i + 1}`, + examples: [ + { french: 'Exemple A', english: 'Example A' }, + { french: 'Exemple B', english: 'Example B' }, + ], + })), }; -function makeUserMessage(text: string, audioUrl?: string): Message { - return { - role: 'user', - text, - timestamp: Date.now(), - audioUrl, - }; -} - -function makeModelMessage(text: string): Message { - return { role: 'model', text, timestamp: Date.now() }; -} - -const SAMPLE_MESSAGES_QUESTIONING: Message[] = [ - makeModelMessage('Bonjour, je suis prêt.'), - makeUserMessage('Quel est le prix de cette voiture ?', 'blob:http://localhost/fake-audio-1'), - makeModelMessage('La voiture coûte trente mille euros.'), - makeUserMessage('Pourquoi est-elle si chère ?', 'blob:http://localhost/fake-audio-2'), -]; - -const SAMPLE_MESSAGES_PERSUASION: Message[] = [ - makeModelMessage('Je ne suis pas convaincu.'), - makeUserMessage('Ce produit est fiable et abordable.', 'blob:http://localhost/fake-audio-3'), - makeModelMessage("D'accord, vous marquez un point."), - makeUserMessage('De plus, il est écologique.', 'blob:http://localhost/fake-audio-4'), -]; - -const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; // base64 for "fakeaudio" +const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; const FAKE_MIME_TYPE = 'audio/webm'; -// Helper: make fetch return a successful audio blob -function setupSuccessfulAudioFetch() { - const fakeBlob = new Blob([Buffer.from(FAKE_AUDIO_BASE64, 'base64')], { type: FAKE_MIME_TYPE }); - mockFetch.mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(fakeBlob), - }); +function userMessage(text: string, audioUrl?: string): Message { + return { role: 'user', text, timestamp: Date.now(), audioUrl }; } -// Helper: make fetch reject (simulate network failure) -function setupFailingAudioFetch() { - mockFetch.mockRejectedValue(new Error('Failed to fetch blob')); +function modelMessage(text: string): Message { + return { role: 'model', text, timestamp: Date.now(), frenchText: text }; } -// --------------------------------------------------------------------------- -// Setup / teardown -// --------------------------------------------------------------------------- +let lastReviewBody: Record | null = null; beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-review'); - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(SAMPLE_REVIEW), - }); - setupSuccessfulAudioFetch(); + lastReviewBody = null; + const fakeBlob = new Blob([Buffer.from(FAKE_AUDIO_BASE64, 'base64')], { type: FAKE_MIME_TYPE }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith('blob:')) { + return { ok: true, blob: async () => fakeBlob } as Response; + } + if (url.includes('/api/tef-review')) { + lastReviewBody = JSON.parse(String(init?.body ?? '{}')) as Record; + return jsonResponse(SAMPLE_REVIEW); + } + return jsonResponse({ error: 'NOT_FOUND' }, 404); + })); }); afterEach(() => { - localStorage.clear(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); - mockFetch.mockReset(); }); -// --------------------------------------------------------------------------- -// Function existence -// --------------------------------------------------------------------------- - -describe('generateTefReview · existence', () => { +describe('generateTefReview · BFF client', () => { it('is exported from tefReviewService', async () => { - const mod = await import('../services/tefReviewService'); - expect(typeof (mod as Record).generateTefReview).toBe('function'); - }); - - it('returns a Promise', () => { - const result = generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - expect(result).toBeInstanceOf(Promise); - }); -}); - -// --------------------------------------------------------------------------- -// Happy path: valid response parsing -// --------------------------------------------------------------------------- - -describe('generateTefReview · happy path', () => { - it('returns a TefReview object with all required top-level fields (no tipsForC1)', async () => { - const result = await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - expect(result).toHaveProperty('cefrLevel'); - expect(result).toHaveProperty('cefrJustification'); - expect(result).toHaveProperty('wentWell'); - expect(result).toHaveProperty('topicSuggestions'); - // tipsForC1 has been removed from the schema — it must NOT appear on the result - expect(result).not.toHaveProperty('tipsForC1'); - }); - - it('preserves cefrLevel and cefrJustification from the model response', async () => { - const result = await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - expect(result.cefrLevel).toBe('B2'); - expect(result.cefrJustification).toBe( - 'The speaker demonstrated solid grammar with occasional errors.' - ); + expect(typeof generateTefReview).toBe('function'); }); - it('preserves wentWell array from the model response', async () => { - const result = await generateTefReview({ + it('returns the Worker review payload', async () => { + const review = await generateTefReview({ exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, + messages: [userMessage('Bonjour', 'blob:http://localhost/a')], + elapsedSeconds: 30, }); - - expect(result.wentWell).toEqual(['Good use of connectors', 'Clear pronunciation']); + expect(review).toEqual(SAMPLE_REVIEW); + expect(lastReviewBody?.exerciseType).toBe('questioning'); + const turns = lastReviewBody?.turns as Array>; + expect(turns.some((turn) => turn.audioBase64 === FAKE_AUDIO_BASE64)).toBe(true); }); - it('calls ai.models.generateContent exactly once', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - expect(mockGenerateContent).toHaveBeenCalledTimes(1); - }); -}); - -// --------------------------------------------------------------------------- -// Prompt construction: exercise types -// --------------------------------------------------------------------------- - -describe('generateTefReview · prompt construction', () => { - it('includes user audio as inlineData (not transcript text) when audio fetch succeeds — questioning', async () => { - // beforeEach sets up successful audio fetch - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - // inlineData should be present for user audio - expect(promptText).toContain('inlineData'); - // transcript text should NOT be included when audio is available - expect(promptText).not.toContain('Quel est le prix de cette voiture'); - }); - - it('includes user audio as inlineData (not transcript text) when audio fetch succeeds — persuasion', async () => { - // beforeEach sets up successful audio fetch - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad summary.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - // inlineData should be present for user audio - expect(promptText).toContain('inlineData'); - // transcript text should NOT be included when audio is available - expect(promptText).not.toContain('Ce produit est fiable et abordable'); - }); - - it('includes "questioning" context cue in the prompt for questioning type', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - // The prompt should mention questioning or questions to give context to the model - expect(promptText.toLowerCase()).toMatch(/question/); - }); - - it('includes "persuasion" context cue in the prompt for persuasion type', async () => { - await generateTefReview({ + it('still posts when messages are empty', async () => { + const review = await generateTefReview({ exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad summary.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - expect(promptText.toLowerCase()).toMatch(/persuad|convinc|advertis/); - }); - - it('requests application/json response mime type', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - expect(JSON.stringify(callArg)).toContain('application/json'); - }); - - it('includes elapsedSeconds in the prompt', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 247, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - expect(promptText).toContain('247'); - }); -}); - -// --------------------------------------------------------------------------- -// Prompt construction: guide content -// --------------------------------------------------------------------------- - -describe('generateTefReview · guide content in prompt', () => { - // The ?raw imports resolve to empty strings via __mocks__/rawMock.ts in the - // test environment. We can only verify that the prompt construction logic - // attempted to include guide content (i.e. the call happens without crashing - // and a generateContent call is made). Specific content inclusion is verified - // in integration / manual tests where real files are loaded. - - it('does not crash for questioning type (guide raw imports resolve to empty string)', async () => { - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).resolves.toBeDefined(); - }); - - it('does not crash for persuasion type (guide raw imports resolve to empty string)', async () => { - await expect( - generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }) - ).resolves.toBeDefined(); - }); -}); - -// --------------------------------------------------------------------------- -// Persuasion-specific: adSummary context (objectionState removed) -// --------------------------------------------------------------------------- - -describe('generateTefReview · persuasion adSummary context', () => { - it('includes adSummary in the prompt when provided', async () => { - const adSummary = 'A luxury car advertisement targeting young professionals.'; - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - expect(promptText).toContain(adSummary); - }); -}); - -// --------------------------------------------------------------------------- -// Persuasion-specific: 5-criteria evaluation in prompt -// --------------------------------------------------------------------------- - -describe('generateTefReview · persuasion criteria in prompt', () => { - it('includes "clear" and "interesting" or "presentation" criterion in prompt', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - expect(promptText).toMatch(/clear.*interest|interest.*clear|presentation/); - }); - - it('includes argumentation vocabulary criterion in prompt', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - expect(promptText).toMatch(/argumentation.*vocab|vocab.*argumentation/); - }); - - it('includes 3+ distinct arguments criterion in prompt', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - // Should mention 3 arguments or "three different" arguments - expect(promptText).toMatch(/3.*argument|three.*argument|argument.*3|argument.*three/); - }); - - it('includes examples / developed arguments criterion in prompt', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - // Should mention examples or "developed" - expect(promptText).toMatch(/example|exemple|developed/); - }); - - it('includes nuance / counter-argument criterion in prompt', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - // Should mention nuance or counter-argument - expect(promptText).toMatch(/nuanc|counter.?argument/); - }); - - it('persuasion prompt does NOT reference objectionState, isConvinced, or currentDirection', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - expect(promptText).not.toMatch(/objectionState|isConvinced|currentDirection/); - }); - - it('persuasion topic suggestions instruct user-perspective persuasive statements (not friend questions)', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - expect(promptText).toMatch(/user could say|persuasive statements|persuader/); - expect(promptText).toMatch(/do not write questions the friend would ask|not questions the friend would ask/); - }); - - it('questioning topic suggestions instruct user questions to the agent', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - expect(promptText).toMatch(/questions the user could ask/); - }); -}); - -// --------------------------------------------------------------------------- -// Persuasion-specific: criteriaEvaluation in response schema -// --------------------------------------------------------------------------- - -describe('generateTefReview · criteriaEvaluation in response schema', () => { - it('includes criteriaEvaluation in the response schema for persuasion type', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const callJson = JSON.stringify(callArg); - expect(callJson).toContain('criteriaEvaluation'); - }); - - it('returns a result that includes criteriaEvaluation array for persuasion type', async () => { - const reviewWithCriteria = { - ...SAMPLE_REVIEW, - criteriaEvaluation: [ - { criterion: 'Clear & interesting presentation', met: true, evidence: 'User clearly introduced the ad.' }, - { criterion: 'Argumentation vocabulary', met: false, evidence: 'Limited use of linking words.' }, - { criterion: '3+ distinct arguments', met: true, evidence: 'Three distinct points raised.' }, - { criterion: 'Arguments developed with examples', met: false, evidence: 'Bare assertions without examples.' }, - { criterion: 'Handled counter-arguments / nuance', met: true, evidence: 'Acknowledged objections.' }, - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(reviewWithCriteria), - }); - - const result = await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }); - - expect(result).toHaveProperty('criteriaEvaluation'); - expect(Array.isArray((result as unknown as Record)?.criteriaEvaluation)).toBe(true); - }); -}); - -// --------------------------------------------------------------------------- -// topicSuggestions field: schema presence and response preservation -// --------------------------------------------------------------------------- - -describe('generateTefReview · topicSuggestions schema and response', () => { - it.each([ - { - label: 'questioning type', - args: { - exerciseType: 'questioning' as const, - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }, - }, - { - label: 'persuasion type', - args: { - exerciseType: 'persuasion' as const, - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }, - }, - ])('encodes the full nested topicSuggestions shape in the response schema for $label', async ({ args }) => { - mockGenerateContent.mockClear(); - await generateTefReview(args); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const ts = callArg.config.responseSchema.properties.topicSuggestions; - - // Top-level array - expect(ts.type).toBe(Type.ARRAY); - - // Each item is an object - expect(ts.items.type).toBe(Type.OBJECT); - - // Item properties include topic and examples - expect(ts.items.properties).toHaveProperty('topic'); - expect(ts.items.properties).toHaveProperty('examples'); - - // Item required fields include topic and examples - expect(ts.items.required).toContain('topic'); - expect(ts.items.required).toContain('examples'); - - // examples is an array - expect(ts.items.properties.examples.type).toBe(Type.ARRAY); - - // Each example has french and english properties - expect(ts.items.properties.examples.items.properties).toHaveProperty('french'); - expect(ts.items.properties.examples.items.properties).toHaveProperty('english'); - - // Example required fields include french and english - expect(ts.items.properties.examples.items.required).toContain('french'); - expect(ts.items.properties.examples.items.required).toContain('english'); - }); - - it('preserves topicSuggestions array values from the model response', async () => { - const result = await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - expect(result).not.toBeNull(); - expect(result!.topicSuggestions).toEqual(SAMPLE_REVIEW.topicSuggestions); - expect(result!.topicSuggestions).toHaveLength(5); - expect(result!.topicSuggestions[0].examples).toHaveLength(2); - expect(result!.topicSuggestions[0].examples[0]).toHaveProperty('french'); - expect(result!.topicSuggestions[0].examples[0]).toHaveProperty('english'); - }); - -}); - -// --------------------------------------------------------------------------- -// objectionState is no longer a parameter -// --------------------------------------------------------------------------- - -describe('generateTefReview · objectionState parameter removed', () => { - it('does not include objectionState in the function signature (TypeScript type check via call)', async () => { - // The function should accept calls without objectionState for persuasion type - // and work correctly — if objectionState was required, this call would fail at type-check time - await expect( - generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - // objectionState intentionally omitted - }) - ).resolves.toBeDefined(); - }); -}); - -// --------------------------------------------------------------------------- -// Audio fetching: successful fetch -// --------------------------------------------------------------------------- - -describe('generateTefReview · audio fetching (success)', () => { - it('calls fetch for each user message audio URL', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - // Two user messages each have an audioUrl - const userMessagesWithAudio = SAMPLE_MESSAGES_QUESTIONING.filter( - (m) => m.role === 'user' && m.audioUrl - ); - expect(mockFetch).toHaveBeenCalledTimes(userMessagesWithAudio.length); - }); - - it('passes the correct blob URLs to fetch', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const fetchedUrls = mockFetch.mock.calls.map((c) => c[0]); - expect(fetchedUrls).toContain('blob:http://localhost/fake-audio-1'); - expect(fetchedUrls).toContain('blob:http://localhost/fake-audio-2'); - }); -}); - -// --------------------------------------------------------------------------- -// Audio fetching: failure → falls back to transcript -// --------------------------------------------------------------------------- - -describe('generateTefReview · audio fetching (failure falls back to transcript)', () => { - it('does not throw when audio fetch fails — falls back to transcript text', async () => { - setupFailingAudioFetch(); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).resolves.toBeDefined(); - }); - - it('still includes transcript text in prompt when audio fetch fails', async () => { - setupFailingAudioFetch(); - - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - // Transcript text from user messages must still appear - expect(promptText).toContain('Quel est le prix de cette voiture'); - }); - - it('still calls generateContent once even when all audio fetches fail', async () => { - setupFailingAudioFetch(); - - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - expect(mockGenerateContent).toHaveBeenCalledTimes(1); - }); -}); - -// --------------------------------------------------------------------------- -// Empty message array -// --------------------------------------------------------------------------- - -describe('generateTefReview · empty messages', () => { - it('does not throw when messages array is empty', async () => { - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: [], - elapsedSeconds: 0, - }) - ).resolves.toBeDefined(); - }); - - it('does not call fetch when there are no messages', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: [], - elapsedSeconds: 0, - }); - - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it('calls generateContent even with no user speech', async () => { - await generateTefReview({ - exerciseType: 'questioning', messages: [], elapsedSeconds: 0, }); - - expect(mockGenerateContent).toHaveBeenCalledTimes(1); - }); - - it('handles messages with only model turns (no user messages)', async () => { - const modelOnlyMessages: Message[] = [ - makeModelMessage('Bonjour.'), - makeModelMessage('Au revoir.'), - ]; - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: modelOnlyMessages, - elapsedSeconds: 30, - }) - ).resolves.toBeDefined(); - - expect(mockFetch).not.toHaveBeenCalled(); - }); -}); - -// --------------------------------------------------------------------------- -// Error handling: malformed / missing response fields -// --------------------------------------------------------------------------- - -describe('generateTefReview · error handling (malformed response)', () => { - it('throws when model returns empty text', async () => { - mockGenerateContent = vi.fn().mockResolvedValue({ text: '' }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(); - }); - - it('throws when model returns non-JSON text', async () => { - mockGenerateContent = vi.fn().mockResolvedValue({ - text: 'Sorry, I cannot help with that.', - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(); - }); - - it('throws when response is missing cefrLevel', async () => { - const { cefrLevel: _omitted, ...withoutCefrLevel } = SAMPLE_REVIEW; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(withoutCefrLevel), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(); - }); - - it('throws when response is missing cefrJustification', async () => { - const { cefrJustification: _omitted, ...withoutJustification } = SAMPLE_REVIEW; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(withoutJustification), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(); - }); - - it('throws when response is missing wentWell', async () => { - const { wentWell: _omitted, ...withoutWentWell } = SAMPLE_REVIEW; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(withoutWentWell), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(); - }); - - it('does not require standardizationItems, mistakes, or vocabularySuggestions', async () => { - const { - mistakes: _m, - vocabularySuggestions: _v, - ...withoutLanguageLists - } = SAMPLE_REVIEW; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(withoutLanguageLists), - }); - - const result = await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - expect(result).not.toBeNull(); - expect(result).not.toHaveProperty('mistakes'); - expect(result).not.toHaveProperty('vocabularySuggestions'); - expect(result).not.toHaveProperty('standardizationItems'); - }); - - it('throws when response is missing topicSuggestions', async () => { - const { topicSuggestions: _omitted, ...withoutTopics } = SAMPLE_REVIEW; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(withoutTopics), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(); - }); - - it('does NOT throw when tipsForC1 is absent from the response (field is no longer required)', async () => { - // tipsForC1 is not part of the schema anymore — omitting it must be valid - const withoutTips = { ...SAMPLE_REVIEW } as Record; - delete withoutTips['tipsForC1']; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(withoutTips), - }); - - const result = await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - expect(result).not.toBeNull(); - expect(result!.cefrLevel).toBe(SAMPLE_REVIEW.cefrLevel); - }); - - it('throws when generateContent call itself rejects', async () => { - mockGenerateContent = vi.fn().mockRejectedValue(new Error('API quota exceeded')); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow('API quota exceeded'); - }); - - it('returns null when the SDK throws a real abort error (not an Error with name AbortError)', async () => { - // The @google/genai SDK (v1.37.0) throws Error { name: 'Error', message: 'exception AbortError: ...' } - // when a request is aborted — its .name is 'Error', NOT 'AbortError', so the current - // catch block checks miss it and the error re-throws. - const sdkAbortError = new Error( - 'exception AbortError: The operation was aborted. sending request' - ); - // Confirm the shape: name is the default 'Error', not 'AbortError' - expect(sdkAbortError.name).toBe('Error'); - mockGenerateContent.mockRejectedValueOnce(sdkAbortError); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).resolves.toBeNull(); - }); - - it('returns null when the SDK throws an APIUserAbortError-style error (name set to APIUserAbortError)', async () => { - // Another abort shape the current checks miss: Error { name: 'APIUserAbortError', message: 'Request was aborted.' } - const apiUserAbortError = new Error('Request was aborted.'); - apiUserAbortError.name = 'APIUserAbortError'; - mockGenerateContent.mockRejectedValueOnce(apiUserAbortError); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).resolves.toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// topicSuggestions runtime shape validation -// --------------------------------------------------------------------------- - -describe('generateTefReview · topicSuggestions runtime validation', () => { - it('throws when topicSuggestions is not an array (e.g. a string)', async () => { - const malformed = { ...SAMPLE_REVIEW, topicSuggestions: 'not an array' }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when topicSuggestions array has fewer than 5 items', async () => { - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: SAMPLE_REVIEW.topicSuggestions.slice(0, 3), - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when a topicSuggestions item is a bare string instead of an object', async () => { - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - 'bare string', - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when a topicSuggestions item is missing the topic field', async () => { - const { topic: _omitted, ...itemWithoutTopic } = SAMPLE_REVIEW.topicSuggestions[0]; - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - itemWithoutTopic, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when a topicSuggestions item has an empty string for topic', async () => { - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - { ...SAMPLE_REVIEW.topicSuggestions[0], topic: '' }, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when a topicSuggestions item has examples that is not an array', async () => { - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - { ...SAMPLE_REVIEW.topicSuggestions[0], examples: 'not an array' }, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when a topicSuggestions item has fewer than 2 examples', async () => { - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - { - ...SAMPLE_REVIEW.topicSuggestions[0], - examples: SAMPLE_REVIEW.topicSuggestions[0].examples.slice(0, 1), - }, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when an example is missing the french field', async () => { - const { french: _omitted, ...exampleWithoutFrench } = - SAMPLE_REVIEW.topicSuggestions[0].examples[0]; - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - { - ...SAMPLE_REVIEW.topicSuggestions[0], - examples: [ - exampleWithoutFrench, - SAMPLE_REVIEW.topicSuggestions[0].examples[1], - ], - }, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when an example has an empty string for french', async () => { - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - { - ...SAMPLE_REVIEW.topicSuggestions[0], - examples: [ - { ...SAMPLE_REVIEW.topicSuggestions[0].examples[0], french: '' }, - SAMPLE_REVIEW.topicSuggestions[0].examples[1], - ], - }, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when an example is missing the english field', async () => { - const { english: _omitted, ...exampleWithoutEnglish } = - SAMPLE_REVIEW.topicSuggestions[0].examples[0]; - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - { - ...SAMPLE_REVIEW.topicSuggestions[0], - examples: [ - exampleWithoutEnglish, - SAMPLE_REVIEW.topicSuggestions[0].examples[1], - ], - }, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when an example has an empty string for english', async () => { - const malformed = { - ...SAMPLE_REVIEW, - topicSuggestions: [ - { - ...SAMPLE_REVIEW.topicSuggestions[0], - examples: [ - { ...SAMPLE_REVIEW.topicSuggestions[0].examples[0], english: '' }, - SAMPLE_REVIEW.topicSuggestions[0].examples[1], - ], - }, - ...SAMPLE_REVIEW.topicSuggestions.slice(1), - ], - }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }) - ).rejects.toThrow(/topicSuggestions/); - }); - - it('throws when topicSuggestions is not an array (persuasion type)', async () => { - const malformed = { ...SAMPLE_REVIEW, topicSuggestions: 'not an array' }; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(malformed), - }); - - await expect( - generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }) - ).rejects.toThrow(/topicSuggestions/); - }); -}); - -// --------------------------------------------------------------------------- -// User-only evaluation scope: prompt must not grade the agent -// --------------------------------------------------------------------------- - -describe('generateTefReview · user-only evaluation scope', () => { - it('prompt explicitly instructs to evaluate ONLY the user\'s French, not the agent', async () => { - await generateTefReview({ - exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - // The prompt must explicitly restrict grading to the user — phrasing like - // "evaluate only the user", "assess only the user's French", "do not assess the agent", - // "only the user's", etc. - expect(promptText.toLowerCase()).toMatch( - /evaluate only the user|assess only the user|only the user.{0,20}french|do not.*assess.*agent|do not.*evaluate.*agent|evaluate.*the user.*only/ - ); + expect(review).toEqual(SAMPLE_REVIEW); + expect(lastReviewBody?.turns).toEqual([]); }); - it('prompt explicitly states agent turns are provided for context only, not for grading', async () => { - await generateTefReview({ + it('returns null on abort', async () => { + vi.mocked(fetch).mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' })); + const review = await generateTefReview({ exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - // The prompt must contain a specific statement that agent turns are "context only" - // (e.g. "context only", "for context only", "agent turns are context", etc.). - // Checked as a substring to avoid false-positive regex matches on distant words. - const hasContextOnly = - promptText.includes('context only') || - promptText.includes('agent turns are context') || - promptText.includes('agent.*for context') || - promptText.includes('context, not for grading') || - promptText.includes('context, not for evaluat'); - expect(hasContextOnly).toBe(true); - }); - - it('prompt explicitly says do not grade or criticize the agent\'s French or performance', async () => { - await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', + messages: [userMessage('Bonjour')], + elapsedSeconds: 10, + signal: AbortSignal.abort(), }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg).toLowerCase(); - // Must explicitly forbid grading/criticising the agent — checked with bounded - // patterns to avoid matching "agent" and "not/do not" from unrelated sentences. - const hasForbiddenGrading = - promptText.includes("do not grade the agent") || - promptText.includes("do not assess the agent") || - promptText.includes("do not evaluate the agent") || - promptText.includes("do not criticise the agent") || - promptText.includes("do not criticize the agent") || - promptText.includes("not grade the agent") || - promptText.includes("not assess the agent") || - promptText.includes("agent's french is not"); - expect(hasForbiddenGrading).toBe(true); + expect(review).toBeNull(); }); - it('agent transcript lines are still present in the prompt for conversational context', async () => { + it('includes agent turns as text-only context', async () => { await generateTefReview({ exerciseType: 'questioning', - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }); - - const callArg = mockGenerateContent.mock.calls[0][0]; - const promptText = JSON.stringify(callArg); - // Agent turns must still appear in the prompt so the model has conversational context - expect(promptText).toContain('[Agent said:'); - }); -}); - -// --------------------------------------------------------------------------- -// Both exercise types: no per-utterance language-feedback lists -// --------------------------------------------------------------------------- - -describe('generateTefReview · no language-feedback lists', () => { - function reviewSchema() { - return mockGenerateContent.mock.calls[0][0].config.responseSchema as { - properties: Record; - required: string[]; - }; - } - - it.each([ - { - label: 'persuasion', - args: { - exerciseType: 'persuasion' as const, - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }, - }, - { - label: 'questioning', - args: { - exerciseType: 'questioning' as const, - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }, - }, - ])('does not ask the model for standard rewrites, mistakes, or vocabulary lists ($label)', async ({ args }) => { - await generateTefReview(args); - - const promptText = JSON.stringify(mockGenerateContent.mock.calls[0][0]); - expect(promptText).not.toMatch(/more standard french|standardizationItems|idiomatic/i); - expect(promptText).not.toContain('Grammatical/lexical mistakes'); - expect(promptText).not.toContain('at least 5 more precise or higher-register alternatives'); - }); - - it.each([ - { - label: 'persuasion', - args: { - exerciseType: 'persuasion' as const, - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', - }, - }, - { - label: 'questioning', - args: { - exerciseType: 'questioning' as const, - messages: SAMPLE_MESSAGES_QUESTIONING, - elapsedSeconds: 120, - }, - }, - ])('omits standardizationItems, mistakes, and vocabularySuggestions from the $label response schema', async ({ args }) => { - await generateTefReview(args); - - const schema = reviewSchema(); - expect(schema.properties).not.toHaveProperty('standardizationItems'); - expect(schema.properties).not.toHaveProperty('mistakes'); - expect(schema.properties).not.toHaveProperty('vocabularySuggestions'); - expect(schema.required).not.toContain('standardizationItems'); - expect(schema.required).not.toContain('mistakes'); - expect(schema.required).not.toContain('vocabularySuggestions'); - }); - - it('succeeds for persuasion when language-feedback lists are omitted', async () => { - const { mistakes: _m, vocabularySuggestions: _v, ...persuasionOnly } = SAMPLE_REVIEW; - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify(persuasionOnly), - }); - - const result = await generateTefReview({ - exerciseType: 'persuasion', - messages: SAMPLE_MESSAGES_PERSUASION, - elapsedSeconds: 90, - adSummary: 'A car ad.', + messages: [ + modelMessage('Bonjour, comment puis-je vous aider?'), + userMessage('Quel est le prix?', 'blob:http://localhost/a'), + ], + elapsedSeconds: 20, }); - - expect(result).not.toBeNull(); - expect(result).not.toHaveProperty('standardizationItems'); - expect(result).not.toHaveProperty('mistakes'); - expect(result).not.toHaveProperty('vocabularySuggestions'); + const turns = lastReviewBody?.turns as Array>; + expect(turns[0]).toMatchObject({ role: 'model', frenchText: 'Bonjour, comment puis-je vous aider?' }); + expect(turns[0]?.audioBase64).toBeUndefined(); }); }); diff --git a/__tests__/transcribeAndCleanupAudioAbortSignal.test.ts b/__tests__/transcribeAndCleanupAudioAbortSignal.test.ts index 26a98d0..6d18efd 100644 --- a/__tests__/transcribeAndCleanupAudioAbortSignal.test.ts +++ b/__tests__/transcribeAndCleanupAudioAbortSignal.test.ts @@ -1,80 +1,29 @@ -/** - * TDD tests for transcribeAndCleanupAudio(audioBase64, mimeType, abortSignal?) - * in services/geminiService.ts. - * - * Contract: - * - transcribeAndCleanupAudio accepts an optional AbortSignal - * - the AbortSignal is forwarded into ai.models.generateContent config - * - responseMimeType/responseSchema remain set alongside abortSignal - * - * Tests FAIL before the implementation is updated. - */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mockParleBff, jsonResponse } from './helpers/mockParleBff'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; +const FAKE_MIME_TYPE = 'audio/webm'; -// --------------------------------------------------------------------------- -// Module-level mock for @google/genai -// --------------------------------------------------------------------------- -vi.mock('@google/genai', async (importActual) => { - const actual = await importActual(); - return { - ...actual, - GoogleGenAI: vi.fn(), - }; +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.resetModules(); }); -import { GoogleGenAI } from '@google/genai'; - -const FAKE_AUDIO_BASE64 = 'ZmFrZWF1ZGlv'; // "fakeaudio" in base64 -const FAKE_MIME_TYPE = 'audio/webm'; - describe('transcribeAndCleanupAudio · AbortSignal forwarding', () => { - let mockGenerateContent: ReturnType; - - beforeEach(() => { - localStorage.setItem('parle_api_key_gemini', 'test-key-transcribe-abort'); - - mockGenerateContent = vi.fn().mockResolvedValue({ - text: JSON.stringify({ rawTranscript: 'RAW', cleanedTranscript: 'CLEANED' }), - }); - - const mockAi = { - models: { - get generateContent() { - return mockGenerateContent; - }, - }, - chats: { create: vi.fn() }, - }; - - vi.mocked(GoogleGenAI).mockReturnValue(mockAi as unknown as GoogleGenAI); - }); - - afterEach(() => { - localStorage.clear(); - vi.restoreAllMocks(); - }); - - it('forwards AbortSignal into generateContent config without dropping JSON response config', async () => { + it('forwards AbortSignal into fetch without dropping cleanup JSON mode', async () => { const abortController = new AbortController(); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.signal).toBe(abortController.signal); + const body = JSON.parse(String(init?.body ?? '{}')) as { cleanup?: boolean }; + expect(body.cleanup).toBe(true); + return jsonResponse({ rawTranscript: 'RAW', cleanedTranscript: 'CLEANED' }); + }); + vi.stubGlobal('fetch', fetchMock); const { transcribeAndCleanupAudio } = await import('../services/geminiService'); - const result = await transcribeAndCleanupAudio(FAKE_AUDIO_BASE64, FAKE_MIME_TYPE, abortController.signal); expect(result).toEqual({ rawTranscript: 'RAW', cleanedTranscript: 'CLEANED' }); - - expect(mockGenerateContent).toHaveBeenCalledTimes(1); - const requestArg = mockGenerateContent.mock.calls[0][0] as any; - - expect(requestArg.config).toBeDefined(); - expect(requestArg.config.abortSignal).toBe(abortController.signal); - - // Must remain set together with abortSignal - expect(requestArg.config.responseMimeType).toBe('application/json'); - expect(requestArg.config.responseSchema).toBeDefined(); - expect(requestArg.config.responseSchema.required).toEqual( - expect.arrayContaining(['rawTranscript', 'cleanedTranscript']) - ); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); - diff --git a/__tests__/worker.ai.auth.test.ts b/__tests__/worker.ai.auth.test.ts new file mode 100644 index 0000000..068e278 --- /dev/null +++ b/__tests__/worker.ai.auth.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { COOKIE_NAME } from '../worker/constants'; +import { handleChat, handleTranscribe } from '../worker/routes/ai'; +import { handleCreateSession } from '../worker/routes/session'; + +const SECRET = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; +const env = { + API_KEY_COOKIE_SECRET: SECRET, +} as Env; + +async function sessionCookie(): Promise { + const response = await handleCreateSession( + new Request('http://localhost:8787/api/session', { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ geminiApiKey: 'AIza-test-key-123456' }), + }), + env + ); + const setCookie = response.headers.get('Set-Cookie') ?? ''; + const match = setCookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`)); + if (!match) throw new Error('missing session cookie'); + return `${COOKIE_NAME}=${match[1]}`; +} + +describe('Worker AI route auth', () => { + it('returns 401 without a session cookie', async () => { + const response = await handleTranscribe( + new Request('http://localhost:8787/api/transcribe', { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ audioBase64: 'Zg==', mimeType: 'audio/webm' }), + }), + env + ); + expect(response.status).toBe(401); + const body = await response.json() as { error: string }; + expect(body.error).toBe('NO_API_KEY_SESSION'); + }); + + it('rejects a mismatched Origin on POST /api/chat', async () => { + const cookie = await sessionCookie(); + const response = await handleChat( + new Request('https://parle.example/api/chat', { + method: 'POST', + headers: { + Origin: 'https://evil.example', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify({ audioBase64: 'Zg==', mimeType: 'audio/webm', history: [] }), + }), + env + ); + expect(response.status).toBe(403); + }); + + it('requires audio on POST /api/chat', async () => { + const cookie = await sessionCookie(); + const response = await handleChat( + new Request('http://localhost:8787/api/chat', { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify({ history: [{ role: 'user', text: 'bonjour' }] }), + }), + env + ); + expect(response.status).toBe(400); + const body = await response.json() as { error: string }; + expect(body.error).toBe('VALIDATION_ERROR'); + }); +}); diff --git a/__tests__/worker.cookies.test.ts b/__tests__/worker.cookies.test.ts new file mode 100644 index 0000000..9a098da --- /dev/null +++ b/__tests__/worker.cookies.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { COOKIE_NAME } from '../worker/constants'; +import { parseCookieHeader, serializeDeletedSessionCookie, serializeSessionCookie } from '../worker/cookies'; + +describe('session cookies', () => { + it('serializes __Host- attributes without Domain', () => { + const header = serializeSessionCookie('sealed-value'); + expect(header.startsWith(`${COOKIE_NAME}=sealed-value;`)).toBe(true); + expect(header).toContain('Path=/'); + expect(header).toContain('HttpOnly'); + expect(header).toContain('Secure'); + expect(header).toContain('SameSite=Strict'); + expect(header).toContain('Max-Age=34560000'); + expect(header).not.toMatch(/Domain=/i); + }); + + it('clears the cookie with Max-Age=0 and matching attributes', () => { + const header = serializeDeletedSessionCookie(); + expect(header).toContain(`${COOKIE_NAME}=;`); + expect(header).toContain('Path=/'); + expect(header).toContain('HttpOnly'); + expect(header).toContain('Secure'); + expect(header).toContain('SameSite=Strict'); + expect(header).toContain('Max-Age=0'); + expect(header).not.toMatch(/Domain=/i); + }); + + it('parses the named cookie from a Cookie header', () => { + expect(parseCookieHeader(`${COOKIE_NAME}=abc; other=1`, COOKIE_NAME)).toBe('abc'); + expect(parseCookieHeader('other=1', COOKIE_NAME)).toBeUndefined(); + }); + + it('treats a malformed percent-encoded cookie as missing instead of throwing', () => { + expect(parseCookieHeader(`${COOKIE_NAME}=%`, COOKIE_NAME)).toBeUndefined(); + expect(parseCookieHeader(`${COOKIE_NAME}=%E0%A4%A`, COOKIE_NAME)).toBeUndefined(); + }); +}); diff --git a/__tests__/worker.csrf.test.ts b/__tests__/worker.csrf.test.ts new file mode 100644 index 0000000..06afb49 --- /dev/null +++ b/__tests__/worker.csrf.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { isAllowedOrigin } from '../worker/csrf'; + +function request(url: string, headers: Record = {}): Request { + return new Request(url, { headers }); +} + +describe('isAllowedOrigin', () => { + it('allows matching origin in production', () => { + const req = request('https://parle.example/api/session', { + Origin: 'https://parle.example', + }); + expect(isAllowedOrigin(req)).toBe(true); + }); + + it('rejects a mismatched production origin', () => { + const req = request('https://parle.example/api/session', { + Origin: 'https://evil.example', + }); + expect(isAllowedOrigin(req)).toBe(false); + }); + + it('allows localhost Origin against a different Worker port', () => { + const req = request('http://localhost:8787/api/session', { + Origin: 'http://localhost:3000', + }); + expect(isAllowedOrigin(req)).toBe(true); + }); + + it('allows 127.0.0.1 Origin against localhost Worker', () => { + const req = request('http://localhost:8787/api/chat', { + Origin: 'http://127.0.0.1:3000', + }); + expect(isAllowedOrigin(req)).toBe(true); + }); + + it('does not treat localhost Origin as valid on a public host', () => { + const req = request('https://parle.example/api/session', { + Origin: 'http://localhost:3000', + }); + expect(isAllowedOrigin(req)).toBe(false); + }); + + it('falls back to Referer origin when Origin is absent', () => { + const req = request('https://parle.example/api/session', { + Referer: 'https://parle.example/settings', + }); + expect(isAllowedOrigin(req)).toBe(true); + }); + + it('allows a request with neither Origin nor Referer', () => { + const req = request('https://parle.example/api/revoke'); + expect(isAllowedOrigin(req)).toBe(true); + }); +}); diff --git a/__tests__/worker.openai.plan.test.ts b/__tests__/worker.openai.plan.test.ts new file mode 100644 index 0000000..f801045 --- /dev/null +++ b/__tests__/worker.openai.plan.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { planScenarioWithOpenAI } from '../worker/openai'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function openaiResponse(content: unknown, status = 200): Response { + return new Response(JSON.stringify({ + choices: [{ message: { content: typeof content === 'string' ? content : JSON.stringify(content) } }], + }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('planScenarioWithOpenAI', () => { + it('throws UPSTREAM_ERROR 502 when the model JSON fails ScenarioSummarySchema', async () => { + vi.stubGlobal('fetch', vi.fn(async () => openaiResponse({ + summary: 'A bakery visit', + characters: [], + steps: [], + }))); + + await expect(planScenarioWithOpenAI('sk-test-openai-key-123456', 'bakery')).rejects.toMatchObject({ + code: 'UPSTREAM_ERROR', + httpStatus: 502, + }); + }); + + it('throws UPSTREAM_ERROR 502 when the model content is not JSON', async () => { + vi.stubGlobal('fetch', vi.fn(async () => openaiResponse('not-json'))); + + await expect(planScenarioWithOpenAI('sk-test-openai-key-123456', 'bakery')).rejects.toMatchObject({ + code: 'UPSTREAM_ERROR', + httpStatus: 502, + }); + }); + + it('returns validated JSON when the schema matches', async () => { + const payload = { + summary: 'A trip to the bakery.', + characters: [{ name: 'Baker', role: 'baker' }], + steps: ['Greet the baker', 'Order a baguette'], + }; + vi.stubGlobal('fetch', vi.fn(async () => openaiResponse(payload))); + + const result = await planScenarioWithOpenAI('sk-test-openai-key-123456', 'bakery'); + expect(JSON.parse(result)).toEqual(payload); + }); +}); diff --git a/__tests__/worker.requestTurns.test.ts b/__tests__/worker.requestTurns.test.ts new file mode 100644 index 0000000..bc011fe --- /dev/null +++ b/__tests__/worker.requestTurns.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { COOKIE_NAME } from '../worker/constants'; +import { handleChat, handleScenarioReview, handleTefReview } from '../worker/routes/ai'; +import { handleCreateSession } from '../worker/routes/session'; + +const SECRET = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; +const env = { + API_KEY_COOKIE_SECRET: SECRET, +} as Env; + +async function sessionCookie(): Promise { + const response = await handleCreateSession( + new Request('http://localhost:8787/api/session', { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ geminiApiKey: 'AIza-test-key-123456' }), + }), + env + ); + const setCookie = response.headers.get('Set-Cookie') ?? ''; + const match = setCookie.match(new RegExp(`${COOKIE_NAME}=([^;]+)`)); + if (!match) throw new Error('missing session cookie'); + return `${COOKIE_NAME}=${match[1]}`; +} + +function jsonRequest(path: string, cookie: string, body: unknown): Request { + return new Request(`http://localhost:8787${path}`, { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify(body), + }); +} + +describe('Worker request-boundary turn validation', () => { + it('rejects null history entries on POST /api/chat with VALIDATION_ERROR', async () => { + const cookie = await sessionCookie(); + const response = await handleChat( + jsonRequest('/api/chat', cookie, { + audioBase64: 'Zg==', + mimeType: 'audio/webm', + history: [null], + }), + env + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: 'VALIDATION_ERROR' }); + }); + + it('rejects invalid history roles on POST /api/chat', async () => { + const cookie = await sessionCookie(); + const response = await handleChat( + jsonRequest('/api/chat', cookie, { + audioBase64: 'Zg==', + mimeType: 'audio/webm', + history: [{ role: 'admin', text: 'bonjour' }], + }), + env + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: 'VALIDATION_ERROR' }); + }); + + it('rejects TEF review turns with a non-string field', async () => { + const cookie = await sessionCookie(); + const response = await handleTefReview( + jsonRequest('/api/tef-review', cookie, { + exerciseType: 'questioning', + turns: [{ role: 'user', text: 12 }], + }), + env + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: 'VALIDATION_ERROR' }); + }); + + it('rejects scenario review turns that are not objects', async () => { + const cookie = await sessionCookie(); + const response = await handleScenarioReview( + jsonRequest('/api/scenario-review', cookie, { + turns: ['bonjour'], + }), + env + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: 'VALIDATION_ERROR' }); + }); +}); diff --git a/__tests__/worker.seal.test.ts b/__tests__/worker.seal.test.ts new file mode 100644 index 0000000..b6505c8 --- /dev/null +++ b/__tests__/worker.seal.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { seal, unseal, unsealWithRotation } from '../worker/seal'; + +const SECRET = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; // 32-byte base64 +const OTHER = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='; + +describe('seal / unseal', () => { + it('rejects a blank secret before hashing', async () => { + await expect(seal({ v: 1 }, ' ')).rejects.toThrow(/API_KEY_COOKIE_SECRET is missing/); + await expect(seal({ v: 1 }, '')).rejects.toThrow(/API_KEY_COOKIE_SECRET is missing/); + }); + + it('round-trips a payload', async () => { + const payload = { v: 1 as const, keys: { gemini: 'AIza-test-key-123456' }, createdAt: 1 }; + const token = await seal(payload, SECRET); + expect(token.startsWith('v1.')).toBe(true); + expect(await unseal(token, SECRET)).toEqual(payload); + }); + + it('returns null for tampered ciphertext', async () => { + const token = await seal({ hello: 'world' }, SECRET); + const parts = token.split('.'); + parts[2] = parts[2].slice(0, -4) + 'aaaa'; + expect(await unseal(parts.join('.'), SECRET)).toBeNull(); + }); + + it('returns null for the wrong secret', async () => { + const token = await seal({ hello: 'world' }, SECRET); + expect(await unseal(token, OTHER)).toBeNull(); + }); + + it('rotates from previous to current secret', async () => { + const payload = { v: 1, n: 42 }; + const oldToken = await seal(payload, OTHER); + const rotated = await unsealWithRotation(oldToken, SECRET, OTHER); + expect(rotated?.payload).toEqual(payload); + expect(rotated?.resealed).toBeTruthy(); + expect(await unseal(rotated!.resealed!, SECRET)).toEqual(payload); + }); +}); diff --git a/__tests__/worker.session.test.ts b/__tests__/worker.session.test.ts new file mode 100644 index 0000000..48499d4 --- /dev/null +++ b/__tests__/worker.session.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { COOKIE_MAX_AGE_SECONDS, COOKIE_NAME } from '../worker/constants'; +import { handleCreateSession, handleRevoke, handleSessionStatus } from '../worker/routes/session'; +import { seal } from '../worker/seal'; +import type { SessionPayload } from '../worker/session'; + +const SECRET = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; +const env = { + API_KEY_COOKIE_SECRET: SECRET, +} as Env; + +function cookieFrom(response: Response): string | null { + return response.headers.get('Set-Cookie'); +} + +describe('session routes', () => { + it('sets an HttpOnly cookie on POST /api/session', async () => { + const request = new Request('http://localhost:8787/api/session', { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ geminiApiKey: 'AIza-test-key-123456' }), + }); + const response = await handleCreateSession(request, env); + expect(response.status).toBe(200); + const body = await response.json() as { hasGemini: boolean; hasOpenai: boolean }; + expect(body.hasGemini).toBe(true); + expect(body.hasOpenai).toBe(false); + const setCookie = cookieFrom(response); + expect(setCookie).toContain(COOKIE_NAME); + expect(setCookie).toContain('HttpOnly'); + expect(setCookie).not.toContain('AIza-test-key-123456'); + }); + + it('rejects a mismatched Origin with 403', async () => { + const request = new Request('https://parle.example/api/session', { + method: 'POST', + headers: { + Origin: 'https://evil.example', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ geminiApiKey: 'AIza-test-key-123456' }), + }); + const response = await handleCreateSession(request, env); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'FORBIDDEN' }); + }); + + it('merges OpenAI without dropping an existing Gemini key', async () => { + const payload: SessionPayload = { + v: 1, + keys: { gemini: 'AIza-existing-gemini-key' }, + createdAt: Math.floor(Date.now() / 1000), + }; + const token = await seal(payload, SECRET); + const request = new Request('http://localhost:8787/api/session', { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + Cookie: `${COOKIE_NAME}=${token}`, + }, + body: JSON.stringify({ openaiApiKey: 'sk-test-openai-key-123456' }), + }); + const response = await handleCreateSession(request, env); + const body = await response.json() as { hasGemini: boolean; hasOpenai: boolean }; + expect(body.hasGemini).toBe(true); + expect(body.hasOpenai).toBe(true); + }); + + it('returns hasGemini false when the cookie is missing', async () => { + const request = new Request('http://localhost:8787/api/session/status'); + const response = await handleSessionStatus(request, env); + expect(await response.json()).toMatchObject({ hasGemini: false, hasOpenai: false, hasApiKey: false }); + expect(cookieFrom(response)).toBeNull(); + }); + + it('clears the cookie on POST /api/revoke without requiring JSON', async () => { + const request = new Request('http://localhost:8787/api/revoke', { + method: 'POST', + headers: { Origin: 'http://localhost:3000' }, + }); + const response = await handleRevoke(request, env); + expect(response.status).toBe(200); + expect(cookieFrom(response)).toContain('Max-Age=0'); + expect(await response.json()).toMatchObject({ success: true, hasApiKey: false }); + }); + + it('treats a session older than COOKIE_MAX_AGE_SECONDS as invalid and clears the cookie', async () => { + const payload: SessionPayload = { + v: 1, + keys: { gemini: 'AIza-existing-gemini-key' }, + createdAt: Math.floor(Date.now() / 1000) - COOKIE_MAX_AGE_SECONDS - 1, + }; + const token = await seal(payload, SECRET); + const request = new Request('http://localhost:8787/api/session/status', { + headers: { Cookie: `${COOKIE_NAME}=${token}` }, + }); + const response = await handleSessionStatus(request, env); + expect(await response.json()).toMatchObject({ hasGemini: false, hasOpenai: false, hasApiKey: false }); + expect(cookieFrom(response)).toContain('Max-Age=0'); + }); + + it('treats a sealed payload with null keys as invalid and clears the cookie', async () => { + const token = await seal({ + v: 1, + keys: null, + createdAt: Math.floor(Date.now() / 1000), + }, SECRET); + const request = new Request('http://localhost:8787/api/session/status', { + headers: { Cookie: `${COOKIE_NAME}=${token}` }, + }); + const response = await handleSessionStatus(request, env); + expect(await response.json()).toMatchObject({ hasGemini: false, hasOpenai: false, hasApiKey: false }); + expect(cookieFrom(response)).toContain('Max-Age=0'); + }); + + it('treats a sealed payload with a non-string provider key as invalid', async () => { + const token = await seal({ + v: 1, + keys: { gemini: 123 }, + createdAt: Math.floor(Date.now() / 1000), + }, SECRET); + const request = new Request('http://localhost:8787/api/session/status', { + headers: { Cookie: `${COOKIE_NAME}=${token}` }, + }); + const response = await handleSessionStatus(request, env); + expect(await response.json()).toMatchObject({ hasApiKey: false }); + expect(cookieFrom(response)).toContain('Max-Age=0'); + }); + + it('rejects a control-character key', async () => { + const request = new Request('http://localhost:8787/api/session', { + method: 'POST', + headers: { + Origin: 'http://localhost:3000', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ geminiApiKey: 'AIza-bad\u0000-key-123456' }), + }); + const response = await handleCreateSession(request, env); + expect(response.status).toBe(400); + }); +}); diff --git a/__tests__/worker.tefReview.prompt.test.ts b/__tests__/worker.tefReview.prompt.test.ts new file mode 100644 index 0000000..be4804c --- /dev/null +++ b/__tests__/worker.tefReview.prompt.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { buildTefReviewParts, validateTefReview } from '../worker/prompts/tefReview'; + +describe('buildTefReviewParts', () => { + it('scopes evaluation to the user and keeps agent turns as context', () => { + const { parts } = buildTefReviewParts({ + exerciseType: 'questioning', + elapsedSeconds: 42, + adSummary: 'A gym membership ad', + turns: [ + { role: 'model', frenchText: 'Bonjour, comment puis-je vous aider?' }, + { role: 'user', audioBase64: 'ZmFrZQ==', mimeType: 'audio/webm', text: 'Quel est le prix?' }, + ], + }); + const text = parts.map((part) => 'text' in part ? part.text : '').join('\n'); + expect(text).toMatch(/Evaluate only the user's French/i); + expect(text).toMatch(/context only, not for grading/i); + expect(text).toMatch(/do not grade the agent's French/i); + expect(text).toContain('[Agent said: Bonjour, comment puis-je vous aider?]'); + expect(text).toContain('ELAPSED TIME: 42 seconds'); + expect(text).toContain('A gym membership ad'); + expect(parts.some((part) => 'inlineData' in part && part.inlineData.data === 'ZmFrZQ==')).toBe(true); + }); + + it('includes persuasion criteria and user-perspective topic suggestions', () => { + const { parts, responseSchema } = buildTefReviewParts({ + exerciseType: 'persuasion', + elapsedSeconds: 120, + turns: [], + }); + const text = parts.map((part) => 'text' in part ? part.text : '').join('\n'); + expect(text).toMatch(/Clear & interesting presentation/i); + expect(text).toMatch(/argumentation vocabulary/i); + expect(text).not.toMatch(/objectionState|isConvinced|currentDirection/); + expect(text).toMatch(/persuasive statements from the user's perspective/i); + expect(JSON.stringify(responseSchema)).toMatch(/criteriaEvaluation/); + }); +}); + +describe('validateTefReview', () => { + const validSuggestions = Array.from({ length: 5 }, (_, i) => ({ + topic: `Topic ${i + 1}`, + examples: [ + { french: 'Exemple A', english: 'Example A' }, + { french: 'Exemple B', english: 'Example B' }, + ], + })); + + it('accepts a complete questioning review', () => { + const review = validateTefReview({ + cefrLevel: 'B2', + cefrJustification: 'Solid grammar.', + wentWell: ['Pronunciation'], + topicSuggestions: validSuggestions, + }, 'questioning'); + expect(review.cefrLevel).toBe('B2'); + }); + + it('requires criteriaEvaluation for persuasion', () => { + expect(() => validateTefReview({ + cefrLevel: 'B2', + cefrJustification: 'Solid grammar.', + wentWell: ['Pronunciation'], + topicSuggestions: validSuggestions, + }, 'persuasion')).toThrow(/criteriaEvaluation/); + }); + + it('requires at least 5 topic suggestions with 2 bilingual examples', () => { + expect(() => validateTefReview({ + cefrLevel: 'B2', + cefrJustification: 'Solid grammar.', + wentWell: ['Pronunciation'], + topicSuggestions: validSuggestions.slice(0, 2), + }, 'questioning')).toThrow(/topicSuggestions/); + }); +}); diff --git a/components/ApiKeySetup.tsx b/components/ApiKeySetup.tsx index 5c7df13..ab84c19 100644 --- a/components/ApiKeySetup.tsx +++ b/components/ApiKeySetup.tsx @@ -1,5 +1,11 @@ import React, { useState, useEffect } from 'react'; -import { getApiKey, setApiKey } from '../services/apiKeyService'; +import { BffError } from '../services/bffClient'; +import { + getCachedSessionStatus, + refreshSessionStatus, + revokeKeys, + saveKeys, +} from '../services/apiKeyService'; import { GearIcon } from './icons/GearIcon'; import { EyeIcon } from './icons/EyeIcon'; import { EyeOffIcon } from './icons/EyeOffIcon'; @@ -9,56 +15,74 @@ interface ApiKeySetupProps { onClose: () => void; onSave?: () => void; onImported?: () => void; + initialError?: string | null; } -export const ApiKeySetup: React.FC = ({ onClose, onSave, onImported }) => { +export const ApiKeySetup: React.FC = ({ + onClose, + onSave, + onImported, + initialError = null, +}) => { const [geminiKey, setGeminiKey] = useState(''); const [openaiKey, setOpenaiKey] = useState(''); const [showGeminiKey, setShowGeminiKey] = useState(false); const [showOpenaiKey, setShowOpenaiKey] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(initialError); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState(getCachedSessionStatus()); - // Pre-fill fields with existing keys from localStorage useEffect(() => { - const storedGemini = getApiKey('gemini'); - const storedOpenai = getApiKey('openai'); - if (storedGemini) { - setGeminiKey(storedGemini); - } - if (storedOpenai) { - setOpenaiKey(storedOpenai); - } + void refreshSessionStatus().then(setStatus); }, []); - const handleSave = () => { + const handleSave = async () => { try { setError(null); - // setApiKey already handles empty strings by removing the key - setApiKey('gemini', geminiKey); - setApiKey('openai', openaiKey); - + setSaving(true); + const next = await saveKeys({ + geminiApiKey: geminiKey, + openaiApiKey: openaiKey, + }); + setStatus(next); + setGeminiKey(''); + setOpenaiKey(''); if (onSave) { onSave(); } onClose(); - } catch (error) { - console.error('Error saving API keys:', error); - setError('Failed to save API keys. Please try again.'); + } catch (err) { + console.error('Error saving API keys:', err); + if (err instanceof BffError) { + setError(err.message || 'Could not save your API keys. Please try again.'); + } else { + setError('Could not save your API keys. Please try again.'); + } + } finally { + setSaving(false); + } + }; + + const handleRevoke = async (provider?: 'gemini' | 'openai') => { + try { + setError(null); + const next = await revokeKeys(provider); + setStatus(next); + if (onSave) onSave(); + } catch (err) { + console.error('Error removing API keys:', err); + setError('Could not remove the saved key. Please try again.'); } }; - // Handle Escape key useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape' || event.code === 'Escape') { onClose(); } }; - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; + return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); return ( @@ -70,76 +94,103 @@ export const ApiKeySetup: React.FC = ({ onClose, onSave, onImp className="bg-white border border-parle-navy-100 rounded-2xl w-full max-w-md shadow-2xl p-6 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()} > - {/* Header */}

Settings

- {/* Gemini Section */}
+ {status.hasGemini && ( +

A Gemini key is already saved for this browser.

+ )}
setGeminiKey(e.target.value)} - placeholder="Enter your Gemini API key" + placeholder={status.hasGemini ? 'Leave blank to keep the saved key' : 'Enter your Gemini API key'} + autoComplete="off" className="w-full bg-white border border-parle-navy-200 rounded-lg pl-4 pr-10 py-2 text-parle-navy-900 text-sm focus:ring-2 focus:ring-parle-blue-500 focus:outline-none" />
- - Get a Gemini API Key → - +
+ + Get a Gemini API Key → + + {status.hasGemini && ( + + )} +
- {/* OpenAI Section */}
+ {status.hasOpenai && ( +

An OpenAI key is already saved for this browser.

+ )}
setOpenaiKey(e.target.value)} - placeholder="Enter your OpenAI API key" + placeholder={status.hasOpenai ? 'Leave blank to keep the saved key' : 'Enter your OpenAI API key'} + autoComplete="off" className="w-full bg-white border border-parle-navy-200 rounded-lg pl-4 pr-10 py-2 text-parle-navy-900 text-sm focus:ring-2 focus:ring-parle-blue-500 focus:outline-none" />
- - Get an OpenAI API Key → - +
+ + Get an OpenAI API Key → + + {status.hasOpenai && ( + + )} +
{error && ( @@ -149,25 +200,7 @@ export const ApiKeySetup: React.FC = ({ onClose, onSave, onImp )}

- Keys are stored locally in your browser. localStorage is vulnerable to{' '} - - XSS attacks - - . For production, deploy your version of the app from the{' '} - - source code - - {' '}and use environment variables. + Keys are encrypted and stored only for this browser profile. Leave a field blank to keep a key you already saved. Saving an empty form does not delete keys — use Remove instead.

@@ -179,10 +212,11 @@ export const ApiKeySetup: React.FC = ({ onClose, onSave, onImp Cancel diff --git a/e2e/scenario-description-abort.spec.ts b/e2e/scenario-description-abort.spec.ts index b06066b..8c9baf8 100644 --- a/e2e/scenario-description-abort.spec.ts +++ b/e2e/scenario-description-abort.spec.ts @@ -3,15 +3,6 @@ import { test, expect } from '@playwright/test'; test.describe('ScenarioSetup · describe by voice abort/discard', () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { - // Provide dummy API keys so the app doesn't block with the API key modal. - try { - localStorage.setItem('parle_api_key_gemini', 'test-e2e-gemini'); - localStorage.setItem('parle_api_key_openai', 'test-e2e-openai'); - } catch { - // Ignore localStorage failures (shouldn't happen in real browser contexts) - } - - // ---- Stub microphone/audio recording ---- // The ScenarioSetup "describe by voice" flow depends on Web Audio + MediaRecorder. // In CI/headless Playwright we stub these so the UI can progress deterministically. const fakeStream = { @@ -91,6 +82,17 @@ test.describe('ScenarioSetup · describe by voice abort/discard', () => { }); await page.goto('/'); + await page.evaluate(async () => { + await fetch('/api/session', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + geminiApiKey: 'test-e2e-gemini-key-16', + openaiApiKey: 'test-e2e-openai-key-16', + }), + }); + }); }); test('closing while transcription is in-flight discards stale transcript', async ({ page }) => { @@ -114,7 +116,7 @@ test.describe('ScenarioSetup · describe by voice abort/discard', () => { // Intercept Gemini transcription calls and keep the first/second attempts pending // until the test explicitly resolves them. - await page.route('**/models/gemini-2.5-flash-lite:generateContent*', async route => { + await page.route('**/api/transcribe', async route => { const req = route.request(); let bodyJson: any = null; try { @@ -124,9 +126,7 @@ test.describe('ScenarioSetup · describe by voice abort/discard', () => { } const bodyStr = bodyJson ? JSON.stringify(bodyJson) : ''; - const looksLikeScenarioTranscription = - bodyStr.includes('produce two versions of the transcript') || - bodyStr.includes('Transcribe this audio exactly as spoken'); + const looksLikeScenarioTranscription = true; if (!looksLikeScenarioTranscription) { // We only expect scenario transcription calls in this test. @@ -155,7 +155,7 @@ test.describe('ScenarioSetup · describe by voice abort/discard', () => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(payload), + body: JSON.stringify({ rawTranscript: '', cleanedTranscript: '' }), }); } catch { // Ignore fulfillment errors @@ -204,24 +204,11 @@ test.describe('ScenarioSetup · describe by voice abort/discard', () => { const fulfillTranscription = async (pending: PendingRoute | undefined, raw: string, cleaned: string) => { if (!pending || pending.fulfilled) return; pending.fulfilled = true; - const payload = { - candidates: [ - { - content: { - role: 'model', - parts: [{ text: JSON.stringify({ rawTranscript: raw, cleanedTranscript: cleaned }) }], - }, - }, - ], - }; - - // Route fulfillment may throw if the request was fully aborted, but the app should still - // remain responsive; we treat that as acceptable for this regression test. try { await pending.route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify(payload), + body: JSON.stringify({ rawTranscript: raw, cleanedTranscript: cleaned }), }); } catch { // Intentionally ignored. diff --git a/package-lock.json b/package-lock.json index 3e02a8b..4c27e1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@cloudflare/workers-types": "^5.20260911.1", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", @@ -31,7 +32,8 @@ "postcss": "^8.5.6", "typescript": "~5.8.2", "vite": "^7.3.1", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "wrangler": "^4.131.1" } }, "node_modules/@acemir/cssom": { @@ -430,6 +432,148 @@ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260911.1.tgz", + "integrity": "sha512-785eaY1bkR1cm4Z/PCUeteZYmTMe6lre2zz63/GdGGimsoMsKxgl4brFPRukim8iv28EyD1XoCB/VPYF20BERA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260911.1.tgz", + "integrity": "sha512-WU4bFqEN0H7ndGWxoedegv95DmNVBtv0ncXcHG9nYFTUI78sxEb0qoT3U6Ga4hyBkzsJFBX/zvVBIGX3qKldGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260911.1.tgz", + "integrity": "sha512-0Y2gy62oxQxWa38qinSPE6zNL5+JmumJtDY9AWW1HB8KHuATxN71o5MGzmVFfB8PwZsiHfUd2Sv7O22krCOrhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260911.1.tgz", + "integrity": "sha512-kttNPnx1r2lCqFUoMH62z7CqGV+j4QBbw5fdtaz4pzOrzBv0AWkNATt7onFUe+SwP8zhcepMtbm2F4kKzTf6VA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260911.1.tgz", + "integrity": "sha512-5iO/YfoBDOgO3CrHdkiiVP8SL3O2jC+c6Ux3d378TSPKLhU5+CgHjtE/ZSodWQrzr4FzFRqdW8S7n5nbyD1MHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260911.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260911.1.tgz", + "integrity": "sha512-yiAvknjulcU85B3yB4aKOn9+l+garWP+AbHgsdCFckeRYDdhZ1rPULi64BDf52R3VTaKqxi47kF+o9ZbjsCt5g==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -562,6 +706,17 @@ "node": ">=20.19.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1053,713 +1208,1684 @@ "node": ">=18.0.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@langchain/core": { - "version": "1.1.24", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.24.tgz", - "integrity": "sha512-u6l0dmMHN/2PCsY6stXoh9CH1OTlVR5Gjz0JjT1XRPuidAlu3kTq4ivW95xCog/PRhiAsCh6GCEC4/PqhNrcgQ==", - "license": "MIT", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], "dependencies": { - "@cfworker/json-schema": "^4.0.2", - "ansi-styles": "^5.0.0", - "camelcase": "6", - "decamelize": "1.2.0", - "js-tiktoken": "^1.0.12", - "langsmith": ">=0.5.0 <1.0.0", - "mustache": "^4.2.0", - "p-queue": "^6.6.2", - "uuid": "^10.0.0", - "zod": "^3.25.76 || ^4" + "@img/sharp-wasm32": "0.35.4" }, "engines": { - "node": ">=20" - } - }, - "node_modules/@langchain/core/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "license": "MIT", - "engines": { - "node": ">=10" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/google-genai": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.18.tgz", - "integrity": "sha512-ucUlxcmJ4MS4MZBqAxv0mefpNAbWMS8unE3BFoKpbyk/iMWKmgA0k2SIJ7FcLNh9FHIwILtO0hWWFATYo8WuAA==", - "license": "MIT", - "dependencies": { - "@google/generative-ai": "^0.24.0", - "uuid": "^11.1.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@langchain/core": "^1.1.23" + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/google-genai/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/langgraph": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.1.4.tgz", - "integrity": "sha512-9OhRF+7Zvcpure8TLtBrxfJDo0PAoHZhfzcPL6M3CsGXiYqLWm5tQe+FYqn9zRIV7IwphqVEl1QDNbOkVgo+kw==", - "license": "MIT", - "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.0", - "@langchain/langgraph-sdk": "~1.6.0", - "@standard-schema/spec": "1.1.0", - "uuid": "^10.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@langchain/core": "^1.1.16", - "zod": "^3.25.32 || ^4.2.0", - "zod-to-json-schema": "^3.x" - }, - "peerDependenciesMeta": { - "zod-to-json-schema": { - "optional": true - } + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/langgraph-checkpoint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz", - "integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==", - "license": "MIT", - "dependencies": { - "uuid": "^10.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@langchain/core": "^1.0.1" + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/langgraph-sdk": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.2.tgz", - "integrity": "sha512-UzRZsnDqdTmeitf/K5yZnVdl+V+7bDj/hQUXm+Y8TwWUuKtWUDocIReKgAmPQLoIz0AN8bOUt0QGnIISmCZyuA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.15", - "p-queue": "^9.0.1", - "p-retry": "^7.1.1", - "uuid": "^13.0.0" - }, - "peerDependencies": { - "@langchain/core": "^1.1.16", - "react": "^18 || ^19", - "react-dom": "^18 || ^19" - }, - "peerDependenciesMeta": { - "@langchain/core": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", - "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^7.0.0" - }, - "engines": { - "node": ">=20" - }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", - "license": "MIT", - "engines": { - "node": ">=20" - }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@langchain/openai": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.2.7.tgz", - "integrity": "sha512-vR9zoF0/EZ03X0Tc6woIEWRDSDSr2l64n+MQCW8NduScJtBJs5r/Ng3Lrp2bjtJQywEMQoOhcrV2DMmAIPWgnw==", - "license": "MIT", - "dependencies": { - "js-tiktoken": "^1.0.12", - "openai": "^6.18.0", - "zod": "^3.25.76 || ^4" - }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" + "node": ">=20.9.0" }, - "peerDependencies": { - "@langchain/core": "^1.0.0" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, - "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "Apache-2.0", - "dependencies": { - "playwright": "1.58.2" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, - "bin": { - "playwright": "cli.js" + "funding": { + "url": "https://opencollective.com/libvips" }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "funding": { + "url": "https://opencollective.com/libvips" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "funding": { + "url": "https://opencollective.com/libvips" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" } }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "funding": { + "url": "https://opencollective.com/libvips" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "license": "MIT", + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@emnapi/runtime": "^1.11.3" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=20.9.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "license": "MIT", + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@img/sharp-wasm32": "0.35.4" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=20.9.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "engines": { + "node": ">=6.0.0" } }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "node_modules/@langchain/core": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.24.tgz", + "integrity": "sha512-u6l0dmMHN/2PCsY6stXoh9CH1OTlVR5Gjz0JjT1XRPuidAlu3kTq4ivW95xCog/PRhiAsCh6GCEC4/PqhNrcgQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": ">=0.5.0 <1.0.0", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=20" + } + }, + "node_modules/@langchain/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "node_modules/@langchain/google-genai": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.1.18.tgz", + "integrity": "sha512-ucUlxcmJ4MS4MZBqAxv0mefpNAbWMS8unE3BFoKpbyk/iMWKmgA0k2SIJ7FcLNh9FHIwILtO0hWWFATYo8WuAA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" + "@google/generative-ai": "^0.24.0", + "uuid": "^11.1.0" }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "engines": { + "node": ">=20" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "peerDependencies": { + "@langchain/core": "^1.1.23" } }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { + "node_modules/@langchain/google-genai/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/@langchain/langgraph": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.1.4.tgz", + "integrity": "sha512-9OhRF+7Zvcpure8TLtBrxfJDo0PAoHZhfzcPL6M3CsGXiYqLWm5tQe+FYqn9zRIV7IwphqVEl1QDNbOkVgo+kw==", + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^1.0.0", + "@langchain/langgraph-sdk": "~1.6.0", + "@standard-schema/spec": "1.1.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.16", + "zod": "^3.25.32 || ^4.2.0", + "zod-to-json-schema": "^3.x" + }, + "peerDependenciesMeta": { + "zod-to-json-schema": { "optional": true } } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, + "node_modules/@langchain/langgraph-checkpoint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz", + "integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==", + "license": "MIT", + "dependencies": { + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.0.1" + } + }, + "node_modules/@langchain/langgraph-sdk": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.2.tgz", + "integrity": "sha512-UzRZsnDqdTmeitf/K5yZnVdl+V+7bDj/hQUXm+Y8TwWUuKtWUDocIReKgAmPQLoIz0AN8bOUt0QGnIISmCZyuA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "p-queue": "^9.0.1", + "p-retry": "^7.1.1", + "uuid": "^13.0.0" + }, + "peerDependencies": { + "@langchain/core": "^1.1.16", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@langchain/core": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" + "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz", + "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/@langchain/openai": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.2.7.tgz", + "integrity": "sha512-vR9zoF0/EZ03X0Tc6woIEWRDSDSr2l64n+MQCW8NduScJtBJs5r/Ng3Lrp2bjtJQywEMQoOhcrV2DMmAIPWgnw==", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "^6.18.0", + "zod": "^3.25.76 || ^4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", "cpu": [ "arm64" ], @@ -1768,12 +2894,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", "cpu": [ "arm64" ], @@ -1782,12 +2911,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", "cpu": [ "x64" ], @@ -1796,26 +2928,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", "cpu": [ "x64" ], @@ -1824,12 +2945,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", "cpu": [ "arm" ], @@ -1838,26 +2962,32 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", "cpu": [ "arm64" ], @@ -1866,3557 +2996,3851 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", "cpu": [ - "loong64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], "cpu": [ - "loong64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", "cpu": [ - "ppc64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", "cpu": [ - "ppc64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" - ] + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" - ], + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" - ], + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", - "cpu": [ - "s390x" - ], + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "peer": true }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "dependencies": { + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@babel/types": "^7.28.2" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "undici-types": "~6.21.0" + } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", "license": "MIT" }, - "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 10" + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@vitest/spy": "4.0.18", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", - "cpu": [ - "x64" - ], + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", - "cpu": [ - "x64" - ], + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", - "cpu": [ - "arm" - ], + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 10" + "node": ">= 14" } }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">= 10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "tslib": "^2.0.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=10" } }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", - "cpu": [ - "arm64" - ], + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" } }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", - "cpu": [ - "x64" - ], + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">= 10" + "node": ">=12" } }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", - "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", + "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "postcss": "^8.4.41", - "tailwindcss": "4.1.18" + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" + "require-from-string": "^2.0.2" } }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" + "node": "*" } }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", "dev": true, "license": "MIT" }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "bin": { + "browserslist": "cli.js" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" + "engines": { + "node": ">=18" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "node_modules/console-table-printer": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", + "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "simple-wcswidth": "^1.1.2" } }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, - "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.5", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", - "dev": true, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 8" } }, - "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", - "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-0" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "MIT" }, - "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", - "pathe": "^2.0.3" + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=20" } }, - "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.0.18", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, - "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" } }, - "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", "engines": { - "node": ">= 14" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">=12" + "node": ">=6.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=0.10.0" } }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, "engines": { - "node": ">=10" + "node": ">=6" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "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", - "dependencies": { - "dequal": "^2.0.3" + "engines": { + "node": ">=8" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - } + "peer": true }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", "dev": true, "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": "*" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": ">=6" } }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=12.0.0" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", - "dev": true, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "funding": [ { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" }, { - "type": "github", - "url": "https://github.com/sponsors/ai" + "type": "paypal", + "url": "https://paypal.me/jimmywarting" } ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": "^12.20 || >= 14.13" } }, - "node_modules/chalk/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", "dependencies": { - "color-convert": "^2.0.1" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=8" + "node": ">=14" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "fetch-blob": "^3.1.2" }, "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/console-table-printer": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", - "integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==", - "license": "MIT", - "dependencies": { - "simple-wcswidth": "^1.1.2" + "node": ">=12.20.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "node": ">=18" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" }, "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=6.9.0" } }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=6" } }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.3" + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", "engines": { - "node": ">=0.10.0" + "node": ">=14" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, "engines": { - "node": ">=6" + "node": ">=18" } }, - "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", + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", "dependencies": { - "safe-buffer": "^5.0.1" + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=10.13.0" + "node": ">= 14" } }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "engines": { + "node": ">= 14" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", "dependencies": { - "@types/estree": "^1.0.0" + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } }, - "node_modules/fake-indexeddb": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", - "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, "engines": { - "node": ">=12.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "canvas": { "optional": true } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": "^12.20 || >= 14.13" + "node": ">=6" } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT" - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "bignumber.js": "^9.0.0" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">=12.20.0" + "node": ">=6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" } }, - "node_modules/gaxios": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", - "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", - "license": "Apache-2.0", + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2", - "rimraf": "^5.0.1" - }, + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", + "node_modules/langchain": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.24.tgz", + "integrity": "sha512-NpaOmDZ4dP16sLkY+Y49Q9mrV2fdTy81t+yTw7f3K7/zZtvuQk6IH/bIzfy3bdOD8TERethvR2mOmgueRDbZBw==", + "license": "MIT", "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" + "@langchain/langgraph": "^1.1.2", + "@langchain/langgraph-checkpoint": "^1.0.0", + "langsmith": ">=0.5.0 <1.0.0", + "uuid": "^10.0.0", + "zod": "^3.25.76 || ^4" }, "engines": { - "node": ">=18" + "node": ">=20" + }, + "peerDependencies": { + "@langchain/core": "^1.1.24" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, + "node_modules/langsmith": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.3.tgz", + "integrity": "sha512-FZqMBKqZxhi5H1YDlCvwryMxAZN+YJfxPVJuERW41XwINMn2T4nT2JB3CWrh1blf+OWmOB3Gqd6O8gYjVVwUGQ==", "license": "MIT", - "engines": { - "node": ">=6.9.0" + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", + "node_modules/langsmith/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=6" + "node": ">=10" } }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "detect-libc": "^2.0.3" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" } }, - "node_modules/google-auth-library": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", - "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^8.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC" - }, - "node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 14" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 14" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-network-error": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", - "license": "MIT", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=16" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "yallist": "^3.0.2" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { - "jiti": "lib/jiti-cli.mjs" + "lz-string": "bin/bin.js" } }, - "node_modules/js-tiktoken": { - "version": "1.0.21", - "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", - "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { - "base64-js": "^1.5.1" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", "dev": true, - "license": "MIT" + "license": "CC0-1.0" }, - "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/miniflare": { + "version": "5.20260911.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260911.0-alpha.tgz", + "integrity": "sha512-CRieJmvHx+7rNqnA5SKdsYsER6rfkUIE/jruIUw+fLhsQ4sORfuMtr3+FQzsQ9/y8lhk061V4Fl1DFdHiyBB6g==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", - "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", - "xml-name-validator": "^5.0.0" + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260911.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, - "peerDependencies": { - "canvas": "^3.0.0" + "engines": { + "node": ">=16 || 14 >=14.17" }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", "engines": { - "node": ">=6" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" + "bin": { + "mustache": "bin/mustache" } }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "bin": { - "json5": "lib/cli.js" + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "engines": { + "node": ">=10.5.0" } }, - "node_modules/langchain": { - "version": "1.2.24", - "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.2.24.tgz", - "integrity": "sha512-NpaOmDZ4dP16sLkY+Y49Q9mrV2fdTy81t+yTw7f3K7/zZtvuQk6IH/bIzfy3bdOD8TERethvR2mOmgueRDbZBw==", + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { - "@langchain/langgraph": "^1.1.2", - "@langchain/langgraph-checkpoint": "^1.0.0", - "langsmith": ">=0.5.0 <1.0.0", - "uuid": "^10.0.0", - "zod": "^3.25.76 || ^4" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": ">=20" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependencies": { - "@langchain/core": "^1.1.24" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, - "node_modules/langsmith": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.5.3.tgz", - "integrity": "sha512-FZqMBKqZxhi5H1YDlCvwryMxAZN+YJfxPVJuERW41XwINMn2T4nT2JB3CWrh1blf+OWmOB3Gqd6O8gYjVVwUGQ==", - "license": "MIT", - "dependencies": { - "@types/uuid": "^10.0.0", - "chalk": "^4.1.2", - "console-table-printer": "^2.12.1", - "p-queue": "^6.6.2", - "semver": "^7.6.3", - "uuid": "^10.0.0" + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/openai": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.21.0.tgz", + "integrity": "sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" }, "peerDependencies": { - "@opentelemetry/api": "*", - "@opentelemetry/exporter-trace-otlp-proto": "*", - "@opentelemetry/sdk-trace-base": "*", - "openai": "*" + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/exporter-trace-otlp-proto": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { + "ws": { "optional": true }, - "openai": { + "zod": { "optional": true } } }, - "node_modules/langsmith/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=4" } }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" }, "engines": { - "node": ">= 12.0.0" + "node": ">=8" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=20" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=8" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=16 || 14 >=14.18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, "engines": { - "node": ">= 12.0.0" + "node": ">=18" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "optionalDependencies": { + "fsevents": "2.3.2" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": ">=18" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, - "license": "MPL-2.0", + "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">= 12.0.0" + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "peer": true, "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": ">=8" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "peer": true, "engines": { - "node": ">= 12.0.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "yallist": "^3.0.2" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" + "engines": { + "node": ">=6" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "CC0-1.0" + "license": "MIT", + "peer": true }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=0.10.0" } }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=10" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, "license": "MIT", - "bin": { - "mustache": "bin/mustache" + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/jimmywarting" + "url": "https://github.com/sponsors/feross" }, { - "type": "github", - "url": "https://paypal.me/jimmywarting" + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } + "license": "MIT" }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "xmlchars": "^2.2.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "node": ">=v12.22.7" } }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/openai": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.21.0.tgz", - "integrity": "sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA==", - "license": "Apache-2.0", + "license": "ISC", "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "license": "MIT", - "engines": { - "node": ">=4" + "semver": "bin/semver.js" } }, - "node_modules/p-queue": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", - "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", - "license": "MIT", + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "eventemitter3": "^4.0.4", - "p-timeout": "^3.2.0" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": ">=8" + "node": ">=20.9.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-retry": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", - "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", - "license": "MIT", - "dependencies": { - "is-network-error": "^1.1.0" + "url": "https://opencollective.com/libvips" }, - "engines": { - "node": ">=20" + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/p-timeout": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", - "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", - "license": "MIT", - "dependencies": { - "p-finally": "^1.0.0" + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=8" + "node": ">=10" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "shebang-regex": "^3.0.0" }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">=8" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", + "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", + "license": "MIT" }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, - "license": "Apache-2.0", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=8" } }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=8" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", - "peer": true, "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "ansi-regex": "^5.0.1" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=8" } }, - "node_modules/pretty-format/node_modules/ansi-regex": { + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" + "dependencies": { + "min-indent": "^1.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">=8" } }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=8" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, - "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, - "peerDependencies": { - "react": "^19.2.3" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=14.0.0" } }, - "node_modules/react-remove-scroll": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", - "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, "license": "MIT", "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + "tldts-core": "^7.0.25" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "bin": { + "tldts": "bin/cli.js" } }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "license": "MIT", + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" + "tldts": "^7.0.5" }, "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=16" } }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, "license": "MIT", "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=20" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=8" + "node": ">=14.17" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=20.18.1" } }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" + "pathe": "^2.0.3" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/browserslist" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "xmlchars": "^2.2.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, - "engines": { - "node": ">=v12.22.7" + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", - "engines": { - "node": ">=8" + "bin": { + "uuid": "dist/bin/uuid" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, - "node_modules/simple-wcswidth": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", - "integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==", - "license": "MIT" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=0.10.0" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" }, "engines": { - "node": ">=12" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 8" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=20" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", "dependencies": { - "ansi-regex": "^5.0.1" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">=8" + "node": ">= 8" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, "engines": { "node": ">=8" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "node_modules/workerd": { + "version": "1.20260911.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260911.1.tgz", + "integrity": "sha512-vRr8QdBxueQOZJO1hRCI73EZlix87IAyBAcSyI3rA1VB+6oxjw3oaqzYnIV8C4IOPtUgihbdMAgzkb5GM4V7DQ==", "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" }, "engines": { - "node": ">=8" + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260911.1", + "@cloudflare/workerd-darwin-arm64": "1.20260911.1", + "@cloudflare/workerd-linux-64": "1.20260911.1", + "@cloudflare/workerd-linux-arm64": "1.20260911.1", + "@cloudflare/workerd-windows-64": "1.20260911.1" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", + "node_modules/wrangler": { + "version": "4.131.1", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.131.1.tgz", + "integrity": "sha512-1u5FMdJAn6UOcL02cVsIITcnHrk6mC7N+RF10EkVhPL18R/o9g5BZb4PCjByL+3AsRP5wQpppCIPHhYPRmIJwg==", + "dev": true, + "license": "MIT OR Apache-2.0", "dependencies": { - "has-flag": "^4.0.0" + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260911.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260911.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=8" + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260911.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "node_modules/wrangler/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", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=18" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "node_modules/wrangler/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": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { "node": ">=18" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/wrangler/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": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=18" } }, - "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "node_modules/wrangler/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" + ], "engines": { - "node": ">=14.0.0" + "node": ">=18" } }, - "node_modules/tldts": { - "version": "7.0.25", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", - "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "node_modules/wrangler/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", - "dependencies": { - "tldts-core": "^7.0.25" - }, - "bin": { - "tldts": "bin/cli.js" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/tldts-core": { - "version": "7.0.25", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", - "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "node_modules/wrangler/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": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "node_modules/wrangler/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", - "dependencies": { - "punycode": "^2.3.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "node_modules/wrangler/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": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "node_modules/wrangler/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" + ], "engines": { - "node": ">=20.18.1" + "node": ">=18" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/wrangler/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", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "node_modules/wrangler/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", - "dependencies": { - "tslib": "^2.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=18" } }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "node_modules/wrangler/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", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=18" } }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" + "node_modules/wrangler/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", - "bin": { - "uuid": "dist/bin/uuid" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vaul": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", - "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "node_modules/wrangler/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", - "dependencies": { - "@radix-ui/react-dialog": "^1.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "node_modules/wrangler/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", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "node_modules/wrangler/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", - "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } + "node": ">=18" } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "node_modules/wrangler/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", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "node_modules/wrangler/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" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "node_modules/wrangler/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": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "node_modules/wrangler/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" + ], "engines": { - "node": ">=20" + "node": ">=18" } }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "node_modules/wrangler/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", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, + "node_modules/wrangler/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" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "node_modules/wrangler/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", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/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" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/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" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/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" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/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", "bin": { - "why-is-node-running": "cli.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=8" + "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/wrap-ansi": { @@ -5511,9 +6935,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5555,6 +6979,31 @@ "dev": true, "license": "ISC" }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 25058fa..65d4fac 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,12 @@ "type": "module", "scripts": { "dev": "vite", + "dev:worker": "wrangler dev --port 8787", + "dev:full": "npx --yes concurrently -k \"npm run dev\" \"npm run dev:worker\"", "build": "vite build", "preview": "vite preview", + "deploy": "npm run build && wrangler deploy", + "types": "wrangler types --include-runtime false", "test": "vitest run", "test:e2e": "PLAYWRIGHT_BROWSERS_PATH=./.playwright-browsers playwright test", "test:e2e:install": "PLAYWRIGHT_BROWSERS_PATH=./.playwright-browsers playwright install" @@ -24,6 +28,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@cloudflare/workers-types": "^5.20260911.1", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.9.1", @@ -35,6 +40,7 @@ "postcss": "^8.5.6", "typescript": "~5.8.2", "vite": "^7.3.1", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "wrangler": "^4.131.1" } } diff --git a/playwright.config.ts b/playwright.config.ts index 0cb8348..dfa419e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -28,10 +28,18 @@ export default defineConfig({ }, }, ], - webServer: { - command: 'npm run dev', - port: 3000, - timeout: 30000, - reuseExistingServer: !process.env.CI, - }, + webServer: [ + { + command: 'npm run dev', + port: 3000, + timeout: 30000, + reuseExistingServer: !process.env.CI, + }, + { + command: 'npm run dev:worker', + port: 8787, + timeout: 30000, + reuseExistingServer: !process.env.CI, + }, + ], }); diff --git a/services/apiKeyService.ts b/services/apiKeyService.ts index 1925d08..3be0838 100644 --- a/services/apiKeyService.ts +++ b/services/apiKeyService.ts @@ -1,78 +1,110 @@ -/** - * API Key Management Service - * Handles storage and retrieval of API keys from localStorage - * with fallback to environment variables for backward compatibility. - */ +import { BffError, bffFetch } from './bffClient'; -const STORAGE_KEY_PREFIX = 'parle_api_key_'; +const LEGACY_STORAGE_PREFIX = 'parle_api_key_'; -/** - * Get an API key from localStorage for a specific provider. - * Uses the canonical prefixed key format: parle_api_key_. - */ -export const getApiKey = (provider: 'gemini' | 'openai'): string | null => { - try { - const key = localStorage.getItem(`${STORAGE_KEY_PREFIX}${provider}`); - return key || null; - } catch (error) { - console.error(`Error reading ${provider} API key from localStorage:`, error); - return null; - } -}; +export interface SessionStatus { + hasGemini: boolean; + hasOpenai: boolean; + hasApiKey: boolean; + createdAt?: number; +} -/** - * Set an API key in localStorage for a specific provider. - */ -export const setApiKey = (provider: 'gemini' | 'openai', key: string): void => { +const emptyStatus = (): SessionStatus => ({ + hasGemini: false, + hasOpenai: false, + hasApiKey: false, +}); + +let cachedStatus: SessionStatus = emptyStatus(); + +function applyStatus(status: SessionStatus): SessionStatus { + cachedStatus = { + hasGemini: Boolean(status.hasGemini), + hasOpenai: Boolean(status.hasOpenai), + hasApiKey: Boolean(status.hasApiKey || status.hasGemini || status.hasOpenai), + ...(typeof status.createdAt === 'number' ? { createdAt: status.createdAt } : {}), + }; + return cachedStatus; +} + +export function getCachedSessionStatus(): SessionStatus { + return cachedStatus; +} + +export function hydrateSessionStatus(status: Partial): SessionStatus { + return applyStatus({ + ...emptyStatus(), + ...status, + hasApiKey: Boolean(status.hasApiKey ?? (status.hasGemini || status.hasOpenai)), + }); +} + +export async function refreshSessionStatus(): Promise { try { - if (key.trim()) { - localStorage.setItem(`${STORAGE_KEY_PREFIX}${provider}`, key.trim()); - } else { - // Remove key if empty string - localStorage.removeItem(`${STORAGE_KEY_PREFIX}${provider}`); + const status = await bffFetch('/api/session/status'); + return applyStatus(status); + } catch (err) { + if (err instanceof BffError && err.code === 'UPSTREAM_AUTH_FAILED') { + return cachedStatus; } - } catch (error) { - console.error(`Error saving ${provider} API key to localStorage:`, error); - throw error; + return applyStatus(emptyStatus()); } -}; +} -/** - * Check if an API key exists in localStorage for a specific provider. - */ -export const hasApiKey = (provider: 'gemini' | 'openai'): boolean => { - return getApiKey(provider) !== null; -}; +export async function saveKeys(input: { + geminiApiKey?: string; + openaiApiKey?: string; +}): Promise { + const status = await bffFetch('/api/session', { + method: 'POST', + body: JSON.stringify({ + ...(input.geminiApiKey?.trim() ? { geminiApiKey: input.geminiApiKey.trim() } : {}), + ...(input.openaiApiKey?.trim() ? { openaiApiKey: input.openaiApiKey.trim() } : {}), + }), + }); + return applyStatus(status); +} -/** - * Get API key from localStorage, falling back to environment variable. - * Checks localStorage first, then process.env for backward compatibility. - */ -export const getApiKeyOrEnv = (provider: 'gemini' | 'openai'): string | null => { - // Check localStorage first - const storedKey = getApiKey(provider); - if (storedKey) { - return storedKey; +export async function revokeKeys(provider?: 'gemini' | 'openai'): Promise { + const status = await bffFetch('/api/revoke', { + method: 'POST', + body: JSON.stringify(provider ? { provider } : {}), + }); + return applyStatus(status); +} + +export function clearLegacyLocalStorageKeys(): void { + try { + localStorage.removeItem(`${LEGACY_STORAGE_PREFIX}gemini`); + localStorage.removeItem(`${LEGACY_STORAGE_PREFIX}openai`); + } catch { + // Ignore quota / private-mode failures. } +} - // Fallback to environment variable - const envKey = provider === 'gemini' - ? process.env.GEMINI_API_KEY - : process.env.OPENAI_API_KEY; - - return envKey || null; -}; +/** @deprecated Keys are never readable from JavaScript after the BFF migration. */ +export const getApiKey = (_provider: 'gemini' | 'openai'): string | null => null; /** - * Check if at least one API key is available (from localStorage or env). + * Test/legacy helper: updates the in-memory session flags only. + * Does not store the raw key. */ -export const hasAnyApiKey = (): boolean => { - return getApiKeyOrEnv('gemini') !== null || getApiKeyOrEnv('openai') !== null; +export const setApiKey = (provider: 'gemini' | 'openai', key: string): void => { + if (provider === 'gemini') { + cachedStatus.hasGemini = Boolean(key.trim()); + } else { + cachedStatus.hasOpenai = Boolean(key.trim()); + } + cachedStatus.hasApiKey = cachedStatus.hasGemini || cachedStatus.hasOpenai; }; -/** - * Check if a specific provider has an API key available (from localStorage or env). - */ -export const hasApiKeyOrEnv = (provider: 'gemini' | 'openai'): boolean => { - return getApiKeyOrEnv(provider) !== null; +export const hasApiKey = (provider: 'gemini' | 'openai'): boolean => { + return provider === 'gemini' ? cachedStatus.hasGemini : cachedStatus.hasOpenai; }; + +/** @deprecated Raw keys are never available to the client. Use hasApiKeyOrEnv. */ +export const getApiKeyOrEnv = (_provider: 'gemini' | 'openai'): string | null => null; + +export const hasAnyApiKey = (): boolean => cachedStatus.hasApiKey; + +export const hasApiKeyOrEnv = (provider: 'gemini' | 'openai'): boolean => hasApiKey(provider); diff --git a/services/bffClient.ts b/services/bffClient.ts new file mode 100644 index 0000000..445f6e8 --- /dev/null +++ b/services/bffClient.ts @@ -0,0 +1,78 @@ +import { isAbortLikeError } from '../utils/isAbortLikeError'; + +export type BffErrorCode = + | 'NO_API_KEY_SESSION' + | 'INVALID_API_KEY_SESSION' + | 'UPSTREAM_AUTH_FAILED' + | 'UPSTREAM_ERROR' + | 'INTERNAL_ERROR' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'METHOD_NOT_ALLOWED' + | 'VALIDATION_ERROR' + | 'MISSING_PROVIDER_KEY'; + +export class BffError extends Error { + readonly code: BffErrorCode; + readonly httpStatus: number; + + constructor(code: BffErrorCode, httpStatus: number, message?: string) { + super(message || code); + this.name = 'BffError'; + this.code = code; + this.httpStatus = httpStatus; + } +} + +const ERROR_CODES: ReadonlySet = new Set([ + 'NO_API_KEY_SESSION', + 'INVALID_API_KEY_SESSION', + 'UPSTREAM_AUTH_FAILED', + 'UPSTREAM_ERROR', + 'INTERNAL_ERROR', + 'FORBIDDEN', + 'NOT_FOUND', + 'METHOD_NOT_ALLOWED', + 'VALIDATION_ERROR', + 'MISSING_PROVIDER_KEY', +]); + +function isBffErrorCode(value: string): value is BffErrorCode { + return ERROR_CODES.has(value); +} + +export async function bffFetch(path: string, init: RequestInit = {}): Promise { + let response: Response; + try { + const headers = new Headers(init.headers); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + response = await fetch(path, { + credentials: 'same-origin', + ...init, + headers, + }); + } catch (err) { + if (isAbortLikeError(err)) throw err; + throw new BffError('UPSTREAM_ERROR', 502, 'Network error'); + } + + let parsed: unknown = null; + const contentType = response.headers.get('Content-Type') ?? ''; + if (contentType.includes('application/json')) { + try { + parsed = await response.json(); + } catch { + parsed = null; + } + } + + if (!response.ok) { + const body = parsed as { error?: string; message?: string } | null; + const code = body?.error && isBffErrorCode(body.error) ? body.error : 'UPSTREAM_ERROR'; + throw new BffError(code, response.status, body?.message); + } + + return parsed as T; +} diff --git a/services/geminiService.ts b/services/geminiService.ts index e49c1f4..49fd0c9 100644 --- a/services/geminiService.ts +++ b/services/geminiService.ts @@ -1,320 +1,37 @@ -import { GoogleGenAI, Chat, Modality, Type } from "@google/genai"; -import { z } from "zod"; -import { base64ToBytes, pcmToWav } from "./audioUtils"; -import { VoiceResponse, Scenario, Message } from "../types"; -import { getConversationHistory, addToHistory } from "./conversationHistory"; -import { generateScenarioSystemInstruction, generateScenarioSummaryPrompt, parseHintFromResponse, parseMultiCharacterResponse } from "./scenarioService"; -import { getApiKeyOrEnv } from "./apiKeyService"; -import { fetchAudioAsInlineData } from "../utils/fetchAudioAsInlineData"; - -// Gemini TTS output format constants -const DEFAULT_PCM_SAMPLE_RATE = 24000; // 24kHz sample rate -const DEFAULT_PCM_CHANNELS = 1; // Mono audio +import { pcmToWav, base64ToBytes } from './audioUtils'; +import { VoiceResponse, Scenario, Message } from '../types'; +import { addToHistory, getConversationHistory } from './conversationHistory'; +import { + FreeConversationSchema, + ImageAnalysisSchema, + RoadmapSingleCharacterSchema, + TefQuestioningSchema, + SingleCharacterSchema, + createMultiCharacterSchema, +} from '../shared/chatSchemas'; +import { fetchAudioAsInlineData } from '../utils/fetchAudioAsInlineData'; +import { isAbortLikeError } from '../utils/isAbortLikeError'; +import { bffFetch } from './bffClient'; + +const DEFAULT_PCM_SAMPLE_RATE = 24000; +const DEFAULT_PCM_CHANNELS = 1; /** Wall-clock maximum (ms) for transcribe + chat + TTS in `sendVoiceMessage`. */ export const PIPELINE_MAX_MS = 90_000; -// Define the system instruction to enforce the language constraint -const SYSTEM_INSTRUCTION = ` -You are a friendly and patient French language tutor. -Your goal is to help the user practice speaking French. - -RESPONSE FORMAT (CRITICAL): -You MUST respond with structured JSON in this exact format: -{ - "french": "Your complete French response here", - "english": "The English translation here" -} - -Example: -User says: "Bonjour, je suis fatigue." (User means "I am tired" but made a mistake) -You respond with JSON: -{ - "french": "Bonjour! Oh, tu es fatigué ? Pourquoi es-tu fatigué aujourd'hui ?", - "english": "Hello! Oh, you are tired? Why are you tired today?" -} - -GUIDELINES: -1. Understand what the user says, but don't repeat it verbatim. Briefly acknowledge understanding when needed, but focus on responding naturally. -2. If the user makes a mistake, gently correct them in your French response, but keep the conversation flowing naturally. -3. Put your COMPLETE French response in the "french" field -4. Put the COMPLETE ENGLISH translation in the "english" field -5. Keep French and English SEPARATE - do NOT combine them in one field -`; - -let ai: GoogleGenAI | null = null; -let chatSession: Chat | null = null; -// Track how many messages from shared history have been synced to the session -let syncedMessageCount = 0; -// (debug instrumentation removed) -// Track the active scenario for scenario-aware prompting let activeScenario: Scenario | null = null; -// Store pending scenario and history when ai is not yet initialized let pendingScenario: Scenario | null = null; let pendingHistory: Array<{ role: string; content: string }> | null = null; +let storedPriorMessages: Message[] = []; +let syncedMessageCount = 0; -/** - * Max number of characters supported in multi-character scenarios - */ -const MAX_CHARACTERS = 5; - -/** - * Zod schema for single-character response. - * Separates French and English for TTS control. - */ -const SingleCharacterSchema = z.object({ - french: z.string().describe("The complete response in French only"), - english: z.string().describe("The English translation of the French response"), - hint: z.string().describe("Hint for what the user should say or ask next - brief description in English") -}); - -/** - * Zod schema for TEF Questioning mode response. - * Adds optional isRepeat field to flag repeated questions. - */ -const TefQuestioningSchema = z.object({ - french: z.string().describe("The complete response in French only"), - english: z.string().describe("The English translation of the French response"), - hint: z.string().describe("Suggestion of a question the user could ask next - brief description in English"), - isRepeat: z.boolean().optional().describe("true if the user asked a question that was already answered"), - conceptLabels: z.array(z.string()).describe("Array of 2-4 word topic labels in English for the question asked (e.g. ['pricing', 'opening hours']). Always include this field — use an empty array if no topic applies."), -}); - -/** - * Zod schema for single-character scenarios that carry roadmap steps. - * Adds a required "currentStepIndex" field so the client can auto-advance the - * scenario roadmap sidebar. Mirrors the isTefQuestioning conditional-schema - * precedent: this field must ONLY be present when the scenario has a non-empty - * `steps` array, so it is a separate schema branch rather than an - * always-present optional field (see AGENTS.md "TEF Ad Questioning Mode: - * Schema Selection"). - */ -const RoadmapSingleCharacterSchema = SingleCharacterSchema.extend({ - currentStepIndex: z.number().int().min(0).describe( - "0-based index into the scenario roadmap steps list (given in the system instruction) of the step the conversation currently reflects." - ), -}); - -/** - * Zod schema for free conversation mode response. - * Separates French and English for TTS control, with optional hint. - */ -const FreeConversationSchema = z.object({ - french: z.string().describe("The complete response in French only"), - english: z.string().describe("The English translation of the French response") -}); - -/** - * Zod schema for image analysis responses (confirmTefAdImage / confirmTefAdImageForQuestioning). - */ -const ImageAnalysisSchema = z.object({ - summary: z.string().min(1), - roleSummary: z.string().min(1), -}); - -/** - * Create Zod schema for multi-character response. - * Uses fixed labels ("Character 1", "Character 2", etc.) instead of actual names - * because LLMs don't reliably use exact character names in structured output. - * The processing code maps these labels back to actual characters by index. - * - * When the scenario also carries roadmap steps, this extends the base shape - * with a required "currentStepIndex" field — the same conditional-schema - * precedent used by `RoadmapSingleCharacterSchema` (see AGENTS.md "Scenario - * Roadmap: Schema Selection..."). Multi-character scenarios (e.g. a bakery - * visit with a Baker + Cashier) are common for role-play, so the roadmap - * field must be available here too, not just on the single-character branch. - */ -const createMultiCharacterSchema = (scenario: Scenario) => { - const count = Math.min(scenario.characters!.length, MAX_CHARACTERS); - const labels = Array.from({ length: count }, (_, i) => `Character ${i + 1}`); - - // Allow hint at top level OR inside each character response (LLMs place it inconsistently) - const base = z.object({ - characterResponses: z.array( - z.object({ - characterName: z.string().describe(`Must be one of: ${labels.join(', ')}`), - french: z.string().describe("The character's complete response in French only"), - english: z.string().describe("The English translation of the French response"), - hint: z.string().optional().describe("Optional per-character hint") - }) - ), - hint: z.string().optional().describe("Hint for what the user should say or ask next - brief description in English") - }); - - const hasRoadmapSteps = !!scenario.steps && scenario.steps.length > 0; - return hasRoadmapSteps - ? base.extend({ - currentStepIndex: z.number().int().min(0).describe( - "0-based index into the scenario roadmap steps list (given in the system instruction) of the step the conversation currently reflects." - ), - }) - : base; -}; - -/** - * Convert a standard JSON Schema object (as produced by z.toJSONSchema) to the - * uppercase-typed format required by the Gemini SDK's responseSchema field. - * Gemini expects "OBJECT", "STRING", "ARRAY" etc.; z.toJSONSchema produces lowercase. - * Only passes through fields that the Gemini Schema type supports. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function toGeminiSchema(jsonSchema: Record): Record { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: Record = {}; - if (jsonSchema.type) result.type = (jsonSchema.type as string).toUpperCase(); - if (jsonSchema.description) result.description = jsonSchema.description; - if (jsonSchema.properties) { - result.properties = Object.fromEntries( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - Object.entries(jsonSchema.properties as Record>).map( - ([k, v]) => [k, toGeminiSchema(v)] - ) - ); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (jsonSchema.items) result.items = toGeminiSchema(jsonSchema.items as Record); - if (jsonSchema.required) result.required = jsonSchema.required; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (jsonSchema.anyOf) result.anyOf = (jsonSchema.anyOf as Record[]).map(toGeminiSchema); - if (jsonSchema.enum) result.enum = jsonSchema.enum; - if (jsonSchema.nullable !== undefined) result.nullable = jsonSchema.nullable; - return result; -} - -/** - * Gemini-format response schemas derived from the Zod schemas above. - * These are passed as responseSchema to the chat session config, which prevents - * the model from returning an unexpected JSON shape (e.g. an array of turns). - * Derived via toGeminiSchema so the shape stays in sync with the Zod definitions. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const SINGLE_CHARACTER_RESPONSE_SCHEMA = toGeminiSchema(z.toJSONSchema(SingleCharacterSchema) as Record); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const FREE_CONVERSATION_RESPONSE_SCHEMA = toGeminiSchema(z.toJSONSchema(FreeConversationSchema) as Record); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const TEF_QUESTIONING_RESPONSE_SCHEMA = toGeminiSchema(z.toJSONSchema(TefQuestioningSchema) as Record); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const ROADMAP_RESPONSE_SCHEMA = toGeminiSchema(z.toJSONSchema(RoadmapSingleCharacterSchema) as Record); -const createGeminiMultiCharacterSchema = (scenario: Scenario) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - toGeminiSchema(z.toJSONSchema(createMultiCharacterSchema(scenario)) as Record); - -/** - * Picks the response schema for a given active scenario (or free conversation - * when null). Shared by createChatSession() (session-level config) and - * sendVoiceMessage() (per-request config) — both need the exact same - * branching, so this is the single place that order lives: multi-character, - * no scenario, TEF questioning, roadmap, then single-character fallback. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function selectResponseSchema(scenario: Scenario | null): Record { - if (scenario && scenario.characters && scenario.characters.length > 1) { - return createGeminiMultiCharacterSchema(scenario); - } - if (!scenario) { - return FREE_CONVERSATION_RESPONSE_SCHEMA; - } - if (scenario.isTefQuestioning) { - return TEF_QUESTIONING_RESPONSE_SCHEMA; - } - if (scenario.steps && scenario.steps.length > 0) { - return ROADMAP_RESPONSE_SCHEMA; - } - return SINGLE_CHARACTER_RESPONSE_SCHEMA; -} - -/** - * Helper function to create the chat session with current state. - * Only call when ai is initialized. - */ -function createChatSession(): void { - if (!ai) { - return; - } - - const systemInstruction = activeScenario - ? generateScenarioSystemInstruction(activeScenario) - : SYSTEM_INSTRUCTION; - - // Convert history to SDK format if provided - const historyMessages = pendingHistory ? pendingHistory.map(msg => ({ - role: msg.role === 'user' ? 'user' : 'model', - parts: [{ text: msg.content }] - })) : undefined; - - // Pick the response schema that matches the scenario type. - // This enforces the JSON shape at the API level, preventing the model from - // returning an array of turns instead of a single response object. - // isTefQuestioning is a sub-case of having a single-character activeScenario, - // so it is only evaluated once we know activeScenario is non-null. - const responseSchema = selectResponseSchema(activeScenario); - - chatSession = ai.chats.create({ - model: 'gemini-2.5-flash-lite', - config: { - systemInstruction: systemInstruction, - // Always use JSON response format for structured French/English separation - responseMimeType: 'application/json', - responseSchema, - }, - ...(historyMessages && { history: historyMessages }), - }); - - // Update sync counter if history was provided - if (pendingHistory) { - syncedMessageCount = pendingHistory.length; - } else { - syncedMessageCount = 0; - } +const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; - // Clear pending history after successful session creation - pendingHistory = null; +function unsupportedImageError(mimeType: string): Error { + const typeLabels = SUPPORTED_IMAGE_TYPES.map((t) => t.replace('image/', '').toUpperCase()).join(', '); + return new Error(`Unsupported image type "${mimeType}". Please use ${typeLabels}.`); } -/** - * Resets the Gemini session and sync counter. - * Should be called when clearing conversation history. - * Optionally can set a new scenario for scenario-aware prompting. - * Can optionally pass history to initialize the session with existing messages. - * - * Always persists the scenario and history in state, even if ai is not yet initialized. - * When ai is initialized later, call this again or initializeSession to create the actual session. - */ -export const resetSession = (scenario?: Scenario | null, history?: Array<{ role: string; content: string }>) => { - // Always update the module-level state, regardless of ai initialization - activeScenario = scenario || null; - pendingScenario = scenario || null; - - if (history) { - pendingHistory = history; - } else { - // Only reset sync counter if no history is provided (clearing state) - syncedMessageCount = 0; - pendingHistory = null; - } - - // Only create the actual chat session if ai is initialized - if (ai) { - createChatSession(); - } -}; - -/** - * Sets the active scenario and resets the session with new instructions. - */ -export const setScenario = (scenario: Scenario | null) => { - resetSession(scenario); -}; - -type ChatHistoryPart = - | { text: string } - | { inlineData: { data: string; mimeType: string } }; - -/** - * Collapse consecutive model bubbles (multi-character turns) into one model - * entry so Gemini chat history stays strictly alternating user/model. - */ function collapseMessagesForChatHistory(messages: Message[]): Message[] { const collapsed: Message[] = []; for (const message of messages) { @@ -332,42 +49,22 @@ function collapseMessagesForChatHistory(messages: Message[]): Message[] { return collapsed; } -/** - * Rebuild the Gemini chat from UI messages using each user's recorded audio - * (same audio-first approach as TEF/scenario review). `messages` must be the - * complete prior turns only (ending on a model message) — the last user audio - * is sent separately via sendVoiceMessage. - * - * Sets syncedMessageCount to the current shared text-history length so a later - * sendVoiceMessage will not overwrite this audio-backed session with text sync. - */ -export const resetSessionWithUserAudioHistory = async ( - scenario: Scenario | null, - messages: Message[], - signal?: AbortSignal -): Promise => { - ensureAiInitialized(); - if (!ai) { - throw new Error('Chat session not initialized.'); - } - - activeScenario = scenario || null; - pendingScenario = scenario || null; - pendingHistory = null; - - const systemInstruction = activeScenario - ? generateScenarioSystemInstruction(activeScenario) - : SYSTEM_INSTRUCTION; - const responseSchema = selectResponseSchema(activeScenario); +type ChatHistoryTurn = { + role: 'user' | 'model'; + text?: string; + frenchText?: string; + audioBase64?: string; + mimeType?: string; +}; +async function historyTurnsFromMessages(messages: Message[], signal?: AbortSignal): Promise { const collapsed = collapseMessagesForChatHistory(messages); - const historyMessages: Array<{ role: string; parts: ChatHistoryPart[] }> = []; + const history: ChatHistoryTurn[] = []; for (const message of collapsed) { if (signal?.aborted) { throw new DOMException('Request aborted', 'AbortError'); } - if (message.role === 'user') { const audioUrl = typeof message.audioUrl === 'string' ? message.audioUrl : undefined; if (audioUrl) { @@ -376,600 +73,275 @@ export const resetSessionWithUserAudioHistory = async ( throw new DOMException('Request aborted', 'AbortError'); } if (audioData) { - historyMessages.push({ + history.push({ role: 'user', - parts: [{ inlineData: { data: audioData.base64, mimeType: audioData.mimeType } }], + audioBase64: audioData.base64, + mimeType: audioData.mimeType, }); continue; } } - // Last-resort fallback only when the blob is missing/unreadable - historyMessages.push({ role: 'user', parts: [{ text: message.text }] }); + history.push({ role: 'user', text: message.text }); continue; } + history.push({ + role: 'model', + frenchText: message.frenchText, + text: message.text, + }); + } + return history; +} + +function textHistoryFallback(): ChatHistoryTurn[] { + if (!pendingHistory?.length) return []; + return pendingHistory.map((msg) => ({ + role: msg.role === 'user' ? 'user' : 'model', + text: msg.content, + })); +} + +export const resetSession = (scenario?: Scenario | null, history?: Array<{ role: string; content: string }>) => { + activeScenario = scenario || null; + pendingScenario = scenario || null; + storedPriorMessages = []; - const modelText = message.frenchText || message.text; - historyMessages.push({ role: 'model', parts: [{ text: modelText }] }); + if (history) { + pendingHistory = history; + } else { + syncedMessageCount = 0; + pendingHistory = null; } +}; +export const setScenario = (scenario: Scenario | null) => { + resetSession(scenario); +}; + +export const resetSessionWithUserAudioHistory = async ( + scenario: Scenario | null, + messages: Message[], + signal?: AbortSignal +): Promise => { if (signal?.aborted) { throw new DOMException('Request aborted', 'AbortError'); } - - chatSession = ai.chats.create({ - model: 'gemini-2.5-flash-lite', - config: { - systemInstruction, - responseMimeType: 'application/json', - responseSchema, - }, - ...(historyMessages.length > 0 ? { history: historyMessages } : {}), - }); - - // Keep sync counter aligned with shared text history so sendVoiceMessage does - // not replace this audio-backed session via text-only lazy sync. + activeScenario = scenario || null; + pendingScenario = scenario || null; + pendingHistory = null; + storedPriorMessages = messages; syncedMessageCount = getConversationHistory().length; }; -/** - * Ensures the Gemini AI instance is initialized - */ -function ensureAiInitialized(): void { - if (!ai) { - const apiKey = getApiKeyOrEnv('gemini'); - if (!apiKey) { - throw new Error("Missing Gemini API Key"); - } - try { - ai = new GoogleGenAI({ apiKey }); - } catch { - // Fallback for test environments where GoogleGenAI is mocked as a plain function - // (e.g. vi.fn().mockReturnValue() produces an arrow function that cannot be constructed) - ai = (GoogleGenAI as unknown as (opts: { apiKey: string }) => GoogleGenAI)({ apiKey }); - } +export const initializeSession = async () => { + if (pendingScenario) { + activeScenario = pendingScenario; } -} +}; -/** - * Analyzes an advertisement image and returns a summary and role confirmation. - * One-shot call (not a chat session) with inline image data. - */ export const confirmTefAdImage = async ( imageBase64: string, mimeType: string ): Promise<{ summary: string; roleSummary: string }> => { - const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; if (!SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - const typeLabels = SUPPORTED_IMAGE_TYPES.map(t => t.replace('image/', '').toUpperCase()).join(', '); - throw new Error(`Unsupported image type "${mimeType}". Please use ${typeLabels}.`); + throw unsupportedImageError(mimeType); } - - ensureAiInitialized(); - - const response = await ai!.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ - parts: [ - { - text: `Look at this advertisement image. Please respond with a JSON object containing: -1. "summary": A concise 2-3 sentence description of what the advertisement is for, what product or service it promotes, and its key selling points or tagline if visible. -2. "roleSummary": A brief confirmation (1-2 sentences) that you understand the ad and are ready to play the role of a skeptical French-speaking friend that the user must persuade about this product/service. - -Respond ONLY with valid JSON in this format: -{ - "summary": "...", - "roleSummary": "..." -}` - }, - { - inlineData: { - data: imageBase64, - mimeType: mimeType, - }, - }, - ], - }], - config: { - responseMimeType: 'application/json', - }, + const result = await bffFetch<{ summary: string; roleSummary: string }>('/api/tef-ad-confirm', { + method: 'POST', + body: JSON.stringify({ imageBase64, mimeType, mode: 'persuasion' }), }); - - const text = response.text || ''; - if (!text.trim()) { - throw new Error('No response received from image analysis'); - } - - let parsedRaw: unknown; - try { - parsedRaw = JSON.parse(text); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse image analysis response: ${errorMessage}. Raw: ${text}`); - } - - const validation = ImageAnalysisSchema.safeParse(parsedRaw); + const validation = ImageAnalysisSchema.safeParse(result); if (!validation.success) { throw new Error(`Image analysis response validation failed: ${validation.error.message}`); } - - return { - summary: validation.data.summary, - roleSummary: validation.data.roleSummary, - }; + return validation.data; }; -/** - * Analyzes an advertisement image and returns a summary and role confirmation for questioning mode. - * One-shot call (not a chat session) with inline image data. - * Describes a customer service agent role (not skeptical friend). - */ export const confirmTefAdImageForQuestioning = async ( imageBase64: string, mimeType: string ): Promise<{ summary: string; roleSummary: string }> => { - const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; if (!SUPPORTED_IMAGE_TYPES.includes(mimeType)) { - const typeLabels = SUPPORTED_IMAGE_TYPES.map(t => t.replace('image/', '').toUpperCase()).join(', '); - throw new Error(`Unsupported image type "${mimeType}". Please use ${typeLabels}.`); + throw unsupportedImageError(mimeType); } - - ensureAiInitialized(); - - const response = await ai!.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ - parts: [ - { - text: `Look at this advertisement image. Please respond with a JSON object containing: -1. "summary": A concise 2-3 sentence description of what the advertisement is for, what product or service it promotes, and its key selling points or tagline if visible. -2. "roleSummary": A brief confirmation (1-2 sentences) that you understand the ad and are ready to play the role of a customer service agent for the company in this ad — answering caller questions briefly and accurately without volunteering extra information. - -Respond ONLY with valid JSON in this format: -{ - "summary": "...", - "roleSummary": "..." -}` - }, - { - inlineData: { - data: imageBase64, - mimeType: mimeType, - }, - }, - ], - }], - config: { - responseMimeType: 'application/json', - }, + const result = await bffFetch<{ summary: string; roleSummary: string }>('/api/tef-ad-confirm', { + method: 'POST', + body: JSON.stringify({ imageBase64, mimeType, mode: 'questioning' }), }); - - const text = response.text || ''; - if (!text.trim()) { - throw new Error('No response received from image analysis'); - } - - let parsedRaw: unknown; - try { - parsedRaw = JSON.parse(text); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse image analysis response: ${errorMessage}. Raw: ${text}`); - } - - const validation = ImageAnalysisSchema.safeParse(parsedRaw); + const validation = ImageAnalysisSchema.safeParse(result); if (!validation.success) { throw new Error(`Image analysis response validation failed: ${validation.error.message}`); } - - return { - summary: validation.data.summary, - roleSummary: validation.data.roleSummary, - }; + return validation.data; }; -/** - * Gets AI's understanding/summary of a scenario description. - */ -export const processScenarioDescription = async (description: string): Promise => { - ensureAiInitialized(); - - const response = await ai.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ - parts: [{ text: generateScenarioSummaryPrompt(description) }], - }], - }); - - return response.text || "I understand the scenario. Ready to begin when you are!"; -}; - -/** - * Transcribes audio to text using Gemini. - */ -export const transcribeAudio = async (audioBase64: string, mimeType: string): Promise => { - ensureAiInitialized(); - - const response = await ai.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ - parts: [ - { text: "Transcribe this audio exactly as spoken. Only output the transcription, nothing else." }, - { - inlineData: { - data: audioBase64, - mimeType: mimeType, - }, - }, - ], - }], +export const transcribeAudio = async ( + audioBase64: string, + mimeType: string, + signal?: AbortSignal +): Promise => { + const result = await bffFetch<{ text: string }>('/api/transcribe', { + method: 'POST', + body: JSON.stringify({ audioBase64, mimeType, cleanup: false }), + signal, }); - - const text = response.text || ""; - if (!text.trim()) { - throw new Error("Transcription returned empty text"); + if (!result.text?.trim()) { + throw new Error('Transcription returned empty text'); } - return text; + return result.text; }; -/** - * Transcribes audio and produces both a raw transcript and a cleaned-up version - * in a single LLM call using structured output. - */ export const transcribeAndCleanupAudio = async ( audioBase64: string, mimeType: string, signal?: AbortSignal ): Promise<{ rawTranscript: string; cleanedTranscript: string }> => { - ensureAiInitialized(); - - const response = await ai!.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ - parts: [ - { - text: `Listen to this audio and produce two versions of the transcript: - -1. "rawTranscript": Transcribe the audio exactly as spoken, including all filler words, false starts, repetitions, self-corrections, and hesitations. - -2. "cleanedTranscript": A cleaned-up version of the same transcript with the following removed: - - Filler words (um, uh, like, you know, so, etc.) - - False starts and repetitions - - Self-corrections and clarifications (e.g., "I mean", "actually", "wait no") - - Verbal pauses and hesitations - The cleaned version should preserve the core meaning and intent, reading smoothly while staying natural.` - }, - { - inlineData: { - data: audioBase64, - mimeType: mimeType, - }, - }, - ], - }], - config: { - responseMimeType: 'application/json', - abortSignal: signal, - responseSchema: { - type: Type.OBJECT, - properties: { - rawTranscript: { - type: Type.STRING, - description: 'Exact transcription of the audio as spoken, including all filler words and hesitations', - }, - cleanedTranscript: { - type: Type.STRING, - description: 'Cleaned-up version with fillers, false starts, and self-corrections removed', - }, - }, - required: ['rawTranscript', 'cleanedTranscript'], - }, - }, + const result = await bffFetch<{ rawTranscript: string; cleanedTranscript: string }>('/api/transcribe', { + method: 'POST', + body: JSON.stringify({ audioBase64, mimeType, cleanup: true }), + signal, }); - - const text = response.text || ""; - if (!text.trim()) { - throw new Error("Transcription returned empty response"); - } - - let parsed; - try { - parsed = JSON.parse(text); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse Gemini transcription response: ${errorMessage}. Raw response: ${text}`); - } - return { - rawTranscript: parsed.rawTranscript || "", - cleanedTranscript: parsed.cleanedTranscript || "", + rawTranscript: result.rawTranscript || '', + cleanedTranscript: result.cleanedTranscript || '', }; }; -/** - * Initializes the Gemini Chat session. - * Must be called with a valid API Key. - * Creates a fresh session and uses any pending scenario/history that was set before ai was initialized. - */ -export const initializeSession = async () => { - ensureAiInitialized(); - // We use gemini-2.5-flash-lite for the logic/conversation as it handles audio input well, - // but we will ask for TEXT output to maintain REST compatibility, then TTS it. - - // If there was a pending scenario set before ai was initialized, use it - if (pendingScenario) { - activeScenario = pendingScenario; - } - - // Create session with any pending state (scenario, history) - createChatSession(); -}; - -/** - * Generate speech audio for a specific character using Gemini TTS - * @param text The text to convert to speech - * @param voiceName The Gemini voice name to use - * @returns Blob URL for the generated audio - */ export const generateCharacterSpeech = async ( text: string, voiceName: string, signal?: AbortSignal ): Promise => { - if (!ai) { - ensureAiInitialized(); - } - - // Sanitize text to prevent breaking the delimiter - const sanitizedText = text.replace(/<\/text>/g, '<\\/text>'); - - const systemPrompt = `You are to read out the following text in a friendly, encouraging tone. When speaking French, use a natural French accent. You MUST output ONLY AUDIO, not TEXT. Again, ONLY AUDIO, not TEXT. Here's the text enclosed in tags: ${sanitizedText}`; - - const ttsResponse = await ai!.models.generateContent({ - model: 'gemini-2.5-flash-preview-tts', - contents: [{ parts: [{ text: systemPrompt }] }], - config: { - abortSignal: signal, - responseModalities: [Modality.AUDIO], - speechConfig: { - voiceConfig: { - prebuiltVoiceConfig: { - voiceName: voiceName - } - } - } - } + const result = await bffFetch<{ audioBase64: string; mimeType?: string }>('/api/tts', { + method: 'POST', + body: JSON.stringify({ text, voiceName }), + signal, }); - - // Extract audio from TTS response - const candidate = ttsResponse.candidates?.[0]; - const parts = candidate?.content?.parts; - - if (!parts || parts.length === 0) { - throw new Error(`No content received from TTS model for character with voice ${voiceName}.`); - } - - // Find the inline data part which contains the audio - const audioPart = parts.find(p => p.inlineData); - - if (!audioPart || !audioPart.inlineData) { + if (!result.audioBase64) { throw new Error(`No audio data received from TTS model for character with voice ${voiceName}.`); } - - // Convert base64 to blob and create URL - const audioBytes = base64ToBytes(audioPart.inlineData.data); - // Gemini TTS returns raw PCM, convert it to WAV format + const audioBytes = base64ToBytes(result.audioBase64); const audioBlob = pcmToWav(audioBytes, DEFAULT_PCM_SAMPLE_RATE, DEFAULT_PCM_CHANNELS); - const audioUrl = URL.createObjectURL(audioBlob); - - return audioUrl; + return URL.createObjectURL(audioBlob); }; -/** - * Sends a user audio blob to the model and returns the response with audio and text. - * Optionally accepts a contextText string to inject per-turn context (e.g., objection direction/round). - */ export const sendVoiceMessage = async ( audioBase64: string, mimeType: string, signal?: AbortSignal, - contextText?: string + contextText?: string, + priorMessages?: Message[] ): Promise => { - if (!chatSession || !ai) { - if (activeScenario) { - await resetSession(activeScenario); - } else { - await initializeSession(); - } - if (!chatSession || !ai) { - throw new Error("Chat session not initialized."); - } + if (pendingScenario && !activeScenario) { + activeScenario = pendingScenario; } - if (signal?.aborted) { throw new DOMException('Request aborted', 'AbortError'); } try { - // Step 1: Transcribe user audio - const transcribeResponse = await ai.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ - parts: [ - { text: "Transcribe this audio exactly as spoken. Only output the transcription, nothing else." }, - { - inlineData: { - data: audioBase64, - mimeType: mimeType, - }, - }, - ], - }], - config: { - abortSignal: signal, - }, + const transcribeResult = await bffFetch<{ text: string }>('/api/transcribe', { + method: 'POST', + body: JSON.stringify({ audioBase64, mimeType, cleanup: false }), + signal, }); - - const userText = transcribeResponse.text || ""; - - // Validate transcription - don't proceed with empty text - if (!userText || userText.trim().length === 0) { - throw new Error("Transcription failed or returned empty text. Please try speaking again."); + const userText = transcribeResult.text || ''; + if (!userText.trim()) { + throw new Error('Transcription failed or returned empty text. Please try speaking again.'); } - // Sync session with shared history if needed (lazy sync when actually sending a message) - // This happens when switching back to Gemini from another provider const sharedHistory = getConversationHistory(); - - // If there are unsynced messages, recreate the session with full history - // This avoids redundant API calls from replaying messages one by one - if (sharedHistory.length > syncedMessageCount) { - // Recreate session with all history passed directly to the SDK - resetSession(activeScenario, sharedHistory); - // Ensure session was created successfully - if (!chatSession) { - throw new Error("Failed to sync session with history"); - } - } - - // Step 2: Send User Audio to Chat Model to get Text Response - // Build message parts: optionally prepend a context text part before the audio - const messageParts: Array<{ text: string } | { inlineData: { data: string; mimeType: string } }> = []; - if (contextText) { - messageParts.push({ text: contextText }); + if (sharedHistory.length > syncedMessageCount && !priorMessages?.length && !storedPriorMessages.length) { + pendingHistory = sharedHistory; } - messageParts.push({ inlineData: { data: audioBase64, mimeType: mimeType } }); - // NOTE: Passing per-request config does NOT inherit chat-level config. - // When we pass abortSignal here, we must also include responseMimeType/responseSchema - // or the SDK may return plain text (which would break JSON parsing below). - const systemInstructionForThisRequest = activeScenario - ? generateScenarioSystemInstruction(activeScenario) - : SYSTEM_INSTRUCTION; - - const responseSchemaForThisRequest = selectResponseSchema(activeScenario); - - const chatResponse = await chatSession.sendMessage({ - message: messageParts, - config: { - abortSignal: signal, - systemInstruction: systemInstructionForThisRequest, - responseMimeType: 'application/json', - responseSchema: responseSchemaForThisRequest, - }, + const messagesForHistory = priorMessages ?? storedPriorMessages; + const history = messagesForHistory.length + ? await historyTurnsFromMessages(messagesForHistory, signal) + : textHistoryFallback(); + + const chatResult = await bffFetch<{ modelJson: unknown }>('/api/chat', { + method: 'POST', + body: JSON.stringify({ + audioBase64, + mimeType, + scenario: activeScenario, + history, + ...(contextText ? { contextText } : {}), + }), + signal, }); - const rawModelText = chatResponse.text; // Access text property directly - - if (!rawModelText) { - throw new Error("No text response received from chat model."); + const modelJson = chatResult.modelJson; + if (!modelJson || typeof modelJson !== 'object') { + throw new Error('No text response received from chat model.'); } - // Check if this is a multi-character scenario if (activeScenario && activeScenario.characters && activeScenario.characters.length > 1) { - // Parse and validate JSON response with Zod const MultiCharacterSchema = createMultiCharacterSchema(activeScenario); - - let jsonResponse; - try { - jsonResponse = JSON.parse(rawModelText); - } catch (parseError) { - const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); - throw new Error(`Failed to parse multi-character response as JSON: ${errorMessage}. Raw response: ${rawModelText}`); - } - - // Use safeParse for better error handling - const validationResult = MultiCharacterSchema.safeParse(jsonResponse); + const validationResult = MultiCharacterSchema.safeParse(modelJson); if (!validationResult.success) { - throw new Error(`Failed to validate multi-character response: ${validationResult.error.message}. Raw response: ${rawModelText}`); + throw new Error(`Failed to validate multi-character response: ${validationResult.error.message}.`); } - const validated = validationResult.data; - - // Roadmap auto-advance: only present when the schema included it (see - // createMultiCharacterSchema's hasRoadmapSteps branch above). Mirrors - // the extraction pattern used for the single-character roadmap schema. const hasRoadmapSteps = !!activeScenario.steps && activeScenario.steps.length > 0; const currentStepIndex = hasRoadmapSteps && 'currentStepIndex' in validated ? (validated as { currentStepIndex?: number }).currentStepIndex : undefined; - // Map fixed character labels ("Character 1", etc.) back to actual characters by index - const characterResponses = validated.characterResponses.map(resp => { + const characterResponses = validated.characterResponses.map((resp) => { const label = resp.characterName.trim(); - - // Extract the number from "Character N" label const match = label.match(/^character\s+(\d+)$/i); if (!match) { - throw new Error(`Unexpected character label "${label}" — expected format "Character N". Raw response: ${rawModelText}`); + throw new Error(`Unexpected character label "${label}" — expected format "Character N".`); } - - const index = parseInt(match[1], 10) - 1; // Convert 1-based to 0-based + const index = parseInt(match[1], 10) - 1; if (index < 0 || index >= activeScenario.characters!.length) { - throw new Error(`Character index ${index + 1} out of range (scenario has ${activeScenario.characters!.length} characters). Raw response: ${rawModelText}`); + throw new Error(`Character index ${index + 1} out of range (scenario has ${activeScenario.characters!.length} characters).`); } - const character = activeScenario.characters![index]; return { characterId: character.id, characterName: character.name, french: resp.french.trim(), - english: resp.english.trim() + english: resp.english.trim(), }; }); - // Merge successive messages from the same character to reduce TTS requests const mergedCharacterResponses = characterResponses.reduce>((acc, current) => { - if (acc.length === 0) { - return [current]; - } - + if (acc.length === 0) return [current]; const lastResponse = acc[acc.length - 1]; if (lastResponse.characterId === current.characterId) { - // Same character speaking again - merge the messages lastResponse.french = `${lastResponse.french} ${current.french}`; lastResponse.english = `${lastResponse.english} ${current.english}`; return acc; } - - // Different character - add as new response return [...acc, current]; }, []); - // Extract hint: prefer top-level, fall back to last character response's hint const hint = validated.hint || validated.characterResponses[validated.characterResponses.length - 1]?.hint - || "Continue the conversation"; - - const parsed = { - characterResponses: mergedCharacterResponses, - hint - }; + || 'Continue the conversation'; - // Check if operation was cancelled before updating history if (signal?.aborted) { throw new DOMException('Request aborted', 'AbortError'); } - // Generate audio for each character IN PARALLEL (wrapped with abort support) - // Only use French text for TTS - const audioPromises = parsed.characterResponses.map(async (charResp) => { - const character = activeScenario.characters.find(c => c.id === charResp.characterId); - + const audioPromises = mergedCharacterResponses.map(async (charResp) => { + const character = activeScenario!.characters!.find((c) => c.id === charResp.characterId); if (!character) { throw new Error(`Character not found: ${charResp.characterName} (ID: ${charResp.characterId})`); } - const audioUrl = await generateCharacterSpeech(charResp.french, character.voiceName, signal); return { ...charResp, audioUrl, voiceName: character.voiceName }; }); const results = await Promise.allSettled(audioPromises); - if (signal?.aborted) { for (const result of results) { if (result.status === 'fulfilled' && result.value.audioUrl) { @@ -979,227 +351,158 @@ export const sendVoiceMessage = async ( throw new DOMException('Request aborted', 'AbortError'); } - // Process results: extract successes and mark failures const characterAudios = results.map((result, idx) => { if (result.status === 'rejected') { - console.error(`TTS failed for character ${parsed.characterResponses[idx].characterName}:`, result.reason); - // Return character data without audio, flagged as failed - const character = activeScenario.characters.find(c => c.id === parsed.characterResponses[idx].characterId); + console.error(`TTS failed for character ${mergedCharacterResponses[idx].characterName}:`, result.reason); + const character = activeScenario!.characters!.find((c) => c.id === mergedCharacterResponses[idx].characterId); return { - ...parsed.characterResponses[idx], - audioUrl: '', // Use empty string instead of undefined to satisfy type + ...mergedCharacterResponses[idx], + audioUrl: '', audioGenerationFailed: true, - voiceName: character?.voiceName || '' + voiceName: character?.voiceName || '', }; } return { ...result.value, audioGenerationFailed: false }; }); - // Construct combined text for conversation history (French followed by English) - const combinedModelText = parsed.characterResponses.map(cr => `${cr.french} ${cr.english}`).join(' '); - - // Check again after audio generation (user may have aborted during TTS) + const combinedModelText = mergedCharacterResponses.map((cr) => `${cr.french} ${cr.english}`).join(' '); if (signal?.aborted) { - // Revoke any successfully generated audio URLs - characterAudios.forEach(ca => { + characterAudios.forEach((ca) => { if (ca.audioUrl) URL.revokeObjectURL(ca.audioUrl); }); throw new DOMException('Request aborted', 'AbortError'); } - // Sync to shared conversation history - addToHistory("user", userText); - addToHistory("assistant", combinedModelText); + addToHistory('user', userText); + addToHistory('assistant', combinedModelText); syncedMessageCount += 2; - // Return multi-character response - // Combine French and English for display return { - audioUrl: characterAudios.map(ca => ca.audioUrl), - modelText: characterAudios.map(ca => `${ca.french} ${ca.english}`), + audioUrl: characterAudios.map((ca) => ca.audioUrl), + modelText: characterAudios.map((ca) => `${ca.french} ${ca.english}`), userText, - hint: parsed.hint, // Required field, always present - characters: characterAudios.map(ca => ({ + hint, + characters: characterAudios.map((ca) => ({ characterId: ca.characterId, characterName: ca.characterName, voiceName: ca.voiceName, audioGenerationFailed: ca.audioGenerationFailed, - frenchText: ca.french // Include French text for TTS retry + frenchText: ca.french, })), ...(currentStepIndex !== undefined ? { currentStepIndex } : {}), }; - } else { - // Single-character scenario with JSON response - if (activeScenario) { - // Parse and validate JSON response - let jsonResponse; - try { - jsonResponse = JSON.parse(rawModelText); - } catch (parseError) { - const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); - throw new Error(`Failed to parse single-character response as JSON: ${errorMessage}. Raw response: ${rawModelText}`); - } - - // Choose schema: TEF Questioning adds isRepeat/conceptLabels; a scenario - // with roadmap steps adds currentStepIndex. These are separate schema - // branches (see AGENTS.md "TEF Ad Questioning Mode: Schema Selection") - // so each field is only ever present/required for its own scenario type. - const hasRoadmapSteps = !!activeScenario.steps && activeScenario.steps.length > 0; - const schemaToUse = activeScenario.isTefQuestioning - ? TefQuestioningSchema - : hasRoadmapSteps - ? RoadmapSingleCharacterSchema - : SingleCharacterSchema; - - // Use safeParse for better error handling - const validationResult = schemaToUse.safeParse(jsonResponse); - if (!validationResult.success) { - throw new Error(`Failed to validate single-character response: ${validationResult.error.message}. Raw response: ${rawModelText}`); - } - - const validated = validationResult.data; - const isRepeat = activeScenario.isTefQuestioning && 'isRepeat' in validated ? (validated as { isRepeat?: boolean }).isRepeat : undefined; - const conceptLabels = activeScenario.isTefQuestioning && 'conceptLabels' in validated - ? (validated as { conceptLabels?: string[] }).conceptLabels - : undefined; - const currentStepIndex = hasRoadmapSteps && 'currentStepIndex' in validated - ? (validated as { currentStepIndex?: number }).currentStepIndex - : undefined; - - // Check if operation was cancelled before generating audio - if (signal?.aborted) { - throw new DOMException('Request aborted', 'AbortError'); - } + } - // Combine French and English for display and history - const modelText = `${validated.french} ${validated.english}`; + if (activeScenario) { + const hasRoadmapSteps = !!activeScenario.steps && activeScenario.steps.length > 0; + const schemaToUse = activeScenario.isTefQuestioning + ? TefQuestioningSchema + : hasRoadmapSteps + ? RoadmapSingleCharacterSchema + : SingleCharacterSchema; + const validationResult = schemaToUse.safeParse(modelJson); + if (!validationResult.success) { + throw new Error(`Failed to validate single-character response: ${validationResult.error.message}.`); + } + const validated = validationResult.data; + const isRepeat = activeScenario.isTefQuestioning && 'isRepeat' in validated + ? (validated as { isRepeat?: boolean }).isRepeat + : undefined; + const conceptLabels = activeScenario.isTefQuestioning && 'conceptLabels' in validated + ? (validated as { conceptLabels?: string[] }).conceptLabels + : undefined; + const currentStepIndex = hasRoadmapSteps && 'currentStepIndex' in validated + ? (validated as { currentStepIndex?: number }).currentStepIndex + : undefined; - // Step 3: Send Text Response to TTS Model to get Audio (use ONLY French text) - // Use character voice if available, otherwise default (wrapped with abort support) - const voiceName = activeScenario?.characters?.[0]?.voiceName || "aoede"; + if (signal?.aborted) { + throw new DOMException('Request aborted', 'AbortError'); + } - let audioUrl = ''; - try { - audioUrl = await generateCharacterSpeech(validated.french, voiceName, signal); - } catch (ttsError) { - // Re-throw aborts - user cancelled the operation - if (ttsError instanceof DOMException && ttsError.name === 'AbortError') { - throw ttsError; - } - // Log TTS failures but continue with empty audioUrl - console.error('TTS generation failed for single-character response:', ttsError); - // Will return empty audioUrl - UI shows "Audio unavailable" with retry - } + const modelText = `${validated.french} ${validated.english}`; + const voiceName = activeScenario?.characters?.[0]?.voiceName || 'aoede'; + addToHistory('user', userText); + addToHistory('assistant', modelText); + syncedMessageCount += 2; - // Check if aborted after TTS (in case signal was set during generation) - if (signal?.aborted) { - // Revoke audio URL if it was generated - if (audioUrl) URL.revokeObjectURL(audioUrl); - throw new DOMException('Request aborted', 'AbortError'); - } + let audioUrl = ''; + try { + audioUrl = await generateCharacterSpeech(validated.french, voiceName, signal); + } catch (ttsError) { + if (isAbortLikeError(ttsError)) throw ttsError; + console.error('TTS generation failed for single-character response:', ttsError); + } - // Update history after TTS (success or non-abort failure) - // This ensures aborted operations don't pollute history, - // but TTS failures still show text with retry button - addToHistory("user", userText); - addToHistory("assistant", modelText); - syncedMessageCount += 2; + if (signal?.aborted) { + if (audioUrl) URL.revokeObjectURL(audioUrl); + throw new DOMException('Request aborted', 'AbortError'); + } - return { - audioUrl, - userText, - modelText, - hint: validated.hint, + return { + audioUrl, + userText, + modelText, + hint: validated.hint, + voiceName, + audioGenerationFailed: !audioUrl, + ...(isRepeat !== undefined ? { isRepeat } : {}), + ...(conceptLabels !== undefined ? { conceptLabels } : {}), + ...(currentStepIndex !== undefined ? { currentStepIndex } : {}), + characters: [{ + characterId: activeScenario?.characters?.[0]?.id || '', + characterName: activeScenario?.characters?.[0]?.name || '', voiceName, - audioGenerationFailed: !audioUrl, // Empty audioUrl means TTS failed - ...(isRepeat !== undefined ? { isRepeat } : {}), - ...(conceptLabels !== undefined ? { conceptLabels } : {}), - ...(currentStepIndex !== undefined ? { currentStepIndex } : {}), - characters: [{ - characterId: activeScenario?.characters?.[0]?.id || '', - characterName: activeScenario?.characters?.[0]?.name || '', - voiceName, - audioGenerationFailed: !audioUrl, - frenchText: validated.french // Include French text for TTS retry - }] - }; - } else { - // No scenario - free conversation mode with JSON response - // Parse and validate JSON response with Zod - let jsonResponse; - try { - jsonResponse = JSON.parse(rawModelText); - } catch (parseError) { - const errorMessage = parseError instanceof Error ? parseError.message : String(parseError); - throw new Error(`Failed to parse free conversation response as JSON: ${errorMessage}. Raw response: ${rawModelText}`); - } - - // Use safeParse for better error handling - const validationResult = FreeConversationSchema.safeParse(jsonResponse); - if (!validationResult.success) { - throw new Error(`Failed to validate free conversation response: ${validationResult.error.message}. Raw response: ${rawModelText}`); - } - - const validated = validationResult.data; - - // Check if operation was cancelled before generating audio - if (signal?.aborted) { - throw new DOMException('Request aborted', 'AbortError'); - } - - // Combine French and English for display and history - const modelText = `${validated.french} ${validated.english}`; - - // Step 3: Send ONLY French text to TTS (not the English translation) - const voiceName = "aoede"; + audioGenerationFailed: !audioUrl, + frenchText: validated.french, + }], + }; + } - let audioUrl = ''; - try { - audioUrl = await generateCharacterSpeech(validated.french, voiceName, signal); - } catch (ttsError) { - // Re-throw aborts - user cancelled the operation - if (ttsError instanceof DOMException && ttsError.name === 'AbortError') { - throw ttsError; - } - // Log TTS failures but continue with empty audioUrl - console.error('TTS generation failed for free-conversation response:', ttsError); - // Will return empty audioUrl - UI shows "Audio unavailable" with retry - } + const validationResult = FreeConversationSchema.safeParse(modelJson); + if (!validationResult.success) { + throw new Error(`Failed to validate free conversation response: ${validationResult.error.message}.`); + } + const validated = validationResult.data; + if (signal?.aborted) { + throw new DOMException('Request aborted', 'AbortError'); + } - // Check if aborted after TTS (in case signal was set during generation) - if (signal?.aborted) { - // Revoke audio URL if it was generated - if (audioUrl) URL.revokeObjectURL(audioUrl); - throw new DOMException('Request aborted', 'AbortError'); - } + const modelText = `${validated.french} ${validated.english}`; + const voiceName = 'aoede'; + addToHistory('user', userText); + addToHistory('assistant', modelText); + syncedMessageCount += 2; - // Update history after TTS (success or non-abort failure) - // This ensures aborted operations don't pollute history, - // but TTS failures still show text with retry button - addToHistory("user", userText); - addToHistory("assistant", modelText); - syncedMessageCount += 2; + let audioUrl = ''; + try { + audioUrl = await generateCharacterSpeech(validated.french, voiceName, signal); + } catch (ttsError) { + if (isAbortLikeError(ttsError)) throw ttsError; + console.error('TTS generation failed for free-conversation response:', ttsError); + } - return { - audioUrl, - userText, - modelText, - hint: undefined, // No hints in free conversation mode - voiceName, - audioGenerationFailed: !audioUrl, // Empty audioUrl means TTS failed - characters: [{ - characterId: '', - characterName: '', - voiceName, - audioGenerationFailed: !audioUrl, - frenchText: validated.french // Include French text for TTS retry - }] - }; - } + if (signal?.aborted) { + if (audioUrl) URL.revokeObjectURL(audioUrl); + throw new DOMException('Request aborted', 'AbortError'); } + return { + audioUrl, + userText, + modelText, + hint: undefined, + voiceName, + audioGenerationFailed: !audioUrl, + characters: [{ + characterId: '', + characterName: '', + voiceName, + audioGenerationFailed: !audioUrl, + frenchText: validated.french, + }], + }; } catch (error) { - console.error("Error communicating with Gemini:", error); + console.error('Error communicating with Gemini:', error); throw error; } -}; \ No newline at end of file +}; diff --git a/services/openaiService.ts b/services/openaiService.ts index ba118a5..82c2cdb 100644 --- a/services/openaiService.ts +++ b/services/openaiService.ts @@ -1,401 +1,45 @@ -import { ChatOpenAI } from "@langchain/openai"; -import { z } from "zod"; -import { base64ToBlob } from "./audioUtils"; -import { VoiceResponse, Scenario } from "../types"; -import { getConversationHistory, addToHistory } from "./conversationHistory"; -import { generateScenarioSystemInstruction, generateScenarioSummaryPrompt, parseHintFromResponse } from "./scenarioService"; -import { getApiKeyOrEnv } from "./apiKeyService"; -import { isAbortLikeError } from "../utils/isAbortLikeError"; +import { VoiceResponse, Scenario } from '../types'; +import { bffFetch } from './bffClient'; -// Zod schema for scenario extraction -const CharacterSchema = z.object({ - name: z.string().describe("Character name (e.g., Baker, Waiter, Manager)"), - role: z.string().describe("Brief role description (e.g., baker, waiter, hotel receptionist)"), -}); - -const ScenarioSummarySchema = z.object({ - summary: z.string().describe("Brief 2-3 sentence summary of the scenario"), - characters: z.array(CharacterSchema).min(1).max(5).describe("All distinct characters/people the user will interact with in this scenario (1-5 characters)"), - steps: z.array(z.string()).min(2).max(8).describe( - "An ordered list of 2-8 short, concrete conversational beats the user will go through in this scenario " + - "(e.g. 'Greet the baker', 'Ask for a baguette', 'Order two croissants', 'Pay and say goodbye'), in the " + - "order they should naturally occur. Each step should describe a single user action or exchange, phrased " + - "as a short imperative/action label (not a full sentence of narration) so it reads well in a checklist." - ) -}); - -const SYSTEM_INSTRUCTION = ` -You are a friendly and patient French language tutor. -Your goal is to help the user practice speaking French. - -RULES: -1. Understand what the user says, but don't repeat it verbatim. Briefly acknowledge understanding when needed, but focus on responding naturally without restating everything the user said. -2. If the user makes a mistake, gently correct them in your response, but keep the conversation flowing naturally. -3. For EVERY response, you MUST follow this structure: - - First, respond naturally in FRENCH. - - Then, immediately provide the ENGLISH translation of what you just said. - - Do not say "Here is the translation" or explain the format. Just French content, then English content. - -Example interaction: -User: "Bonjour, je suis fatigue." (User means "I am tired" but mispronounced) -You: "Bonjour! Oh, tu es fatigué ? Pourquoi es-tu fatigué aujourd'hui ? ... Hello! Oh, you are tired? Why are you tired today?" -`; - -// Track the active scenario for scenario-aware prompting let activeScenario: Scenario | null = null; -/** - * Determines the appropriate file extension based on mimeType - */ -const getAudioExtension = (mimeType: string): string => { - const lowerMime = mimeType.toLowerCase(); - if (lowerMime.includes('webm')) return 'webm'; - if (lowerMime.includes('mp4') || lowerMime.includes('m4a') || lowerMime.includes('aac')) return 'm4a'; - if (lowerMime.includes('ogg')) return 'ogg'; - if (lowerMime.includes('mp3')) return 'mp3'; - return 'wav'; // Default -}; +const BROWSER_OPENAI_DISABLED = + 'OpenAI audio helpers are not available in the browser. Use the Gemini practice flow.'; -/** - * Sets the active scenario for OpenAI service. - */ export const setScenarioOpenAI = (scenario: Scenario | null) => { activeScenario = scenario; }; -/** - * Gets AI's understanding/summary of a scenario description using OpenAI with structured output. - * - * Accepts an optional `signal` so callers can cancel an in-flight request — - * e.g. when a newer scenario-planning request supersedes this one (see - * `processScenarioDescriptionAndPopulate` in App.tsx, which aborts any - * previous in-flight call before starting a new one, preventing a stale - * response from overwriting fresher data regardless of network settle order). - * An abort-like error is re-thrown (not swallowed into the generic fallback - * response below) so callers can tell an intentional cancel apart from a - * real failure. - */ -export const processScenarioDescriptionOpenAI = async (description: string, signal?: AbortSignal): Promise => { - const apiKey = getApiKeyOrEnv('openai'); - - if (!apiKey) { - throw new Error("Missing OpenAI API Key"); - } - - try { - // Use Langchain ChatOpenAI with structured output - const model = new ChatOpenAI({ - apiKey, - model: "gpt-5-nano", - }); - - const structuredModel = model.withStructuredOutput(ScenarioSummarySchema, { - name: "scenario_summary" - }); - - const result = await structuredModel.invoke(generateScenarioSummaryPrompt(description), { signal }); - - // Result is already validated by Zod through Langchain - return JSON.stringify(result); - } catch (error) { - if (isAbortLikeError(error)) { - throw error; - } - console.warn('Failed to process scenario with OpenAI:', error); - // Fallback response - return JSON.stringify({ - summary: "I understand the scenario. Ready to begin when you are!", - characters: [], - steps: [] - }); - } -}; - -/** - * Transcribes audio to text using OpenAI. - */ -export const transcribeAudioOpenAI = async (audioBase64: string, mimeType: string): Promise => { - const apiKey = getApiKeyOrEnv('openai'); - - if (!apiKey) { - throw new Error("Missing OpenAI API Key"); - } - - const audioBlob = base64ToBlob(audioBase64, mimeType); - const formData = new FormData(); - - const extension = getAudioExtension(mimeType); - formData.append("file", audioBlob, `input.${extension}`); - formData.append("model", "gpt-4o-mini-transcribe"); - - const response = await fetch("https://api.openai.com/v1/audio/transcriptions", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: formData +export const processScenarioDescriptionOpenAI = async ( + description: string, + signal?: AbortSignal +): Promise => { + const payload = await bffFetch<{ result: string }>('/api/scenario-plan', { + method: 'POST', + body: JSON.stringify({ description }), + signal, }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`OpenAI STT Error: ${response.status} ${errorText}`); - } - - const json = await response.json(); - - // Validate response structure - if (!json || typeof json !== 'object') { - throw new Error(`OpenAI STT Error: Invalid response format. Status: ${response.status}`); - } - - if (!json.text || typeof json.text !== 'string') { - const errorDetails = json.error ? JSON.stringify(json.error) : JSON.stringify(json); - throw new Error(`OpenAI STT Error: Missing or invalid transcription text. Status: ${response.status}, Response: ${errorDetails}`); - } - - if (json.text.trim().length === 0) { - throw new Error(`OpenAI STT Error: Transcription returned empty text. Status: ${response.status}`); - } - - return json.text; + return payload.result; }; -/** - * Maps browser MIME types to OpenAI input_audio format strings. - * gpt-4o-audio-preview only supports 'wav' and 'mp3' for input_audio.format. - */ -const getAudioInputFormat = (mimeType: string): string => { - const lowerMime = mimeType.toLowerCase(); - if (lowerMime.includes('wav')) return 'wav'; - if (lowerMime.includes('mp3') || lowerMime.includes('mpeg')) return 'mp3'; - throw new Error( - `Unsupported audio format for gpt-4o-audio-preview input_audio: "${mimeType}". ` + - `Only wav and mp3 are supported. The recorded audio must be transcoded before calling this API.` - ); +export const transcribeAudioOpenAI = async ( + _audioBase64: string, + _mimeType: string +): Promise => { + throw new Error(BROWSER_OPENAI_DISABLED); }; -/** - * Transcribes audio and produces both a raw transcript and a cleaned-up version - * in a single LLM call using structured output via chat completions with audio input. - */ export const transcribeAndCleanupAudioOpenAI = async ( - audioBase64: string, - mimeType: string + _audioBase64: string, + _mimeType: string ): Promise<{ rawTranscript: string; cleanedTranscript: string }> => { - const apiKey = getApiKeyOrEnv('openai'); - - if (!apiKey) { - throw new Error("Missing OpenAI API Key"); - } - - const format = getAudioInputFormat(mimeType); - - const response = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}` - }, - body: JSON.stringify({ - model: "gpt-4o-audio-preview", - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: `Listen to this audio and produce two versions of the transcript: - -1. "rawTranscript": Transcribe the audio exactly as spoken, including all filler words, false starts, repetitions, self-corrections, and hesitations. - -2. "cleanedTranscript": A cleaned-up version of the same transcript with the following removed: - - Filler words (um, uh, like, you know, so, etc.) - - False starts and repetitions - - Self-corrections and clarifications (e.g., "I mean", "actually", "wait no") - - Verbal pauses and hesitations - The cleaned version should preserve the core meaning and intent, reading smoothly while staying natural.` - }, - { - type: "input_audio", - input_audio: { - data: audioBase64, - format: format - } - } - ] - } - ], - response_format: { - type: "json_schema", - json_schema: { - name: "transcript_result", - strict: true, - schema: { - type: "object", - properties: { - rawTranscript: { - type: "string", - description: "Exact transcription of the audio as spoken, including all filler words and hesitations" - }, - cleanedTranscript: { - type: "string", - description: "Cleaned-up version with fillers, false starts, and self-corrections removed" - } - }, - required: ["rawTranscript", "cleanedTranscript"], - additionalProperties: false - } - } - } - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`OpenAI Error: ${response.status} ${errorText}`); - } - - const json = await response.json(); - - if (!json?.choices?.[0]?.message?.content) { - throw new Error("OpenAI returned empty response for transcription"); - } - - const content = json.choices[0].message.content; - let parsed; - try { - parsed = JSON.parse(content); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse OpenAI transcription response: ${errorMessage}. Raw content: ${content}`); - } - - return { - rawTranscript: parsed.rawTranscript || "", - cleanedTranscript: parsed.cleanedTranscript || "", - }; + throw new Error(BROWSER_OPENAI_DISABLED); }; -/** - * Sends a user audio blob to OpenAI models and returns the response with audio and text. - * Pipeline: gpt-4o-mini-transcribe (STT) -> gpt-5-nano (Chat) -> gpt-4o-mini-tts (Speech) - */ export const sendVoiceMessageOpenAI = async ( - audioBase64: string, - mimeType: string + _audioBase64: string, + _mimeType: string ): Promise => { - const apiKey = getApiKeyOrEnv('openai'); - - if (!apiKey) { - throw new Error("Missing OpenAI API Key"); - } - - try { - // --- Step 1: STT (gpt-4o-mini-transcribe) --- - const audioBlob = base64ToBlob(audioBase64, mimeType); - const formData = new FormData(); - - const extension = getAudioExtension(mimeType); - formData.append("file", audioBlob, `input.${extension}`); - formData.append("model", "gpt-4o-mini-transcribe"); - - const sttRes = await fetch("https://api.openai.com/v1/audio/transcriptions", { - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - body: formData - }); - - if (!sttRes.ok) { - const errorText = await sttRes.text(); - throw new Error(`OpenAI STT Error: ${sttRes.status} ${errorText}`); - } - const sttJson = await sttRes.json(); - const userText = sttJson.text; - - // --- Step 2: Chat (gpt-5-nano) --- - // Build messages array with system instruction, shared conversation history, and current user message - // Use scenario-aware instructions if a scenario is active - const systemInstruction = activeScenario - ? generateScenarioSystemInstruction(activeScenario) - : SYSTEM_INSTRUCTION; - - const conversationHistory = getConversationHistory(); - const messages = [ - { role: "system" as const, content: systemInstruction }, - ...conversationHistory, - { role: "user" as const, content: userText } - ]; - - const chatRes = await fetch("https://api.openai.com/v1/chat/completions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}` - }, - body: JSON.stringify({ - model: "gpt-5-nano", - messages: messages - }) - }); - - if (!chatRes.ok) { - const errorText = await chatRes.text(); - throw new Error(`OpenAI Chat Error: ${chatRes.status} ${errorText}`); - } - const chatJson = await chatRes.json(); - - // Defensive validation - if (!chatJson || typeof chatJson !== 'object' || !Array.isArray(chatJson.choices) || chatJson.choices.length === 0) { - throw new Error(`OpenAI Chat Error: Invalid response format. Status: ${chatRes.status}`); - } - - const firstChoice = chatJson.choices[0]; - if (!firstChoice?.message?.content || typeof firstChoice.message.content !== 'string') { - throw new Error(`OpenAI Chat Error: Missing or invalid message content. Status: ${chatRes.status}`); - } - - const rawModelText = firstChoice.message.content; - - // Parse hint from response (only present in scenario mode) - const { text: modelText, hint } = activeScenario - ? parseHintFromResponse(rawModelText) - : { text: rawModelText, hint: null }; - - // Add user and assistant messages to shared conversation history (use text without hint markers) - addToHistory("user", userText); - addToHistory("assistant", modelText); - - // --- Step 3: TTS (gpt-4o-mini-tts) --- (use text without hint) - const ttsRes = await fetch("https://api.openai.com/v1/audio/speech", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}` - }, - body: JSON.stringify({ - model: "gpt-4o-mini-tts", - input: modelText, - voice: "coral", - instructions: "Speak clearly with a friendly, encouraging tone. When speaking French, use a natural French accent." - }) - }); - - if (!ttsRes.ok) { - const errorText = await ttsRes.text(); - throw new Error(`OpenAI TTS Error: ${ttsRes.status} ${errorText}`); - } - const audioArrayBuffer = await ttsRes.arrayBuffer(); - const ttsAudioBlob = new Blob([audioArrayBuffer], { type: 'audio/mpeg' }); - // Note: Callers must call URL.revokeObjectURL(audioUrl) when finished to avoid memory leaks - const audioUrl = URL.createObjectURL(ttsAudioBlob); - - return { - audioUrl, - userText, - modelText, - hint: hint || undefined - }; - - } catch (error) { - console.error("Error communicating with OpenAI:", error); - throw error; - } + void activeScenario; + throw new Error(BROWSER_OPENAI_DISABLED); }; diff --git a/services/scenarioService.ts b/services/scenarioService.ts index 494cce6..3558e8b 100644 --- a/services/scenarioService.ts +++ b/services/scenarioService.ts @@ -1,15 +1,22 @@ -import { Scenario, ScenarioStep } from '../types'; +import { Scenario } from '../types'; import { deleteSavedScenario, listSavedScenarios, saveSavedScenario } from './tefArchiveService'; - -/** - * Defensive accessor for a scenario's roadmap steps. Normalizes a possibly - * legacy scenario (saved before the roadmap feature existed, so it has no - * `steps` key at all) to an empty array, so UI code (roadmap sidebar, mobile - * step chip, etc.) never has to null-check `scenario.steps` itself. - */ -export function getScenarioSteps(scenario: Scenario | null | undefined): ScenarioStep[] { - return scenario?.steps ?? []; -} +import { + generateMultiCharacterSystemInstruction, + generateScenarioSummaryPrompt, + generateScenarioSystemInstruction, + generateTefAdSystemInstruction, + generateTefQuestioningSystemInstruction, + getScenarioSteps, +} from '../shared/prompts'; + +export { + generateMultiCharacterSystemInstruction, + generateScenarioSummaryPrompt, + generateScenarioSystemInstruction, + generateTefAdSystemInstruction, + generateTefQuestioningSystemInstruction, + getScenarioSteps, +}; /** * Defensive fallback seed for the roadmap editor: break a scenario summary @@ -52,160 +59,6 @@ export const saveScenario = (scenario: Scenario): Promise => saveSav */ export const deleteScenario = (scenarioId: string): Promise => deleteSavedScenario(scenarioId); -/** - * Generate the system instruction for scenario practice mode - */ -export const generateScenarioSystemInstruction = (scenario: Scenario): string => { - // Check if this is a multi-character scenario - const isMultiCharacter = scenario.characters && scenario.characters.length > 1; - - if (isMultiCharacter) { - return generateMultiCharacterSystemInstruction(scenario); - } - - const roadmapSection = generateRoadmapInstructionSection(scenario); - - // Single-character scenario with JSON response format - return `You are participating in a role-play scenario to help the user practice French. - -SCENARIO CONTEXT: -${scenario.description} - -YOUR ROLE: -You are playing the role of the other party in the scenario (e.g., shopkeeper, baker, waiter, receptionist, etc.). Follow the general flow of events as described, but respond naturally to what the user says. - -RESPONSE FORMAT (CRITICAL): -You MUST respond with structured JSON in this exact format: -{ - "french": "Your complete French response here", - "english": "The English translation here", - "hint": "Brief description of what the user should say next" -} - -Example: -{ - "french": "Bonjour! Bienvenue dans notre boulangerie. Que puis-je faire pour vous?", - "english": "Hello! Welcome to our bakery. What can I do for you?", - "hint": "Greet the baker and ask about bread" -} - -GUIDELINES: -1. Stay in character as the other party in the scenario -2. Speak in French primarily -3. If the user makes French mistakes, gently model the correct form in your response while staying in character -4. Follow the scenario progression, but adapt naturally to what the user actually says -5. For EVERY response, you MUST provide: - - "french": Your COMPLETE French response (in character) - - "english": The COMPLETE ENGLISH translation - - "hint": Brief description of what the user should say or ask next (in English) -6. When the scenario reaches its natural end, congratulate the user and offer to practice again or try a variation - -ON-DEMAND HINTS: -If the user says "hint", "help", "aide", "je ne sais pas", or seems stuck (very short response, hesitation words like "um", "euh", "uh"), provide a helpful suggestion in your French response. - -PROACTIVE HINTS (REQUIRED): -For EVERY response, you MUST include a "hint" field with a brief description of what the user should say or ask next, in English. Focus on the TOPIC or ACTION, not the exact French words. - -The hint should: -- Describe WHAT to say, not HOW to say it (e.g., "Ask about opening hours" NOT "Je voudrais savoir...") -- Be action-oriented (e.g., "Thank them and say goodbye", "Ask for the price", "Confirm your order") -- Guide the conversation direction without giving away the French words -- Be brief - just a few words describing the next logical step - -START THE SCENARIO: -Begin by greeting the user in character and initiating the scenario. For example, if it's a bakery scenario, greet them as the baker would.${roadmapSection}`; -}; - -/** - * Builds the roadmap-tracking instruction block appended to the single-character - * system instruction when the scenario has roadmap steps. Returns an empty - * string when there are no steps, so it's a no-op for scenarios without a roadmap. - * - * This mirrors the isTefQuestioning conditional-schema precedent (see AGENTS.md): - * the "currentStepIndex" field only exists in the response schema when steps are - * present, so the model must only be told about it in that case too. - */ -function generateRoadmapInstructionSection(scenario: Scenario): string { - const steps = getScenarioSteps(scenario); - if (steps.length === 0) return ''; - - const stepList = steps.map((s, i) => `${i}. ${s.text}`).join('\n'); - - return ` - -SCENARIO ROADMAP (for your internal tracking only — do not read this list aloud or mention step numbers to the user): -${stepList} - -For EVERY response, you MUST also include a "currentStepIndex" field: the 0-based index into the roadmap list above of the step the conversation currently reflects (i.e. the step that was just addressed by the user, or is currently being addressed). Infer this from the conversation so far — do not ask the user which step they are on. Advance one step at a time as the user's utterances address each step; do not skip ahead speculatively.`; -} - -/** - * Generate the system instruction for multi-character scenario practice mode - */ -export const generateMultiCharacterSystemInstruction = (scenario: Scenario): string => { - const roadmapSection = generateRoadmapInstructionSection(scenario); - const characterMapping = scenario.characters!.map((c, i) => `- "Character ${i + 1}" = ${c.name} (${c.role})`).join('\n'); - const exampleResponses = scenario.characters!.slice(0, 2).map((_, i) => ` { - "characterName": "Character ${i + 1}", - "french": "${i === 0 ? 'Bonjour! Bienvenue! Que désirez-vous aujourd\'hui?' : 'Ça fait cinq euros, s\'il vous plaît.'}", - "english": "${i === 0 ? 'Hello! Welcome! What would you like today?' : 'That\'s five euros, please.'}" - }`).join(',\n'); - - return `You are participating in a multi-character role-play scenario to help the user practice French. - -SCENARIO CONTEXT: -${scenario.description} - -YOUR ROLE: -You control MULTIPLE characters in this scenario. Each character is assigned a fixed label: -${characterMapping} - -Each character should respond naturally based on their role. Multiple characters can respond in one turn if contextually appropriate. - -RESPONSE FORMAT (CRITICAL): -You MUST respond with structured JSON. You MUST use the EXACT fixed labels ("Character 1", "Character 2", etc.) as the "characterName" — NOT the character's actual name or role. - -Example: -{ - "characterResponses": [ -${exampleResponses} - ], - "hint": "Ask what you'd like to buy" -} - -IMPORTANT: -- You MUST use EXACTLY "Character 1", "Character 2", etc. as characterName values — never the actual name or role -- Put the French response in the "french" field and the English translation in the "english" field -- Keep French and English SEPARATE - do NOT combine them -- Include a "hint" field with every response - -GUIDELINES: -1. Stay in character for each speaker -2. Speak in French primarily for each character -3. If the user makes French mistakes, gently model the correct form in your response while staying in character -4. Follow the scenario progression, but adapt naturally to what the user actually says -5. Each character's response MUST follow this structure: - - Put their COMPLETE French response (in character) in the "french" field - - Put the COMPLETE ENGLISH translation in the "english" field - - Do NOT combine French and English in one field -6. Decide which character(s) should respond based on the context -7. CRITICAL: NEVER create successive responses from the same character. If the same character needs to speak multiple times in one turn, there MUST be another character's response in between. Characters can speak more than once per turn, but never back-to-back. -8. When the scenario reaches its natural end, have the appropriate character(s) congratulate the user - -ON-DEMAND HINTS: -If the user says "hint", "help", "aide", "je ne sais pas", or seems stuck, have the appropriate character provide a helpful suggestion. - -PROACTIVE HINTS (REQUIRED): -For EVERY response, you MUST include a "hint" field in the JSON with a brief description of what the user should say or ask next, in English. Focus on the TOPIC or ACTION, not the exact French words. Example: "Ask what you'd like to buy" or "Thank them and say goodbye". - -START THE SCENARIO: -Begin by having the appropriate character(s) greet the user and initiate the scenario.${roadmapSection}`; -}; - -/** - * Parse the hint section from an AI response - * Returns the hint text and the response without the hint section - */ export const parseHintFromResponse = (response: string): { text: string; hint: string | null } => { const hintMatch = response.match(/---HINT---\s*([\s\S]*?)\s*---END_HINT---/); @@ -299,152 +152,3 @@ export const parseMultiCharacterResponse = ( return { characterResponses: mergedResponses, hint }; }; - -/** - * Generate the system instruction for TEF Ad Persuasion Practice mode. - * The AI plays a French-speaking friend that the user must convince about the advertised product. - * Objection counting is done deterministically on the client side and injected via per-turn context. - */ -export const generateTefAdSystemInstruction = (adSummary: string, roleConfirmation: string): string => { - return `You are participating in a French conversation practice to help the user prepare for the TEF (Test d'Évaluation de Français) speaking exam. - -AD CONTEXT: -${adSummary} - -YOUR ROLE CONFIRMATION: -${roleConfirmation} - -YOUR ROLE: -You are the user's French-speaking friend. You are a skeptical but open-minded friend who listens to the user's arguments about the advertised product or service. Your role is to create opportunities for the user to demonstrate persuasion skills. Ask challenging questions and raise objections grounded in the advertisement's claims, content, and details — challenge specific things the ad says or implies. - -CONVERSATION GUIDELINES: -- Follow the per-turn context injected with each user message for guidance on the current phase of the conversation. -- Each objection must be grounded in the advertisement's claims, content, and details — challenge specific things the ad says or implies. -- Show genuine curiosity — you are a friend who wants to understand, not just refuse. -- If the user makes a bare claim without an argument, ask "pourquoi?" or "tu peux me donner un exemple?" -- If the user hasn't raised many distinct arguments, raise a new angle of skepticism to force more arguments. -- Near the end (signaled by per-turn context), introduce a counter-argument to challenge the user. -- Acknowledge good points ("C'est vrai que...") but always find a new angle or nuance. The timer ends the session — you will never be fully won over, always maintain some skepticism. - -CRITICAL — STAY IN YOUR ROLE (DO NOT DO THE USER'S JOB): -- You are ONLY the skeptical friend. The USER must do the persuading; you only object, react, and question what THEY say. -- NEVER make the user's arguments for them. User must do the persuading — never argue in favor of the product yourself. -- If you find yourself explaining why the product is good or listing its benefits, STOP: that is the user's job. -- Wait for the user to speak first on each objection before you move on. - -GUIDELINES: -1. Always respond in French primarily — this is French conversation practice -2. Be a realistic friend: raise genuine objections (price, necessity, quality, alternatives, etc.) -3. If the user struggles or gives a very short response, ask follow-up questions to help them continue -4. Keep the conversation natural and flowing — a good friend conversation -5. Gently model correct French in your responses if the user makes mistakes - -RESPONSE FORMAT (CRITICAL): -You MUST respond with structured JSON in this exact format: -{ - "french": "Your complete French response here", - "english": "The English translation here", - "hint": "Brief description of what the user should say next to persuade you" -} - -Example: -{ - "french": "Hmm, je ne sais pas... c'est assez cher, non? Pourquoi est-ce que tu penses que ça vaut le prix?", - "english": "Hmm, I don't know... it's quite expensive, isn't it? Why do you think it's worth the price?", - "hint": "Explain the value for money and what makes it worth the investment" -} - -PACE AND OPENING — LET THE USER INTRODUCE THE TOPIC: -- Do NOT mention the ad or the product first. It is the user's job to introduce the topic: they will tell you about the ad and what they want you (the friend) to do. -- Start with a warm, neutral greeting in French. Wait for the user to bring up the advertisement. Only once they have introduced the topic should you express skepticism and pose objections. -- Do not list the ad's selling points, repeat its taglines, or make the case for the product yourself. Let the user bring the details from the ad; you react to what they say. - -START THE CONVERSATION: -Begin by greeting your friend warmly in French with a neutral opening (e.g. "Salut! Ça va?" or "Salut! Qu'est-ce qu'il y a?"). Do NOT mention the advertisement. Wait for the user to introduce the ad and say what they want to do (e.g. persuade you about a product). Only after the user has introduced the topic should you express skepticism and pose your first objection or question.`; -}; - -/** - * Generate the system instruction for TEF Ad Questioning Practice mode. - * The AI plays a customer service agent for the company in the ad. - * The agent is brief, accurate, and vague — only answering what is asked. - * Repeated questions are flagged via isRepeat: true in the JSON response. - */ -export const generateTefQuestioningSystemInstruction = (adSummary: string, roleConfirmation: string): string => { - return `You are participating in a French conversation practice to help the user prepare for the TEF (Test d'Évaluation de Français) speaking exam. - -AD CONTEXT: -${adSummary} - -YOUR ROLE CONFIRMATION: -${roleConfirmation} - -YOUR ROLE: -You are a customer service agent for the company featured in the advertisement. You answer the phone professionally and respond to the caller's questions. You are brief and accurate but intentionally vague — answer only what is directly asked; do not volunteer unrequested information. Wait passively for the caller's questions and respond only to what they explicitly ask. - -SIMULATION CONTEXT — IMPORTANT: -The caller is already on the phone with you. Do not ask them to call the phone number on the ad or redirect them to that number. You are the agent they reached. - -ANSWER STRATEGY (follow this order): -1. Default — reassuring in-character answers: For most questions, give a short answer that puts the caller at ease. If the ad does not state the detail, invent plausible information (reasonable ballpark prices, typical policies, approximate availability, etc.). Handle the majority of questions this way without redirecting anywhere. -2. Last resort only — website or email: Use a redirect ONLY when the caller clearly persists or pushes for precise information you cannot answer with a simple invented detail without sounding evasive. Then offer exactly one of: (a) direct them to the company's website for full details, or (b) provide a believable customer-service email and ask them to send their specific request there for a written response. If the ad lists no website or email, invent plausible ones consistent with the company name in the ad. Do not offer website or email on the first question about a topic — try a simple reassuring answer first. -3. Never use the ad's phone number as the redirect under any circumstance. - -CUSTOMER SERVICE AGENT GUIDELINES: -- Answer only what the user directly asks. Do not volunteer additional details or expand on topics they have not raised. -- Be polite and professional but concise. Keep responses short. -- Wait for each question from the caller; do not introduce new topics or prompt the caller about what to ask. -- If a question is repeated or substantially the same as a question already answered, set "isRepeat": true in your response. - -REPEAT DETECTION: -- Track all questions the user has already asked in this conversation. -- If the user asks the same question or a question about the same topic that already was answered, flag it by setting "isRepeat": true. -- For a new, distinct question, set "isRepeat": false (or omit the field). -- Always include "conceptLabels" as an array on every response. Each label is 2-4 words in English and must be consistent across the conversation (use the same label whenever the same topic recurs). A question touching multiple topics gets multiple labels. Example: ["internet plans", "pricing"]. - -HINT FIELD — SUGGEST WHAT THE USER COULD ASK NEXT: -For every response, include a "hint" field in English that suggests a question the user could ask next to explore a new topic from the ad. The hint describes what the USER could ask, not what you as the agent will say. -- Focus on topics from the ad that have not yet been covered. -- Example: "Ask about the installation fee" or "Ask whether the contract is monthly or annual". - -RESPONSE FORMAT (CRITICAL): -You MUST respond with structured JSON in this exact format: -{ - "french": "Your complete French response here", - "english": "The English translation here", - "hint": "A suggestion in English of a question the user could ask next", - "isRepeat": false, - "conceptLabels": ["internet plans"] -} - -Example: -{ - "french": "Bonjour, vous êtes bien chez ConnectPlus, service client. Comment puis-je vous aider?", - "english": "Hello, you've reached ConnectPlus customer service. How can I help you?", - "hint": "Ask about the available internet plan speeds", - "isRepeat": false, - "conceptLabels": ["internet plans"] -} - -OPENING THE CALL: -Begin by answering the phone with a professional opening in French. For example: "Bonjour, vous êtes bien chez [company name], service client. Comment puis-je vous aider?" — then wait for the caller's first question. Do not volunteer any information before they ask.`; -}; - -/** - * Generate a prompt to have the AI summarize and confirm understanding of a scenario - */ -export const generateScenarioSummaryPrompt = (description: string): string => { - return `The user wants to practice a French conversation based on this real experience: - -"${description}" - -Please analyze this scenario and identify: -1. ALL distinct characters/people the user will interact with in this scenario -2. If only one character is mentioned or implied, return an array with just that one character -3. Character names should be role-based (e.g., "Baker", "Cashier", "Waiter", "Manager") -4. Keep role descriptions short and lowercase (e.g., "baker", "cashier") -5. Write a brief 2-3 sentence summary confirming understanding and readiness to begin - -Example for "I went to a bakery and spoke to the baker about bread, then paid the cashier": -- Summary: "I understand! You visited a bakery where you'll speak with the baker about bread options, and then complete your purchase with the cashier. I'll play both the baker and cashier roles. Ready to begin when you are!" -- Characters: Baker (role: baker, friendly and knowledgeable), Cashier (role: cashier, efficient and helpful)`; -}; diff --git a/services/scenarioStandardizationReviewService.ts b/services/scenarioStandardizationReviewService.ts index 8efb985..012e1ac 100644 --- a/services/scenarioStandardizationReviewService.ts +++ b/services/scenarioStandardizationReviewService.ts @@ -1,19 +1,44 @@ -import { GoogleGenAI, Type } from '@google/genai'; -import { getApiKeyOrEnv } from './apiKeyService'; import { isAbortLikeError } from '../utils/isAbortLikeError'; import { fetchAudioAsInlineData } from '../utils/fetchAudioAsInlineData'; import type { Message, ScenarioStandardizationReview } from '../types'; - -let ai: GoogleGenAI | null = null; - -function ensureAiInitialized(): void { - if (!ai) { - const apiKey = getApiKeyOrEnv('gemini'); - if (!apiKey) { - throw new Error('Missing Gemini API Key'); +import { bffFetch } from './bffClient'; + +type ReviewTurn = { + role: 'user' | 'model'; + text?: string; + frenchText?: string; + audioBase64?: string; + mimeType?: string; +}; + +async function turnsFromMessages(messages: Message[], signal?: AbortSignal): Promise { + const turns: ReviewTurn[] = []; + for (const message of messages) { + if (signal?.aborted) return turns; + if (message.role === 'user') { + const audioUrl = typeof message.audioUrl === 'string' ? message.audioUrl : undefined; + if (audioUrl) { + const audioData = await fetchAudioAsInlineData(audioUrl, signal); + if (audioData) { + turns.push({ + role: 'user', + text: message.text, + audioBase64: audioData.base64, + mimeType: audioData.mimeType, + }); + continue; + } + } + turns.push({ role: 'user', text: message.text }); + } else { + turns.push({ + role: 'model', + text: message.text, + frenchText: message.frenchText, + }); } - ai = new GoogleGenAI({ apiKey }); } + return turns; } export async function generateScenarioStandardizationReview(params: { @@ -23,151 +48,26 @@ export async function generateScenarioStandardizationReview(params: { signal?: AbortSignal; }): Promise { const { messages, scenarioName, scenarioDescription, signal } = params; - - ensureAiInitialized(); - - type Part = - | { text: string } - | { inlineData: { data: string; mimeType: string } }; - - const parts: Part[] = []; const userMessages = messages.filter((message) => message.role === 'user'); - if (userMessages.length === 0) { return { items: [] }; } - - let preamble = `You are reviewing a French role-play conversation. - -TASK: -Identify only the user's spoken French turns where the idea was understandable but there is a more standard, established, or idiomatic way to express the same idea in French. - -STRICT SCOPE: -- Evaluate only the user's recorded audio turns. -- Use the user's audio as the canonical source for what they said. -- The user's transcript text is only a fallback when audio cannot be fetched. -- Agent turns are context only. Do not evaluate the agent. Do not rewrite the agent. -- Do not give grammar lessons, explanations, CEFR levels, recommendations, or corrections outside the requested rewrites. -- Do not rewrite every sentence. Include only the turns that genuinely sound non-standard or less idiomatic. -- For each selected item, keep the meaning the same and rewrite it in natural, standard French. -- If every user turn already sounds standard enough, return an empty items array. -`; - - if (scenarioName) { - preamble += `\nSCENARIO NAME: ${scenarioName}`; - } - if (scenarioDescription) { - preamble += `\nSCENARIO CONTEXT: ${scenarioDescription}`; - } - - preamble += `\n\nCONVERSATION:\n`; - parts.push({ text: preamble }); - - for (const message of messages) { - if (message.role === 'user') { - const audioUrl = typeof message.audioUrl === 'string' ? message.audioUrl : undefined; - - if (audioUrl) { - const audioData = await fetchAudioAsInlineData(audioUrl, signal); - if (audioData) { - parts.push({ - inlineData: { data: audioData.base64, mimeType: audioData.mimeType }, - }); - continue; - } - } - - parts.push({ text: `[User said (transcript fallback only): ${message.text}]` }); - continue; - } - - const agentText = message.frenchText || message.text; - parts.push({ text: `[Agent said: ${agentText}]` }); - } - - parts.push({ - text: ` -Return ONLY valid JSON matching the required schema: -{ - "items": [ - { - "original": "what the user said", - "standard": "a more standard French way to express the same idea" - } - ] -} -`, - }); - if (signal?.aborted) return null; - let response: { text?: string }; try { - response = await ai!.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ parts }], - config: { - responseMimeType: 'application/json', - ...(signal ? { abortSignal: signal } : {}), - responseSchema: { - type: Type.OBJECT, - properties: { - items: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - original: { type: Type.STRING }, - standard: { type: Type.STRING }, - }, - required: ['original', 'standard'], - }, - }, - }, - required: ['items'], - }, - }, + const turns = await turnsFromMessages(messages, signal); + if (signal?.aborted) return null; + return await bffFetch('/api/scenario-review', { + method: 'POST', + body: JSON.stringify({ + turns, + scenarioName, + scenarioDescription, + }), + signal, }); } catch (err) { if (isAbortLikeError(err)) return null; throw err; } - - const text = response.text || ''; - if (!text.trim()) { - throw new Error('No response received from role-play review generation'); - } - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse role-play review response: ${msg}. Raw: ${text}`); - } - - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('Role-play review response is not an object'); - } - - const obj = parsed as Record; - if (!Array.isArray(obj.items)) { - throw new Error('Role-play review response missing required field: "items"'); - } - - for (let i = 0; i < obj.items.length; i++) { - const item = obj.items[i]; - if (typeof item !== 'object' || item === null) { - throw new Error(`Role-play review response field "items[${i}]" must be an object`); - } - const itemObj = item as Record; - if (typeof itemObj.original !== 'string' || itemObj.original.trim() === '') { - throw new Error(`Role-play review response field "items[${i}].original" must be a non-empty string`); - } - if (typeof itemObj.standard !== 'string' || itemObj.standard.trim() === '') { - throw new Error(`Role-play review response field "items[${i}].standard" must be a non-empty string`); - } - } - - return obj as ScenarioStandardizationReview; } diff --git a/services/tefReviewService.ts b/services/tefReviewService.ts index 22c077c..6e07661 100644 --- a/services/tefReviewService.ts +++ b/services/tefReviewService.ts @@ -1,74 +1,46 @@ -import { GoogleGenAI, Type } from '@google/genai'; -import { getApiKeyOrEnv } from './apiKeyService'; import { isAbortLikeError } from '../utils/isAbortLikeError'; import { fetchAudioAsInlineData } from '../utils/fetchAudioAsInlineData'; import type { Message, TefReview } from '../types'; - -// --------------------------------------------------------------------------- -// Synthesized TEF evaluation guidance -// Distilled from the official test guides — only what matters for review. -// --------------------------------------------------------------------------- - -const SECTION_A_GUIDANCE = `TEF Canada Oral Expression — Section A: Prise d'information (5 minutes) - -WHAT THE TEST EXPECTS: -Ask approximately 10 questions about a classified ad over the phone to a customer service representative. Evaluators assess linguistic skills only — grammar, vocabulary variety, pronunciation, and fluency. The relevance of questions is not graded. - -EVALUATION CRITERIA: -- Question formation: correct subject-verb inversion (Habite-t-il ? Va-t-elle ?) or "est-ce que" structure -- Range of interrogative adverbs: quoi/que, qui, quand, où, comment -- Fluency and spontaneity: ability to react to answers and sustain a natural conversation -- Vocabulary breadth: varied and accurate rather than repetitive simple phrases - -WHAT EXAMINERS LOOK FOR (tips from the test creators): -- Avoid questions learnt by heart — examiners notice and penalise recitation -- React to the agent's answers to demonstrate comprehension and conversational flexibility -- Aim for a flowing conversation, not 10 isolated questions fired in sequence -- The priority is fluent speech with varied vocabulary`; - -const SECTION_B_GUIDANCE = `TEF Canada Oral Expression — Section B: Argumentation (10 minutes) - -WHAT THE TEST EXPECTS: -Present a classified ad to a skeptical friend and argue to convince them to participate. Evaluators assess how clearly you present, how persuasively you argue, how well you structure reasoning, and how fluently you adapt to the conversation. - -EVALUATION CRITERIA: -- Argumentation vocabulary: verbs of advice (je te conseille de, je te recommande de, je te propose de) and linking words (parce que, car, donc, c'est pourquoi, en effet, d'ailleurs, de plus) -- Use of document context: extract and rephrase information from the ad — do NOT recite it verbatim -- Persuasive structure: arguments that address the friend's situation and objections directly -- Fluency and naturalness in conversation -- Variety and accuracy of vocabulary and sentence structures - -WHAT EXAMINERS LOOK FOR (tips from the test creators): -- This is NOT a reading test — rephrase the ad's content, never recite it word for word -- Tailor arguments to the friend's specific context (their interests, situation) -- Justify claims with linking words and reasons, not bare assertions -- Demonstrate understanding of the instructions by adapting to your conversation partner`; - -// --------------------------------------------------------------------------- -// AI initialization (same pattern as geminiService.ts) -// --------------------------------------------------------------------------- - -let ai: GoogleGenAI | null = null; - -function ensureAiInitialized(): void { - if (!ai) { - const apiKey = getApiKeyOrEnv('gemini'); - if (!apiKey) { - throw new Error('Missing Gemini API Key'); - } - try { - ai = new GoogleGenAI({ apiKey }); - } catch { - // Fallback for test environments where GoogleGenAI is mocked as a plain function - ai = (GoogleGenAI as unknown as (opts: { apiKey: string }) => GoogleGenAI)({ apiKey }); +import { bffFetch } from './bffClient'; + +type ReviewTurn = { + role: 'user' | 'model'; + text?: string; + frenchText?: string; + audioBase64?: string; + mimeType?: string; +}; + +async function turnsFromMessages(messages: Message[], signal?: AbortSignal): Promise { + const turns: ReviewTurn[] = []; + for (const message of messages) { + if (signal?.aborted) return turns; + if (message.role === 'user') { + const audioUrl = typeof message.audioUrl === 'string' ? message.audioUrl : undefined; + if (audioUrl) { + const audioData = await fetchAudioAsInlineData(audioUrl, signal); + if (audioData) { + turns.push({ + role: 'user', + text: message.text, + audioBase64: audioData.base64, + mimeType: audioData.mimeType, + }); + continue; + } + } + turns.push({ role: 'user', text: message.text }); + } else { + turns.push({ + role: 'model', + text: message.text, + frenchText: message.frenchText, + }); } } + return turns; } -// --------------------------------------------------------------------------- -// Main function -// --------------------------------------------------------------------------- - export async function generateTefReview(params: { exerciseType: 'questioning' | 'persuasion'; messages: Message[]; @@ -77,302 +49,23 @@ export async function generateTefReview(params: { signal?: AbortSignal; }): Promise { const { exerciseType, messages, adSummary, elapsedSeconds, signal } = params; - - ensureAiInitialized(); - - // Build prompt parts - type Part = - | { text: string } - | { inlineData: { data: string; mimeType: string } }; - - const parts: Part[] = []; - - // Preamble - const exerciseLabel = - exerciseType === 'questioning' - ? 'TEF Section A – Prise d\'information (questioning a customer service agent about an advertisement)' - : 'TEF Section B – Argumentation (persuading a skeptical friend about an advertisement)'; - - const sectionGuidance = exerciseType === 'questioning' ? SECTION_A_GUIDANCE : SECTION_B_GUIDANCE; - - let preamble = `You are an expert French language evaluator specialising in TEF Canada oral expression assessments. - -EXERCISE TYPE: ${exerciseLabel} -ELAPSED TIME: ${elapsedSeconds} seconds -TARGET LEVEL: C1 - -${sectionGuidance} -`; - - if (adSummary) { - preamble += `\nADVERTISEMENT CONTEXT:\n${adSummary}\n`; - } - - if (exerciseType === 'persuasion') { - preamble += ` -PERSUASION CRITERIA TO EVALUATE (assess each explicitly in the criteriaEvaluation field): -1. Clear & interesting presentation — Did the user present the advertisement clearly and in an engaging way? -2. Argumentation vocabulary — Did the user use advice verbs (je vous conseille, il faudrait que) and linking words (en revanche, de plus, car, donc, c'est pourquoi)? -3. 3+ distinct arguments — Did the user raise more than three distinct arguments? -4. Arguments developed with examples — Did the user support each argument with a concrete example? -5. Nuanced / counter-arguments — Did the user nuance their position or address counter-arguments? -`; - } - - preamble += ` -EVALUATION SCOPE — IMPORTANT: -Evaluate only the user's French. The [Agent said: ...] lines in the transcript are provided for context only, not for grading. Do not assess the agent, do not grade the agent's French, and do not criticize the agent's performance. Focus all feedback exclusively on what the user said. - -AUDIO NOTE: Audio recordings are the primary source for evaluating speech quality (pronunciation, fluency, spontaneous grammar). Use the transcript as a reference guide. Where they conflict, trust the audio. - -CONVERSATION TRANSCRIPT: -`; - - parts.push({ text: preamble }); - - // Process messages - if (messages.length === 0) { - parts.push({ text: '[No conversation turns recorded. The user did not speak during this session.]' }); - } else { - for (const message of messages) { - if (message.role === 'user') { - const audioUrl = typeof message.audioUrl === 'string' ? message.audioUrl : undefined; - - if (audioUrl) { - const audioData = await fetchAudioAsInlineData(audioUrl, signal); - if (audioData) { - // Audio available — send only the audio; no transcript to avoid misleading the model - parts.push({ - inlineData: { data: audioData.base64, mimeType: audioData.mimeType }, - }); - } else { - // Audio fetch failed — fall back to transcript only - parts.push({ text: `[User said (transcript only — audio unavailable): ${message.text}]` }); - } - } else { - parts.push({ text: `[User said (transcript only): ${message.text}]` }); - } - } else { - // Model/agent turn - const agentText = (message as Message & { frenchText?: string }).frenchText || message.text; - parts.push({ text: `[Agent said: ${agentText}]` }); - } - } - } - - const topicSuggestionInstructions = - exerciseType === 'persuasion' - ? `3. Topic suggestions: suggest at least 5 additional persuasive angles/arguments the user could have used to convince their skeptical friend - - Each topic should describe an argument angle (e.g. "Le rapport qualité-prix", "La flexibilité des horaires") - - For EACH suggested topic, provide at least 2 short spoken examples in French that the USER could say TO their friend — persuasive statements from the user's perspective (e.g. "Je te conseille de...", "Tu devrais...", "C'est une super opportunité parce que...") - - Do NOT write questions the friend would ask — the examples must be convincing things the user (the persuader) could say - - Include an English translation for each French example` - : `3. Topic suggestions: suggest at least 5 additional relevant topics/angles the user could have asked about - - For EACH suggested topic, provide at least 2 short spoken examples in French as questions the user could ask the customer service agent - - Include an English translation for each French example`; - - // Epilogue with evaluation instructions - const epilogue = ` - -EVALUATION INSTRUCTIONS: -Based on the conversation above, provide a structured CEFR evaluation. Assess the user's spoken French on: -1. CEFR level (A1, A2, B1, B2, C1, or C2) with a 1–2 sentence justification -2. What the user did well (concrete positive observations) -${topicSuggestionInstructions} - -Return ONLY valid JSON matching the required schema. Do not include any markdown or explanation outside the JSON.`; - - parts.push({ text: epilogue }); - - // Bail out early if already aborted (before the expensive API call) if (signal?.aborted) return null; - // API call - let response: { text?: string }; try { - response = await ai!.models.generateContent({ - model: 'gemini-2.5-flash-lite', - contents: [{ parts }], - config: { - responseMimeType: 'application/json', - ...(signal ? { abortSignal: signal } : {}), - responseSchema: { - type: Type.OBJECT, - properties: { - cefrLevel: { - type: Type.STRING, - description: 'CEFR level (one of "A1", "A2", "B1", "B2", "C1", "C2")', - }, - cefrJustification: { - type: Type.STRING, - description: '1-2 sentences explaining the level assessment', - }, - wentWell: { - type: Type.ARRAY, - items: { type: Type.STRING }, - description: 'List of things the user did well', - }, - topicSuggestions: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - topic: { type: Type.STRING }, - examples: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - french: { - type: Type.STRING, - description: - exerciseType === 'persuasion' - ? 'Persuasive French statement the user could say to convince their friend (not a question from the friend)' - : 'French question the user could ask the customer service agent', - }, - english: { type: Type.STRING }, - }, - required: ['french', 'english'], - }, - description: - exerciseType === 'persuasion' - ? 'At least 2 persuasive French statements (with English translations) the user could say to their friend' - : 'At least 2 French questions (with English translations) the user could ask the agent', - }, - }, - required: ['topic', 'examples'], - }, - description: - exerciseType === 'persuasion' - ? 'Additional persuasive argument angles the user could have mentioned, each with 2 bilingual example statements the user could say to their friend (not questions the friend would ask)' - : 'Additional relevant topics/angles the user could have asked about, each with 2 bilingual example questions', - }, - ...(exerciseType === 'persuasion' ? { - criteriaEvaluation: { - type: Type.ARRAY, - items: { - type: Type.OBJECT, - properties: { - criterion: { type: Type.STRING, description: 'Name of the criterion' }, - met: { type: Type.BOOLEAN, description: 'Whether the criterion was met' }, - evidence: { type: Type.STRING, description: 'Evidence from the conversation supporting the assessment' }, - }, - required: ['criterion', 'met', 'evidence'], - }, - description: 'Assessment of each of the 5 TEF persuasion criteria', - }, - } : {}), - }, - required: [ - 'cefrLevel', - 'cefrJustification', - 'wentWell', - 'topicSuggestions', - ...(exerciseType === 'persuasion' ? ['criteriaEvaluation'] : []), - ], - }, - }, + const turns = await turnsFromMessages(messages, signal); + if (signal?.aborted) return null; + return await bffFetch('/api/tef-review', { + method: 'POST', + body: JSON.stringify({ + exerciseType, + elapsedSeconds, + adSummary, + turns, + }), + signal, }); } catch (err) { if (isAbortLikeError(err)) return null; throw err; } - - // Parse and validate - const text = response.text || ''; - if (!text.trim()) { - throw new Error('No response received from review generation'); - } - - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse review response: ${msg}. Raw: ${text}`); - } - - if (typeof parsed !== 'object' || parsed === null) { - throw new Error('Review response is not an object'); - } - - const obj = parsed as Record; - - const required = [ - 'cefrLevel', - 'cefrJustification', - 'wentWell', - 'topicSuggestions', - ] as const; - - for (const field of required) { - if (!(field in obj)) { - throw new Error(`Review response missing required field: "${field}"`); - } - } - - if (exerciseType === 'persuasion' && !Array.isArray(obj['criteriaEvaluation'])) { - throw new Error('Review response missing required field: "criteriaEvaluation"'); - } - - // Validate topicSuggestions: TefTopicSuggestion[] - const topicSuggestions = obj['topicSuggestions']; - if (!Array.isArray(topicSuggestions)) { - throw new Error( - `Review response field "topicSuggestions" has invalid type: expected array, got ${typeof topicSuggestions}` - ); - } - if (topicSuggestions.length < 5) { - throw new Error( - `Review response field "topicSuggestions" has insufficient length: expected at least 5, got ${topicSuggestions.length}` - ); - } - for (let i = 0; i < topicSuggestions.length; i++) { - const item = topicSuggestions[i]; - if (typeof item !== 'object' || item === null) { - throw new Error( - `Review response field "topicSuggestions[${i}]" must be an object, got ${typeof item}` - ); - } - const itemObj = item as Record; - if (typeof itemObj['topic'] !== 'string' || itemObj['topic'].trim() === '') { - throw new Error( - `Review response field "topicSuggestions[${i}].topic" must be a non-empty string` - ); - } - const examples = itemObj['examples']; - if (!Array.isArray(examples)) { - throw new Error( - `Review response field "topicSuggestions[${i}].examples" has invalid type: expected array, got ${typeof examples}` - ); - } - if (examples.length < 2) { - throw new Error( - `Review response field "topicSuggestions[${i}].examples" has insufficient length: expected at least 2, got ${examples.length}` - ); - } - for (let j = 0; j < examples.length; j++) { - const example = examples[j]; - if (typeof example !== 'object' || example === null) { - throw new Error( - `Review response field "topicSuggestions[${i}].examples[${j}]" must be an object, got ${typeof example}` - ); - } - const exObj = example as Record; - if (typeof exObj['french'] !== 'string' || exObj['french'].trim() === '') { - throw new Error( - `Review response field "topicSuggestions[${i}].examples[${j}].french" must be a non-empty string` - ); - } - if (typeof exObj['english'] !== 'string' || exObj['english'].trim() === '') { - throw new Error( - `Review response field "topicSuggestions[${i}].examples[${j}].english" must be a non-empty string` - ); - } - } - } - - if (signal?.aborted) return null; - - return obj as unknown as TefReview; } diff --git a/shared/chatSchemas.ts b/shared/chatSchemas.ts new file mode 100644 index 0000000..95c6a7c --- /dev/null +++ b/shared/chatSchemas.ts @@ -0,0 +1,132 @@ +import { z } from 'zod'; +import type { Scenario } from '../types'; + +export const MAX_CHARACTERS = 5; + +export const SingleCharacterSchema = z.object({ + french: z.string().describe('The complete response in French only'), + english: z.string().describe('The English translation of the French response'), + hint: z.string().describe('Hint for what the user should say or ask next - brief description in English'), +}); + +export const TefQuestioningSchema = z.object({ + french: z.string().describe('The complete response in French only'), + english: z.string().describe('The English translation of the French response'), + hint: z.string().describe('Suggestion of a question the user could ask next - brief description in English'), + isRepeat: z.boolean().optional().describe('true if the user asked a question that was already answered'), + conceptLabels: z.array(z.string()).describe( + "Array of 2-4 word topic labels in English for the question asked (e.g. ['pricing', 'opening hours']). Always include this field — use an empty array if no topic applies." + ), +}); + +export const RoadmapSingleCharacterSchema = SingleCharacterSchema.extend({ + currentStepIndex: z.number().int().min(0).describe( + '0-based index into the scenario roadmap steps list (given in the system instruction) of the step the conversation currently reflects.' + ), +}); + +export const FreeConversationSchema = z.object({ + french: z.string().describe('The complete response in French only'), + english: z.string().describe('The English translation of the French response'), +}); + +export const ImageAnalysisSchema = z.object({ + summary: z.string().min(1), + roleSummary: z.string().min(1), +}); + +export const TranscribeCleanupSchema = z.object({ + rawTranscript: z.string(), + cleanedTranscript: z.string(), +}); + +export const ScenarioSummarySchema = z.object({ + summary: z.string().describe('Brief 2-3 sentence summary of the scenario'), + characters: z.array(z.object({ + name: z.string().describe('Character name (e.g., Baker, Waiter, Manager)'), + role: z.string().describe('Brief role description (e.g., baker, waiter, hotel receptionist)'), + })).min(1).max(5).describe('All distinct characters/people the user will interact with in this scenario (1-5 characters)'), + steps: z.array(z.string()).min(2).max(8).describe( + "An ordered list of 2-8 short, concrete conversational beats the user will go through in this scenario" + ), +}); + +export const ChatHistoryTurnSchema = z.object({ + role: z.enum(['user', 'model']), + text: z.string().optional(), + frenchText: z.string().optional(), + audioBase64: z.string().optional(), + mimeType: z.string().optional(), +}); + +export const ChatHistoryTurnsSchema = z.array(ChatHistoryTurnSchema); + +export const createMultiCharacterSchema = (scenario: Scenario) => { + const count = Math.min(scenario.characters!.length, MAX_CHARACTERS); + const labels = Array.from({ length: Math.max(count, 1) }, (_, i) => `Character ${i + 1}`) as [ + string, + ...string[], + ]; + + const base = z.object({ + characterResponses: z.array( + z.object({ + characterName: z.enum(labels).describe(`Must be one of: ${labels.join(', ')}`), + french: z.string().describe("The character's complete response in French only"), + english: z.string().describe('The English translation of the French response'), + hint: z.string().optional().describe('Optional per-character hint'), + }) + ), + hint: z.string().optional().describe('Hint for what the user should say or ask next - brief description in English'), + }); + + const hasRoadmapSteps = !!scenario.steps && scenario.steps.length > 0; + return hasRoadmapSteps + ? base.extend({ + currentStepIndex: z.number().int().min(0).describe( + '0-based index into the scenario roadmap steps list (given in the system instruction) of the step the conversation currently reflects.' + ), + }) + : base; +}; + +export function toGeminiSchema(jsonSchema: Record): Record { + const result: Record = {}; + if (jsonSchema.type) result.type = (jsonSchema.type as string).toUpperCase(); + if (jsonSchema.description) result.description = jsonSchema.description; + if (jsonSchema.properties) { + result.properties = Object.fromEntries( + Object.entries(jsonSchema.properties as Record>).map( + ([k, v]) => [k, toGeminiSchema(v)] + ) + ); + } + if (jsonSchema.items) result.items = toGeminiSchema(jsonSchema.items as Record); + if (jsonSchema.required) result.required = jsonSchema.required; + if (jsonSchema.anyOf) { + result.anyOf = (jsonSchema.anyOf as Record[]).map(toGeminiSchema); + } + if (jsonSchema.enum) result.enum = jsonSchema.enum; + if (jsonSchema.nullable !== undefined) result.nullable = jsonSchema.nullable; + return result; +} + +export function selectZodChatSchema(scenario: Scenario | null) { + if (scenario && scenario.characters && scenario.characters.length > 1) { + return createMultiCharacterSchema(scenario); + } + if (!scenario) { + return FreeConversationSchema; + } + if (scenario.isTefQuestioning) { + return TefQuestioningSchema; + } + if (scenario.steps && scenario.steps.length > 0) { + return RoadmapSingleCharacterSchema; + } + return SingleCharacterSchema; +} + +export function selectGeminiResponseSchema(scenario: Scenario | null): Record { + return toGeminiSchema(z.toJSONSchema(selectZodChatSchema(scenario)) as Record); +} diff --git a/shared/prompts.ts b/shared/prompts.ts new file mode 100644 index 0000000..037a58f --- /dev/null +++ b/shared/prompts.ts @@ -0,0 +1,369 @@ +import { Scenario, ScenarioStep } from '../types'; + +export const FREE_CONVERSATION_SYSTEM_INSTRUCTION = ` +You are a friendly and patient French language tutor. +Your goal is to help the user practice speaking French. + +RESPONSE FORMAT (CRITICAL): +You MUST respond with structured JSON in this exact format: +{ + "french": "Your complete French response here", + "english": "The English translation here" +} + +Example: +User says: "Bonjour, je suis fatigue." (User means "I am tired" but made a mistake) +You respond with JSON: +{ + "french": "Bonjour! Oh, tu es fatigué ? Pourquoi es-tu fatigué aujourd'hui ?", + "english": "Hello! Oh, you are tired? Why are you tired today?" +} + +GUIDELINES: +1. Understand what the user says, but don't repeat it verbatim. Briefly acknowledge understanding when needed, but focus on responding naturally. +2. If the user makes a mistake, gently correct them in your French response, but keep the conversation flowing naturally. +3. Put your COMPLETE French response in the "french" field +4. Put the COMPLETE ENGLISH translation in the "english" field +5. Keep French and English SEPARATE - do NOT combine them in one field +`; + +export const TRANSCRIBE_EXACT_PROMPT = + 'Transcribe this audio exactly as spoken. Only output the transcription, nothing else.'; + +export const TRANSCRIBE_AND_CLEANUP_PROMPT = `Listen to this audio and produce two versions of the transcript: + +1. "rawTranscript": Transcribe the audio exactly as spoken, including all filler words, false starts, repetitions, self-corrections, and hesitations. + +2. "cleanedTranscript": A cleaned-up version of the same transcript with the following removed: + - Filler words (um, uh, like, you know, so, etc.) + - False starts and repetitions + - Self-corrections and clarifications (e.g., "I mean", "actually", "wait no") + - Verbal pauses and hesitations + The cleaned version should preserve the core meaning and intent, reading smoothly while staying natural.`; + +export const TEF_AD_IMAGE_PROMPT = `Look at this advertisement image. Please respond with a JSON object containing: +1. "summary": A concise 2-3 sentence description of what the advertisement is for, what product or service it promotes, and its key selling points or tagline if visible. +2. "roleSummary": A brief confirmation (1-2 sentences) that you understand the ad and are ready to play the role of a skeptical French-speaking friend that the user must persuade about this product/service. + +Respond ONLY with valid JSON in this format: +{ + "summary": "...", + "roleSummary": "..." +}`; + +export const TEF_QUESTIONING_IMAGE_PROMPT = `Look at this advertisement image. Please respond with a JSON object containing: +1. "summary": A concise 2-3 sentence description of what the advertisement is for, what product or service it promotes, and its key selling points or tagline if visible. +2. "roleSummary": A brief confirmation (1-2 sentences) that you understand the ad and are ready to play the role of a customer service agent for the company in this ad — answering caller questions briefly and accurately without volunteering extra information. + +Respond ONLY with valid JSON in this format: +{ + "summary": "...", + "roleSummary": "..." +}`; + +export function ttsSystemPrompt(text: string): string { + const sanitizedText = text.replace(/<\/text>/g, '<\\/text>'); + return `You are to read out the following text in a friendly, encouraging tone. When speaking French, use a natural French accent. You MUST output ONLY AUDIO, not TEXT. Again, ONLY AUDIO, not TEXT. Here's the text enclosed in tags: ${sanitizedText}`; +} + +export function getScenarioSteps(scenario: Scenario | null | undefined): ScenarioStep[] { + return scenario?.steps ?? []; +} + +/** + * Generate the system instruction for scenario practice mode + */ +export const generateScenarioSystemInstruction = (scenario: Scenario): string => { + // Check if this is a multi-character scenario + const isMultiCharacter = scenario.characters && scenario.characters.length > 1; + + if (isMultiCharacter) { + return generateMultiCharacterSystemInstruction(scenario); + } + + const roadmapSection = generateRoadmapInstructionSection(scenario); + + // Single-character scenario with JSON response format + return `You are participating in a role-play scenario to help the user practice French. + +SCENARIO CONTEXT: +${scenario.description} + +YOUR ROLE: +You are playing the role of the other party in the scenario (e.g., shopkeeper, baker, waiter, receptionist, etc.). Follow the general flow of events as described, but respond naturally to what the user says. + +RESPONSE FORMAT (CRITICAL): +You MUST respond with structured JSON in this exact format: +{ + "french": "Your complete French response here", + "english": "The English translation here", + "hint": "Brief description of what the user should say next" +} + +Example: +{ + "french": "Bonjour! Bienvenue dans notre boulangerie. Que puis-je faire pour vous?", + "english": "Hello! Welcome to our bakery. What can I do for you?", + "hint": "Greet the baker and ask about bread" +} + +GUIDELINES: +1. Stay in character as the other party in the scenario +2. Speak in French primarily +3. If the user makes French mistakes, gently model the correct form in your response while staying in character +4. Follow the scenario progression, but adapt naturally to what the user actually says +5. For EVERY response, you MUST provide: + - "french": Your COMPLETE French response (in character) + - "english": The COMPLETE ENGLISH translation + - "hint": Brief description of what the user should say or ask next (in English) +6. When the scenario reaches its natural end, congratulate the user and offer to practice again or try a variation + +ON-DEMAND HINTS: +If the user says "hint", "help", "aide", "je ne sais pas", or seems stuck (very short response, hesitation words like "um", "euh", "uh"), provide a helpful suggestion in your French response. + +PROACTIVE HINTS (REQUIRED): +For EVERY response, you MUST include a "hint" field with a brief description of what the user should say or ask next, in English. Focus on the TOPIC or ACTION, not the exact French words. + +The hint should: +- Describe WHAT to say, not HOW to say it (e.g., "Ask about opening hours" NOT "Je voudrais savoir...") +- Be action-oriented (e.g., "Thank them and say goodbye", "Ask for the price", "Confirm your order") +- Guide the conversation direction without giving away the French words +- Be brief - just a few words describing the next logical step + +START THE SCENARIO: +Begin by greeting the user in character and initiating the scenario. For example, if it's a bakery scenario, greet them as the baker would.${roadmapSection}`; +}; + +/** + * Builds the roadmap-tracking instruction block appended to the single-character + * system instruction when the scenario has roadmap steps. Returns an empty + * string when there are no steps, so it's a no-op for scenarios without a roadmap. + * + * This mirrors the isTefQuestioning conditional-schema precedent (see AGENTS.md): + * the "currentStepIndex" field only exists in the response schema when steps are + * present, so the model must only be told about it in that case too. + */ +function generateRoadmapInstructionSection(scenario: Scenario): string { + const steps = getScenarioSteps(scenario); + if (steps.length === 0) return ''; + + const stepList = steps.map((s, i) => `${i}. ${s.text}`).join('\n'); + + return ` + +SCENARIO ROADMAP (for your internal tracking only — do not read this list aloud or mention step numbers to the user): +${stepList} + +For EVERY response, you MUST also include a "currentStepIndex" field: the 0-based index into the roadmap list above of the step the conversation currently reflects (i.e. the step that was just addressed by the user, or is currently being addressed). Infer this from the conversation so far — do not ask the user which step they are on. Advance one step at a time as the user's utterances address each step; do not skip ahead speculatively.`; +} + +/** + * Generate the system instruction for multi-character scenario practice mode + */ +export const generateMultiCharacterSystemInstruction = (scenario: Scenario): string => { + const roadmapSection = generateRoadmapInstructionSection(scenario); + const characterMapping = scenario.characters!.map((c, i) => `- "Character ${i + 1}" = ${c.name} (${c.role})`).join('\n'); + const exampleResponses = scenario.characters!.slice(0, 2).map((_, i) => ` { + "characterName": "Character ${i + 1}", + "french": "${i === 0 ? 'Bonjour! Bienvenue! Que désirez-vous aujourd\'hui?' : 'Ça fait cinq euros, s\'il vous plaît.'}", + "english": "${i === 0 ? 'Hello! Welcome! What would you like today?' : 'That\'s five euros, please.'}" + }`).join(',\n'); + + return `You are participating in a multi-character role-play scenario to help the user practice French. + +SCENARIO CONTEXT: +${scenario.description} + +YOUR ROLE: +You control MULTIPLE characters in this scenario. Each character is assigned a fixed label: +${characterMapping} + +Each character should respond naturally based on their role. Multiple characters can respond in one turn if contextually appropriate. + +RESPONSE FORMAT (CRITICAL): +You MUST respond with structured JSON. You MUST use the EXACT fixed labels ("Character 1", "Character 2", etc.) as the "characterName" — NOT the character's actual name or role. + +Example: +{ + "characterResponses": [ +${exampleResponses} + ], + "hint": "Ask what you'd like to buy" +} + +IMPORTANT: +- You MUST use EXACTLY "Character 1", "Character 2", etc. as characterName values — never the actual name or role +- Put the French response in the "french" field and the English translation in the "english" field +- Keep French and English SEPARATE - do NOT combine them +- Include a "hint" field with every response + +GUIDELINES: +1. Stay in character for each speaker +2. Speak in French primarily for each character +3. If the user makes French mistakes, gently model the correct form in your response while staying in character +4. Follow the scenario progression, but adapt naturally to what the user actually says +5. Each character's response MUST follow this structure: + - Put their COMPLETE French response (in character) in the "french" field + - Put the COMPLETE ENGLISH translation in the "english" field + - Do NOT combine French and English in one field +6. Decide which character(s) should respond based on the context +7. CRITICAL: NEVER create successive responses from the same character. If the same character needs to speak multiple times in one turn, there MUST be another character's response in between. Characters can speak more than once per turn, but never back-to-back. +8. When the scenario reaches its natural end, have the appropriate character(s) congratulate the user + +ON-DEMAND HINTS: +If the user says "hint", "help", "aide", "je ne sais pas", or seems stuck, have the appropriate character provide a helpful suggestion. + +PROACTIVE HINTS (REQUIRED): +For EVERY response, you MUST include a "hint" field in the JSON with a brief description of what the user should say or ask next, in English. Focus on the TOPIC or ACTION, not the exact French words. Example: "Ask what you'd like to buy" or "Thank them and say goodbye". + +START THE SCENARIO: +Begin by having the appropriate character(s) greet the user and initiate the scenario.${roadmapSection}`; +}; + +/** + * Parse the hint section from an AI response + * Returns the hint text and the response without the hint section + */ +export const generateTefAdSystemInstruction = (adSummary: string, roleConfirmation: string): string => { + return `You are participating in a French conversation practice to help the user prepare for the TEF (Test d'Évaluation de Français) speaking exam. + +AD CONTEXT: +${adSummary} + +YOUR ROLE CONFIRMATION: +${roleConfirmation} + +YOUR ROLE: +You are the user's French-speaking friend. You are a skeptical but open-minded friend who listens to the user's arguments about the advertised product or service. Your role is to create opportunities for the user to demonstrate persuasion skills. Ask challenging questions and raise objections grounded in the advertisement's claims, content, and details — challenge specific things the ad says or implies. + +CONVERSATION GUIDELINES: +- Follow the per-turn context injected with each user message for guidance on the current phase of the conversation. +- Each objection must be grounded in the advertisement's claims, content, and details — challenge specific things the ad says or implies. +- Show genuine curiosity — you are a friend who wants to understand, not just refuse. +- If the user makes a bare claim without an argument, ask "pourquoi?" or "tu peux me donner un exemple?" +- If the user hasn't raised many distinct arguments, raise a new angle of skepticism to force more arguments. +- Near the end (signaled by per-turn context), introduce a counter-argument to challenge the user. +- Acknowledge good points ("C'est vrai que...") but always find a new angle or nuance. The timer ends the session — you will never be fully won over, always maintain some skepticism. + +CRITICAL — STAY IN YOUR ROLE (DO NOT DO THE USER'S JOB): +- You are ONLY the skeptical friend. The USER must do the persuading; you only object, react, and question what THEY say. +- NEVER make the user's arguments for them. User must do the persuading — never argue in favor of the product yourself. +- If you find yourself explaining why the product is good or listing its benefits, STOP: that is the user's job. +- Wait for the user to speak first on each objection before you move on. + +GUIDELINES: +1. Always respond in French primarily — this is French conversation practice +2. Be a realistic friend: raise genuine objections (price, necessity, quality, alternatives, etc.) +3. If the user struggles or gives a very short response, ask follow-up questions to help them continue +4. Keep the conversation natural and flowing — a good friend conversation +5. Gently model correct French in your responses if the user makes mistakes + +RESPONSE FORMAT (CRITICAL): +You MUST respond with structured JSON in this exact format: +{ + "french": "Your complete French response here", + "english": "The English translation here", + "hint": "Brief description of what the user should say next to persuade you" +} + +Example: +{ + "french": "Hmm, je ne sais pas... c'est assez cher, non? Pourquoi est-ce que tu penses que ça vaut le prix?", + "english": "Hmm, I don't know... it's quite expensive, isn't it? Why do you think it's worth the price?", + "hint": "Explain the value for money and what makes it worth the investment" +} + +PACE AND OPENING — LET THE USER INTRODUCE THE TOPIC: +- Do NOT mention the ad or the product first. It is the user's job to introduce the topic: they will tell you about the ad and what they want you (the friend) to do. +- Start with a warm, neutral greeting in French. Wait for the user to bring up the advertisement. Only once they have introduced the topic should you express skepticism and pose objections. +- Do not list the ad's selling points, repeat its taglines, or make the case for the product yourself. Let the user bring the details from the ad; you react to what they say. + +START THE CONVERSATION: +Begin by greeting your friend warmly in French with a neutral opening (e.g. "Salut! Ça va?" or "Salut! Qu'est-ce qu'il y a?"). Do NOT mention the advertisement. Wait for the user to introduce the ad and say what they want to do (e.g. persuade you about a product). Only after the user has introduced the topic should you express skepticism and pose your first objection or question.`; +}; + +/** + * Generate the system instruction for TEF Ad Questioning Practice mode. + * The AI plays a customer service agent for the company in the ad. + * The agent is brief, accurate, and vague — only answering what is asked. + * Repeated questions are flagged via isRepeat: true in the JSON response. + */ +export const generateTefQuestioningSystemInstruction = (adSummary: string, roleConfirmation: string): string => { + return `You are participating in a French conversation practice to help the user prepare for the TEF (Test d'Évaluation de Français) speaking exam. + +AD CONTEXT: +${adSummary} + +YOUR ROLE CONFIRMATION: +${roleConfirmation} + +YOUR ROLE: +You are a customer service agent for the company featured in the advertisement. You answer the phone professionally and respond to the caller's questions. You are brief and accurate but intentionally vague — answer only what is directly asked; do not volunteer unrequested information. Wait passively for the caller's questions and respond only to what they explicitly ask. + +SIMULATION CONTEXT — IMPORTANT: +The caller is already on the phone with you. Do not ask them to call the phone number on the ad or redirect them to that number. You are the agent they reached. + +ANSWER STRATEGY (follow this order): +1. Default — reassuring in-character answers: For most questions, give a short answer that puts the caller at ease. If the ad does not state the detail, invent plausible information (reasonable ballpark prices, typical policies, approximate availability, etc.). Handle the majority of questions this way without redirecting anywhere. +2. Last resort only — website or email: Use a redirect ONLY when the caller clearly persists or pushes for precise information you cannot answer with a simple invented detail without sounding evasive. Then offer exactly one of: (a) direct them to the company's website for full details, or (b) provide a believable customer-service email and ask them to send their specific request there for a written response. If the ad lists no website or email, invent plausible ones consistent with the company name in the ad. Do not offer website or email on the first question about a topic — try a simple reassuring answer first. +3. Never use the ad's phone number as the redirect under any circumstance. + +CUSTOMER SERVICE AGENT GUIDELINES: +- Answer only what the user directly asks. Do not volunteer additional details or expand on topics they have not raised. +- Be polite and professional but concise. Keep responses short. +- Wait for each question from the caller; do not introduce new topics or prompt the caller about what to ask. +- If a question is repeated or substantially the same as a question already answered, set "isRepeat": true in your response. + +REPEAT DETECTION: +- Track all questions the user has already asked in this conversation. +- If the user asks the same question or a question about the same topic that already was answered, flag it by setting "isRepeat": true. +- For a new, distinct question, set "isRepeat": false (or omit the field). +- Always include "conceptLabels" as an array on every response. Each label is 2-4 words in English and must be consistent across the conversation (use the same label whenever the same topic recurs). A question touching multiple topics gets multiple labels. Example: ["internet plans", "pricing"]. + +HINT FIELD — SUGGEST WHAT THE USER COULD ASK NEXT: +For every response, include a "hint" field in English that suggests a question the user could ask next to explore a new topic from the ad. The hint describes what the USER could ask, not what you as the agent will say. +- Focus on topics from the ad that have not yet been covered. +- Example: "Ask about the installation fee" or "Ask whether the contract is monthly or annual". + +RESPONSE FORMAT (CRITICAL): +You MUST respond with structured JSON in this exact format: +{ + "french": "Your complete French response here", + "english": "The English translation here", + "hint": "A suggestion in English of a question the user could ask next", + "isRepeat": false, + "conceptLabels": ["internet plans"] +} + +Example: +{ + "french": "Bonjour, vous êtes bien chez ConnectPlus, service client. Comment puis-je vous aider?", + "english": "Hello, you've reached ConnectPlus customer service. How can I help you?", + "hint": "Ask about the available internet plan speeds", + "isRepeat": false, + "conceptLabels": ["internet plans"] +} + +OPENING THE CALL: +Begin by answering the phone with a professional opening in French. For example: "Bonjour, vous êtes bien chez [company name], service client. Comment puis-je vous aider?" — then wait for the caller's first question. Do not volunteer any information before they ask.`; +}; + +/** + * Generate a prompt to have the AI summarize and confirm understanding of a scenario + */ +export const generateScenarioSummaryPrompt = (description: string): string => { + return `The user wants to practice a French conversation based on this real experience: + +"${description}" + +Please analyze this scenario and identify: +1. ALL distinct characters/people the user will interact with in this scenario +2. If only one character is mentioned or implied, return an array with just that one character +3. Character names should be role-based (e.g., "Baker", "Cashier", "Waiter", "Manager") +4. Keep role descriptions short and lowercase (e.g., "baker", "cashier") +5. Write a brief 2-3 sentence summary confirming understanding and readiness to begin + +Example for "I went to a bakery and spoke to the baker about bread, then paid the cashier": +- Summary: "I understand! You visited a bakery where you'll speak with the baker about bread options, and then complete your purchase with the cashier. I'll play both the baker and cashier roles. Ready to begin when you are!" +- Characters: Baker (role: baker, friendly and knowledgeable), Cashier (role: cashier, efficient and helpful)`; +}; diff --git a/tsconfig.json b/tsconfig.json index 2c6eed5..8c4f1f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ ], "skipLibCheck": true, "types": [ + "./worker-configuration.d.ts", "node" ], "moduleResolution": "bundler", diff --git a/vite.config.ts b/vite.config.ts index e9fd603..8016e9f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,23 +1,22 @@ +import { defineConfig } from 'vite'; import path from 'path'; -import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, '.', ''); - return { - server: { - port: 3000, - host: '0.0.0.0', +export default defineConfig({ + server: { + port: 3000, + host: '0.0.0.0', + proxy: { + '/api': { + target: 'http://127.0.0.1:8787', + changeOrigin: false, }, - plugins: [react()], - define: { - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), - 'process.env.OPENAI_API_KEY': JSON.stringify(env.OPENAI_API_KEY) - }, - resolve: { - alias: { - '@': path.resolve(__dirname, '.'), - } - } - }; + }, + }, + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts index bb02c60..f825cc4 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,8 @@ import '@testing-library/jest-dom/vitest'; + +if (typeof URL.createObjectURL !== 'function') { + URL.createObjectURL = () => 'blob:mock-audio'; +} +if (typeof URL.revokeObjectURL !== 'function') { + URL.revokeObjectURL = () => {}; +} diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts new file mode 100644 index 0000000..5331392 --- /dev/null +++ b/worker-configuration.d.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 6715edd8665bf32377aa078f9bf9d43f) +interface __BaseEnv_Env { + ASSETS: Fetcher; + API_KEY_COOKIE_SECRET: string; +} +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./worker/index"); + } + interface Env extends __BaseEnv_Env {} +} +interface Env extends __BaseEnv_Env {} +type StringifyValues> = { + [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; +}; +declare namespace NodeJS { + interface ProcessEnv extends StringifyValues> {} +} diff --git a/worker/constants.ts b/worker/constants.ts new file mode 100644 index 0000000..f5c1288 --- /dev/null +++ b/worker/constants.ts @@ -0,0 +1,12 @@ +export const COOKIE_NAME = '__Host-parle_user_api_key'; +export const COOKIE_MAX_AGE_SECONDS = 34_560_000; // 400 days +export const COOKIE_PATH = '/'; + +export const GEMINI_CHAT_MODEL = 'gemini-2.5-flash-lite'; +export const GEMINI_TTS_MODEL = 'gemini-2.5-flash-preview-tts'; +export const OPENAI_PLAN_MODEL = 'gpt-5-nano'; + +export const UPSTREAM_TIMEOUT_MS = 30_000; + +export const MIN_API_KEY_LENGTH = 16; +export const MAX_API_KEY_LENGTH = 512; diff --git a/worker/cookies.ts b/worker/cookies.ts new file mode 100644 index 0000000..1627411 --- /dev/null +++ b/worker/cookies.ts @@ -0,0 +1,33 @@ +import { COOKIE_MAX_AGE_SECONDS, COOKIE_NAME, COOKIE_PATH } from './constants'; + +const SECURITY_ATTRIBUTES = `Path=${COOKIE_PATH}; HttpOnly; Secure; SameSite=Strict`; + +export function parseCookieHeader(header: string | null, name: string): string | undefined { + if (!header) return undefined; + const parts = header.split(';'); + for (const part of parts) { + const separator = part.indexOf('='); + if (separator === -1) continue; + const key = part.slice(0, separator).trim(); + if (key === name) { + try { + return decodeURIComponent(part.slice(separator + 1).trim()); + } catch { + return undefined; + } + } + } + return undefined; +} + +export function readNamedCookie(request: Request, name = COOKIE_NAME): string | undefined { + return parseCookieHeader(request.headers.get('Cookie'), name); +} + +export function serializeSessionCookie(value: string, maxAge = COOKIE_MAX_AGE_SECONDS): string { + return `${COOKIE_NAME}=${encodeURIComponent(value)}; ${SECURITY_ATTRIBUTES}; Max-Age=${maxAge}`; +} + +export function serializeDeletedSessionCookie(): string { + return `${COOKIE_NAME}=; ${SECURITY_ATTRIBUTES}; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`; +} diff --git a/worker/csrf.ts b/worker/csrf.ts new file mode 100644 index 0000000..e528c9c --- /dev/null +++ b/worker/csrf.ts @@ -0,0 +1,45 @@ +function isLoopbackHostname(hostname: string): boolean { + const host = hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + +/** + * Origin check for mutating /api routes. + * Production: Origin (or Referer origin) must match the request URL origin. + * Localhost: allow loopback hostnames even when the Vite proxy port differs + * from the Worker listen port. + * + * If both Origin and Referer are missing, the request is allowed (same-site + * non-CORS navigations / some tooling). Documented choice from the BFF lessons. + */ +export function isAllowedOrigin(request: Request): boolean { + const originHeader = request.headers.get('Origin'); + const refererHeader = request.headers.get('Referer'); + const requestUrl = new URL(request.url); + + let candidateOrigin: string | null = originHeader; + if (!candidateOrigin && refererHeader) { + try { + candidateOrigin = new URL(refererHeader).origin; + } catch { + return false; + } + } + + if (!candidateOrigin) { + return true; + } + + let candidateUrl: URL; + try { + candidateUrl = new URL(candidateOrigin); + } catch { + return false; + } + + if (candidateUrl.origin === requestUrl.origin) { + return true; + } + + return isLoopbackHostname(candidateUrl.hostname) && isLoopbackHostname(requestUrl.hostname); +} diff --git a/worker/env.d.ts b/worker/env.d.ts new file mode 100644 index 0000000..df27dba --- /dev/null +++ b/worker/env.d.ts @@ -0,0 +1,12 @@ +/** + * Optional rotation secret. Set via `.dev.vars` or `wrangler secret put`. + * `Fetcher` is declared here because `wrangler types --include-runtime false` + * emits `Env.ASSETS: Fetcher` without the runtime typedef. + */ +type Fetcher = { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +}; + +interface Env { + API_KEY_COOKIE_SECRET_PREVIOUS?: string; +} diff --git a/worker/gemini.ts b/worker/gemini.ts new file mode 100644 index 0000000..aa8af79 --- /dev/null +++ b/worker/gemini.ts @@ -0,0 +1,28 @@ +import { GoogleGenAI, Modality, Type } from '@google/genai'; +import { GEMINI_CHAT_MODEL, GEMINI_TTS_MODEL, UPSTREAM_TIMEOUT_MS } from './constants'; +import { classifyProviderError } from './upstream'; + +export function createGeminiClient(apiKey: string): GoogleGenAI { + return new GoogleGenAI({ apiKey }); +} + +export async function geminiGenerateContent( + apiKey: string, + params: Parameters[0], + signal?: AbortSignal +) { + const ai = createGeminiClient(apiKey); + try { + return await ai.models.generateContent({ + ...params, + config: { + ...(params.config ?? {}), + abortSignal: signal, + }, + }); + } catch (err) { + throw Object.assign(new Error('gemini_generate_failed'), classifyProviderError(err), { cause: err }); + } +} + +export { GEMINI_CHAT_MODEL, GEMINI_TTS_MODEL, UPSTREAM_TIMEOUT_MS, Modality, Type }; diff --git a/worker/http.ts b/worker/http.ts new file mode 100644 index 0000000..547f1ef --- /dev/null +++ b/worker/http.ts @@ -0,0 +1,35 @@ +export type BffErrorCode = + | 'NO_API_KEY_SESSION' + | 'INVALID_API_KEY_SESSION' + | 'UPSTREAM_AUTH_FAILED' + | 'UPSTREAM_ERROR' + | 'INTERNAL_ERROR' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'METHOD_NOT_ALLOWED' + | 'VALIDATION_ERROR' + | 'MISSING_PROVIDER_KEY'; + +export function json( + body: unknown, + status = 200, + extraHeaders?: HeadersInit +): Response { + const headers = new Headers(extraHeaders); + headers.set('Content-Type', 'application/json'); + return new Response(JSON.stringify(body), { status, headers }); +} + +export function errorJson( + error: BffErrorCode, + status: number, + message?: string, + extraHeaders?: HeadersInit +): Response { + return json(message ? { error, message } : { error }, status, extraHeaders); +} + +export function isJsonContentType(request: Request): boolean { + const contentType = request.headers.get('Content-Type') ?? ''; + return contentType.toLowerCase().includes('application/json'); +} diff --git a/worker/index.ts b/worker/index.ts new file mode 100644 index 0000000..a1d17ff --- /dev/null +++ b/worker/index.ts @@ -0,0 +1,59 @@ +import { errorJson, json } from './http'; +import { + handleChat, + handleScenarioPlan, + handleScenarioReview, + handleTefAdConfirm, + handleTefReview, + handleTranscribe, + handleTts, +} from './routes/ai'; +import { handleCreateSession, handleRevoke, handleSessionStatus } from './routes/session'; + +export default { + async fetch(request: Request, env: Env): Promise { + try { + const url = new URL(request.url); + const { pathname } = url; + + if (pathname === '/api/session' && request.method === 'POST') { + return handleCreateSession(request, env); + } + if (pathname === '/api/session/status' && request.method === 'GET') { + return handleSessionStatus(request, env); + } + if (pathname === '/api/revoke' && request.method === 'POST') { + return handleRevoke(request, env); + } + if (pathname === '/api/transcribe') { + return handleTranscribe(request, env); + } + if (pathname === '/api/chat') { + return handleChat(request, env); + } + if (pathname === '/api/tts') { + return handleTts(request, env); + } + if (pathname === '/api/tef-ad-confirm') { + return handleTefAdConfirm(request, env); + } + if (pathname === '/api/tef-review') { + return handleTefReview(request, env); + } + if (pathname === '/api/scenario-review') { + return handleScenarioReview(request, env); + } + if (pathname === '/api/scenario-plan') { + return handleScenarioPlan(request, env); + } + + if (pathname.startsWith('/api/')) { + return errorJson('NOT_FOUND', 404); + } + + return env.ASSETS.fetch(request); + } catch { + return errorJson('INTERNAL_ERROR', 500); + } + }, +}; diff --git a/worker/openai.ts b/worker/openai.ts new file mode 100644 index 0000000..19961e9 --- /dev/null +++ b/worker/openai.ts @@ -0,0 +1,92 @@ +import { OPENAI_PLAN_MODEL, UPSTREAM_TIMEOUT_MS } from './constants'; +import { classifyHttpStatus, UPSTREAM_GENERIC_MESSAGE } from './upstream'; +import { ScenarioSummarySchema } from '../shared/chatSchemas'; +import { generateScenarioSummaryPrompt } from '../shared/prompts'; + +function upstreamPlanError(): never { + throw Object.assign(new Error('openai_plan_failed'), { + code: 'UPSTREAM_ERROR', + httpStatus: 502, + message: UPSTREAM_GENERIC_MESSAGE, + }); +} + +export async function planScenarioWithOpenAI( + apiKey: string, + description: string, + signal?: AbortSignal +): Promise { + const timeout = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + signal: combined, + body: JSON.stringify({ + model: OPENAI_PLAN_MODEL, + messages: [{ role: 'user', content: generateScenarioSummaryPrompt(description) }], + response_format: { + type: 'json_schema', + json_schema: { + name: 'scenario_summary', + strict: true, + schema: { + type: 'object', + properties: { + summary: { type: 'string' }, + characters: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + role: { type: 'string' }, + }, + required: ['name', 'role'], + additionalProperties: false, + }, + }, + steps: { + type: 'array', + items: { type: 'string' }, + }, + }, + required: ['summary', 'characters', 'steps'], + additionalProperties: false, + }, + }, + }, + }), + }); + + if (!response.ok) { + const bodyText = await response.text(); + const mapped = classifyHttpStatus(response.status, bodyText); + throw Object.assign(new Error('openai_plan_failed'), mapped); + } + + const json = await response.json() as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = json.choices?.[0]?.message?.content; + if (!content) { + upstreamPlanError(); + } + + let parsed: unknown; + try { + parsed = JSON.parse(content as string); + } catch { + upstreamPlanError(); + } + + const validated = ScenarioSummarySchema.safeParse(parsed); + if (!validated.success) { + upstreamPlanError(); + } + return JSON.stringify(validated.data); +} diff --git a/worker/prompts/scenarioReview.ts b/worker/prompts/scenarioReview.ts new file mode 100644 index 0000000..6afcb2d --- /dev/null +++ b/worker/prompts/scenarioReview.ts @@ -0,0 +1,61 @@ +export type ReviewTurn = { + role: 'user' | 'model'; + text?: string; + frenchText?: string; + audioBase64?: string; + mimeType?: string; +}; + +export function buildScenarioReviewParts(params: { + turns: ReviewTurn[]; + scenarioName?: string; + scenarioDescription?: string; +}): Array<{ text: string } | { inlineData: { data: string; mimeType: string } }> { + const parts: Array<{ text: string } | { inlineData: { data: string; mimeType: string } }> = []; + let preamble = `You are reviewing a French role-play conversation. + +TASK: +Identify only the user's spoken French turns where the idea was understandable but there is a more standard, established, or idiomatic way to express the same idea in French. + +STRICT SCOPE: +- Evaluate only the user's recorded audio turns. +- Use the user's audio as the canonical source for what they said. +- The user's transcript text is only a fallback when audio cannot be fetched. +- Agent turns are context only. Do not evaluate the agent. Do not rewrite the agent. +- Do not give grammar lessons, explanations, CEFR levels, recommendations, or corrections outside the requested rewrites. +- Do not rewrite every sentence. Include only the turns that genuinely sound non-standard or less idiomatic. +- For each selected item, keep the meaning the same and rewrite it in natural, standard French. +- If every user turn already sounds standard enough, return an empty items array. +`; + if (params.scenarioName) preamble += `\nSCENARIO NAME: ${params.scenarioName}`; + if (params.scenarioDescription) preamble += `\nSCENARIO CONTEXT: ${params.scenarioDescription}`; + preamble += `\n\nCONVERSATION:\n`; + parts.push({ text: preamble }); + + for (const turn of params.turns) { + if (turn.role === 'user') { + if (turn.audioBase64 && turn.mimeType) { + parts.push({ inlineData: { data: turn.audioBase64, mimeType: turn.mimeType } }); + } else { + parts.push({ text: `[User said (transcript fallback only): ${turn.text ?? ''}]` }); + } + continue; + } + parts.push({ text: `[Agent said: ${turn.frenchText || turn.text || ''}]` }); + } + + parts.push({ + text: ` +Return ONLY valid JSON matching the required schema: +{ + "items": [ + { + "original": "what the user said", + "standard": "a more standard French way to express the same idea" + } + ] +} +`, + }); + return parts; +} diff --git a/worker/prompts/tefReview.ts b/worker/prompts/tefReview.ts new file mode 100644 index 0000000..1802c0d --- /dev/null +++ b/worker/prompts/tefReview.ts @@ -0,0 +1,230 @@ +import { Type } from '@google/genai'; +import type { TefReview } from '../../types'; + +export type ReviewTurn = { + role: 'user' | 'model'; + text?: string; + frenchText?: string; + audioBase64?: string; + mimeType?: string; +}; + +const SECTION_A_GUIDANCE = `TEF Canada Oral Expression — Section A: Prise d'information (5 minutes) + +WHAT THE TEST EXPECTS: +Ask approximately 10 questions about a classified ad over the phone to a customer service representative. Evaluators assess linguistic skills only — grammar, vocabulary variety, pronunciation, and fluency. The relevance of questions is not graded. + +EVALUATION CRITERIA: +- Question formation: correct subject-verb inversion (Habite-t-il ? Va-t-elle ?) or "est-ce que" structure +- Range of interrogative adverbs: quoi/que, qui, quand, où, comment +- Fluency and spontaneity: ability to react to answers and sustain a natural conversation +- Vocabulary breadth: varied and accurate rather than repetitive simple phrases + +WHAT EXAMINERS LOOK FOR (tips from the test creators): +- Avoid questions learnt by heart — examiners notice and penalise recitation +- React to the agent's answers to demonstrate comprehension and conversational flexibility +- Aim for a flowing conversation, not 10 isolated questions fired in sequence +- The priority is fluent speech with varied vocabulary`; + +const SECTION_B_GUIDANCE = `TEF Canada Oral Expression — Section B: Argumentation (10 minutes) + +WHAT THE TEST EXPECTS: +Present a classified ad to a skeptical friend and argue to convince them to participate. Evaluators assess how clearly you present, how persuasively you argue, how well you structure reasoning, and how fluently you adapt to the conversation. + +EVALUATION CRITERIA: +- Argumentation vocabulary: verbs of advice (je te conseille de, je te recommande de, je te propose de) and linking words (parce que, car, donc, c'est pourquoi, en effet, d'ailleurs, de plus) +- Use of document context: extract and rephrase information from the ad — do NOT recite it verbatim +- Persuasive structure: arguments that address the friend's situation and objections directly +- Fluency and naturalness in conversation +- Variety and accuracy of vocabulary and sentence structures + +WHAT EXAMINERS LOOK FOR (tips from the test creators): +- This is NOT a reading test — rephrase the ad's content, never recite it word for word +- Tailor arguments to the friend's specific context (their interests, situation) +- Justify claims with linking words and reasons, not bare assertions +- Demonstrate understanding of the instructions by adapting to your conversation partner`; + +export function tefReviewResponseSchema(exerciseType: 'questioning' | 'persuasion') { + return { + type: Type.OBJECT, + properties: { + cefrLevel: { type: Type.STRING }, + cefrJustification: { type: Type.STRING }, + wentWell: { type: Type.ARRAY, items: { type: Type.STRING } }, + topicSuggestions: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + topic: { type: Type.STRING }, + examples: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + french: { type: Type.STRING }, + english: { type: Type.STRING }, + }, + required: ['french', 'english'], + }, + }, + }, + required: ['topic', 'examples'], + }, + }, + ...(exerciseType === 'persuasion' + ? { + criteriaEvaluation: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + criterion: { type: Type.STRING }, + met: { type: Type.BOOLEAN }, + evidence: { type: Type.STRING }, + }, + required: ['criterion', 'met', 'evidence'], + }, + }, + } + : {}), + }, + required: [ + 'cefrLevel', + 'cefrJustification', + 'wentWell', + 'topicSuggestions', + ...(exerciseType === 'persuasion' ? ['criteriaEvaluation'] : []), + ], + }; +} + +export function buildTefReviewParts(params: { + exerciseType: 'questioning' | 'persuasion'; + elapsedSeconds: number; + adSummary?: string; + turns: ReviewTurn[]; +}): { parts: Array<{ text: string } | { inlineData: { data: string; mimeType: string } }>; responseSchema: ReturnType } { + const { exerciseType, elapsedSeconds, adSummary, turns } = params; + const parts: Array<{ text: string } | { inlineData: { data: string; mimeType: string } }> = []; + + const exerciseLabel = + exerciseType === 'questioning' + ? "TEF Section A – Prise d'information (questioning a customer service agent about an advertisement)" + : 'TEF Section B – Argumentation (persuading a skeptical friend about an advertisement)'; + const sectionGuidance = exerciseType === 'questioning' ? SECTION_A_GUIDANCE : SECTION_B_GUIDANCE; + + let preamble = `You are an expert French language evaluator specialising in TEF Canada oral expression assessments. + +EXERCISE TYPE: ${exerciseLabel} +ELAPSED TIME: ${elapsedSeconds} seconds +TARGET LEVEL: C1 + +${sectionGuidance} +`; + + if (adSummary) { + preamble += `\nADVERTISEMENT CONTEXT:\n${adSummary}\n`; + } + + if (exerciseType === 'persuasion') { + preamble += ` +PERSUASION CRITERIA TO EVALUATE (assess each explicitly in the criteriaEvaluation field): +1. Clear & interesting presentation — Did the user present the advertisement clearly and in an engaging way? +2. Argumentation vocabulary — Did the user use advice verbs (je vous conseille, il faudrait que) and linking words (en revanche, de plus, car, donc, c'est pourquoi)? +3. 3+ distinct arguments — Did the user raise more than three distinct arguments? +4. Arguments developed with examples — Did the user support each argument with a concrete example? +5. Nuanced / counter-arguments — Did the user nuance their position or address counter-arguments? +`; + } + + preamble += ` +EVALUATION SCOPE — IMPORTANT: +Evaluate only the user's French. The [Agent said: ...] lines in the transcript are provided for context only, not for grading. Do not assess the agent, do not grade the agent's French, and do not criticize the agent's performance. Focus all feedback exclusively on what the user said. + +AUDIO NOTE: Audio recordings are the primary source for evaluating speech quality (pronunciation, fluency, spontaneous grammar). Use the transcript as a reference guide. Where they conflict, trust the audio. + +CONVERSATION TRANSCRIPT: +`; + parts.push({ text: preamble }); + + if (turns.length === 0) { + parts.push({ text: '[No conversation turns recorded. The user did not speak during this session.]' }); + } else { + for (const turn of turns) { + if (turn.role === 'user') { + if (turn.audioBase64 && turn.mimeType) { + parts.push({ inlineData: { data: turn.audioBase64, mimeType: turn.mimeType } }); + } else { + parts.push({ text: `[User said (transcript only — audio unavailable): ${turn.text ?? ''}]` }); + } + } else { + parts.push({ text: `[Agent said: ${turn.frenchText || turn.text || ''}]` }); + } + } + } + + const topicSuggestionInstructions = + exerciseType === 'persuasion' + ? `3. Topic suggestions: suggest at least 5 additional persuasive angles/arguments the user could have used to convince their skeptical friend + - Each topic should describe an argument angle (e.g. "Le rapport qualité-prix", "La flexibilité des horaires") + - For EACH suggested topic, provide at least 2 short spoken examples in French that the USER could say TO their friend — persuasive statements from the user's perspective (e.g. "Je te conseille de...", "Tu devrais...", "C'est une super opportunité parce que...") + - Do NOT write questions the friend would ask — the examples must be convincing things the user (the persuader) could say + - Include an English translation for each French example` + : `3. Topic suggestions: suggest at least 5 additional relevant topics/angles the user could have asked about + - For EACH suggested topic, provide at least 2 short spoken examples in French as questions the user could ask the customer service agent + - Include an English translation for each French example`; + + parts.push({ + text: ` + +EVALUATION INSTRUCTIONS: +Based on the conversation above, provide a structured CEFR evaluation. Assess the user's spoken French on: +1. CEFR level (A1, A2, B1, B2, C1, or C2) with a 1–2 sentence justification +2. What the user did well (concrete positive observations) +${topicSuggestionInstructions} + +Return ONLY valid JSON matching the required schema. Do not include any markdown or explanation outside the JSON.`, + }); + + return { parts, responseSchema: tefReviewResponseSchema(exerciseType) }; +} + +export function validateTefReview(parsed: unknown, exerciseType: 'questioning' | 'persuasion'): TefReview { + if (typeof parsed !== 'object' || parsed === null) { + throw new Error('Review response is not an object'); + } + const obj = parsed as Record; + for (const field of ['cefrLevel', 'cefrJustification', 'wentWell', 'topicSuggestions'] as const) { + if (!(field in obj)) { + throw new Error(`Review response missing required field: "${field}"`); + } + } + if (exerciseType === 'persuasion' && !Array.isArray(obj.criteriaEvaluation)) { + throw new Error('Review response missing required field: "criteriaEvaluation"'); + } + const topicSuggestions = obj.topicSuggestions; + if (!Array.isArray(topicSuggestions) || topicSuggestions.length < 5) { + throw new Error('Review response field "topicSuggestions" has insufficient length: expected at least 5'); + } + for (let i = 0; i < topicSuggestions.length; i++) { + const item = topicSuggestions[i] as Record; + if (typeof item?.topic !== 'string' || !item.topic.trim()) { + throw new Error(`Review response field "topicSuggestions[${i}].topic" must be a non-empty string`); + } + const examples = item.examples; + if (!Array.isArray(examples) || examples.length < 2) { + throw new Error(`Review response field "topicSuggestions[${i}].examples" has insufficient length: expected at least 2`); + } + for (let j = 0; j < examples.length; j++) { + const example = examples[j] as Record; + if (typeof example?.french !== 'string' || !example.french.trim()) { + throw new Error(`Review response field "topicSuggestions[${i}].examples[${j}].french" must be a non-empty string`); + } + if (typeof example?.english !== 'string' || !example.english.trim()) { + throw new Error(`Review response field "topicSuggestions[${i}].examples[${j}].english" must be a non-empty string`); + } + } + } + return obj as unknown as TefReview; +} diff --git a/worker/routes/ai.ts b/worker/routes/ai.ts new file mode 100644 index 0000000..0a059fa --- /dev/null +++ b/worker/routes/ai.ts @@ -0,0 +1,510 @@ +import { GoogleGenAI, Modality, Type } from '@google/genai'; +import type { Scenario } from '../../types'; +import { + ChatHistoryTurnsSchema, + ImageAnalysisSchema, + TranscribeCleanupSchema, + selectGeminiResponseSchema, + selectZodChatSchema, +} from '../../shared/chatSchemas'; +import { + FREE_CONVERSATION_SYSTEM_INSTRUCTION, + TEF_AD_IMAGE_PROMPT, + TEF_QUESTIONING_IMAGE_PROMPT, + TRANSCRIBE_AND_CLEANUP_PROMPT, + TRANSCRIBE_EXACT_PROMPT, + generateScenarioSystemInstruction, + ttsSystemPrompt, +} from '../../shared/prompts'; +import { isAbortLikeError } from '../../utils/isAbortLikeError'; +import { GEMINI_CHAT_MODEL, GEMINI_TTS_MODEL, UPSTREAM_TIMEOUT_MS } from '../constants'; +import { isAllowedOrigin } from '../csrf'; +import { errorJson, isJsonContentType, json } from '../http'; +import { requireGeminiSession, requireOpenaiSession, slidingSessionCookie } from '../session'; +import { classifyProviderError } from '../upstream'; +import { planScenarioWithOpenAI } from '../openai'; +import { buildScenarioReviewParts } from '../prompts/scenarioReview'; +import { buildTefReviewParts, validateTefReview } from '../prompts/tefReview'; + +type ChatHistoryTurn = { + role: 'user' | 'model'; + text?: string; + frenchText?: string; + audioBase64?: string; + mimeType?: string; +}; + +function parseTurnsField(value: unknown, fieldName: string): ChatHistoryTurn[] | Response { + if (value === undefined) return []; + const parsed = ChatHistoryTurnsSchema.safeParse(value); + if (!parsed.success) { + return errorJson('VALIDATION_ERROR', 400, `Invalid ${fieldName}`); + } + return parsed.data; +} + +function originDenied(): Response { + return errorJson('FORBIDDEN', 403); +} + +function abortSignal(request: Request): AbortSignal { + const timeout = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS); + return request.signal ? AbortSignal.any([request.signal, timeout]) : timeout; +} + +async function readJsonBody(request: Request): Promise | Response> { + if (!isAllowedOrigin(request)) return originDenied(); + if (!isJsonContentType(request)) { + return errorJson('VALIDATION_ERROR', 400, 'Content-Type must be application/json'); + } + try { + const body = await request.json(); + if (typeof body !== 'object' || body === null) { + return errorJson('VALIDATION_ERROR', 400, 'Invalid JSON body'); + } + return body as Record; + } catch { + return errorJson('VALIDATION_ERROR', 400, 'Invalid JSON body'); + } +} + +function cookieHeaders(setCookie?: string): HeadersInit | undefined { + if (!setCookie) return undefined; + const headers = new Headers(); + headers.append('Set-Cookie', setCookie); + return headers; +} + +function sessionError( + reason: 'missing' | 'invalid' | 'missing_provider', + setCookie?: string +): Response { + if (reason === 'missing') { + return errorJson('NO_API_KEY_SESSION', 401, undefined, cookieHeaders(setCookie)); + } + if (reason === 'invalid') { + return errorJson('INVALID_API_KEY_SESSION', 401, undefined, cookieHeaders(setCookie)); + } + return errorJson('MISSING_PROVIDER_KEY', 401, 'This action needs a configured API key.', cookieHeaders(setCookie)); +} + +function mapCaught(err: unknown): Response { + if (isAbortLikeError(err)) { + return errorJson('UPSTREAM_ERROR', 504, 'The AI request was aborted.'); + } + if (typeof err === 'object' && err !== null && 'code' in err) { + const mapped = err as { code: string; httpStatus?: number; message?: string }; + if (mapped.code === 'UPSTREAM_AUTH_FAILED' || mapped.code === 'UPSTREAM_ERROR') { + return errorJson( + mapped.code, + mapped.httpStatus ?? (mapped.code === 'UPSTREAM_AUTH_FAILED' ? 401 : 502), + mapped.message + ); + } + } + try { + const mapped = classifyProviderError(err); + return errorJson(mapped.code, mapped.httpStatus, mapped.message); + } catch (abortErr) { + if (isAbortLikeError(abortErr)) { + return errorJson('UPSTREAM_ERROR', 504, 'The AI request was aborted.'); + } + return errorJson('INTERNAL_ERROR', 500); + } +} + +export async function handleTranscribe(request: Request, env: Env): Promise { + if (request.method !== 'POST') return errorJson('METHOD_NOT_ALLOWED', 405); + const bodyOrErr = await readJsonBody(request); + if (bodyOrErr instanceof Response) return bodyOrErr; + const session = await requireGeminiSession(request, env); + if (!session.ok) return sessionError(session.reason, session.setCookie); + + const audioBase64 = bodyOrErr.audioBase64; + const mimeType = bodyOrErr.mimeType; + const cleanup = bodyOrErr.cleanup === true; + if (typeof audioBase64 !== 'string' || typeof mimeType !== 'string') { + return errorJson('VALIDATION_ERROR', 400, 'audioBase64 and mimeType are required'); + } + + try { + const ai = new GoogleGenAI({ apiKey: session.geminiKey }); + const response = await ai.models.generateContent({ + model: GEMINI_CHAT_MODEL, + contents: [{ + parts: [ + { text: cleanup ? TRANSCRIBE_AND_CLEANUP_PROMPT : TRANSCRIBE_EXACT_PROMPT }, + { inlineData: { data: audioBase64, mimeType } }, + ], + }], + config: { + abortSignal: abortSignal(request), + ...(cleanup + ? { + responseMimeType: 'application/json', + responseSchema: { + type: Type.OBJECT, + properties: { + rawTranscript: { type: Type.STRING }, + cleanedTranscript: { type: Type.STRING }, + }, + required: ['rawTranscript', 'cleanedTranscript'], + }, + } + : {}), + }, + }); + const text = response.text || ''; + if (!text.trim()) { + return errorJson('UPSTREAM_ERROR', 502, 'Transcription returned empty text'); + } + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + if (cleanup) { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return errorJson('UPSTREAM_ERROR', 502, 'Failed to parse transcription JSON'); + } + const validated = TranscribeCleanupSchema.safeParse(parsed); + if (!validated.success) { + return errorJson('VALIDATION_ERROR', 502, 'Transcription JSON failed validation'); + } + return json(validated.data, 200, cookieHeaders(setCookie)); + } + return json({ text }, 200, cookieHeaders(setCookie)); + } catch (err) { + return mapCaught(err); + } +} + +export async function handleChat(request: Request, env: Env): Promise { + if (request.method !== 'POST') return errorJson('METHOD_NOT_ALLOWED', 405); + const bodyOrErr = await readJsonBody(request); + if (bodyOrErr instanceof Response) return bodyOrErr; + const session = await requireGeminiSession(request, env); + if (!session.ok) return sessionError(session.reason, session.setCookie); + + const audioBase64 = bodyOrErr.audioBase64; + const mimeType = bodyOrErr.mimeType; + if (typeof audioBase64 !== 'string' || typeof mimeType !== 'string') { + return errorJson('VALIDATION_ERROR', 400, 'audioBase64 and mimeType are required'); + } + const scenario = (bodyOrErr.scenario ?? null) as Scenario | null; + const historyOrErr = parseTurnsField(bodyOrErr.history, 'history'); + if (historyOrErr instanceof Response) return historyOrErr; + const history = historyOrErr; + const contextText = typeof bodyOrErr.contextText === 'string' && bodyOrErr.contextText.trim() + ? bodyOrErr.contextText + : undefined; + + const historyMessages: Array<{ role: string; parts: Array<{ text: string } | { inlineData: { data: string; mimeType: string } }> }> = []; + for (const turn of history) { + if (turn.role === 'user') { + if (turn.audioBase64 && turn.mimeType) { + historyMessages.push({ + role: 'user', + parts: [{ inlineData: { data: turn.audioBase64, mimeType: turn.mimeType } }], + }); + } else if (typeof turn.text === 'string') { + historyMessages.push({ role: 'user', parts: [{ text: turn.text }] }); + } + } else { + const modelText = turn.frenchText || turn.text || ''; + historyMessages.push({ role: 'model', parts: [{ text: modelText }] }); + } + } + + const systemInstruction = scenario + ? generateScenarioSystemInstruction(scenario) + : FREE_CONVERSATION_SYSTEM_INSTRUCTION; + const responseSchema = selectGeminiResponseSchema(scenario); + const zodSchema = selectZodChatSchema(scenario); + + try { + const ai = new GoogleGenAI({ apiKey: session.geminiKey }); + const signal = abortSignal(request); + const chat = ai.chats.create({ + model: GEMINI_CHAT_MODEL, + config: { + systemInstruction, + responseMimeType: 'application/json', + responseSchema, + abortSignal: signal, + }, + ...(historyMessages.length > 0 ? { history: historyMessages } : {}), + }); + + const messageParts: Array<{ text: string } | { inlineData: { data: string; mimeType: string } }> = []; + if (contextText) messageParts.push({ text: contextText }); + messageParts.push({ inlineData: { data: audioBase64, mimeType } }); + + const chatResponse = await chat.sendMessage({ + message: messageParts, + config: { + abortSignal: signal, + systemInstruction, + responseMimeType: 'application/json', + responseSchema, + }, + }); + + const raw = chatResponse.text; + if (!raw) { + return errorJson('UPSTREAM_ERROR', 502, 'No text response received from chat model.'); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return errorJson('VALIDATION_ERROR', 502, 'Model response was not valid JSON'); + } + const validated = zodSchema.safeParse(parsed); + if (!validated.success) { + return errorJson('VALIDATION_ERROR', 502, `Model response failed schema validation: ${validated.error.message}`); + } + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + return json({ modelJson: validated.data }, 200, cookieHeaders(setCookie)); + } catch (err) { + return mapCaught(err); + } +} + +export async function handleTts(request: Request, env: Env): Promise { + if (request.method !== 'POST') return errorJson('METHOD_NOT_ALLOWED', 405); + const bodyOrErr = await readJsonBody(request); + if (bodyOrErr instanceof Response) return bodyOrErr; + const session = await requireGeminiSession(request, env); + if (!session.ok) return sessionError(session.reason, session.setCookie); + + const text = bodyOrErr.text; + const voiceName = bodyOrErr.voiceName; + if (typeof text !== 'string' || !text.trim() || typeof voiceName !== 'string') { + return errorJson('VALIDATION_ERROR', 400, 'text and voiceName are required'); + } + + try { + const ai = new GoogleGenAI({ apiKey: session.geminiKey }); + const ttsResponse = await ai.models.generateContent({ + model: GEMINI_TTS_MODEL, + contents: [{ parts: [{ text: ttsSystemPrompt(text) }] }], + config: { + abortSignal: abortSignal(request), + responseModalities: [Modality.AUDIO], + speechConfig: { + voiceConfig: { + prebuiltVoiceConfig: { voiceName: voiceName.toLowerCase() }, + }, + }, + }, + }); + const audioPart = ttsResponse.candidates?.[0]?.content?.parts?.find((part) => part.inlineData); + if (!audioPart?.inlineData?.data) { + return errorJson('UPSTREAM_ERROR', 502, 'No audio data received from TTS model'); + } + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + return json( + { audioBase64: audioPart.inlineData.data, mimeType: audioPart.inlineData.mimeType || 'audio/pcm' }, + 200, + cookieHeaders(setCookie) + ); + } catch (err) { + return mapCaught(err); + } +} + +export async function handleTefAdConfirm(request: Request, env: Env): Promise { + if (request.method !== 'POST') return errorJson('METHOD_NOT_ALLOWED', 405); + const bodyOrErr = await readJsonBody(request); + if (bodyOrErr instanceof Response) return bodyOrErr; + const session = await requireGeminiSession(request, env); + if (!session.ok) return sessionError(session.reason, session.setCookie); + + const imageBase64 = bodyOrErr.imageBase64; + const mimeType = bodyOrErr.mimeType; + const mode = bodyOrErr.mode === 'questioning' ? 'questioning' : 'persuasion'; + const SUPPORTED = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif']; + if (typeof imageBase64 !== 'string' || typeof mimeType !== 'string') { + return errorJson('VALIDATION_ERROR', 400, 'imageBase64 and mimeType are required'); + } + if (!SUPPORTED.includes(mimeType)) { + return errorJson('VALIDATION_ERROR', 400, `Unsupported image type "${mimeType}"`); + } + + try { + const ai = new GoogleGenAI({ apiKey: session.geminiKey }); + const response = await ai.models.generateContent({ + model: GEMINI_CHAT_MODEL, + contents: [{ + parts: [ + { text: mode === 'questioning' ? TEF_QUESTIONING_IMAGE_PROMPT : TEF_AD_IMAGE_PROMPT }, + { inlineData: { data: imageBase64, mimeType } }, + ], + }], + config: { + abortSignal: abortSignal(request), + responseMimeType: 'application/json', + }, + }); + const text = response.text || ''; + if (!text.trim()) { + return errorJson('UPSTREAM_ERROR', 502, 'No response received from image analysis'); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return errorJson('VALIDATION_ERROR', 502, 'Image analysis response was not valid JSON'); + } + const validated = ImageAnalysisSchema.safeParse(parsed); + if (!validated.success) { + return errorJson('VALIDATION_ERROR', 502, 'Image analysis response failed validation'); + } + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + return json(validated.data, 200, cookieHeaders(setCookie)); + } catch (err) { + return mapCaught(err); + } +} + +export async function handleTefReview(request: Request, env: Env): Promise { + if (request.method !== 'POST') return errorJson('METHOD_NOT_ALLOWED', 405); + const bodyOrErr = await readJsonBody(request); + if (bodyOrErr instanceof Response) return bodyOrErr; + const session = await requireGeminiSession(request, env); + if (!session.ok) return sessionError(session.reason, session.setCookie); + + const exerciseType = bodyOrErr.exerciseType === 'questioning' ? 'questioning' : bodyOrErr.exerciseType === 'persuasion' ? 'persuasion' : null; + if (!exerciseType) { + return errorJson('VALIDATION_ERROR', 400, 'exerciseType must be questioning or persuasion'); + } + const elapsedSeconds = typeof bodyOrErr.elapsedSeconds === 'number' ? bodyOrErr.elapsedSeconds : 0; + const adSummary = typeof bodyOrErr.adSummary === 'string' ? bodyOrErr.adSummary : undefined; + const turnsOrErr = parseTurnsField(bodyOrErr.turns, 'turns'); + if (turnsOrErr instanceof Response) return turnsOrErr; + const turns = turnsOrErr; + + try { + const { parts, responseSchema } = buildTefReviewParts({ + exerciseType, + elapsedSeconds, + adSummary, + turns, + }); + const ai = new GoogleGenAI({ apiKey: session.geminiKey }); + const response = await ai.models.generateContent({ + model: GEMINI_CHAT_MODEL, + contents: [{ parts }], + config: { + abortSignal: abortSignal(request), + responseMimeType: 'application/json', + responseSchema, + }, + }); + const text = response.text || ''; + if (!text.trim()) { + return errorJson('UPSTREAM_ERROR', 502, 'No response received from review generation'); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return errorJson('VALIDATION_ERROR', 502, 'Review response was not valid JSON'); + } + const review = validateTefReview(parsed, exerciseType); + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + return json(review, 200, cookieHeaders(setCookie)); + } catch (err) { + if (err instanceof Error && err.message.startsWith('Review response')) { + return errorJson('VALIDATION_ERROR', 502, err.message); + } + return mapCaught(err); + } +} + +export async function handleScenarioReview(request: Request, env: Env): Promise { + if (request.method !== 'POST') return errorJson('METHOD_NOT_ALLOWED', 405); + const bodyOrErr = await readJsonBody(request); + if (bodyOrErr instanceof Response) return bodyOrErr; + const session = await requireGeminiSession(request, env); + if (!session.ok) return sessionError(session.reason, session.setCookie); + + const turnsOrErr = parseTurnsField(bodyOrErr.turns, 'turns'); + if (turnsOrErr instanceof Response) return turnsOrErr; + const turns = turnsOrErr; + const hasUser = turns.some((turn) => turn.role === 'user'); + if (!hasUser) { + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + return json({ items: [] }, 200, cookieHeaders(setCookie)); + } + + try { + const parts = buildScenarioReviewParts({ + turns, + scenarioName: typeof bodyOrErr.scenarioName === 'string' ? bodyOrErr.scenarioName : undefined, + scenarioDescription: typeof bodyOrErr.scenarioDescription === 'string' ? bodyOrErr.scenarioDescription : undefined, + }); + const ai = new GoogleGenAI({ apiKey: session.geminiKey }); + const response = await ai.models.generateContent({ + model: GEMINI_CHAT_MODEL, + contents: [{ parts }], + config: { + abortSignal: abortSignal(request), + responseMimeType: 'application/json', + responseSchema: { + type: Type.OBJECT, + properties: { + items: { + type: Type.ARRAY, + items: { + type: Type.OBJECT, + properties: { + original: { type: Type.STRING }, + standard: { type: Type.STRING }, + }, + required: ['original', 'standard'], + }, + }, + }, + required: ['items'], + }, + }, + }); + const text = response.text || ''; + if (!text.trim()) { + return errorJson('UPSTREAM_ERROR', 502, 'No response received from role-play review generation'); + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return errorJson('VALIDATION_ERROR', 502, 'Role-play review response was not valid JSON'); + } + if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { items?: unknown }).items)) { + return errorJson('VALIDATION_ERROR', 502, 'Role-play review response missing required field: "items"'); + } + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + return json(parsed, 200, cookieHeaders(setCookie)); + } catch (err) { + return mapCaught(err); + } +} + +export async function handleScenarioPlan(request: Request, env: Env): Promise { + if (request.method !== 'POST') return errorJson('METHOD_NOT_ALLOWED', 405); + const bodyOrErr = await readJsonBody(request); + if (bodyOrErr instanceof Response) return bodyOrErr; + const session = await requireOpenaiSession(request, env); + if (!session.ok) return sessionError(session.reason, session.setCookie); + const description = bodyOrErr.description; + if (typeof description !== 'string' || !description.trim()) { + return errorJson('VALIDATION_ERROR', 400, 'description is required'); + } + try { + const result = await planScenarioWithOpenAI(session.openaiKey, description, abortSignal(request)); + const setCookie = await slidingSessionCookie(session.payload, env, session.setCookie); + return json({ result }, 200, cookieHeaders(setCookie)); + } catch (err) { + return mapCaught(err); + } +} diff --git a/worker/routes/session.ts b/worker/routes/session.ts new file mode 100644 index 0000000..168ce93 --- /dev/null +++ b/worker/routes/session.ts @@ -0,0 +1,128 @@ +import { serializeDeletedSessionCookie } from '../cookies'; +import { isAllowedOrigin } from '../csrf'; +import { errorJson, isJsonContentType, json } from '../http'; +import { + mergeSessionKeys, + publicSessionStatus, + readSession, + sealSession, + type SessionPayload, +} from '../session'; + +function originDenied(): Response { + return errorJson('FORBIDDEN', 403); +} + +export async function handleCreateSession(request: Request, env: Env): Promise { + if (request.method !== 'POST') { + return errorJson('METHOD_NOT_ALLOWED', 405); + } + if (!isAllowedOrigin(request)) { + return originDenied(); + } + if (!isJsonContentType(request)) { + return errorJson('VALIDATION_ERROR', 400, 'Content-Type must be application/json'); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorJson('VALIDATION_ERROR', 400, 'Invalid JSON body'); + } + if (typeof body !== 'object' || body === null) { + return errorJson('VALIDATION_ERROR', 400, 'Invalid JSON body'); + } + + const existing = await readSession(request, env); + const existingKeys = existing.status === 'ok' ? existing.payload.keys : undefined; + const createdAt = + existing.status === 'ok' ? existing.payload.createdAt : Math.floor(Date.now() / 1000); + + const merged = mergeSessionKeys(existingKeys, body as Record); + if (merged.error) { + return errorJson('VALIDATION_ERROR', 400, merged.error); + } + + if (!merged.keys.gemini && !merged.keys.openai) { + const headers = new Headers(); + headers.append('Set-Cookie', serializeDeletedSessionCookie()); + return json({ success: true, ...publicSessionStatus(null) }, 200, headers); + } + + const payload: SessionPayload = { v: 1, keys: merged.keys, createdAt }; + const headers = new Headers(); + headers.append('Set-Cookie', await sealSession(payload, env)); + return json({ success: true, ...publicSessionStatus(payload) }, 200, headers); +} + +export async function handleSessionStatus(request: Request, env: Env): Promise { + if (request.method !== 'GET') { + return errorJson('METHOD_NOT_ALLOWED', 405); + } + + const result = await readSession(request, env); + if (result.status === 'missing') { + return json(publicSessionStatus(null)); + } + if (result.status === 'invalid') { + const headers = new Headers(); + headers.append('Set-Cookie', serializeDeletedSessionCookie()); + return json(publicSessionStatus(null), 200, headers); + } + + const headers = new Headers(); + if (result.resealed) { + headers.append('Set-Cookie', await sealSession(result.payload, env)); + } + return json(publicSessionStatus(result.payload), 200, headers); +} + +export async function handleRevoke(request: Request, env: Env): Promise { + if (request.method !== 'POST') { + return errorJson('METHOD_NOT_ALLOWED', 405); + } + if (!isAllowedOrigin(request)) { + return originDenied(); + } + + let body: Record = {}; + const contentType = request.headers.get('Content-Type') ?? ''; + if (contentType && isJsonContentType(request)) { + try { + const parsed = await request.json(); + if (typeof parsed === 'object' && parsed !== null) { + body = parsed as Record; + } + } catch { + return errorJson('VALIDATION_ERROR', 400, 'Invalid JSON body'); + } + } + + const provider = body.provider; + if (provider === 'gemini' || provider === 'openai') { + const existing = await readSession(request, env); + const existingKeys = existing.status === 'ok' ? existing.payload.keys : undefined; + const merged = mergeSessionKeys(existingKeys, { + removeGemini: provider === 'gemini', + removeOpenai: provider === 'openai', + }); + if (!merged.keys.gemini && !merged.keys.openai) { + const headers = new Headers(); + headers.append('Set-Cookie', serializeDeletedSessionCookie()); + return json({ success: true, ...publicSessionStatus(null) }, 200, headers); + } + const payload: SessionPayload = { + v: 1, + keys: merged.keys, + createdAt: existing.status === 'ok' ? existing.payload.createdAt : Math.floor(Date.now() / 1000), + }; + const headers = new Headers(); + headers.append('Set-Cookie', await sealSession(payload, env)); + return json({ success: true, ...publicSessionStatus(payload) }, 200, headers); + } + + const headers = new Headers(); + headers.append('Set-Cookie', serializeDeletedSessionCookie()); + return json({ success: true, ...publicSessionStatus(null) }, 200, headers); +} diff --git a/worker/seal.ts b/worker/seal.ts new file mode 100644 index 0000000..3b4c0b9 --- /dev/null +++ b/worker/seal.ts @@ -0,0 +1,89 @@ +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function rejectEmptySecret(secret: string): void { + if (typeof secret !== 'string' || secret.trim().length === 0) { + throw new Error('API_KEY_COOKIE_SECRET is missing'); + } +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function fromBase64Url(value: string): Uint8Array { + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + out[i] = binary.charCodeAt(i); + } + return out; +} + +async function deriveRawKey(secret: string): Promise { + rejectEmptySecret(secret); + try { + const decoded = Uint8Array.from(atob(secret), (c) => c.charCodeAt(0)); + if (decoded.length === 32) { + return decoded; + } + } catch { + // fall through to SHA-256 + } + return new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(secret))); +} + +export async function importAesKey(secret: string): Promise { + const raw = await deriveRawKey(secret); + return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} + +export async function seal(payload: unknown, secret: string): Promise { + const key = await importAesKey(secret); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = encoder.encode(JSON.stringify(payload)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext) + ); + return `v1.${toBase64Url(iv)}.${toBase64Url(ciphertext)}`; +} + +export async function unseal(token: string, secret: string): Promise { + const parts = token.split('.'); + if (parts.length !== 3 || parts[0] !== 'v1' || !parts[1] || !parts[2]) { + return null; + } + try { + const key = await importAesKey(secret); + const iv = fromBase64Url(parts[1]); + const ciphertext = fromBase64Url(parts[2]); + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return JSON.parse(decoder.decode(plain)) as T; + } catch { + return null; + } +} + +export async function unsealWithRotation( + token: string, + currentSecret: string, + previousSecret?: string +): Promise<{ payload: T; resealed?: string } | null> { + const current = await unseal(token, currentSecret); + if (current) { + return { payload: current }; + } + if (previousSecret && previousSecret.trim().length > 0) { + const previous = await unseal(token, previousSecret); + if (previous) { + const resealed = await seal(previous, currentSecret); + return { payload: previous, resealed }; + } + } + return null; +} diff --git a/worker/session.ts b/worker/session.ts new file mode 100644 index 0000000..5c983e6 --- /dev/null +++ b/worker/session.ts @@ -0,0 +1,169 @@ +import { COOKIE_MAX_AGE_SECONDS, MAX_API_KEY_LENGTH, MIN_API_KEY_LENGTH } from './constants'; +import { readNamedCookie, serializeDeletedSessionCookie, serializeSessionCookie } from './cookies'; +import { seal, unsealWithRotation } from './seal'; + +export interface SessionKeys { + gemini?: string; + openai?: string; +} + +export interface SessionPayload { + v: 1; + keys: SessionKeys; + createdAt: number; +} + +export type SessionReadResult = + | { status: 'missing' } + | { status: 'invalid' } + | { status: 'ok'; payload: SessionPayload; resealed?: string }; + +export function isPlausibleApiKey(key: string): boolean { + if (key.length < MIN_API_KEY_LENGTH || key.length > MAX_API_KEY_LENGTH) { + return false; + } + if (/[\u0000-\u001f\u007f]/.test(key)) { + return false; + } + return true; +} + +function isValidSessionKeys(keys: unknown): keys is SessionKeys { + if (typeof keys !== 'object' || keys === null || Array.isArray(keys)) { + return false; + } + const record = keys as Record; + if (record.gemini !== undefined && typeof record.gemini !== 'string') { + return false; + } + if (record.openai !== undefined && typeof record.openai !== 'string') { + return false; + } + return true; +} + +export function publicSessionStatus(payload: SessionPayload | null): { + hasGemini: boolean; + hasOpenai: boolean; + hasApiKey: boolean; + createdAt?: number; +} { + const hasGemini = Boolean(payload?.keys.gemini); + const hasOpenai = Boolean(payload?.keys.openai); + return { + hasGemini, + hasOpenai, + hasApiKey: hasGemini || hasOpenai, + ...(payload ? { createdAt: payload.createdAt } : {}), + }; +} + +export async function readSession(request: Request, env: Env): Promise { + const token = readNamedCookie(request); + if (!token) { + return { status: 'missing' }; + } + const result = await unsealWithRotation( + token, + env.API_KEY_COOKIE_SECRET, + env.API_KEY_COOKIE_SECRET_PREVIOUS + ); + if (!result || result.payload.v !== 1 || !isValidSessionKeys(result.payload.keys)) { + return { status: 'invalid' }; + } + const createdAt = result.payload.createdAt; + if (typeof createdAt !== 'number' || !Number.isFinite(createdAt)) { + return { status: 'invalid' }; + } + const nowSeconds = Math.floor(Date.now() / 1000); + if (nowSeconds - createdAt > COOKIE_MAX_AGE_SECONDS) { + return { status: 'invalid' }; + } + return { status: 'ok', payload: result.payload, resealed: result.resealed }; +} + +export async function requireSession(request: Request, env: Env): Promise< + | { ok: true; payload: SessionPayload; setCookie?: string } + | { ok: false; reason: 'missing' | 'invalid'; setCookie?: string } +> { + const result = await readSession(request, env); + if (result.status === 'missing') { + return { ok: false, reason: 'missing' }; + } + if (result.status === 'invalid') { + return { ok: false, reason: 'invalid', setCookie: serializeDeletedSessionCookie() }; + } + return { + ok: true, + payload: result.payload, + setCookie: result.resealed ? serializeSessionCookie(result.resealed) : undefined, + }; +} + +export async function requireGeminiSession(request: Request, env: Env): Promise< + | { ok: true; payload: SessionPayload; geminiKey: string; setCookie?: string } + | { ok: false; reason: 'missing' | 'invalid' | 'missing_provider'; setCookie?: string } +> { + const session = await requireSession(request, env); + if (!session.ok) return session; + const geminiKey = session.payload.keys.gemini; + if (!geminiKey) { + return { ok: false, reason: 'missing_provider', setCookie: session.setCookie }; + } + return { ok: true, payload: session.payload, geminiKey, setCookie: session.setCookie }; +} + +export async function requireOpenaiSession(request: Request, env: Env): Promise< + | { ok: true; payload: SessionPayload; openaiKey: string; setCookie?: string } + | { ok: false; reason: 'missing' | 'invalid' | 'missing_provider'; setCookie?: string } +> { + const session = await requireSession(request, env); + if (!session.ok) return session; + const openaiKey = session.payload.keys.openai; + if (!openaiKey) { + return { ok: false, reason: 'missing_provider', setCookie: session.setCookie }; + } + return { ok: true, payload: session.payload, openaiKey, setCookie: session.setCookie }; +} + +export async function sealSession(payload: SessionPayload, env: Env): Promise { + return serializeSessionCookie(await seal(payload, env.API_KEY_COOKIE_SECRET)); +} + +export async function slidingSessionCookie( + payload: SessionPayload, + env: Env, + existingSetCookie?: string +): Promise { + if (existingSetCookie) return existingSetCookie; + return sealSession(payload, env); +} + +export function mergeSessionKeys( + existing: SessionKeys | undefined, + incoming: { geminiApiKey?: unknown; openaiApiKey?: unknown; removeGemini?: unknown; removeOpenai?: unknown } +): { keys: SessionKeys; error?: string } { + const keys: SessionKeys = { ...(existing ?? {}) }; + + if (incoming.removeGemini === true) { + delete keys.gemini; + } else if (typeof incoming.geminiApiKey === 'string' && incoming.geminiApiKey.trim()) { + const trimmed = incoming.geminiApiKey.trim(); + if (!isPlausibleApiKey(trimmed)) { + return { keys, error: 'Invalid Gemini API key' }; + } + keys.gemini = trimmed; + } + + if (incoming.removeOpenai === true) { + delete keys.openai; + } else if (typeof incoming.openaiApiKey === 'string' && incoming.openaiApiKey.trim()) { + const trimmed = incoming.openaiApiKey.trim(); + if (!isPlausibleApiKey(trimmed)) { + return { keys, error: 'Invalid OpenAI API key' }; + } + keys.openai = trimmed; + } + + return { keys }; +} diff --git a/worker/upstream.ts b/worker/upstream.ts new file mode 100644 index 0000000..bbb5353 --- /dev/null +++ b/worker/upstream.ts @@ -0,0 +1,53 @@ +import { isAbortLikeError } from '../utils/isAbortLikeError'; +import type { BffErrorCode } from './http'; + +export const UPSTREAM_AUTH_MESSAGE = + 'The configured API key was rejected by the provider. Please update your key.'; + +export const UPSTREAM_GENERIC_MESSAGE = 'The AI provider could not complete this request.'; + +export function classifyProviderError(err: unknown): { + code: BffErrorCode; + httpStatus: number; + message: string; +} { + if (isAbortLikeError(err)) { + throw err; + } + + const status = + typeof err === 'object' && err !== null && 'status' in err && typeof (err as { status: unknown }).status === 'number' + ? (err as { status: number }).status + : undefined; + const text = err instanceof Error ? `${err.name} ${err.message}` : String(err); + + const authLike = + status === 401 || + status === 403 || + /unauthenticated|permission_denied|api_key_invalid|api key not valid|invalid api key|api_key_invalid|key has been blocked|expired api key/i.test( + text + ); + + if (authLike) { + return { code: 'UPSTREAM_AUTH_FAILED', httpStatus: 401, message: UPSTREAM_AUTH_MESSAGE }; + } + + return { code: 'UPSTREAM_ERROR', httpStatus: 502, message: UPSTREAM_GENERIC_MESSAGE }; +} + +export function classifyHttpStatus(status: number, bodyText: string): { + code: BffErrorCode; + httpStatus: number; + message: string; +} { + const authLike = + status === 401 || + status === 403 || + /unauthenticated|permission_denied|api_key_invalid|api key not valid|invalid api key|key has been blocked/i.test( + bodyText + ); + if (authLike) { + return { code: 'UPSTREAM_AUTH_FAILED', httpStatus: 401, message: UPSTREAM_AUTH_MESSAGE }; + } + return { code: 'UPSTREAM_ERROR', httpStatus: 502, message: UPSTREAM_GENERIC_MESSAGE }; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 1cc1338..645821b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,7 +1,19 @@ { + "$schema": "./node_modules/wrangler/config-schema.json", "name": "parle", - "compatibility_date": "2026-01-23", + "main": "worker/index.ts", + // Secrets live in `.dev.vars` locally and `wrangler secret put` in production: + // API_KEY_COOKIE_SECRET, optional API_KEY_COOKIE_SECRET_PREVIOUS + "compatibility_date": "2026-09-13", + "compatibility_flags": ["nodejs_compat"], "assets": { - "directory": "./dist" + "directory": "./dist", + "binding": "ASSETS", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api/*"] + }, + "observability": { + "enabled": true, + "head_sampling_rate": 1 } }