diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 52e7dc9..f03c2be 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -449,6 +449,7 @@ ${userInputText} const result = streamText({ model, + abortSignal: req.signal, ...(process.env.MAX_OUTPUT_TOKENS && { maxOutputTokens: parseInt(process.env.MAX_OUTPUT_TOKENS, 10), }), diff --git a/components/chat-input.tsx b/components/chat-input.tsx index 275a37e..6cf966b 100644 --- a/components/chat-input.tsx +++ b/components/chat-input.tsx @@ -7,6 +7,7 @@ import { Link, Loader2, Send, + Square, } from "lucide-react" import type React from "react" import { @@ -154,6 +155,7 @@ interface ChatInputProps { status: "submitted" | "streaming" | "ready" | "error" onSubmit: (e: React.FormEvent) => void onChange: (e: React.ChangeEvent) => void + onStop?: () => void files?: File[] onFileChange?: (files: File[]) => void pdfData?: Map< @@ -182,6 +184,7 @@ export const ChatInput = forwardRef( status, onSubmit, onChange, + onStop, files = [], onFileChange = () => {}, pdfData = new Map(), @@ -552,24 +555,30 @@ export const ChatInput = forwardRef( showUnvalidatedModels={showUnvalidatedModels} />
- + {(status === "streaming" || status === "submitted") && + onStop ? ( + + ) : ( + + )}
{ - await handleToolCall({ toolCall }, addToolOutput) - }, - onError: (error) => { - // Handle server-side quota limit (429 response) - // AI SDK puts the full response body in error.message for non-OK responses - try { - const data = JSON.parse(error.message) - if (data.type === "request") { - quotaManager.showQuotaLimitToast(data.used, data.limit) - return - } - if (data.type === "token") { - quotaManager.showTokenLimitToast(data.used, data.limit) - return - } - if (data.type === "tpm") { - quotaManager.showTPMLimitToast(data.limit) - return - } - } catch { - // Not JSON, fall through to string matching for backwards compatibility + const { + messages, + sendMessage, + addToolOutput, + status, + error, + setMessages, + stop, + } = useChat({ + transport: new DefaultChatTransport({ + api: getApiEndpoint("/api/chat"), + }), + onToolCall: async ({ toolCall }) => { + await handleToolCall({ toolCall }, addToolOutput) + }, + onError: (error) => { + // Handle server-side quota limit (429 response) + // AI SDK puts the full response body in error.message for non-OK responses + try { + const data = JSON.parse(error.message) + if (data.type === "request") { + quotaManager.showQuotaLimitToast(data.used, data.limit) + return } + if (data.type === "token") { + quotaManager.showTokenLimitToast(data.used, data.limit) + return + } + if (data.type === "tpm") { + quotaManager.showTPMLimitToast(data.limit) + return + } + } catch { + // Not JSON, fall through to string matching for backwards compatibility + } - // Fallback to string matching - if (error.message.includes("Daily request limit")) { - quotaManager.showQuotaLimitToast() - return - } - if (error.message.includes("Daily token limit")) { - quotaManager.showTokenLimitToast() - return + // Fallback to string matching + if (error.message.includes("Daily request limit")) { + quotaManager.showQuotaLimitToast() + return + } + if (error.message.includes("Daily token limit")) { + quotaManager.showTokenLimitToast() + return + } + if ( + error.message.includes("Rate limit exceeded") || + error.message.includes("tokens per minute") + ) { + quotaManager.showTPMLimitToast() + return + } + + // Silence access code error in console since it's handled by UI + if (!error.message.includes("Invalid or missing access code")) { + console.error("Chat error:", error) + } + + // Translate technical errors into user-friendly messages + // The server now handles detailed error messages, so we can display them directly. + // But we still handle connection/network errors that happen before reaching the server. + let friendlyMessage = error.message + + // Simple check for network errors if message is generic + if (friendlyMessage === "Failed to fetch") { + friendlyMessage = "Network error. Please check your connection." + } + + // Truncated tool input error (model output limit too low) + if (friendlyMessage.includes("toolUse.input is invalid")) { + friendlyMessage = + "Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength." + } + + // Translate image not supported error + if ( + friendlyMessage.includes("image content block") || + friendlyMessage.toLowerCase().includes("image_url") + ) { + friendlyMessage = "This model doesn't support image input." + } + + // Add system message for error so it can be cleared + setMessages((currentMessages) => { + const errorMessage = { + id: `error-${Date.now()}`, + role: "system" as const, + content: friendlyMessage, + parts: [{ type: "text" as const, text: friendlyMessage }], } + return [...currentMessages, errorMessage] + }) + + if (error.message.includes("Invalid or missing access code")) { + // Show settings dialog to help user fix it + setShowSettingsDialog(true) + } + }, + onFinish: () => {}, + sendAutomaticallyWhen: ({ messages }) => { + const isInContinuationMode = partialXmlRef.current.length > 0 + + const shouldRetry = hasToolErrors( + messages as unknown as ChatMessage[], + ) + + if (!shouldRetry) { + // No error, reset retry count and clear state + autoRetryCountRef.current = 0 + continuationRetryCountRef.current = 0 + partialXmlRef.current = "" + return false + } + + // Continuation mode: limited retries for truncation handling + if (isInContinuationMode) { if ( - error.message.includes("Rate limit exceeded") || - error.message.includes("tokens per minute") + continuationRetryCountRef.current >= + MAX_CONTINUATION_RETRY_COUNT ) { - quotaManager.showTPMLimitToast() - return - } - - // Silence access code error in console since it's handled by UI - if (!error.message.includes("Invalid or missing access code")) { - console.error("Chat error:", error) - } - - // Translate technical errors into user-friendly messages - // The server now handles detailed error messages, so we can display them directly. - // But we still handle connection/network errors that happen before reaching the server. - let friendlyMessage = error.message - - // Simple check for network errors if message is generic - if (friendlyMessage === "Failed to fetch") { - friendlyMessage = - "Network error. Please check your connection." - } - - // Truncated tool input error (model output limit too low) - if (friendlyMessage.includes("toolUse.input is invalid")) { - friendlyMessage = - "Output was truncated before the diagram could be generated. Try a simpler request or increase the maxOutputLength." - } - - // Translate image not supported error - if ( - friendlyMessage.includes("image content block") || - friendlyMessage.toLowerCase().includes("image_url") - ) { - friendlyMessage = "This model doesn't support image input." - } - - // Add system message for error so it can be cleared - setMessages((currentMessages) => { - const errorMessage = { - id: `error-${Date.now()}`, - role: "system" as const, - content: friendlyMessage, - parts: [ - { type: "text" as const, text: friendlyMessage }, - ], - } - return [...currentMessages, errorMessage] - }) - - if (error.message.includes("Invalid or missing access code")) { - // Show settings dialog to help user fix it - setShowSettingsDialog(true) - } - }, - onFinish: () => {}, - sendAutomaticallyWhen: ({ messages }) => { - const isInContinuationMode = partialXmlRef.current.length > 0 - - const shouldRetry = hasToolErrors( - messages as unknown as ChatMessage[], - ) - - if (!shouldRetry) { - // No error, reset retry count and clear state - autoRetryCountRef.current = 0 + toast.error( + formatMessage(dict.errors.continuationRetryLimit, { + max: MAX_CONTINUATION_RETRY_COUNT, + }), + ) continuationRetryCountRef.current = 0 partialXmlRef.current = "" return false } - - // Continuation mode: limited retries for truncation handling - if (isInContinuationMode) { - if ( - continuationRetryCountRef.current >= - MAX_CONTINUATION_RETRY_COUNT - ) { - toast.error( - formatMessage(dict.errors.continuationRetryLimit, { - max: MAX_CONTINUATION_RETRY_COUNT, - }), - ) - continuationRetryCountRef.current = 0 - partialXmlRef.current = "" - return false - } - continuationRetryCountRef.current++ - } else { - // Regular error: check retry count limit - if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) { - toast.error( - formatMessage(dict.errors.retryLimit, { - max: MAX_AUTO_RETRY_COUNT, - }), - ) - autoRetryCountRef.current = 0 - partialXmlRef.current = "" - return false - } - // Increment retry count for actual errors - autoRetryCountRef.current++ + continuationRetryCountRef.current++ + } else { + // Regular error: check retry count limit + if (autoRetryCountRef.current >= MAX_AUTO_RETRY_COUNT) { + toast.error( + formatMessage(dict.errors.retryLimit, { + max: MAX_AUTO_RETRY_COUNT, + }), + ) + autoRetryCountRef.current = 0 + partialXmlRef.current = "" + return false } + // Increment retry count for actual errors + autoRetryCountRef.current++ + } - return true - }, - }) + return true + }, + }) // Store sendMessage in ref for use in callbacks (like handleImproveWithSuggestions) useEffect(() => { @@ -993,6 +997,29 @@ export default function ChatPanel({ } } + // Handle stop button click + const handleStop = useCallback(() => { + const lastMessage = messages[messages.length - 1] + const toolParts = lastMessage?.parts?.filter( + (part: any) => + part.type?.startsWith("tool-") && + part.state === "input-streaming", + ) + + toolParts?.forEach((part: any) => { + if (part.toolCallId) { + addToolOutput({ + tool: part.type.replace("tool-", ""), + toolCallId: part.toolCallId, + state: "output-error", + errorText: "Stopped by user", + }) + } + }) + + stop() + }, [messages, addToolOutput, stop]) + // Send chat message with headers const sendChatMessage = ( parts: any, @@ -1359,6 +1386,7 @@ export default function ChatPanel({ status={status} onSubmit={onFormSubmit} onChange={handleInputChange} + onStop={handleStop} files={files} onFileChange={handleFileChange} pdfData={pdfData} diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index dda6b8b..62a0b46 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -34,7 +34,7 @@ "chat": { "placeholder": "Describe your diagram or upload a file...", "send": "Send", - "sending": "Sending...", + "stopGeneration": "Stop generation", "sendMessage": "Send message", "clearConversation": "Clear conversation", "diagramHistory": "Diagram history", diff --git a/lib/i18n/dictionaries/ja.json b/lib/i18n/dictionaries/ja.json index d94257b..942e054 100644 --- a/lib/i18n/dictionaries/ja.json +++ b/lib/i18n/dictionaries/ja.json @@ -34,7 +34,7 @@ "chat": { "placeholder": "ダイアグラムを説明するか、ファイルをアップロード...", "send": "送信", - "sending": "送信中...", + "stopGeneration": "生成を停止", "sendMessage": "メッセージを送信", "clearConversation": "会話をクリア", "diagramHistory": "ダイアグラム履歴", diff --git a/lib/i18n/dictionaries/zh-Hant.json b/lib/i18n/dictionaries/zh-Hant.json index 0cc20b2..23a9c67 100644 --- a/lib/i18n/dictionaries/zh-Hant.json +++ b/lib/i18n/dictionaries/zh-Hant.json @@ -34,7 +34,7 @@ "chat": { "placeholder": "描述您的圖表或上傳檔案...", "send": "傳送", - "sending": "傳送中...", + "stopGeneration": "停止產生", "sendMessage": "傳送訊息", "clearConversation": "清除對話", "diagramHistory": "圖表歷史", diff --git a/lib/i18n/dictionaries/zh.json b/lib/i18n/dictionaries/zh.json index 93837d2..e37a59c 100644 --- a/lib/i18n/dictionaries/zh.json +++ b/lib/i18n/dictionaries/zh.json @@ -34,7 +34,7 @@ "chat": { "placeholder": "描述您的图表或上传文件...", "send": "发送", - "sending": "发送中...", + "stopGeneration": "停止生成", "sendMessage": "发送消息", "clearConversation": "清除对话", "diagramHistory": "图表历史",