diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 7916d36..57dd64e 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -154,9 +154,28 @@ ${lastMessageText} messages: allMessages, ...(providerOptions && { providerOptions }), ...(headers && { headers }), - onFinish: ({ usage, providerMetadata }) => { - console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2)); - console.log('[Cache] Usage:', JSON.stringify(usage, null, 2)); + onFinish: ({ usage, providerMetadata, finishReason, text, toolCalls }) => { + // Detect potential mid-stream failures (e.g., Bedrock 503 ServiceUnavailableException) + // When this happens, usage is empty and providerMetadata is undefined + const hasUsage = usage && Object.keys(usage).length > 0; + if (!hasUsage) { + console.error('[Stream Error] Empty usage detected - possible Bedrock 503 or mid-stream failure'); + console.error('[Stream Error] finishReason:', finishReason); + console.error('[Stream Error] text received:', text?.substring(0, 200) || '(none)'); + console.error('[Stream Error] toolCalls:', toolCalls?.length || 0); + // Log the user's last message for debugging + const lastUserMsg = enhancedMessages.filter(m => m.role === 'user').pop(); + if (lastUserMsg) { + const content = lastUserMsg.content; + const preview = Array.isArray(content) + ? (content.find((c) => c.type === 'text') as { type: 'text'; text: string } | undefined)?.text?.substring(0, 100) + : String(content).substring(0, 100); + console.error('[Stream Error] Last user message preview:', preview); + } + } else { + console.log('[Cache] Full providerMetadata:', JSON.stringify(providerMetadata, null, 2)); + console.log('[Cache] Usage:', JSON.stringify(usage, null, 2)); + } }, tools: { // Client-side tool that will be executed on the client @@ -232,6 +251,23 @@ IMPORTANT: Keep edits concise: ? error.message : JSON.stringify(error); + // Check for Bedrock service errors (503, throttling, etc.) + if (errorString.includes('ServiceUnavailable') || + errorString.includes('503') || + errorString.includes('temporarily unavailable')) { + console.error('[Bedrock Error] ServiceUnavailableException:', errorString); + return 'The AI service is temporarily unavailable. Please try again in a few seconds.'; + } + + // Check for throttling errors + if (errorString.includes('ThrottlingException') || + errorString.includes('rate limit') || + errorString.includes('too many requests') || + errorString.includes('429')) { + console.error('[Bedrock Error] ThrottlingException:', errorString); + return 'Too many requests. Please wait a moment and try again.'; + } + // Check for image not supported error (e.g., DeepSeek models) if (errorString.includes('image_url') || errorString.includes('unknown variant') || diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index c02d337..66c3b71 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -61,6 +61,7 @@ export default function ChatPanel({ const [files, setFiles] = useState([]); const [showHistory, setShowHistory] = useState(false); const [input, setInput] = useState(""); + const [streamingError, setStreamingError] = useState(null); // Store XML snapshots for each user message (keyed by message index) const xmlSnapshotsRef = useRef>(new Map()); @@ -71,7 +72,7 @@ export default function ChatPanel({ chartXMLRef.current = chartXML; }, [chartXML]); - const { messages, sendMessage, addToolOutput, status, error, setMessages } = + const { messages, sendMessage, addToolResult, status, error, setMessages, stop } = useChat({ transport: new DefaultChatTransport({ api: "/api/chat", @@ -83,13 +84,13 @@ export default function ChatPanel({ const validationError = validateMxCellStructure(xml); if (validationError) { - addToolOutput({ + addToolResult({ tool: "display_diagram", toolCallId: toolCall.toolCallId, output: validationError, }); } else { - addToolOutput({ + addToolResult({ tool: "display_diagram", toolCallId: toolCall.toolCallId, output: "Successfully displayed the diagram.", @@ -122,7 +123,7 @@ export default function ChatPanel({ onDisplayChart(editedXml); - addToolOutput({ + addToolResult({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, output: `Successfully applied ${edits.length} edit(s) to the diagram.`, @@ -136,7 +137,7 @@ export default function ChatPanel({ ? error.message : String(error); - addToolOutput({ + addToolResult({ tool: "edit_diagram", toolCallId: toolCall.toolCallId, output: `Edit failed: ${errorMessage} @@ -153,9 +154,64 @@ Please retry with an adjusted search pattern or use display_diagram if retries a }, onError: (error) => { console.error("Chat error:", error); + setStreamingError(error); }, }); + // Streaming timeout detection - detects when stream stalls mid-response (e.g., Bedrock 503) + // This catches cases where onError doesn't fire because headers were already sent + const lastMessageCountRef = useRef(0); + const lastMessagePartsRef = useRef(0); + + useEffect(() => { + // Clear streaming error and reset refs when status changes to ready + if (status === "ready") { + setStreamingError(null); + lastMessageCountRef.current = 0; + lastMessagePartsRef.current = 0; + return; + } + + if (status !== "streaming") return; + + const STALL_TIMEOUT_MS = 15000; // 15 seconds without any update + + // Capture current state BEFORE setting timeout + // This way we compare against values at the time timeout was set + const currentPartsCount = messages.reduce( + (acc, msg) => acc + (msg.parts?.length || 0), + 0 + ); + const capturedMessageCount = messages.length; + const capturedPartsCount = currentPartsCount; + + // Update refs immediately so next effect run has fresh values + lastMessageCountRef.current = messages.length; + lastMessagePartsRef.current = currentPartsCount; + + const timeoutId = setTimeout(() => { + // Re-count parts at timeout time + const newPartsCount = messages.reduce( + (acc, msg) => acc + (msg.parts?.length || 0), + 0 + ); + + // If no change since timeout was set, stream has stalled + if ( + messages.length === capturedMessageCount && + newPartsCount === capturedPartsCount + ) { + console.error("[Streaming Timeout] No activity for 15s - forcing error state"); + setStreamingError( + new Error("Connection lost. The AI service may be temporarily unavailable. Please try again.") + ); + stop(); // Allow user to retry by transitioning status to "ready" + } + }, STALL_TIMEOUT_MS); + + return () => clearTimeout(timeoutId); + }, [status, messages, stop]); + const messagesEndRef = useRef(null); useEffect(() => { @@ -167,8 +223,11 @@ Please retry with an adjusted search pattern or use display_diagram if retries a const onFormSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const isProcessing = status === "streaming" || status === "submitted"; + // Allow retry if there's a streaming error (workaround for stop() not transitioning status) + const isProcessing = (status === "streaming" || status === "submitted") && !streamingError; if (input.trim() && !isProcessing) { + // Clear any previous streaming error before starting new request + setStreamingError(null); try { let chartXml = await onFetchChart(); chartXml = formatXML(chartXml); @@ -415,7 +474,7 @@ Please retry with an adjusted search pattern or use display_diagram if retries a