From 1be3c6b140adf9444dd79ae6b15ebff245a3ea87 Mon Sep 17 00:00:00 2001 From: Chair403 <98937891+Chair403@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:12:46 +0800 Subject: [PATCH 1/4] Fix user-facing AI generation errors Map provider failures to safe, actionable messages without exposing raw API responses. --- src/hooks/ai-generation/graphComposer.ts | 177 +++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/src/hooks/ai-generation/graphComposer.ts b/src/hooks/ai-generation/graphComposer.ts index c1afc5c5..fcdad4e4 100644 --- a/src/hooks/ai-generation/graphComposer.ts +++ b/src/hooks/ai-generation/graphComposer.ts @@ -119,6 +119,183 @@ export function toFinalEdges( .filter((edge): edge is FlowEdge => edge !== null); } +export function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + const message = error.message; + const normalizedMessage = message.toLowerCase(); + + if ( + normalizedMessage.includes('model') && + (normalizedMessage.includes('404') || + normalizedMessage.includes('not found') || + normalizedMessage.includes('unavailable') || + normalizedMessage.includes('not supported')) + ) { + return 'The selected AI model is unavailable or no longer supported. Please select another model and try again.'; + } + + if ( + normalizedMessage.includes('401') || + normalizedMessage.includes('invalid api key') || + normalizedMessage.includes('api key') + ) { + return 'Invalid or missing API key. Please check your AI settings.'; + } + if (normalizedMessage.includes('403') || normalizedMessage.includes('forbidden')) { + return 'Access forbidden. Please check your API key permissions.'; + } + if ( + normalizedMessage.includes('429') || + normalizedMessage.includes('rate limit') || + normalizedMessage.includes('quota') + ) { + return 'Rate limit exceeded. Please wait a moment and try again.'; + } + if ( + /\b5\d{2}\b/.test(normalizedMessage) || + normalizedMessage.includes('internal server error') + ) { + return 'AI provider server error. Please try again later.'; + } + if ( + normalizedMessage.includes('network') || + normalizedMessage.includes('fetch') + ) { + return 'Network error. Please check your internet connection.'; + } + if (normalizedMessage.includes('timeout') || normalizedMessage.includes('timed out')) { + return 'Request timed out. Please try again.'; + } + if (normalizedMessage.includes('cursor') || normalizedMessage.includes('parse')) { + return 'Failed to parse AI response. Please try a different prompt.'; + } + if (normalizedMessage.includes('context length') || normalizedMessage.includes('token')) { + return 'Prompt too long. Try simplifying your request.'; + } + } + return 'The AI request failed. Please try again or choose a different model.'; +} +import { createDefaultEdge } from '@/constants'; +import { createId } from '@/lib/id'; +import { APP_DSL_CODE_FENCE_ALIASES } from '@/lib/legacyBranding'; +import { createLogger } from '@/lib/logger'; +import { parseOpenFlowDSL } from '@/lib/openFlowDSLParser'; +import type { FlowEdge, FlowNode } from '@/lib/types'; + +const logger = createLogger({ scope: 'graphComposer' }); + +export interface ParsedFlowResult { + nodes: FlowNode[]; + edges: FlowEdge[]; +} + +export function parseDslOrThrow(dslText: string): ParsedFlowResult { + const codeFenceAliasPattern = ['yaml', 'openflow', ...APP_DSL_CODE_FENCE_ALIASES].join('|'); + const cleanDsl = dslText + .replace(new RegExp(`\`\`\`(${codeFenceAliasPattern}|)?`, 'g'), '') + .replace(/```/g, '') + .trim(); + const parseResult = parseOpenFlowDSL(cleanDsl); + if (parseResult.error) { + throw new Error(parseResult.error); + } + return { + nodes: parseResult.nodes as FlowNode[], + edges: parseResult.edges, + }; +} + +export function buildIdMap( + parsedNodes: FlowNode[], + existingNodes: FlowNode[] +): Map { + const idMap = new Map(); + const existingById = new Map(existingNodes.map((n) => [n.id, n])); + + parsedNodes.forEach((parsedNode) => { + // Prefer exact ID match — the AI preserved the existing node ID + if (existingById.has(parsedNode.id)) { + idMap.set(parsedNode.id, parsedNode.id); + return; + } + // Fall back to label match for AI responses that generated new IDs + const byLabel = existingNodes.find( + (n) => n.data.label?.toLowerCase() === parsedNode.data.label?.toLowerCase() + ); + idMap.set(parsedNode.id, byLabel ? byLabel.id : parsedNode.id); + }); + + return idMap; +} + +export function toFinalNodes(parsedNodes: FlowNode[], idMap: Map): FlowNode[] { + const seen = new Set(); + const result: FlowNode[] = []; + + for (const node of parsedNodes) { + const finalId = idMap.get(node.id) ?? node.id; + if (seen.has(finalId)) { + logger.warn('Duplicate node ID after ID mapping — skipping.', { finalId }); + continue; + } + seen.add(finalId); + result.push({ ...node, id: finalId, type: node.type || 'process' }); + } + + return result; +} + +export function toFinalEdges( + parsedEdges: FlowEdge[], + idMap: Map, + globalEdgeOptions: { + type: ReturnType['type']; + animated: boolean; + strokeWidth: number; + color?: string; + } +): FlowEdge[] { + return parsedEdges + .map((edge) => { + const sourceId = idMap.get(edge.source); + const targetId = idMap.get(edge.target); + + if (!sourceId || !targetId) { + logger.warn('Skipping edge with missing node.', { + sourceId: edge.source, + targetId: edge.target, + }); + return null; + } + + const defaultEdge = createDefaultEdge(sourceId, targetId); + let edgeType = edge.type; + if (edgeType === 'default' || !edgeType) { + edgeType = globalEdgeOptions.type === 'default' ? undefined : globalEdgeOptions.type; + } + if ((edge.data as { styleType?: string } | undefined)?.styleType === 'curved') { + edgeType = 'default'; + } + + return { + ...defaultEdge, + ...edge, + id: createId(`e-${sourceId}-${targetId}`), + source: sourceId, + target: targetId, + type: edgeType, + animated: edge.animated || globalEdgeOptions.animated, + style: { + ...defaultEdge.style, + ...edge.style, + strokeWidth: globalEdgeOptions.strokeWidth, + ...(globalEdgeOptions.color ? { stroke: globalEdgeOptions.color } : {}), + }, + } as FlowEdge; + }) + .filter((edge): edge is FlowEdge => edge !== null); +} + export function toErrorMessage(error: unknown): string { if (error instanceof Error) { const message = error.message; From deef9c0947dfe3f8fd985b54d066fcee7b7dc8e5 Mon Sep 17 00:00:00 2001 From: Chair403 <98937891+Chair403@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:13:32 +0800 Subject: [PATCH 2/4] Add tests for safe AI error messages Cover model availability, provider failures, and unknown-error redaction. --- src/hooks/ai-generation/graphComposer.test.ts | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/hooks/ai-generation/graphComposer.test.ts b/src/hooks/ai-generation/graphComposer.test.ts index bf98655a..dcd1a5fc 100644 --- a/src/hooks/ai-generation/graphComposer.test.ts +++ b/src/hooks/ai-generation/graphComposer.test.ts @@ -26,6 +26,121 @@ function createNode(id: string, label: string): FlowNode { }; } +describe('graphComposer', () => { + it('parses fenced DSL and throws parser errors', () => { + vi.mocked(parseOpenFlowDSL).mockReturnValueOnce({ + nodes: [createNode('n1', 'Node 1')], + edges: [], + }); + + expect(parseDslOrThrow('```flowmind\nflow: "Test"\n```')).toEqual({ + nodes: [createNode('n1', 'Node 1')], + edges: [], + }); + + vi.mocked(parseOpenFlowDSL).mockReturnValueOnce({ + nodes: [], + edges: [], + error: 'Line 1: Invalid DSL', + }); + + expect(() => parseDslOrThrow('broken')).toThrow('Line 1: Invalid DSL'); + }); + + it('reuses ids by matching labels and normalizes final nodes', () => { + const parsedNodes = [createNode('generated-1', 'Billing Service')]; + const existingNodes = [createNode('existing-1', 'Billing Service')]; + const idMap = buildIdMap(parsedNodes, existingNodes); + + expect(idMap.get('generated-1')).toBe('existing-1'); + expect(toFinalNodes(parsedNodes, idMap)[0].id).toBe('existing-1'); + }); + + it('normalizes final edges with global edge options and skips missing-node edges', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const finalEdges = toFinalEdges( + [ + { id: 'e1', source: 'a', target: 'b', type: 'default' } as FlowEdge, + { id: 'e2', source: 'missing', target: 'b' } as FlowEdge, + ], + new Map([ + ['a', 'node-a'], + ['b', 'node-b'], + ]), + { + type: 'smoothstep', + animated: true, + strokeWidth: 4, + color: '#123456', + } + ); + + expect(finalEdges).toHaveLength(1); + expect(finalEdges[0].source).toBe('node-a'); + expect(finalEdges[0].target).toBe('node-b'); + expect(finalEdges[0].animated).toBe(true); + expect(finalEdges[0].style?.strokeWidth).toBe(4); + expect(finalEdges[0].style?.stroke).toBe('#123456'); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('maps a raw model-not-found response to a user-friendly message', () => { + const rawError = + 'models/gemini-3-pro is not found for API version v1beta, or is not supported for generateContent. Service: generativelanguage.googleapis.com (404)'; + const message = toErrorMessage(new Error(rawError)); + + expect(message).toBe( + 'The selected AI model is unavailable or no longer supported. Please select another model and try again.' + ); + expect(message).not.toContain('generativelanguage.googleapis.com'); + }); + + it.each([ + ['INVALID API KEY', 'Invalid or missing API key. Please check your AI settings.'], + ['Request failed with status 403', 'Access forbidden. Please check your API key permissions.'], + ['Quota exceeded', 'Rate limit exceeded. Please wait a moment and try again.'], + ['Provider returned 503', 'AI provider server error. Please try again later.'], + ['Failed to fetch', 'Network error. Please check your internet connection.'], + ])('maps %s without exposing provider details', (rawError, expectedMessage) => { + expect(toErrorMessage(new Error(rawError))).toBe(expectedMessage); + }); + + it('does not expose unknown error details', () => { + const expectedMessage = 'The AI request failed. Please try again or choose a different model.'; + + expect(toErrorMessage(new Error('upstream service secret detail'))).toBe(expectedMessage); + expect(toErrorMessage('raw provider response')).toBe(expectedMessage); + expect(toErrorMessage('')).toBe(expectedMessage); + }); +}); +import { describe, expect, it, vi } from 'vitest'; +import type { FlowEdge, FlowNode } from '@/lib/types'; +import { parseOpenFlowDSL } from '@/lib/openFlowDSLParser'; +import { + buildIdMap, + parseDslOrThrow, + toErrorMessage, + toFinalEdges, + toFinalNodes, +} from './graphComposer'; + +vi.mock('@/lib/openFlowDSLParser', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + parseOpenFlowDSL: vi.fn(), + }; +}); + +function createNode(id: string, label: string): FlowNode { + return { + id, + type: 'process', + position: { x: 0, y: 0 }, + data: { label, color: 'slate', shape: 'rounded' }, + }; +} + describe('graphComposer', () => { it('parses fenced DSL and throws parser errors', () => { vi.mocked(parseOpenFlowDSL).mockReturnValueOnce({ From 9799f1294c726fda4716c5974ba1990a77658303 Mon Sep 17 00:00:00 2001 From: Chair403 <98937891+Chair403@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:19:32 +0800 Subject: [PATCH 3/4] Clean up graphComposer by removing unused code Removed unused imports and functions related to parsing and building flow nodes and edges. --- src/hooks/ai-generation/graphComposer.ts | 167 ----------------------- 1 file changed, 167 deletions(-) diff --git a/src/hooks/ai-generation/graphComposer.ts b/src/hooks/ai-generation/graphComposer.ts index fcdad4e4..05cc04ad 100644 --- a/src/hooks/ai-generation/graphComposer.ts +++ b/src/hooks/ai-generation/graphComposer.ts @@ -175,170 +175,3 @@ export function toErrorMessage(error: unknown): string { } return 'The AI request failed. Please try again or choose a different model.'; } -import { createDefaultEdge } from '@/constants'; -import { createId } from '@/lib/id'; -import { APP_DSL_CODE_FENCE_ALIASES } from '@/lib/legacyBranding'; -import { createLogger } from '@/lib/logger'; -import { parseOpenFlowDSL } from '@/lib/openFlowDSLParser'; -import type { FlowEdge, FlowNode } from '@/lib/types'; - -const logger = createLogger({ scope: 'graphComposer' }); - -export interface ParsedFlowResult { - nodes: FlowNode[]; - edges: FlowEdge[]; -} - -export function parseDslOrThrow(dslText: string): ParsedFlowResult { - const codeFenceAliasPattern = ['yaml', 'openflow', ...APP_DSL_CODE_FENCE_ALIASES].join('|'); - const cleanDsl = dslText - .replace(new RegExp(`\`\`\`(${codeFenceAliasPattern}|)?`, 'g'), '') - .replace(/```/g, '') - .trim(); - const parseResult = parseOpenFlowDSL(cleanDsl); - if (parseResult.error) { - throw new Error(parseResult.error); - } - return { - nodes: parseResult.nodes as FlowNode[], - edges: parseResult.edges, - }; -} - -export function buildIdMap( - parsedNodes: FlowNode[], - existingNodes: FlowNode[] -): Map { - const idMap = new Map(); - const existingById = new Map(existingNodes.map((n) => [n.id, n])); - - parsedNodes.forEach((parsedNode) => { - // Prefer exact ID match — the AI preserved the existing node ID - if (existingById.has(parsedNode.id)) { - idMap.set(parsedNode.id, parsedNode.id); - return; - } - // Fall back to label match for AI responses that generated new IDs - const byLabel = existingNodes.find( - (n) => n.data.label?.toLowerCase() === parsedNode.data.label?.toLowerCase() - ); - idMap.set(parsedNode.id, byLabel ? byLabel.id : parsedNode.id); - }); - - return idMap; -} - -export function toFinalNodes(parsedNodes: FlowNode[], idMap: Map): FlowNode[] { - const seen = new Set(); - const result: FlowNode[] = []; - - for (const node of parsedNodes) { - const finalId = idMap.get(node.id) ?? node.id; - if (seen.has(finalId)) { - logger.warn('Duplicate node ID after ID mapping — skipping.', { finalId }); - continue; - } - seen.add(finalId); - result.push({ ...node, id: finalId, type: node.type || 'process' }); - } - - return result; -} - -export function toFinalEdges( - parsedEdges: FlowEdge[], - idMap: Map, - globalEdgeOptions: { - type: ReturnType['type']; - animated: boolean; - strokeWidth: number; - color?: string; - } -): FlowEdge[] { - return parsedEdges - .map((edge) => { - const sourceId = idMap.get(edge.source); - const targetId = idMap.get(edge.target); - - if (!sourceId || !targetId) { - logger.warn('Skipping edge with missing node.', { - sourceId: edge.source, - targetId: edge.target, - }); - return null; - } - - const defaultEdge = createDefaultEdge(sourceId, targetId); - let edgeType = edge.type; - if (edgeType === 'default' || !edgeType) { - edgeType = globalEdgeOptions.type === 'default' ? undefined : globalEdgeOptions.type; - } - if ((edge.data as { styleType?: string } | undefined)?.styleType === 'curved') { - edgeType = 'default'; - } - - return { - ...defaultEdge, - ...edge, - id: createId(`e-${sourceId}-${targetId}`), - source: sourceId, - target: targetId, - type: edgeType, - animated: edge.animated || globalEdgeOptions.animated, - style: { - ...defaultEdge.style, - ...edge.style, - strokeWidth: globalEdgeOptions.strokeWidth, - ...(globalEdgeOptions.color ? { stroke: globalEdgeOptions.color } : {}), - }, - } as FlowEdge; - }) - .filter((edge): edge is FlowEdge => edge !== null); -} - -export function toErrorMessage(error: unknown): string { - if (error instanceof Error) { - const message = error.message; - - if ( - message.includes('401') || - message.includes('invalid api key') || - message.includes('API key') - ) { - return 'Invalid or missing API key. Please check your AI settings.'; - } - if (message.includes('403') || message.includes('Forbidden')) { - return 'Access forbidden. Please check your API key permissions.'; - } - if (message.includes('429') || message.includes('rate limit') || message.includes('quota')) { - return 'Rate limit exceeded. Please wait a moment and try again.'; - } - if (message.includes('500') || message.includes('internal server error')) { - return 'AI provider server error. Please try again later.'; - } - if ( - message.includes('network') || - message.includes('fetch') || - message.includes('Failed to fetch') - ) { - return 'Network error. Please check your internet connection.'; - } - if (message.includes('timeout') || message.includes('timed out')) { - return 'Request timed out. Please try again.'; - } - if (message.includes('cursor') || message.includes('parse')) { - return 'Failed to parse AI response. Please try a different prompt.'; - } - if (message.includes('context length') || message.includes('token')) { - return 'Prompt too long. Try simplifying your request.'; - } - - if (message) { - return message; - } - } - if (typeof error === 'string' && error) { - return error; - } - return 'An unexpected error occurred. Please try again.'; -} From 70cf4a9f156578f63422921baabf0f33195c7504 Mon Sep 17 00:00:00 2001 From: Chair403 <98937891+Chair403@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:20:01 +0800 Subject: [PATCH 4/4] Remove graphComposer tests and related mocks Removed tests for graphComposer functionality and mock implementations. --- src/hooks/ai-generation/graphComposer.test.ts | 92 ------------------- 1 file changed, 92 deletions(-) diff --git a/src/hooks/ai-generation/graphComposer.test.ts b/src/hooks/ai-generation/graphComposer.test.ts index dcd1a5fc..0de96702 100644 --- a/src/hooks/ai-generation/graphComposer.test.ts +++ b/src/hooks/ai-generation/graphComposer.test.ts @@ -113,95 +113,3 @@ describe('graphComposer', () => { expect(toErrorMessage('')).toBe(expectedMessage); }); }); -import { describe, expect, it, vi } from 'vitest'; -import type { FlowEdge, FlowNode } from '@/lib/types'; -import { parseOpenFlowDSL } from '@/lib/openFlowDSLParser'; -import { - buildIdMap, - parseDslOrThrow, - toErrorMessage, - toFinalEdges, - toFinalNodes, -} from './graphComposer'; - -vi.mock('@/lib/openFlowDSLParser', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - parseOpenFlowDSL: vi.fn(), - }; -}); - -function createNode(id: string, label: string): FlowNode { - return { - id, - type: 'process', - position: { x: 0, y: 0 }, - data: { label, color: 'slate', shape: 'rounded' }, - }; -} - -describe('graphComposer', () => { - it('parses fenced DSL and throws parser errors', () => { - vi.mocked(parseOpenFlowDSL).mockReturnValueOnce({ - nodes: [createNode('n1', 'Node 1')], - edges: [], - }); - - expect(parseDslOrThrow('```flowmind\nflow: "Test"\n```')).toEqual({ - nodes: [createNode('n1', 'Node 1')], - edges: [], - }); - - vi.mocked(parseOpenFlowDSL).mockReturnValueOnce({ - nodes: [], - edges: [], - error: 'Line 1: Invalid DSL', - }); - - expect(() => parseDslOrThrow('broken')).toThrow('Line 1: Invalid DSL'); - }); - - it('reuses ids by matching labels and normalizes final nodes', () => { - const parsedNodes = [createNode('generated-1', 'Billing Service')]; - const existingNodes = [createNode('existing-1', 'Billing Service')]; - const idMap = buildIdMap(parsedNodes, existingNodes); - - expect(idMap.get('generated-1')).toBe('existing-1'); - expect(toFinalNodes(parsedNodes, idMap)[0].id).toBe('existing-1'); - }); - - it('normalizes final edges with global edge options and skips missing-node edges', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - const finalEdges = toFinalEdges( - [ - { id: 'e1', source: 'a', target: 'b', type: 'default' } as FlowEdge, - { id: 'e2', source: 'missing', target: 'b' } as FlowEdge, - ], - new Map([ - ['a', 'node-a'], - ['b', 'node-b'], - ]), - { - type: 'smoothstep', - animated: true, - strokeWidth: 4, - color: '#123456', - } - ); - - expect(finalEdges).toHaveLength(1); - expect(finalEdges[0].source).toBe('node-a'); - expect(finalEdges[0].target).toBe('node-b'); - expect(finalEdges[0].animated).toBe(true); - expect(finalEdges[0].style?.strokeWidth).toBe(4); - expect(finalEdges[0].style?.stroke).toBe('#123456'); - expect(warnSpy).toHaveBeenCalled(); - }); - - it('maps unknown errors to a stable message', () => { - expect(toErrorMessage(new Error('boom'))).toBe('boom'); - expect(toErrorMessage('bad')).toBe('bad'); - expect(toErrorMessage('')).toBe('An unexpected error occurred. Please try again.'); - }); -});