Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
43 changes: 43 additions & 0 deletions src/components/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-row mb-0.5 justify-start" aria-label="Error message">
<div className="w-full">
{detail ? (
<Accordion className="px-0">
<AccordionItem
key="error"
aria-label={headline}
title={<span className="text-sm font-medium text-danger">{headline}</span>}
indicator={<Icon icon="circle-x" className="text-danger" size={16} />}
classNames={{
content: 'pt-0 pb-2',
}}
>
<pre className="whitespace-pre-wrap text-sm text-default-600 font-sans m-0">
{detail}
</pre>
</AccordionItem>
</Accordion>
) : (
<div className="flex flex-row items-center gap-2 py-2">
<Icon icon="circle-x" className="text-danger" size={16} />
<span className="text-sm font-medium text-danger">{headline}</span>
</div>
)}
</div>
</div>
);
}
46 changes: 46 additions & 0 deletions src/components/__tests__/ErrorMessage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ErrorMessage message={message} />);
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(<ErrorMessage message={message} />);

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(<ErrorMessage message={message} />);
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();
});
});
4 changes: 3 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/zh-hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@
"sendMessage": "发送消息...",
"thinking": "思考过程",
"thought": "思考过程",
"sources": "来源"
"sources": "来源",
"errorHeadline": "获取响应失败。",
"errorHint": "请检查 API 密钥、模型和网络连接后重试。"
},
"common": {
"brand": "SnapMind",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/zh-hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@
"sendMessage": "傳送訊息...",
"thinking": "思考過程",
"thought": "思考過程",
"sources": "來源"
"sources": "來源",
"errorHeadline": "獲取回應失敗。",
"errorHint": "請檢查 API 金鑰、模型和網路連線後重試。"
},
"common": {
"brand": "SnapMind",
Expand Down
5 changes: 5 additions & 0 deletions src/pages/ChatMessage/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 <ErrorMessage message={message} />;
}

// Define role-based style classes
const bubbleBaseClasses = 'p-[10px_14px] text-base leading-normal break-words transition-colors';

Expand Down
28 changes: 23 additions & 5 deletions src/pages/ChatPopup/ChatPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
5 changes: 4 additions & 1 deletion src/services/AIService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
92 changes: 92 additions & 0 deletions src/services/__tests__/AIService.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 21 additions & 3 deletions src/services/providers/__tests__/OllamaProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 () => {
Expand Down
19 changes: 11 additions & 8 deletions src/services/providers/adapters/ollamaRequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
},
Expand Down
Loading
Loading