@tpitre/story-ui 4.14.0 → 4.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/cli/setup.js +3 -3
  2. package/dist/mcp-server/index.js +0 -6
  3. package/dist/mcp-server/routes/canvasGenerate.d.ts +23 -1
  4. package/dist/mcp-server/routes/canvasGenerate.d.ts.map +1 -1
  5. package/dist/mcp-server/routes/canvasGenerate.js +179 -33
  6. package/dist/mcp-server/routes/canvasSave.d.ts +7 -0
  7. package/dist/mcp-server/routes/canvasSave.d.ts.map +1 -1
  8. package/dist/mcp-server/routes/canvasSave.js +21 -7
  9. package/dist/story-generator/llm-providers/claude-provider.d.ts.map +1 -1
  10. package/dist/story-generator/llm-providers/claude-provider.js +0 -39
  11. package/dist/story-generator/llm-providers/gemini-provider.d.ts.map +1 -1
  12. package/dist/story-generator/llm-providers/gemini-provider.js +12 -36
  13. package/dist/story-generator/llm-providers/index.js +3 -3
  14. package/dist/story-generator/llm-providers/openai-provider.d.ts.map +1 -1
  15. package/dist/story-generator/llm-providers/openai-provider.js +10 -50
  16. package/dist/story-generator/llm-providers/settings-manager.d.ts.map +1 -1
  17. package/dist/story-generator/llm-providers/settings-manager.js +4 -7
  18. package/dist/templates/StoryUI/StoryUIPanel.css +30 -1
  19. package/dist/templates/StoryUI/voice/VoiceCanvas.d.ts +1 -1
  20. package/dist/templates/StoryUI/voice/VoiceCanvas.d.ts.map +1 -1
  21. package/dist/templates/StoryUI/voice/VoiceCanvas.js +58 -16
  22. package/dist/templates/StoryUI/voice/VoiceCanvas.tsx +102 -71
  23. package/dist/templates/StoryUI/voice/types.d.ts +1 -1
  24. package/dist/templates/StoryUI/voice/types.d.ts.map +1 -1
  25. package/dist/templates/StoryUI/voice/types.ts +2 -1
  26. package/dist/templates/StoryUI/voice/voiceCommands.d.ts +4 -1
  27. package/dist/templates/StoryUI/voice/voiceCommands.d.ts.map +1 -1
  28. package/dist/templates/StoryUI/voice/voiceCommands.js +40 -7
  29. package/dist/templates/StoryUI/voice/voiceCommands.ts +42 -6
  30. package/package.json +5 -3
  31. package/templates/StoryUI/StoryUIPanel.css +30 -1
  32. package/templates/StoryUI/voice/VoiceCanvas.tsx +102 -71
  33. package/templates/StoryUI/voice/types.ts +2 -1
  34. package/templates/StoryUI/voice/voiceCommands.ts +42 -6
  35. package/dist/mcp-server/routes/canvasIntent.d.ts +0 -15
  36. package/dist/mcp-server/routes/canvasIntent.d.ts.map +0 -1
  37. package/dist/mcp-server/routes/canvasIntent.js +0 -553
  38. package/dist/mcp-server/routes/canvasPreview.d.ts +0 -14
  39. package/dist/mcp-server/routes/canvasPreview.d.ts.map +0 -1
  40. package/dist/mcp-server/routes/canvasPreview.js +0 -25
  41. package/dist/templates/StoryUI/voice/canvas/ComponentRenderer.d.ts +0 -14
  42. package/dist/templates/StoryUI/voice/canvas/ComponentRenderer.d.ts.map +0 -1
  43. package/dist/templates/StoryUI/voice/canvas/ComponentRenderer.js +0 -90
  44. package/dist/templates/StoryUI/voice/canvas/ComponentRenderer.tsx +0 -168
  45. package/dist/templates/StoryUI/voice/canvas/componentRegistry.d.ts +0 -21
  46. package/dist/templates/StoryUI/voice/canvas/componentRegistry.d.ts.map +0 -1
  47. package/dist/templates/StoryUI/voice/canvas/componentRegistry.js +0 -24
  48. package/dist/templates/StoryUI/voice/canvas/componentRegistry.ts +0 -30
  49. package/dist/templates/StoryUI/voice/canvas/operations.d.ts +0 -8
  50. package/dist/templates/StoryUI/voice/canvas/operations.d.ts.map +0 -1
  51. package/dist/templates/StoryUI/voice/canvas/operations.js +0 -189
  52. package/dist/templates/StoryUI/voice/canvas/operations.ts +0 -233
  53. package/dist/templates/StoryUI/voice/canvas/types.d.ts +0 -89
  54. package/dist/templates/StoryUI/voice/canvas/types.d.ts.map +0 -1
  55. package/dist/templates/StoryUI/voice/canvas/types.js +0 -2
  56. package/dist/templates/StoryUI/voice/canvas/types.ts +0 -106
  57. package/templates/StoryUI/voice/canvas/ComponentRenderer.tsx +0 -168
  58. package/templates/StoryUI/voice/canvas/componentRegistry.ts +0 -30
  59. package/templates/StoryUI/voice/canvas/operations.ts +0 -233
  60. package/templates/StoryUI/voice/canvas/types.ts +0 -106
