From 03073352d85200a3174396980fc2ac9ba01b8695 Mon Sep 17 00:00:00 2001 From: Louis-7 Date: Tue, 7 Jul 2026 10:55:54 +0800 Subject: [PATCH 1/6] feat(chat): render AI request failures as a distinct collapsible error message - Add 'error' role and optional detail field to the Message type. - Render role:'error' via a new ErrorMessage component (HeroUI Accordion, circle-x + text-danger, collapsed by default, no stack trace). - Emit role:'error' with provider error.message + i18n hint on request failure and guard the streaming-placeholder splice so constructor-time throws no longer swallow the user's message. - Filter role:'error' and role:'system' out of AIService before send so error and abort notices never reach the LLM (also fixes latent Anthropic/Google systemPrompt corruption from abort messages). - Add errorHeadline / errorHint keys to en, zh-hans, zh-hant locales. - Add tests for ErrorMessage rendering and AIService message filtering. --- src/components/ErrorMessage.tsx | 43 +++++++++ .../__tests__/ErrorMessage.test.tsx | 46 +++++++++ src/i18n/locales/en.json | 4 +- src/i18n/locales/zh-hans.json | 4 +- src/i18n/locales/zh-hant.json | 4 +- src/pages/ChatMessage/ChatMessage.tsx | 5 + src/pages/ChatPopup/ChatPopup.tsx | 28 +++++- src/services/AIService.ts | 8 +- src/services/__tests__/AIService.test.ts | 93 +++++++++++++++++++ src/types/chat.d.ts | 3 +- 10 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/components/ErrorMessage.tsx create mode 100644 src/components/__tests__/ErrorMessage.test.tsx create mode 100644 src/services/__tests__/AIService.test.ts diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..afc5ed0 --- /dev/null +++ b/src/components/ErrorMessage.tsx @@ -0,0 +1,43 @@ +import { Accordion, AccordionItem } from '@heroui/react'; + +import Icon from './Icon'; +import { Message } from '@/types/chat'; +import { getTextContent } from '@/services/providers/core/messageUtils'; + +interface ErrorMessageProps { + message: Message; +} + +export default function ErrorMessage({ message }: ErrorMessageProps) { + const headline = getTextContent(message.content); + const detail = message.detail; + + return ( +
+
+ {detail ? ( + + {headline}} + indicator={} + classNames={{ + content: 'pt-0 pb-2', + }} + > +
+                {detail}
+              
+
+
+ ) : ( +
+ + {headline} +
+ )} +
+
+ ); +} diff --git a/src/components/__tests__/ErrorMessage.test.tsx b/src/components/__tests__/ErrorMessage.test.tsx new file mode 100644 index 0000000..c858fe0 --- /dev/null +++ b/src/components/__tests__/ErrorMessage.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ErrorMessage from '../ErrorMessage'; +import { Message } from '@/types/chat'; + +describe('ErrorMessage', () => { + it('renders headline for role:error message', () => { + const message: Message = { + role: 'error', + content: 'Failed to get response.', + detail: 'API error: 401 Invalid API key', + }; + render(); + expect(screen.getByText('Failed to get response.')).toBeInTheDocument(); + }); + + it('keeps detail hidden until expanded', async () => { + const user = userEvent.setup(); + const message: Message = { + role: 'error', + content: 'Failed to get response.', + detail: 'API error: 401 Invalid API key', + }; + render(); + + expect(screen.queryByText(/API error: 401 Invalid API key/)).not.toBeInTheDocument(); + + const trigger = screen.getByRole('button', { name: /Failed to get response/i }); + await user.click(trigger); + + expect(screen.getByText(/API error: 401 Invalid API key/)).toBeInTheDocument(); + }); + + it('renders headline only when detail is missing', () => { + const message: Message = { + role: 'error', + content: 'Failed to get response.', + }; + render(); + expect(screen.getByText('Failed to get response.')).toBeInTheDocument(); + // No accordion button rendered when there's no detail to reveal. + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 19c9f91..1d0dc33 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -127,7 +127,9 @@ "sendMessage": "Send a message...", "thinking": "Thinking", "thought": "Thought", - "sources": "Sources" + "sources": "Sources", + "errorHeadline": "Failed to get response.", + "errorHint": "Check your API key, model, and network connection, then try again." }, "common": { "brand": "SnapMind", diff --git a/src/i18n/locales/zh-hans.json b/src/i18n/locales/zh-hans.json index 74cb9ec..36830fb 100644 --- a/src/i18n/locales/zh-hans.json +++ b/src/i18n/locales/zh-hans.json @@ -127,7 +127,9 @@ "sendMessage": "发送消息...", "thinking": "思考过程", "thought": "思考过程", - "sources": "来源" + "sources": "来源", + "errorHeadline": "获取响应失败。", + "errorHint": "请检查 API 密钥、模型和网络连接后重试。" }, "common": { "brand": "SnapMind", diff --git a/src/i18n/locales/zh-hant.json b/src/i18n/locales/zh-hant.json index 95da50d..cf6832b 100644 --- a/src/i18n/locales/zh-hant.json +++ b/src/i18n/locales/zh-hant.json @@ -127,7 +127,9 @@ "sendMessage": "傳送訊息...", "thinking": "思考過程", "thought": "思考過程", - "sources": "來源" + "sources": "來源", + "errorHeadline": "獲取回應失敗。", + "errorHint": "請檢查 API 金鑰、模型和網路連線後重試。" }, "common": { "brand": "SnapMind", diff --git a/src/pages/ChatMessage/ChatMessage.tsx b/src/pages/ChatMessage/ChatMessage.tsx index ee690f5..4790510 100644 --- a/src/pages/ChatMessage/ChatMessage.tsx +++ b/src/pages/ChatMessage/ChatMessage.tsx @@ -6,6 +6,7 @@ import rehypeHighlight from 'rehype-highlight'; import { Message, ImageContentPart } from '@/types/chat'; import ThinkingMessage from '@/components/ThinkingMessage'; +import ErrorMessage from '@/components/ErrorMessage'; import MessageWebSources from '@/components/MessageWebSources'; import { useChatMessage } from '@/hooks/useChatMessage'; @@ -35,6 +36,10 @@ export default function ChatMessage({ message }: ChatMessageProps) { const isUser = message.role === 'user'; const { thinking, main, isThinking } = useChatMessage(message.content, isUser); + if (message.role === 'error') { + return ; + } + // Define role-based style classes const bubbleBaseClasses = 'p-[10px_14px] text-base leading-normal break-words transition-colors'; diff --git a/src/pages/ChatPopup/ChatPopup.tsx b/src/pages/ChatPopup/ChatPopup.tsx index b2523d1..101abe6 100644 --- a/src/pages/ChatPopup/ChatPopup.tsx +++ b/src/pages/ChatPopup/ChatPopup.tsx @@ -150,16 +150,34 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) { // Keep unfinished message, add a new message for abort setMessages((msgs) => [...msgs, { role: 'system', content: 'Response is aborted.' }]); } else { - setMessages((msgs) => [ - ...msgs.slice(0, -1), // Remove the streaming message - { role: 'assistant', content: 'Error: Unable to get response.' }, - ]); + const errorMessage = error instanceof Error ? error.message : String(error ?? ''); + const hint = t( + 'chat.errorHint', + 'Check your API key, model, and network connection, then try again.' + ); + const detail = errorMessage ? `${errorMessage}\n\n${hint}` : hint; + setMessages((msgs) => { + // Only remove the last message if it's the empty streaming placeholder. + // (If AIService constructor threw, the placeholder was never appended and + // slice(0, -1) would delete the user's message.) + const last = msgs[msgs.length - 1]; + const isPlaceholder = last?.role === 'assistant' && last?.content === ''; + const base = isPlaceholder ? msgs.slice(0, -1) : msgs; + return [ + ...base, + { + role: 'error', + content: t('chat.errorHeadline', 'Failed to get response.'), + detail, + }, + ]; + }); } } finally { setLoading(false); } }, - [settings] + [settings, t] ); useEffect(() => { diff --git a/src/services/AIService.ts b/src/services/AIService.ts index f11d0f7..d882716 100644 --- a/src/services/AIService.ts +++ b/src/services/AIService.ts @@ -116,9 +116,15 @@ export class AIService { ...(options?.onWebSources ? { onWebSources: options.onWebSources } : {}), }; + // Filter out local-only messages (errors, aborted-notice system messages). + // These are display-only and should never be replayed to the LLM. + const cleanMessages = messages.filter( + (m) => m.role === 'user' || m.role === 'assistant' + ); + return { role: 'assistant', - content: await activeProvider.sendMessage(messages, providerOptions, onToken), + content: await activeProvider.sendMessage(cleanMessages, providerOptions, onToken), }; } catch (err) { loggerService.error(`[renderer] ${this.providerSetting.id} error:`, err); diff --git a/src/services/__tests__/AIService.test.ts b/src/services/__tests__/AIService.test.ts new file mode 100644 index 0000000..8b6b5ca --- /dev/null +++ b/src/services/__tests__/AIService.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { AIService } from '../AIService'; +import type { Message } from '@/types/chat'; + +const sendMessageSpy = vi.fn(); + +vi.mock('../providers/ProviderFactory', () => ({ + default: { + createProvider: vi.fn(() => ({ + sendMessage: sendMessageSpy, + getModels: vi.fn(), + })), + }, +})); + +function makeSettings() { + return { + chat: { + defaultProvider: 'openai', + defaultModel: 'gpt-4', + temperature: 0.7, + max_tokens: 2048, + top_p: 0.95, + streamingEnabled: true, + reasoningEnabled: false, + webSearchEnabled: false, + }, + providers: [ + { + id: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + host: 'https://api.openai.com/v1', + models: [{ id: 'gpt-4' }], + }, + ], + } as any; +} + +describe('AIService.sendMessageToAI', () => { + beforeEach(() => { + sendMessageSpy.mockReset(); + sendMessageSpy.mockResolvedValue('ok'); + }); + + it('filters out role:"error" messages before sending to provider', async () => { + const service = new AIService(makeSettings()); + const messages: Message[] = [ + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'hello' }, + { role: 'error', content: 'Failed to get response.', detail: 'API error: 401' }, + { role: 'user', content: 'try again' }, + ]; + + await service.sendMessageToAI(messages, () => {}); + + expect(sendMessageSpy).toHaveBeenCalledTimes(1); + const sent = sendMessageSpy.mock.calls[0][0] as Message[]; + expect(sent).toHaveLength(3); + expect(sent.every((m) => m.role !== 'error')).toBe(true); + expect(sent.map((m) => m.content)).toEqual(['hi', 'hello', 'try again']); + }); + + it('filters out role:"system" (abort-notice) messages before sending', async () => { + const service = new AIService(makeSettings()); + const messages: Message[] = [ + { role: 'user', content: 'do it' }, + { role: 'system', content: 'Response is aborted.' }, + { role: 'user', content: 'retry' }, + ]; + + await service.sendMessageToAI(messages, () => {}); + + const sent = sendMessageSpy.mock.calls[0][0] as Message[]; + expect(sent.every((m) => m.role !== 'system')).toBe(true); + expect(sent).toHaveLength(2); + }); + + it('preserves user and assistant messages in order', async () => { + const service = new AIService(makeSettings()); + const messages: Message[] = [ + { role: 'user', content: 'a' }, + { role: 'assistant', content: 'b' }, + { role: 'user', content: 'c' }, + ]; + + await service.sendMessageToAI(messages, () => {}); + + const sent = sendMessageSpy.mock.calls[0][0] as Message[]; + expect(sent).toEqual(messages); + }); +}); diff --git a/src/types/chat.d.ts b/src/types/chat.d.ts index 9e2e223..7d1ee9c 100644 --- a/src/types/chat.d.ts +++ b/src/types/chat.d.ts @@ -17,7 +17,8 @@ export type ImageContentPart = { export type ContentPart = TextContentPart | ImageContentPart; export type Message = { - role: 'user' | 'assistant' | 'system'; + role: 'user' | 'assistant' | 'system' | 'error'; content: string | ContentPart[]; sources?: ChatSource[]; + detail?: string; }; From 524b37ec8f319b25a8d0c43bfb4196ce95841258 Mon Sep 17 00:00:00 2001 From: Louis-7 Date: Tue, 7 Jul 2026 10:59:20 +0800 Subject: [PATCH 2/6] chore(release): bump version to 0.5.4-beta.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d78c332..23d05ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "SnapMind", - "version": "0.5.3", + "version": "0.5.4-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "SnapMind", - "version": "0.5.3", + "version": "0.5.4-beta.1", "license": "Apache-2.0", "dependencies": { "@heroui/react": "^2.8.9", diff --git a/package.json b/package.json index 161cc97..82e25a8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "SnapMind", "private": true, - "version": "0.5.3", + "version": "0.5.4-beta.1", "description": "A quick prompt launcher.", "author": "Louis Liu", "license": "Apache-2.0", From 0ab1cc240a831fe96bf3f6180df7711930de0faf Mon Sep 17 00:00:00 2001 From: Louis-7 Date: Mon, 13 Jul 2026 14:55:33 +0800 Subject: [PATCH 3/6] fix(chat): only strip error messages from AI request history System messages were previously filtered alongside errors, dropping legitimate context from the LLM. Keep everything except role:"error". --- src/services/AIService.ts | 7 ++----- src/services/__tests__/AIService.test.ts | 5 ++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/services/AIService.ts b/src/services/AIService.ts index d882716..f0e353d 100644 --- a/src/services/AIService.ts +++ b/src/services/AIService.ts @@ -116,11 +116,8 @@ export class AIService { ...(options?.onWebSources ? { onWebSources: options.onWebSources } : {}), }; - // Filter out local-only messages (errors, aborted-notice system messages). - // These are display-only and should never be replayed to the LLM. - const cleanMessages = messages.filter( - (m) => m.role === 'user' || m.role === 'assistant' - ); + // Error messages are display-only and should never be replayed to the LLM. + const cleanMessages = messages.filter((m) => m.role !== 'error'); return { role: 'assistant', diff --git a/src/services/__tests__/AIService.test.ts b/src/services/__tests__/AIService.test.ts index 8b6b5ca..c659bcc 100644 --- a/src/services/__tests__/AIService.test.ts +++ b/src/services/__tests__/AIService.test.ts @@ -62,7 +62,7 @@ describe('AIService.sendMessageToAI', () => { expect(sent.map((m) => m.content)).toEqual(['hi', 'hello', 'try again']); }); - it('filters out role:"system" (abort-notice) messages before sending', async () => { + it('preserves role:"system" messages when sending', async () => { const service = new AIService(makeSettings()); const messages: Message[] = [ { role: 'user', content: 'do it' }, @@ -73,8 +73,7 @@ describe('AIService.sendMessageToAI', () => { await service.sendMessageToAI(messages, () => {}); const sent = sendMessageSpy.mock.calls[0][0] as Message[]; - expect(sent.every((m) => m.role !== 'system')).toBe(true); - expect(sent).toHaveLength(2); + expect(sent).toEqual(messages); }); it('preserves user and assistant messages in order', async () => { From 644f2b8bcdf4ebeed460bd4f2d3c50a30a4b1819 Mon Sep 17 00:00:00 2001 From: Louis-7 Date: Mon, 13 Jul 2026 14:59:24 +0800 Subject: [PATCH 4/6] chore(release): bump version to 0.5.4-beta.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23d05ab..6d6e817 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "SnapMind", - "version": "0.5.4-beta.1", + "version": "0.5.4-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "SnapMind", - "version": "0.5.4-beta.1", + "version": "0.5.4-beta.2", "license": "Apache-2.0", "dependencies": { "@heroui/react": "^2.8.9", diff --git a/package.json b/package.json index 82e25a8..838e498 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "SnapMind", "private": true, - "version": "0.5.4-beta.1", + "version": "0.5.4-beta.2", "description": "A quick prompt launcher.", "author": "Louis Liu", "license": "Apache-2.0", From 0bbb7a7b84cba757dd7211cbd6bac8a63a374a3a Mon Sep 17 00:00:00 2001 From: Louis-7 Date: Thu, 23 Jul 2026 10:21:33 +0800 Subject: [PATCH 5/6] fix: think flag been set to `undefined` and Ollama reconized it as default(true) issue --- .../__tests__/OllamaProvider.test.ts | 24 ++++++++++++++++--- .../adapters/ollamaRequestBuilder.ts | 19 ++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/services/providers/__tests__/OllamaProvider.test.ts b/src/services/providers/__tests__/OllamaProvider.test.ts index 9326ea6..179d32c 100644 --- a/src/services/providers/__tests__/OllamaProvider.test.ts +++ b/src/services/providers/__tests__/OllamaProvider.test.ts @@ -231,7 +231,7 @@ describe('OllamaProvider', () => { expect(body.think).toBe('medium'); }); - it('should omit think when reasoning is disabled', async () => { + it('should set think: false when reasoning is disabled so thinking models stop thinking', async () => { setupFetchMock( mockFetchResponse({ message: { content: 'Response' }, @@ -240,13 +240,31 @@ describe('OllamaProvider', () => { ); await provider.sendMessage(messages, { - model: 'llama2', + model: 'deepseek-r1', + stream: false, + reasoning: false, + }); + + const body = JSON.parse((global.fetch as any).mock.calls[0][1].body); + expect(body.think).toBe(false); + }); + + it('should use think: low for gpt-oss when reasoning is disabled (gpt-oss ignores think: false)', async () => { + setupFetchMock( + mockFetchResponse({ + message: { content: 'Response' }, + done: true, + }) + ); + + await provider.sendMessage(messages, { + model: 'gpt-oss:120b-cloud', stream: false, reasoning: false, }); const body = JSON.parse((global.fetch as any).mock.calls[0][1].body); - expect(body.think).toBeUndefined(); + expect(body.think).toBe('low'); }); it('should stream thinking then content into think tags', async () => { diff --git a/src/services/providers/adapters/ollamaRequestBuilder.ts b/src/services/providers/adapters/ollamaRequestBuilder.ts index 6429364..ad074f1 100644 --- a/src/services/providers/adapters/ollamaRequestBuilder.ts +++ b/src/services/providers/adapters/ollamaRequestBuilder.ts @@ -14,15 +14,19 @@ import { toOllamaMessage } from '../core/messageUtils'; const OLLAMA_DEFAULT_ORIGIN = 'http://localhost:11434'; -/** Ollama `/api/chat` `think` field — GPT-OSS ignores booleans; use a level. */ +/** Ollama `/api/chat` `think` field. + * Reasoning models (deepseek-r1, qwen3, …) think by default on Ollama unless + * `think` is explicitly set to `false`, so we always send the user's choice. + * GPT-OSS is a reasoning model at its core — it ignores `think: false` and keeps + * thinking; the best we can do when the user disables thinking is drop to the + * lowest reasoning effort. */ function ollamaThinkFromReasoning( reasoning: boolean | undefined, model: string | undefined -): boolean | 'low' | 'medium' | 'high' | undefined { - if (!reasoning) return undefined; - const id = (model || '').toLowerCase(); - if (id.includes('gpt-oss')) return 'medium'; - return true; +): boolean | 'low' | 'medium' | 'high' { + const isGptOss = (model || '').toLowerCase().includes('gpt-oss'); + if (!reasoning) return isGptOss ? 'low' : false; + return isGptOss ? 'medium' : true; } export const ollamaRequestBuilder: RequestBuilder = { @@ -59,8 +63,7 @@ export const ollamaRequestBuilder: RequestBuilder = { messages: messages.map((m) => ({ role: m.role, ...toOllamaMessage(m.content) })), stream: options?.stream !== false, }; - const think = ollamaThinkFromReasoning(options?.reasoning, options?.model); - if (think !== undefined) body.think = think; + body.think = ollamaThinkFromReasoning(options?.reasoning, options?.model); if (Object.keys(runtimeOptions).length > 0) body.options = runtimeOptions; return body; }, From f6df959426b75141543dc6400da287a3a3332366 Mon Sep 17 00:00:00 2001 From: Louis-7 Date: Thu, 23 Jul 2026 10:24:34 +0800 Subject: [PATCH 6/6] chore(release): bump version to 0.5.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6d6e817..377acc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "SnapMind", - "version": "0.5.4-beta.2", + "version": "0.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "SnapMind", - "version": "0.5.4-beta.2", + "version": "0.5.4", "license": "Apache-2.0", "dependencies": { "@heroui/react": "^2.8.9", diff --git a/package.json b/package.json index 838e498..cef29a8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "SnapMind", "private": true, - "version": "0.5.4-beta.2", + "version": "0.5.4", "description": "A quick prompt launcher.", "author": "Louis Liu", "license": "Apache-2.0",