diff --git a/package-lock.json b/package-lock.json index d78c332..377acc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "SnapMind", - "version": "0.5.3", + "version": "0.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "SnapMind", - "version": "0.5.3", + "version": "0.5.4", "license": "Apache-2.0", "dependencies": { "@heroui/react": "^2.8.9", diff --git a/package.json b/package.json index 161cc97..cef29a8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "SnapMind", "private": true, - "version": "0.5.3", + "version": "0.5.4", "description": "A quick prompt launcher.", "author": "Louis Liu", "license": "Apache-2.0", 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..f0e353d 100644 --- a/src/services/AIService.ts +++ b/src/services/AIService.ts @@ -116,9 +116,12 @@ export class AIService { ...(options?.onWebSources ? { onWebSources: options.onWebSources } : {}), }; + // Error messages are display-only and should never be replayed to the LLM. + const cleanMessages = messages.filter((m) => m.role !== 'error'); + 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..c659bcc --- /dev/null +++ b/src/services/__tests__/AIService.test.ts @@ -0,0 +1,92 @@ +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('preserves role:"system" messages when 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).toEqual(messages); + }); + + 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/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; }, 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; };