package/dist/cli/setup.js CHANGED
@@ -206,21 +206,21 @@ const LLM_PROVIDERS = {
206
206
  claude: {
207
207
  name: 'Claude (Anthropic)',
208
208
  envKey: 'ANTHROPIC_API_KEY',
209
- models: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-4-20250514', 'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001'],
209
+ models: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'],
210
210
  docsUrl: 'https://console.anthropic.com/',
211
211
  description: 'Recommended - Best for complex reasoning and code quality'
212
212
  },
213
213
  openai: {
214
214
  name: 'OpenAI (GPT)',
215
215
  envKey: 'OPENAI_API_KEY',
216
- models: ['gpt-4.1', 'gpt-4.1-mini', 'o3', 'o4-mini', 'gpt-4o', 'gpt-4o-mini'],
216
+ models: ['gpt-5.4', 'gpt-5.4-mini', 'o4-mini'],
217
217
  docsUrl: 'https://platform.openai.com/api-keys',
218
218
  description: 'Versatile and fast'
219
219
  },
220
220
  gemini: {
221
221
  name: 'Google Gemini',
222
222
  envKey: 'GEMINI_API_KEY',
223
- models: ['gemini-3.1-pro-preview', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', 'gemini-3-flash-preview'],
223
+ models: ['gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-flash'],
224
224
  docsUrl: 'https://aistudio.google.com/app/apikey',
225
225
  description: 'Cost-effective with good performance'
226
226
  }
@@ -23,10 +23,8 @@ import { getProviders, getModels, configureProviderRoute, validateApiKey, setDef
23
23
  import { listFrameworks, detectCurrentFramework, getFrameworkDetails, validateStoryForFramework, postProcessStoryForFramework, } from './routes/frameworks.js';
24
24
  import mcpRemoteRouter from './routes/mcpRemote.js';
25
25
  // Voice Canvas endpoints
26
- import { canvasIntentHandler, warmCanvasComponentCache } from './routes/canvasIntent.js';
27
26
  import { canvasSaveHandler } from './routes/canvasSave.js';
28
27
  import { canvasGenerateHandler, ensureVoiceCanvasStory } from './routes/canvasGenerate.js';
29
- import { canvasPreviewHandler } from './routes/canvasPreview.js';
30
28
  import { getAdapterRegistry } from '../story-generator/framework-adapters/index.js';
31
29
  // Manifest — story ↔ chat source of truth
32
30
  import { manifestGetHandler, manifestPatchHandler, manifestDeleteHandler, manifestReconcileHandler, manifestPollHandler, } from './routes/manifest.js';
@@ -110,9 +108,7 @@ app.post('/mcp/generate-story', generateStoryFromPrompt);
110
108
  app.post('/mcp/generate-story-stream', generateStoryFromPromptStream);
111
109
  // Voice Canvas endpoints
112
110
  app.post('/mcp/canvas-generate', canvasGenerateHandler); // generate + write voice-canvas.stories.tsx
113
- app.post('/mcp/canvas-preview', canvasPreviewHandler); // undo/redo: rewrite voice-canvas.stories.tsx
114
111
  app.post('/mcp/canvas-save', canvasSaveHandler); // save canvas to named .stories.tsx
115
- app.post('/mcp/canvas-intent', canvasIntentHandler); // legacy (kept for compatibility)
116
112
  // Manifest — story ↔ chat source of truth
117
113
  // NOTE: /reconcile must be registered BEFORE /:fileName to avoid route conflict
118
114
  app.get('/story-ui/manifest/poll', manifestPollHandler);
@@ -883,8 +879,6 @@ if (storybookProxyEnabled) {
883
879
  app.listen(PORT, () => {
884
880
  console.error(`MCP server running on port ${PORT}`);
885
881
  console.error(`Stories will be generated to: ${config.generatedStoriesPath}`);
886
- // Pre-warm canvas component cache in background so first voice request is fast
887
- warmCanvasComponentCache().catch(() => { });
888
882
  // Ensure voice-canvas scratchpad story file exists before client polling starts.
889
883
  // If it's missing, the first canvas generate creates it, triggering a false-positive
890
884
  // "externally generated story" detection which reloads the page and kills Voice Canvas.
@@ -15,11 +15,33 @@
15
15
  */
16
16
  import { Request, Response } from 'express';
17
17
  export declare const VOICE_CANVAS_STORY_ID = "generated-voice-canvas--default";
18
- export declare function ensureReactLive(): void;
18
+ export declare function ensureReactLive(): Promise<void>;
19
19
  /**
20
20
  * Write the static voice-canvas story template if it doesn't exist yet.
21
21
  * Subsequent calls are no-ops — the file never changes after initial creation.
22
22
  */
23
23
  export declare function ensureVoiceCanvasStory(storiesDir: string): void;
24
+ /**
25
+ * If the LLM forgot to add a render() call (required by react-live noInline mode),
26
+ * detect the last defined PascalCase component and append render(<ComponentName />).
27
+ * This prevents the "No-Inline evaluations must call render" error when voice input
28
+ * is ambiguous or short and the LLM skips the final line.
29
+ */
30
+ export declare function ensureRenderCall(code: string): string;
31
+ /**
32
+ * Extract the canvas component code from the LLM response.
33
+ * Handles markdown code fences and stray text.
34
+ */
35
+ export declare function extractCanvasCode(response: string): string;
36
+ /**
37
+ * Scan LLM-generated canvas code for dangerous patterns and neutralize them.
38
+ *
39
+ * Instead of rejecting the entire response, each dangerous token is replaced
40
+ * with a safe alternative (typically a comment + `undefined` or `void(`) so
41
+ * the rest of the generated JSX remains functional.
42
+ *
43
+ * Returns the sanitized code string. Logs a warning for every pattern found.
44
+ */
45
+ export declare function sanitizeCanvasCode(code: string): string;
24
46
  export declare function canvasGenerateHandler(req: Request, res: Response): Promise<Response<any, Record<string, any>>>;
25
47
  //# sourceMappingURL=canvasGenerate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"canvasGenerate.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasGenerate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAW5C,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAmIvE,wBAAgB,eAAe,IAAI,IAAI,CAuBtC;AAID;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAU/D;AAuBD,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CAwEtE"}
1
+ {"version":3,"file":"canvasGenerate.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasGenerate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAkB5C,eAAO,MAAM,qBAAqB,oCAAoC,CAAC;AAuIvE,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAmCrD;AAID;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAU/D;AAID;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOrD;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAc1D;AAgED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAsBvD;AAID,wBAAsB,qBAAqB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CA0GtE"}
@@ -15,12 +15,17 @@
15
15
  */
16
16
  import fs from 'fs';
17
17
  import path from 'path';
18
- import { execSync } from 'child_process';
18
+ import { exec } from 'child_process';
19
+ import { promisify } from 'util';
20
+ const execAsync = promisify(exec);
19
21
  import { loadUserConfig } from '../../story-generator/configLoader.js';
20
22
  import { EnhancedComponentDiscovery } from '../../story-generator/enhancedComponentDiscovery.js';
21
23
  import { buildClaudePrompt } from '../../story-generator/promptGenerator.js';
22
24
  import { chatCompletion } from '../../story-generator/llm-providers/story-llm-service.js';
23
25
  import { logger } from '../../story-generator/logger.js';
26
+ // ── Component discovery cache ─────────────────────────────────
27
+ let _componentCache = null;
28
+ const COMPONENT_CACHE_TTL = 300000; // 5 minutes
24
29
  // ── Constants ─────────────────────────────────────────────────
25
30
  export const VOICE_CANVAS_STORY_ID = 'generated-voice-canvas--default';
26
31
  const VOICE_CANVAS_STORY_FILE = 'voice-canvas.stories.tsx';
@@ -122,6 +127,8 @@ export const Default: StoryObj = {
122
127
 
123
128
  useEffect(() => {
124
129
  const handler = (e: MessageEvent) => {
130
+ // Only accept messages from same origin to prevent cross-origin code injection
131
+ if (e.origin !== window.location.origin) return;
125
132
  if (e.data?.type === 'VOICE_CANVAS_UPDATE' && typeof e.data.code === 'string') {
126
133
  setCode(e.data.code);
127
134
  try { localStorage.setItem('${LS_KEY}', e.data.code); } catch {}
@@ -149,31 +156,43 @@ export const Default: StoryObj = {
149
156
  * Detects pnpm / yarn / npm automatically.
150
157
  */
151
158
  let reactLiveChecked = false;
152
- export function ensureReactLive() {
159
+ let reactLiveInstalling = null;
160
+ export async function ensureReactLive() {
153
161
  if (reactLiveChecked)
154
162
  return;
155
- reactLiveChecked = true;
163
+ // If another request is already installing, wait for it
164
+ if (reactLiveInstalling)
165
+ return reactLiveInstalling;
156
166
  const cwd = process.cwd();
157
167
  const reactLiveDir = path.join(cwd, 'node_modules', 'react-live');
158
- if (fs.existsSync(reactLiveDir))
168
+ if (fs.existsSync(reactLiveDir)) {
169
+ reactLiveChecked = true;
159
170
  return;
171
+ }
160
172
  logger.log('[canvas-generate] react-live not found — installing...');
161
- try {
162
- let cmd = 'npm install react-live --save';
163
- if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) {
164
- cmd = 'pnpm add react-live';
173
+ reactLiveInstalling = (async () => {
174
+ try {
175
+ let cmd = 'npm install react-live --save';
176
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'))) {
177
+ cmd = 'pnpm add react-live';
178
+ }
179
+ else if (fs.existsSync(path.join(cwd, 'yarn.lock'))) {
180
+ cmd = 'yarn add react-live';
181
+ }
182
+ await execAsync(cmd, { cwd });
183
+ reactLiveChecked = true;
184
+ logger.log('[canvas-generate] react-live installed successfully');
165
185
  }
166
- else if (fs.existsSync(path.join(cwd, 'yarn.lock'))) {
167
- cmd = 'yarn add react-live';
186
+ catch (err) {
187
+ const msg = err instanceof Error ? err.message : String(err);
188
+ logger.error('[canvas-generate] Could not auto-install react-live', { error: msg });
189
+ logger.log('[canvas-generate] Run manually: npm install react-live');
168
190
  }
169
- execSync(cmd, { cwd, stdio: 'pipe' });
170
- logger.log('[canvas-generate] react-live installed successfully');
171
- }
172
- catch (err) {
173
- const msg = err instanceof Error ? err.message : String(err);
174
- logger.error('[canvas-generate] Could not auto-install react-live', { error: msg });
175
- logger.log('[canvas-generate] Run manually: npm install react-live');
176
- }
191
+ finally {
192
+ reactLiveInstalling = null;
193
+ }
194
+ })();
195
+ return reactLiveInstalling;
177
196
  }
178
197
  // ── Write story to disk (once) ────────────────────────────────
179
198
  /**
@@ -192,29 +211,144 @@ export function ensureVoiceCanvasStory(storiesDir) {
192
211
  }
193
212
  }
194
213
  // ── Code extraction ───────────────────────────────────────────
214
+ /**
215
+ * If the LLM forgot to add a render() call (required by react-live noInline mode),
216
+ * detect the last defined PascalCase component and append render(<ComponentName />).
217
+ * This prevents the "No-Inline evaluations must call render" error when voice input
218
+ * is ambiguous or short and the LLM skips the final line.
219
+ */
220
+ export function ensureRenderCall(code) {
221
+ if (/\brender\s*\(/.test(code))
222
+ return code;
223
+ // Find the last PascalCase component/const defined in the code
224
+ const matches = [...code.matchAll(/(?:const|function)\s+([A-Z][A-Za-z0-9]*)/g)];
225
+ const componentName = matches.at(-1)?.[1] ?? 'Canvas';
226
+ return `${code}\nrender(<${componentName} />);`;
227
+ }
195
228
  /**
196
229
  * Extract the canvas component code from the LLM response.
197
230
  * Handles markdown code fences and stray text.
198
231
  */
199
- function extractCanvasCode(response) {
232
+ export function extractCanvasCode(response) {
233
+ let code;
200
234
  // Prefer explicit code fence
201
235
  const fenceMatch = response.match(/```(?:jsx|tsx|js|ts)?\n([\s\S]+?)\n```/);
202
- if (fenceMatch)
203
- return fenceMatch[1].trim();
204
- // Fall back: find the Canvas component block
205
- const canvasMatch = response.match(/(const Canvas\s*=[\s\S]+?render\s*\(<Canvas\s*\/>?\);?\s*$)/m);
206
- if (canvasMatch)
207
- return canvasMatch[1].trim();
208
- // Last resort: return the whole response trimmed
209
- return response.trim();
236
+ if (fenceMatch) {
237
+ code = fenceMatch[1].trim();
238
+ }
239
+ else {
240
+ // Fall back: find the Canvas component block
241
+ const canvasMatch = response.match(/(const Canvas\s*=[\s\S]+?render\s*\(<Canvas\s*\/>?\);?\s*$)/m);
242
+ code = canvasMatch ? canvasMatch[1].trim() : response.trim();
243
+ }
244
+ return ensureRenderCall(code);
245
+ }
246
+ // ── Security sanitization ─────────────────────────────────────
247
+ /**
248
+ * Dangerous patterns that must be neutralized in LLM-generated canvas code.
249
+ *
250
+ * Each entry defines a regex (applied with the global flag) and a replacement
251
+ * string. The replacement comments out the dangerous call so the surrounding
252
+ * code still parses — this avoids rejecting an entire response because the LLM
253
+ * happened to mention one of these tokens inside a string literal or comment.
254
+ *
255
+ * Categories covered:
256
+ * - Arbitrary code execution (eval, Function constructor)
257
+ * - Cookie / domain access
258
+ * - Storage APIs (localStorage, sessionStorage)
259
+ * - Network requests (fetch, XMLHttpRequest, WebSocket)
260
+ * - Location manipulation
261
+ * - Script injection
262
+ * - Unsafe React patterns (dangerouslySetInnerHTML)
263
+ * - Prototype pollution (__proto__, constructor.prototype)
264
+ * - Dynamic / CommonJS imports
265
+ */
266
+ const DANGEROUS_PATTERNS = [
267
+ // Arbitrary code execution
268
+ { pattern: /\beval\s*\(/g, label: 'eval()', replacement: '/* [sanitized: eval] */void(' },
269
+ { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()', replacement: '/* [sanitized: new Function] */void(' },
270
+ { pattern: /\bFunction\s*\(/g, label: 'Function()', replacement: '/* [sanitized: Function] */void(' },
271
+ // Cookie / domain access
272
+ { pattern: /\bdocument\.cookie\b/g, label: 'document.cookie', replacement: '/* [sanitized: document.cookie] */undefined' },
273
+ { pattern: /\bdocument\.domain\b/g, label: 'document.domain', replacement: '/* [sanitized: document.domain] */undefined' },
274
+ // Storage APIs
275
+ { pattern: /\blocalStorage\b/g, label: 'localStorage', replacement: '/* [sanitized: localStorage] */undefined' },
276
+ { pattern: /\bsessionStorage\b/g, label: 'sessionStorage', replacement: '/* [sanitized: sessionStorage] */undefined' },
277
+ // Network requests
278
+ { pattern: /\bfetch\s*\(/g, label: 'fetch()', replacement: '/* [sanitized: fetch] */void(' },
279
+ { pattern: /\bnew\s+XMLHttpRequest\b/g, label: 'XMLHttpRequest', replacement: '/* [sanitized: XMLHttpRequest] */undefined' },
280
+ { pattern: /\bXMLHttpRequest\b/g, label: 'XMLHttpRequest', replacement: '/* [sanitized: XMLHttpRequest] */undefined' },
281
+ { pattern: /\bnew\s+WebSocket\s*\(/g, label: 'WebSocket', replacement: '/* [sanitized: WebSocket] */void(' },
282
+ { pattern: /\bWebSocket\s*\(/g, label: 'WebSocket', replacement: '/* [sanitized: WebSocket] */void(' },
283
+ // Location manipulation
284
+ { pattern: /\bwindow\.location\b/g, label: 'window.location', replacement: '/* [sanitized: window.location] */undefined' },
285
+ // Script injection
286
+ { pattern: /<script\b/gi, label: '<script>', replacement: '/* [sanitized: script tag] */undefined' },
287
+ // Unsafe React patterns
288
+ { pattern: /\bdangerouslySetInnerHTML\b/g, label: 'dangerouslySetInnerHTML', replacement: '/* [sanitized: dangerouslySetInnerHTML] */undefined' },
289
+ // Prototype pollution
290
+ { pattern: /__proto__/g, label: '__proto__', replacement: '/* [sanitized: __proto__] */undefined' },
291
+ { pattern: /\bconstructor\.prototype\b/g, label: 'constructor.prototype', replacement: '/* [sanitized: constructor.prototype] */undefined' },
292
+ // Dynamic imports
293
+ { pattern: /\bimport\s*\(/g, label: 'dynamic import()', replacement: '/* [sanitized: dynamic import] */void(' },
294
+ // CommonJS require
295
+ { pattern: /\brequire\s*\(/g, label: 'require()', replacement: '/* [sanitized: require] */void(' },
296
+ ];
297
+ /**
298
+ * Scan LLM-generated canvas code for dangerous patterns and neutralize them.
299
+ *
300
+ * Instead of rejecting the entire response, each dangerous token is replaced
301
+ * with a safe alternative (typically a comment + `undefined` or `void(`) so
302
+ * the rest of the generated JSX remains functional.
303
+ *
304
+ * Returns the sanitized code string. Logs a warning for every pattern found.
305
+ */
306
+ export function sanitizeCanvasCode(code) {
307
+ let sanitized = code;
308
+ const found = [];
309
+ for (const { pattern, label, replacement } of DANGEROUS_PATTERNS) {
310
+ // Reset lastIndex in case the regex was used before (global flag)
311
+ pattern.lastIndex = 0;
312
+ if (pattern.test(sanitized)) {
313
+ found.push(label);
314
+ // Reset again before replace — .test() advances lastIndex
315
+ pattern.lastIndex = 0;
316
+ sanitized = sanitized.replace(pattern, replacement);
317
+ }
318
+ }
319
+ if (found.length > 0) {
320
+ logger.warn(`[canvas-generate] Sanitized ${found.length} dangerous pattern(s) from LLM output: ${found.join(', ')}`);
321
+ }
322
+ return sanitized;
210
323
  }
211
324
  // ── Handler ───────────────────────────────────────────────────
212
325
  export async function canvasGenerateHandler(req, res) {
213
326
  try {
214
- const { prompt, canvasCode, provider, model, conversationHistory = [], } = req.body;
327
+ let { prompt, canvasCode, provider, model, conversationHistory = [], } = req.body;
215
328
  if (!prompt || typeof prompt !== 'string') {
216
329
  return res.status(400).json({ error: 'prompt is required' });
217
330
  }
331
+ // ── Request body size limits (truncate, don't reject) ──────
332
+ const MAX_PROMPT = 5000;
333
+ const MAX_CANVAS_CODE = 50000;
334
+ const MAX_HISTORY_ENTRIES = 50;
335
+ const MAX_HISTORY_CONTENT = 10000;
336
+ if (prompt.length > MAX_PROMPT) {
337
+ prompt = prompt.slice(0, MAX_PROMPT);
338
+ }
339
+ if (canvasCode && typeof canvasCode === 'string' && canvasCode.length > MAX_CANVAS_CODE) {
340
+ canvasCode = canvasCode.slice(0, MAX_CANVAS_CODE);
341
+ }
342
+ if (Array.isArray(conversationHistory)) {
343
+ if (conversationHistory.length > MAX_HISTORY_ENTRIES) {
344
+ conversationHistory = conversationHistory.slice(-MAX_HISTORY_ENTRIES);
345
+ }
346
+ for (const entry of conversationHistory) {
347
+ if (entry && typeof entry.content === 'string' && entry.content.length > MAX_HISTORY_CONTENT) {
348
+ entry.content = entry.content.slice(0, MAX_HISTORY_CONTENT);
349
+ }
350
+ }
351
+ }
218
352
  // Load config + discover components — same quality context as standard generation
219
353
  const config = loadUserConfig();
220
354
  // Voice Canvas requires React — it uses react-live to render JSX in the browser.
@@ -223,8 +357,16 @@ export async function canvasGenerateHandler(req, res) {
223
357
  error: `Voice Canvas is only available for React-based Storybook projects. Current framework: ${config.componentFramework}`,
224
358
  });
225
359
  }
226
- const discovery = new EnhancedComponentDiscovery(config);
227
- const components = await discovery.discoverAll();
360
+ let components;
361
+ const now = Date.now();
362
+ if (_componentCache && now - _componentCache.timestamp < COMPONENT_CACHE_TTL) {
363
+ components = _componentCache.components;
364
+ }
365
+ else {
366
+ const discovery = new EnhancedComponentDiscovery(config);
367
+ components = await discovery.discoverAll();
368
+ _componentCache = { components, timestamp: now };
369
+ }
228
370
  // Build the system prompt using the standard prompt pipeline
229
371
  const baseSystemPrompt = await buildClaudePrompt(prompt, config, components);
230
372
  const systemPrompt = baseSystemPrompt + '\n' + CANVAS_MODE_SUFFIX;
@@ -248,10 +390,14 @@ export async function canvasGenerateHandler(req, res) {
248
390
  maxTokens: 4096,
249
391
  temperature: 0.3,
250
392
  });
251
- // Extract the canvas code from the LLM response
252
- const result = extractCanvasCode(response);
393
+ // Extract the canvas code from the LLM response and sanitize it.
394
+ // sanitizeCanvasCode neutralizes dangerous patterns (eval, fetch, script
395
+ // injection, prototype pollution, etc.) before the code reaches the
396
+ // client where react-live would execute it as arbitrary JS.
397
+ const rawCode = extractCanvasCode(response);
398
+ const result = sanitizeCanvasCode(rawCode);
253
399
  // Ensure react-live is installed and story template exists (no-ops after first run)
254
- ensureReactLive();
400
+ await ensureReactLive();
255
401
  const storiesDir = config.generatedStoriesPath || './src/stories/generated/';
256
402
  ensureVoiceCanvasStory(storiesDir);
257
403
  logger.log(`[canvas-generate] Generated ${result.split('\n').length} lines for: "${prompt.slice(0, 60)}"`);
@@ -9,5 +9,12 @@
9
9
  * Returns: { fileName, filePath, code }
10
10
  */
11
11
  import { Request, Response } from 'express';
12
+ /**
13
+ * Convert a react-live canvas component (JSX string) to a proper .stories.tsx file.
14
+ * The input is in the format: `const Canvas = () => { ... }; render(<Canvas />);`
15
+ */
16
+ export declare function jsxCodeToStory(jsxCode: string, title: string, importPath: string): string;
17
+ /** Derive a readable title from the last voice/text prompt. */
18
+ export declare function titleFromPrompt(prompt: string): string;
12
19
  export declare function canvasSaveHandler(req: Request, res: Response): Promise<Response<any, Record<string, any>>>;
13
20
  //# sourceMappingURL=canvasSave.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"canvasSave.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasSave.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AA0M5C,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CAgGlE"}
1
+ {"version":3,"file":"canvasSave.d.ts","sourceRoot":"","sources":["../../../mcp-server/routes/canvasSave.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AA0I5C;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAwCzF;AAID,+DAA+D;AAC/D,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAatD;AAED,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,QAAQ,+CAiHlE"}
@@ -125,7 +125,7 @@ ${jsx}
125
125
  * Convert a react-live canvas component (JSX string) to a proper .stories.tsx file.
126
126
  * The input is in the format: `const Canvas = () => { ... }; render(<Canvas />);`
127
127
  */
128
- function jsxCodeToStory(jsxCode, title, importPath) {
128
+ export function jsxCodeToStory(jsxCode, title, importPath) {
129
129
  // Remove the render(<Canvas />) call at the end
130
130
  const cleanCode = jsxCode.replace(/\nrender\s*\(<Canvas\s*\/>\);?\s*$/, '').trim();
131
131
  // Extract component names used in JSX (uppercase identifiers after '<')
@@ -166,7 +166,7 @@ function jsxCodeToStory(jsxCode, title, importPath) {
166
166
  }
167
167
  // ── Express handler ─────────────────────────────────────────
168
168
  /** Derive a readable title from the last voice/text prompt. */
169
- function titleFromPrompt(prompt) {
169
+ export function titleFromPrompt(prompt) {
170
170
  // Strip filler words, take first ~6 meaningful words, title-case
171
171
  const stop = new Set(['a', 'an', 'the', 'with', 'and', 'for', 'of', 'to', 'in', 'on', 'at', 'by']);
172
172
  const words = prompt
@@ -183,15 +183,29 @@ function titleFromPrompt(prompt) {
183
183
  }
184
184
  export async function canvasSaveHandler(req, res) {
185
185
  try {
186
- const { tree, jsxCode, title: rawTitle, lastPrompt } = req.body;
186
+ const { tree, jsxCode, title: rawTitle, lastPrompt: rawLastPrompt } = req.body;
187
+ // ── Request body size limits ───────────────────────────────
188
+ const MAX_JSX_CODE = 100000;
189
+ const MAX_TITLE = 200;
190
+ const MAX_LAST_PROMPT = 5000;
191
+ if (jsxCode && typeof jsxCode === 'string' && jsxCode.length > MAX_JSX_CODE) {
192
+ return res.status(400).json({ error: `jsxCode exceeds maximum length of ${MAX_JSX_CODE} characters` });
193
+ }
194
+ // Truncate title and lastPrompt if needed (safe to trim these)
195
+ const safeTitle = (rawTitle && typeof rawTitle === 'string')
196
+ ? rawTitle.slice(0, MAX_TITLE)
197
+ : rawTitle;
198
+ const lastPrompt = (rawLastPrompt && typeof rawLastPrompt === 'string' && rawLastPrompt.length > MAX_LAST_PROMPT)
199
+ ? rawLastPrompt.slice(0, MAX_LAST_PROMPT)
200
+ : rawLastPrompt;
187
201
  // Auto-generate title from last prompt if not provided
188
- const title = (rawTitle && typeof rawTitle === 'string' && rawTitle.trim())
189
- ? rawTitle.trim()
202
+ const title = (safeTitle && typeof safeTitle === 'string' && safeTitle.trim())
203
+ ? safeTitle.trim()
190
204
  : (lastPrompt && typeof lastPrompt === 'string' && lastPrompt.trim())
191
205
  ? titleFromPrompt(lastPrompt.trim())
192
- : 'Voice Canvas';
206
+ : `Canvas ${new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`;
193
207
  const config = loadUserConfig();
194
- const importPath = config.importPath || '@mantine/core';
208
+ const importPath = config.importPath || '';
195
209
  const storiesDir = config.generatedStoriesPath || './src/stories/generated/';
196
210
  let code;
197
211
  if (jsxCode && typeof jsxCode === 'string' && jsxCode.trim()) {
@@ -1 +1 @@
1
- {"version":3,"file":"claude-provider.d.ts","sourceRoot":"","sources":["../../../story-generator/llm-providers/claude-provider.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,YAAY,EACZ,cAAc,EACd,SAAS,EACT,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EACX,gBAAgB,EAGjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AA0HrD,qBAAa,cAAe,SAAQ,eAAe;IACjD,QAAQ,CAAC,IAAI,YAAY;IACzB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAY;IACvC,QAAQ,CAAC,eAAe,cAAiB;gBAE7B,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IAUtC,IAAI,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;IAkE1E,UAAU,CACf,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,CAAC,EAAE,WAAW,GACpB,aAAa,CAAC,WAAW,CAAC;IAuGvB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAgD/D,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,cAAc;IAgCtB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAiBrB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAMrC;AAGD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAErF"}
1
+ {"version":3,"file":"claude-provider.d.ts","sourceRoot":"","sources":["../../../story-generator/llm-providers/claude-provider.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,YAAY,EACZ,cAAc,EACd,SAAS,EACT,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EACX,gBAAgB,EAGjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAmFrD,qBAAa,cAAe,SAAQ,eAAe;IACjD,QAAQ,CAAC,IAAI,YAAY;IACzB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAY;IACvC,QAAQ,CAAC,eAAe,cAAiB;gBAE7B,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IAUtC,IAAI,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;IAkE1E,UAAU,CACf,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,CAAC,EAAE,WAAW,GACpB,aAAa,CAAC,WAAW,CAAC;IAuGvB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAgD/D,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,cAAc;IAgCtB,OAAO,CAAC,eAAe;IAoBvB,OAAO,CAAC,aAAa;IAiBrB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAMrC;AAGD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAErF"}
@@ -33,32 +33,6 @@ const CLAUDE_MODELS = [
33
33
  inputPricePer1kTokens: 0.003,
34
34
  outputPricePer1kTokens: 0.015,
35
35
  },
36
- {
37
- id: 'claude-opus-4-20250514',
38
- name: 'Claude Opus 4',
39
- provider: 'claude',
40
- contextWindow: 200000,
41
- maxOutputTokens: 32000,
42
- supportsVision: true,
43
- supportsDocuments: true,
44
- supportsFunctionCalling: true,
45
- supportsStreaming: true,
46
- inputPricePer1kTokens: 0.015,
47
- outputPricePer1kTokens: 0.075,
48
- },
49
- {
50
- id: 'claude-sonnet-4-5-20250929',
51
- name: 'Claude Sonnet 4.5',
52
- provider: 'claude',
53
- contextWindow: 200000,
54
- maxOutputTokens: 16000,
55
- supportsVision: true,
56
- supportsDocuments: true,
57
- supportsFunctionCalling: true,
58
- supportsStreaming: true,
59
- inputPricePer1kTokens: 0.003,
60
- outputPricePer1kTokens: 0.015,
61
- },
62
36
  {
63
37
  id: 'claude-haiku-4-5-20251001',
64
38
  name: 'Claude Haiku 4.5',
@@ -70,19 +44,6 @@ const CLAUDE_MODELS = [
70
44
  supportsFunctionCalling: true,
71
45
  supportsStreaming: true,
72
46
  inputPricePer1kTokens: 0.0008,
73
- outputPricePer1kTokens: 0.004,
74
- },
75
- {
76
- id: 'claude-sonnet-4-20250514',
77
- name: 'Claude Sonnet 4',
78
- provider: 'claude',
79
- contextWindow: 200000,
80
- maxOutputTokens: 16000,
81
- supportsVision: true,
82
- supportsDocuments: true,
83
- supportsFunctionCalling: true,
84
- supportsStreaming: true,
85
- inputPricePer1kTokens: 0.003,
86
47
  outputPricePer1kTokens: 0.015,
87
48
  },
88
49
  ];
@@ -1 +1 @@
1
- {"version":3,"file":"gemini-provider.d.ts","sourceRoot":"","sources":["../../../story-generator/llm-providers/gemini-provider.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,YAAY,EACZ,cAAc,EACd,SAAS,EACT,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EACX,gBAAgB,EAGjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAkHrD,qBAAa,cAAe,SAAQ,eAAe;IACjD,QAAQ,CAAC,IAAI,YAAY;IACzB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAY;IACvC,QAAQ,CAAC,eAAe,cAAiB;gBAE7B,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IAU5C,OAAO,CAAC,SAAS;IAKX,IAAI,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;IA6D1E,UAAU,CACf,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,CAAC,EAAE,WAAW,GACpB,aAAa,CAAC,WAAW,CAAC;IA+GvB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA8C/D,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,cAAc;IAqCtB,OAAO,CAAC,eAAe;IAmBvB,OAAO,CAAC,eAAe;IAiBvB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAKrC;AAGD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAErF"}
1
+ {"version":3,"file":"gemini-provider.d.ts","sourceRoot":"","sources":["../../../story-generator/llm-providers/gemini-provider.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,YAAY,EACZ,cAAc,EACd,SAAS,EACT,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EACX,gBAAgB,EAGjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAuFrD,qBAAa,cAAe,SAAQ,eAAe;IACjD,QAAQ,CAAC,IAAI,YAAY;IACzB,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAY;IACvC,QAAQ,CAAC,eAAe,cAAiB;gBAE7B,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC;IAU5C,OAAO,CAAC,SAAS;IAKX,IAAI,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,YAAY,CAAC;IA8D1E,UAAU,CACf,QAAQ,EAAE,WAAW,EAAE,EACvB,OAAO,CAAC,EAAE,WAAW,GACpB,aAAa,CAAC,WAAW,CAAC;IAgHvB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA+C/D,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,cAAc;IAqCtB,OAAO,CAAC,eAAe;IAmBvB,OAAO,CAAC,eAAe;IAiBvB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAKrC;AAGD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,cAAc,CAErF"}
@@ -23,8 +23,8 @@ const GEMINI_MODELS = [
23
23
  outputPricePer1kTokens: 0.012,
24
24
  },
25
25
  {
26
- id: 'gemini-2.5-pro',
27
- name: 'Gemini 2.5 Pro',
26
+ id: 'gemini-3-flash-preview',
27
+ name: 'Gemini 3 Flash Preview',
28
28
  provider: 'gemini',
29
29
  contextWindow: 1048576,
30
30
  maxOutputTokens: 65536,
@@ -32,9 +32,8 @@ const GEMINI_MODELS = [
32
32
  supportsDocuments: true,
33
33
  supportsFunctionCalling: true,
34
34
  supportsStreaming: true,
35
- supportsReasoning: true,
36
- inputPricePer1kTokens: 0.00125,
37
- outputPricePer1kTokens: 0.01,
35
+ inputPricePer1kTokens: 0.00015,
36
+ outputPricePer1kTokens: 0.0006,
38
37
  },
39
38
  {
40
39
  id: 'gemini-2.5-flash',
@@ -50,35 +49,9 @@ const GEMINI_MODELS = [
50
49
  inputPricePer1kTokens: 0.00015,
51
50
  outputPricePer1kTokens: 0.0006,
52
51
  },
53
- {
54
- id: 'gemini-2.5-flash-lite',
55
- name: 'Gemini 2.5 Flash Lite',
56
- provider: 'gemini',
57
- contextWindow: 1048576,
58
- maxOutputTokens: 65536,
59
- supportsVision: true,
60
- supportsDocuments: true,
61
- supportsFunctionCalling: true,
62
- supportsStreaming: true,
63
- inputPricePer1kTokens: 0.00008,
64
- outputPricePer1kTokens: 0.0003,
65
- },
66
- {
67
- id: 'gemini-3-flash-preview',
68
- name: 'Gemini 3 Flash Preview',
69
- provider: 'gemini',
70
- contextWindow: 1048576,
71
- maxOutputTokens: 65536,
72
- supportsVision: true,
73
- supportsDocuments: true,
74
- supportsFunctionCalling: true,
75
- supportsStreaming: true,
76
- inputPricePer1kTokens: 0.00015,
77
- outputPricePer1kTokens: 0.0006,
78
- },
79
52
  ];
80
- // Default model - Gemini 2.5 Pro (stable flagship, March 2026)
81
- const DEFAULT_MODEL = 'gemini-2.5-pro';
53
+ // Default model - Gemini 3.1 Pro Preview (flagship, March 2026)
54
+ const DEFAULT_MODEL = 'gemini-3.1-pro-preview';
82
55
  // API configuration
83
56
  const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
84
57
  export class GeminiProvider extends BaseLLMProvider {
@@ -124,12 +97,13 @@ export class GeminiProvider extends BaseLLMProvider {
124
97
  parts: [{ text: systemPrompt }],
125
98
  };
126
99
  }
127
- const url = `${this.getApiUrl(model)}?key=${apiKey}`;
100
+ const url = this.getApiUrl(model);
128
101
  try {
129
102
  const response = await fetch(url, {
130
103
  method: 'POST',
131
104
  headers: {
132
105
  'Content-Type': 'application/json',
106
+ 'x-goog-api-key': apiKey,
133
107
  },
134
108
  body: JSON.stringify(requestBody),
135
109
  signal: AbortSignal.timeout(this.config.timeout || 120000),
@@ -174,12 +148,13 @@ export class GeminiProvider extends BaseLLMProvider {
174
148
  parts: [{ text: systemPrompt }],
175
149
  };
176
150
  }
177
- const url = `${this.getApiUrl(model, true)}?key=${apiKey}&alt=sse`;
151
+ const url = `${this.getApiUrl(model, true)}?alt=sse`;
178
152
  try {
179
153
  const response = await fetch(url, {
180
154
  method: 'POST',
181
155
  headers: {
182
156
  'Content-Type': 'application/json',
157
+ 'x-goog-api-key': apiKey,
183
158
  },
184
159
  body: JSON.stringify(requestBody),
185
160
  signal: AbortSignal.timeout(this.config.timeout || 120000),
@@ -251,11 +226,12 @@ export class GeminiProvider extends BaseLLMProvider {
251
226
  async validateApiKey(apiKey) {
252
227
  try {
253
228
  // Make a minimal API call to validate the key
254
- const url = `${this.getApiUrl('gemini-2.0-flash')}?key=${apiKey}`;
229
+ const url = this.getApiUrl('gemini-2.5-flash');
255
230
  const response = await fetch(url, {
256
231
  method: 'POST',
257
232
  headers: {
258
233
  'Content-Type': 'application/json',
234
+ 'x-goog-api-key': apiKey,
259
235
  },
260
236
  body: JSON.stringify({
261
237
  contents: [{ parts: [{ text: 'Hi' }] }],