diff --git a/main.ts b/main.ts
index e4e4d90..4d7c9e2 100644
--- a/main.ts
+++ b/main.ts
@@ -577,6 +577,21 @@ ipcMain.handle('system:open-install-folder', async () => {
}
});
+// Safely open an external URL in the default browser (http/https only)
+ipcMain.handle('shell:open-external', async (_event, url: string) => {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return { success: false, error: 'Only http and https URLs are allowed' };
+ }
+ await shell.openExternal(url);
+ return { success: true };
+ } catch (error) {
+ logService.error('[main] shell:open-external error:', error);
+ return { success: false, error: error.message };
+ }
+});
+
// Update-related IPC
ipcMain.handle('update:check', () => {
if (autoUpdateService) return autoUpdateService.manualCheck();
diff --git a/preload.ts b/preload.ts
index 8a2f4d0..dac6ee2 100644
--- a/preload.ts
+++ b/preload.ts
@@ -46,6 +46,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
onPermissionChanged: (callback) =>
ipcRenderer.on('permission:changed', (_event, permissions) => callback(permissions)),
offPermissionChanged: () => ipcRenderer.removeAllListeners('permission:changed'),
+ // Open an external URL safely in the default browser
+ openExternalUrl: (url) => ipcRenderer.invoke('shell:open-external', url),
// General app events
quitApp: () => ipcRenderer.send('app:quit'),
// Auto update APIs
diff --git a/settings.default.json b/settings.default.json
index a59e3d7..57aa20e 100644
--- a/settings.default.json
+++ b/settings.default.json
@@ -17,6 +17,7 @@
"top_p": 0.95,
"streamingEnabled": true,
"reasoningEnabled": false,
+ "webSearchEnabled": false,
"defaultModel": null,
"defaultProvider": null
},
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
index 52bae5b..7e401bf 100644
--- a/src/components/Icon.tsx
+++ b/src/components/Icon.tsx
@@ -23,6 +23,7 @@ import {
LuArrowUp,
LuLightbulb,
LuLightbulbOff,
+ LuGlobe,
} from 'react-icons/lu';
import { MdOutlineCleaningServices } from 'react-icons/md';
@@ -66,7 +67,8 @@ type IconType =
| 'paint-roller'
| 'arrow-up'
| 'lightbulb'
- | 'lightbulb-off';
+ | 'lightbulb-off'
+ | 'globe';
interface IconProps {
className?: string;
@@ -153,6 +155,8 @@ function Icon({
return ;
case 'lightbulb-off':
return ;
+ case 'globe':
+ return ;
default:
return null;
}
diff --git a/src/components/MessageWebSources.tsx b/src/components/MessageWebSources.tsx
new file mode 100644
index 0000000..8228d45
--- /dev/null
+++ b/src/components/MessageWebSources.tsx
@@ -0,0 +1,126 @@
+import { useState } from 'react';
+import { Drawer, DrawerContent, DrawerHeader, DrawerBody } from '@heroui/drawer';
+import { LuGlobe, LuExternalLink } from 'react-icons/lu';
+import { useTranslation } from 'react-i18next';
+import { ChatSource } from '@/types/chat';
+
+interface MessageWebSourcesProps {
+ sources: ChatSource[];
+}
+
+function extractHostname(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, '');
+ } catch {
+ return url;
+ }
+}
+
+const PILL_COLORS = [
+ 'bg-blue-500',
+ 'bg-emerald-500',
+ 'bg-amber-500',
+ 'bg-rose-500',
+ 'bg-violet-500',
+ 'bg-cyan-500',
+];
+
+function colorForIndex(i: number): string {
+ return PILL_COLORS[i % PILL_COLORS.length];
+}
+
+function openUrl(url: string) {
+ if (window.electronAPI?.openExternalUrl) {
+ window.electronAPI.openExternalUrl(url);
+ } else {
+ window.open(url, '_blank', 'noopener');
+ }
+}
+
+export default function MessageWebSources({ sources }: MessageWebSourcesProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const { t } = useTranslation();
+
+ if (!sources || sources.length === 0) return null;
+
+ const uniqueHosts = [...new Set(sources.map((s) => extractHostname(s.url)))];
+ const previewCount = Math.min(uniqueHosts.length, 3);
+
+ return (
+ <>
+ {/* Pill trigger */}
+
+
+ {/* Sources drawer */}
+ setIsOpen(false)} placement="right" size="sm">
+
+
+
+ {t('chat.sources')}
+
+
+
+ {sources.map((source, i) => {
+ const host = extractHostname(source.url);
+ return (
+ -
+
+
+ );
+ })}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/WebSearchToggle.tsx b/src/components/WebSearchToggle.tsx
new file mode 100644
index 0000000..06486bc
--- /dev/null
+++ b/src/components/WebSearchToggle.tsx
@@ -0,0 +1,35 @@
+import { useSwitch, VisuallyHidden } from '@heroui/react';
+import Icon from './Icon';
+
+interface WebSearchToggleProps {
+ isSelected: boolean;
+ onValueChange: (checked: boolean) => void;
+ 'aria-label': string;
+}
+
+export default function WebSearchToggle(props: WebSearchToggleProps) {
+ const { Component, slots, isSelected, getBaseProps, getInputProps, getWrapperProps } =
+ useSwitch(props);
+
+ return (
+
(0);
const [autoScroll, setAutoScroll] = useState(true);
const [reasoningEnabled, setReasoningEnabled] = useState(settings.chat.reasoningEnabled ?? false);
+ const [webSearchEnabled, setWebSearchEnabled] = useState(settings.chat.webSearchEnabled ?? false);
// Sync local state when settings change externally (e.g. from Settings page)
useEffect(() => {
setReasoningEnabled(settings.chat.reasoningEnabled ?? false);
+ setWebSearchEnabled(settings.chat.webSearchEnabled ?? false);
}, [settings]); // Note: using `settings` (not `settings.chat.reasoningEnabled`) as the dependency keeps settings in sync across different windows.
// Focus the input when ChatPopup mounts
@@ -90,7 +93,6 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
await aiService.sendMessageToAI(
messagesToSend,
(token) => {
- // Update the last message (assistant) with the new token
setMessages((currentMsgs) => {
const updatedMsgs = [...currentMsgs];
const lastIndex = updatedMsgs.length - 1;
@@ -103,7 +105,19 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
return updatedMsgs;
});
},
- { signal }
+ {
+ signal,
+ onWebSources: (sources: ChatSource[]) => {
+ setMessages((currentMsgs) => {
+ const updatedMsgs = [...currentMsgs];
+ const lastIndex = updatedMsgs.length - 1;
+ if (lastIndex >= 0 && updatedMsgs[lastIndex].role === 'assistant') {
+ updatedMsgs[lastIndex] = { ...updatedMsgs[lastIndex], sources };
+ }
+ return updatedMsgs;
+ });
+ },
+ }
);
} catch (error) {
if (error && error.name === 'AbortError') {
@@ -293,6 +307,14 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
ref={inputRef}
/>
+ {
+ setWebSearchEnabled(checked);
+ setSettings(['chat', 'webSearchEnabled'], checked);
+ }}
+ />
{
setReasoningEnabled(settings.reasoningEnabled ?? false);
+ setWebSearchEnabled(settings.webSearchEnabled ?? false);
}, [settings]); // Note: using `settings` (not `settings.reasoningEnabled`) as the dependency keeps settings in sync across different windows.
return (
@@ -83,6 +85,17 @@ function SettingsChat({ settings, onSettingsChange }: SettingsChatProps) {
onValueChange={(checked) => onSettingsChange(['chat', 'streamingEnabled'], checked)}
/>
+ {
+ setWebSearchEnabled(checked);
+ onSettingsChange(['chat', 'webSearchEnabled'], checked);
+ }}
+ />
+
void;
}
): Promise {
const modelSetting = options?.modelSetting || this.modelSetting;
@@ -101,6 +103,7 @@ export class AIService {
// Prepare options for the provider
// Start from settings.chat defaults, then override with per-call values
const reasoning = options?.reasoning ?? this.settings.chat.reasoningEnabled ?? false;
+ const webSearch = options?.webSearch ?? this.settings.chat.webSearchEnabled ?? false;
const providerOptions = {
model: modelSetting.id,
temperature: options?.temperature ?? this.settings.chat.temperature ?? DEFAULT_TEMPERATURE,
@@ -108,7 +111,9 @@ export class AIService {
top_p: options?.top_p ?? this.settings.chat.top_p ?? DEFAULT_TOP_P,
stream: streamingEnabled,
reasoning,
+ webSearch,
...(options?.signal ? { signal: options.signal } : {}),
+ ...(options?.onWebSources ? { onWebSources: options.onWebSources } : {}),
};
return {
diff --git a/src/services/providers/UnifiedProvider.ts b/src/services/providers/UnifiedProvider.ts
index 272b01a..3696427 100644
--- a/src/services/providers/UnifiedProvider.ts
+++ b/src/services/providers/UnifiedProvider.ts
@@ -71,13 +71,17 @@ export class UnifiedProvider implements Provider {
// --- Parse response ---
if (options?.stream !== false) {
- return this.adapter.parseStreamResponse(res, onToken);
+ return this.adapter.parseStreamResponse(res, onToken, options?.onWebSources);
} else {
const data = await res.json();
const content = this.adapter.extractContentFromResponse(data);
if (typeof onToken === 'function') {
onToken(content);
}
+ if (options?.onWebSources && this.adapter.extractWebSourcesFromResponse) {
+ const sources = this.adapter.extractWebSourcesFromResponse(data);
+ if (sources.length > 0) options.onWebSources(sources);
+ }
return content;
}
}
diff --git a/src/services/providers/__tests__/DeepSeekProvider.test.ts b/src/services/providers/__tests__/DeepSeekProvider.test.ts
index 64f18f2..73b2e64 100644
--- a/src/services/providers/__tests__/DeepSeekProvider.test.ts
+++ b/src/services/providers/__tests__/DeepSeekProvider.test.ts
@@ -184,6 +184,23 @@ describe('DeepSeekProvider', () => {
expect(body.reasoning_effort).toBeUndefined();
});
+ it('should not add web_search_options when webSearch is enabled (OpenAI-only)', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ choices: [{ message: { content: 'ok' } }],
+ })
+ );
+
+ await provider.sendMessage(messages, {
+ model: 'deepseek-chat',
+ stream: false,
+ webSearch: true,
+ });
+
+ const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
+ expect(body.web_search_options).toBeUndefined();
+ });
+
it('should handle API errors', async () => {
setupFetchMock(
mockFetchResponse({ error: 'Rate limit exceeded' }, { ok: false, status: 429 })
diff --git a/src/services/providers/__tests__/GoogleProvider.test.ts b/src/services/providers/__tests__/GoogleProvider.test.ts
index aa07c87..371d10d 100644
--- a/src/services/providers/__tests__/GoogleProvider.test.ts
+++ b/src/services/providers/__tests__/GoogleProvider.test.ts
@@ -8,6 +8,7 @@ import { Message } from '@/types/chat';
import {
mockFetchResponse,
mockSSEResponse,
+ mockStreamingFetchResponse,
setupFetchMock,
} from '../../../../test/utils/mockFetch';
@@ -204,6 +205,57 @@ describe('GoogleProvider', () => {
expect(result).toBe('Generated content');
});
+ it('should call onWebSources with groundingMetadata (non-streaming)', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ candidates: [
+ {
+ content: { parts: [{ text: 'Grounded response' }] },
+ groundingMetadata: {
+ groundingChunks: [
+ { web: { uri: 'https://gemini.example.com/a', title: 'Gemini A' } },
+ { web: { uri: 'https://gemini.example.com/b', title: 'Gemini B' } },
+ ],
+ },
+ },
+ ],
+ })
+ );
+
+ const onWebSources = vi.fn();
+ await provider.sendMessage(messages, {
+ model: 'gemini-pro',
+ stream: false,
+ onWebSources,
+ });
+
+ expect(onWebSources).toHaveBeenCalledWith([
+ { url: 'https://gemini.example.com/a', title: 'Gemini A' },
+ { url: 'https://gemini.example.com/b', title: 'Gemini B' },
+ ]);
+ });
+
+ it('should call onWebSources during streaming with groundingMetadata', async () => {
+ setupFetchMock(
+ mockStreamingFetchResponse([
+ 'data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]}}]}\n',
+ 'data: {"candidates":[{"content":{"parts":[{"text":" world"}]},"groundingMetadata":{"groundingChunks":[{"web":{"uri":"https://stream.gemini.com","title":"Stream"}}]}}]}\n',
+ 'data: [DONE]\n',
+ ])
+ );
+
+ const onWebSources = vi.fn();
+ await provider.sendMessage(
+ messages,
+ { model: 'gemini-pro', stream: true, onWebSources },
+ vi.fn()
+ );
+
+ expect(onWebSources).toHaveBeenCalledWith([
+ { url: 'https://stream.gemini.com', title: 'Stream' },
+ ]);
+ });
+
it('should handle streaming response', async () => {
const tokens: string[] = [];
const onToken = vi.fn((token: string) => tokens.push(token));
@@ -537,5 +589,25 @@ describe('GoogleProvider', () => {
expect(body.generationConfig.thinkingConfig).toBeUndefined();
});
+
+ it('should include google_search tool when webSearch is enabled', () => {
+ const body = buildBody({
+ model: 'gemini-pro',
+ webSearch: true,
+ max_tokens: 1000,
+ });
+
+ expect(body.tools).toEqual([{ google_search: {} }]);
+ expect(body.generationConfig.maxOutputTokens).toBeUndefined();
+ });
+
+ it('should omit tools when webSearch is disabled', () => {
+ const body = buildBody({
+ model: 'gemini-pro',
+ max_tokens: 1000,
+ });
+
+ expect(body.tools).toBeUndefined();
+ });
});
});
diff --git a/src/services/providers/__tests__/OllamaProvider.test.ts b/src/services/providers/__tests__/OllamaProvider.test.ts
index bb0de70..9326ea6 100644
--- a/src/services/providers/__tests__/OllamaProvider.test.ts
+++ b/src/services/providers/__tests__/OllamaProvider.test.ts
@@ -195,6 +195,79 @@ describe('OllamaProvider', () => {
expect(body.options.num_predict).toBe(500);
});
+ it('should set think: true when reasoning is enabled', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ message: { content: 'Response' },
+ done: true,
+ })
+ );
+
+ await provider.sendMessage(messages, {
+ model: 'qwen3',
+ stream: false,
+ reasoning: true,
+ });
+
+ const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
+ expect(body.think).toBe(true);
+ });
+
+ it('should use think level for gpt-oss when reasoning is enabled', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ message: { content: 'Response' },
+ done: true,
+ })
+ );
+
+ await provider.sendMessage(messages, {
+ model: 'gpt-oss:20b',
+ stream: false,
+ reasoning: true,
+ });
+
+ const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
+ expect(body.think).toBe('medium');
+ });
+
+ it('should omit think when reasoning is disabled', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ message: { content: 'Response' },
+ done: true,
+ })
+ );
+
+ await provider.sendMessage(messages, {
+ model: 'llama2',
+ stream: false,
+ reasoning: false,
+ });
+
+ const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
+ expect(body.think).toBeUndefined();
+ });
+
+ it('should stream thinking then content into think tags', async () => {
+ setupFetchMock(
+ mockStreamingFetchResponse([
+ '{"message":{"thinking":"Let me "}}\n',
+ '{"message":{"thinking":"reason."}}\n',
+ '{"message":{"content":"Answer."},"done":true}\n',
+ ])
+ );
+
+ const result = await provider.sendMessage(
+ messages,
+ { model: 'deepseek-r1', stream: true, reasoning: true }
+ );
+
+ expect(result).toBe(
+ '\nLet me reason.\n\n\nAnswer.'
+ );
+ });
+
it('should pass messages in Ollama format', async () => {
setupFetchMock(
mockFetchResponse({
diff --git a/src/services/providers/__tests__/OpenAIProvider.test.ts b/src/services/providers/__tests__/OpenAIProvider.test.ts
index 6271ff7..413d695 100644
--- a/src/services/providers/__tests__/OpenAIProvider.test.ts
+++ b/src/services/providers/__tests__/OpenAIProvider.test.ts
@@ -194,6 +194,102 @@ describe('OpenAIProvider', () => {
expect(body.top_p).toBeUndefined();
});
+ it('should include web_search_options when webSearch is enabled', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ choices: [{ message: { content: 'Grounded response' } }],
+ })
+ );
+
+ await provider.sendMessage(messages, {
+ model: 'gpt-4o-search-preview',
+ stream: false,
+ webSearch: true,
+ max_tokens: 2048,
+ });
+
+ const fetchCall = (global.fetch as any).mock.calls[0];
+ const body = JSON.parse(fetchCall[1].body);
+
+ expect(body.web_search_options).toEqual({});
+ expect(body.max_tokens).toBeUndefined();
+ });
+
+ it('should include web_search_options when reasoning and webSearch are both enabled', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ choices: [{ message: { content: 'ok' } }],
+ })
+ );
+
+ await provider.sendMessage(messages, {
+ model: 'o4-mini',
+ stream: false,
+ reasoning: true,
+ webSearch: true,
+ max_tokens: 4096,
+ });
+
+ const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
+ expect(body.web_search_options).toEqual({});
+ expect(body.max_completion_tokens).toBeUndefined();
+ });
+
+ it('should call onWebSources with url_citation annotations (non-streaming)', async () => {
+ setupFetchMock(
+ mockFetchResponse({
+ choices: [
+ {
+ message: {
+ content: 'Search result',
+ annotations: [
+ {
+ type: 'url_citation',
+ url_citation: {
+ url: 'https://example.com/article',
+ title: 'Example Article',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ })
+ );
+
+ const onWebSources = vi.fn();
+ await provider.sendMessage(messages, {
+ model: 'gpt-4o-search-preview',
+ stream: false,
+ onWebSources,
+ });
+
+ expect(onWebSources).toHaveBeenCalledWith([
+ { url: 'https://example.com/article', title: 'Example Article' },
+ ]);
+ });
+
+ it('should call onWebSources during streaming with annotations', async () => {
+ setupFetchMock(
+ mockStreamingFetchResponse([
+ 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n',
+ 'data: {"choices":[{"delta":{"annotations":[{"type":"url_citation","url_citation":{"url":"https://stream.example.com","title":"Stream"}}]}}]}\n',
+ 'data: [DONE]\n',
+ ])
+ );
+
+ const onWebSources = vi.fn();
+ await provider.sendMessage(
+ messages,
+ { model: 'gpt-4o', stream: true, onWebSources },
+ vi.fn()
+ );
+
+ expect(onWebSources).toHaveBeenCalledWith([
+ { url: 'https://stream.example.com', title: 'Stream' },
+ ]);
+ });
+
it('should handle streaming response with reasoning_content', async () => {
const tokens: string[] = [];
const onToken = vi.fn((token: string) => tokens.push(token));
diff --git a/src/services/providers/__tests__/extractWebSources.test.ts b/src/services/providers/__tests__/extractWebSources.test.ts
new file mode 100644
index 0000000..066ac9e
--- /dev/null
+++ b/src/services/providers/__tests__/extractWebSources.test.ts
@@ -0,0 +1,197 @@
+import { describe, it, expect } from 'vitest';
+import {
+ extractOpenAISources,
+ extractGeminiSources,
+ deduplicateSources,
+} from '../parsers/extractWebSources';
+
+describe('extractOpenAISources', () => {
+ it('should extract url_citation annotations from non-streaming response', () => {
+ const data = {
+ choices: [
+ {
+ message: {
+ content: 'Some response text',
+ annotations: [
+ {
+ type: 'url_citation',
+ url_citation: {
+ url: 'https://example.com/article',
+ title: 'Example Article',
+ },
+ },
+ {
+ type: 'url_citation',
+ url_citation: {
+ url: 'https://docs.example.com/guide',
+ title: 'Guide',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const sources = extractOpenAISources(data);
+ expect(sources).toEqual([
+ { url: 'https://example.com/article', title: 'Example Article' },
+ { url: 'https://docs.example.com/guide', title: 'Guide' },
+ ]);
+ });
+
+ it('should extract url_citation annotations from streaming delta', () => {
+ const data = {
+ choices: [
+ {
+ delta: {
+ annotations: [
+ {
+ type: 'url_citation',
+ url_citation: {
+ url: 'https://stream.example.com/page',
+ title: 'Stream Page',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const sources = extractOpenAISources(data);
+ expect(sources).toEqual([{ url: 'https://stream.example.com/page', title: 'Stream Page' }]);
+ });
+
+ it('should skip non-url_citation annotations', () => {
+ const data = {
+ choices: [
+ {
+ message: {
+ annotations: [
+ { type: 'file_citation', file_citation: { file_id: 'abc' } },
+ {
+ type: 'url_citation',
+ url_citation: { url: 'https://real.com', title: 'Real' },
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ const sources = extractOpenAISources(data);
+ expect(sources).toHaveLength(1);
+ expect(sources[0].url).toBe('https://real.com');
+ });
+
+ it('should return empty array when no annotations', () => {
+ expect(extractOpenAISources({})).toEqual([]);
+ expect(extractOpenAISources({ choices: [{ delta: { content: 'hi' } }] })).toEqual([]);
+ });
+
+ it('should handle url_citation without title', () => {
+ const data = {
+ choices: [
+ {
+ message: {
+ annotations: [{ type: 'url_citation', url_citation: { url: 'https://no-title.com' } }],
+ },
+ },
+ ],
+ };
+
+ const sources = extractOpenAISources(data);
+ expect(sources).toEqual([{ url: 'https://no-title.com', title: undefined }]);
+ });
+});
+
+describe('extractGeminiSources', () => {
+ it('should extract grounding chunks from response', () => {
+ const data = {
+ candidates: [
+ {
+ content: { parts: [{ text: 'Response' }] },
+ groundingMetadata: {
+ groundingChunks: [
+ { web: { uri: 'https://gemini.example.com/a', title: 'Gemini A' } },
+ { web: { uri: 'https://gemini.example.com/b', title: 'Gemini B' } },
+ ],
+ },
+ },
+ ],
+ };
+
+ const sources = extractGeminiSources(data);
+ expect(sources).toEqual([
+ { url: 'https://gemini.example.com/a', title: 'Gemini A' },
+ { url: 'https://gemini.example.com/b', title: 'Gemini B' },
+ ]);
+ });
+
+ it('should handle chunks without title', () => {
+ const data = {
+ candidates: [
+ {
+ groundingMetadata: {
+ groundingChunks: [{ web: { uri: 'https://notitle.com' } }],
+ },
+ },
+ ],
+ };
+
+ const sources = extractGeminiSources(data);
+ expect(sources).toEqual([{ url: 'https://notitle.com', title: undefined }]);
+ });
+
+ it('should skip non-web chunks', () => {
+ const data = {
+ candidates: [
+ {
+ groundingMetadata: {
+ groundingChunks: [
+ { web: { uri: 'https://web.com', title: 'Web' } },
+ { retrievedContext: { uri: 'gs://bucket/file' } },
+ ],
+ },
+ },
+ ],
+ };
+
+ const sources = extractGeminiSources(data);
+ expect(sources).toHaveLength(1);
+ expect(sources[0].url).toBe('https://web.com');
+ });
+
+ it('should return empty array when no grounding metadata', () => {
+ expect(extractGeminiSources({})).toEqual([]);
+ expect(
+ extractGeminiSources({ candidates: [{ content: { parts: [{ text: 'hi' }] } }] })
+ ).toEqual([]);
+ });
+});
+
+describe('deduplicateSources', () => {
+ it('should remove duplicate URLs keeping first occurrence', () => {
+ const sources = [
+ { url: 'https://a.com', title: 'First' },
+ { url: 'https://b.com', title: 'B' },
+ { url: 'https://a.com', title: 'Duplicate' },
+ ];
+
+ const result = deduplicateSources(sources);
+ expect(result).toEqual([
+ { url: 'https://a.com', title: 'First' },
+ { url: 'https://b.com', title: 'B' },
+ ]);
+ });
+
+ it('should handle empty array', () => {
+ expect(deduplicateSources([])).toEqual([]);
+ });
+
+ it('should handle single item', () => {
+ const sources = [{ url: 'https://only.com', title: 'Only' }];
+ expect(deduplicateSources(sources)).toEqual(sources);
+ });
+});
diff --git a/src/services/providers/adapters/googleRequestBuilder.ts b/src/services/providers/adapters/googleRequestBuilder.ts
index 12a0197..d468b83 100644
--- a/src/services/providers/adapters/googleRequestBuilder.ts
+++ b/src/services/providers/adapters/googleRequestBuilder.ts
@@ -97,10 +97,20 @@ export const googleRequestBuilder: RequestBuilder = {
};
}
- return {
+ const body: Record = {
contents: googleMessages,
generationConfig,
};
+
+ // Grounding with Google Search (Gemini API)
+ if (options?.webSearch) {
+ body.tools = [{ google_search: {} }];
+ // Grounded responses can be lengthy; remove the output-token cap
+ // so answers aren't truncated by the default limit.
+ delete generationConfig.maxOutputTokens;
+ }
+
+ return body;
},
buildListModelsRequest(config: BaseProviderConfig) {
diff --git a/src/services/providers/adapters/ollamaRequestBuilder.ts b/src/services/providers/adapters/ollamaRequestBuilder.ts
index 527368a..e68a871 100644
--- a/src/services/providers/adapters/ollamaRequestBuilder.ts
+++ b/src/services/providers/adapters/ollamaRequestBuilder.ts
@@ -13,6 +13,17 @@ import { deriveOllamaApiBase } from '../core/urlResolvers';
const OLLAMA_DEFAULT_ORIGIN = 'http://localhost:11434';
+/** Ollama `/api/chat` `think` field — GPT-OSS ignores booleans; use a level. */
+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;
+}
+
export const ollamaRequestBuilder: RequestBuilder = {
providerName: 'Ollama',
requiresApiKey: false,
@@ -47,6 +58,8 @@ export const ollamaRequestBuilder: RequestBuilder = {
messages: messages.map((m) => ({ role: m.role, content: m.content })),
stream: options?.stream !== false,
};
+ const think = ollamaThinkFromReasoning(options?.reasoning, options?.model);
+ if (think !== undefined) body.think = think;
if (Object.keys(runtimeOptions).length > 0) body.options = runtimeOptions;
return body;
},
diff --git a/src/services/providers/adapters/openaiRequestBuilder.ts b/src/services/providers/adapters/openaiRequestBuilder.ts
index 7c8e86c..fd5436e 100644
--- a/src/services/providers/adapters/openaiRequestBuilder.ts
+++ b/src/services/providers/adapters/openaiRequestBuilder.ts
@@ -52,6 +52,17 @@ export function createOpenAIRequestBuilder(opts: OpenAIRequestBuilderOptions): R
},
buildChatBody(messages: Message[], options: ProviderOptions): any {
+ const applyOpenAIWebSearch = (body: any) => {
+ // Chat Completions: web search requires search-capable models + web_search_options (OpenAI only).
+ if (options.webSearch && providerName === 'OpenAI') {
+ body.web_search_options = {};
+ // Web search responses include inline citation markers that count toward
+ // the token limit. Remove the cap so responses aren't truncated.
+ delete body.max_tokens;
+ delete body.max_completion_tokens;
+ }
+ };
+
if (options.reasoning) {
// Reasoning models (o1/o3/o4-mini, DeepSeek-R1, etc.):
// - Use max_completion_tokens instead of max_tokens
@@ -67,10 +78,11 @@ export function createOpenAIRequestBuilder(opts: OpenAIRequestBuilderOptions): R
if (providerName === 'OpenAI') {
body.reasoning_effort = 'medium';
}
+ applyOpenAIWebSearch(body);
return body;
}
- return {
+ const body: any = {
model: options.model,
messages,
max_tokens: options.max_tokens,
@@ -78,6 +90,8 @@ export function createOpenAIRequestBuilder(opts: OpenAIRequestBuilderOptions): R
temperature: options.temperature,
top_p: options.top_p,
};
+ applyOpenAIWebSearch(body);
+ return body;
},
buildListModelsRequest(config: BaseProviderConfig) {
diff --git a/src/services/providers/parsers/anthropicResponseParser.ts b/src/services/providers/parsers/anthropicResponseParser.ts
index 181402b..7e023a5 100644
--- a/src/services/providers/parsers/anthropicResponseParser.ts
+++ b/src/services/providers/parsers/anthropicResponseParser.ts
@@ -5,11 +5,16 @@
// - Non-streaming response: content[0].text
import { ModelSetting } from '@/types/setting';
+import { ChatSource } from '@/types/chat';
import { ResponseParser } from '@/types/providers';
import { parseSSEStream } from '../core/sseStreamParser';
export const anthropicResponseParser: ResponseParser = {
- async parseStreamResponse(res: Response, onToken?: (token: string) => void): Promise {
+ async parseStreamResponse(
+ res: Response,
+ onToken?: (token: string) => void,
+ _onWebSources?: (sources: ChatSource[]) => void
+ ): Promise {
// Track whether we're inside a thinking block
let inThinking = false;
diff --git a/src/services/providers/parsers/extractWebSources.ts b/src/services/providers/parsers/extractWebSources.ts
new file mode 100644
index 0000000..400a8c8
--- /dev/null
+++ b/src/services/providers/parsers/extractWebSources.ts
@@ -0,0 +1,62 @@
+// Extracts web search citation sources from provider-specific response shapes
+// into a unified ChatSource[] format.
+
+import { ChatSource } from '@/types/chat';
+
+/**
+ * Extract sources from an OpenAI response chunk or full response.
+ *
+ * Streaming: annotations appear in `choices[0].delta.annotations`.
+ * Non-streaming: annotations appear in `choices[0].message.annotations`.
+ */
+export function extractOpenAISources(data: any): ChatSource[] {
+ const annotations =
+ data.choices?.[0]?.delta?.annotations ?? data.choices?.[0]?.message?.annotations;
+
+ if (!Array.isArray(annotations)) return [];
+
+ const sources: ChatSource[] = [];
+ for (const ann of annotations) {
+ if (ann.type === 'url_citation' && ann.url_citation?.url) {
+ sources.push({
+ url: ann.url_citation.url,
+ title: ann.url_citation.title || undefined,
+ });
+ }
+ }
+ return sources;
+}
+
+/**
+ * Extract sources from a Gemini response chunk or full response.
+ *
+ * Grounding metadata lives at `candidates[0].groundingMetadata.groundingChunks`.
+ */
+export function extractGeminiSources(data: any): ChatSource[] {
+ const chunks = data.candidates?.[0]?.groundingMetadata?.groundingChunks;
+
+ if (!Array.isArray(chunks)) return [];
+
+ const sources: ChatSource[] = [];
+ for (const chunk of chunks) {
+ const uri = chunk.web?.uri;
+ if (uri) {
+ sources.push({
+ url: uri,
+ title: chunk.web?.title || undefined,
+ });
+ }
+ }
+ return sources;
+}
+
+/** Deduplicate sources by URL, keeping the first occurrence's title. */
+export function deduplicateSources(sources: ChatSource[]): ChatSource[] {
+ const seen = new Map();
+ for (const s of sources) {
+ if (!seen.has(s.url)) {
+ seen.set(s.url, s);
+ }
+ }
+ return Array.from(seen.values());
+}
diff --git a/src/services/providers/parsers/googleResponseParser.ts b/src/services/providers/parsers/googleResponseParser.ts
index 873cfb8..63d42a7 100644
--- a/src/services/providers/parsers/googleResponseParser.ts
+++ b/src/services/providers/parsers/googleResponseParser.ts
@@ -4,31 +4,43 @@
// Model listing: models[] with name like "models/gemini-pro"
import { ModelSetting } from '@/types/setting';
+import { ChatSource } from '@/types/chat';
import { ResponseParser } from '@/types/providers';
import { parseSSEStream } from '../core/sseStreamParser';
+import { extractGeminiSources, deduplicateSources } from './extractWebSources';
export const googleResponseParser: ResponseParser = {
- async parseStreamResponse(res: Response, onToken?: (token: string) => void): Promise {
- // Track whether we're inside a thought block
+ async parseStreamResponse(
+ res: Response,
+ onToken?: (token: string) => void,
+ onWebSources?: (sources: ChatSource[]) => void
+ ): Promise {
let inThought = false;
+ let collectedSources: ChatSource[] = [];
- return parseSSEStream(
+ const result = await parseSSEStream(
res,
(data) => {
+ if (onWebSources) {
+ const chunkSources = extractGeminiSources(data);
+ if (chunkSources.length > 0) {
+ collectedSources = deduplicateSources([...collectedSources, ...chunkSources]);
+ onWebSources(collectedSources);
+ }
+ }
+
const parts = data.candidates?.[0]?.content?.parts;
if (!Array.isArray(parts)) return null;
let token = '';
for (const part of parts) {
if (part.thought) {
- // This is a thinking/thought part
if (!inThought) {
inThought = true;
token += '\n';
}
token += part.text || '';
} else if (part.text) {
- // Regular text part
if (inThought) {
inThought = false;
token += '\n\n\n';
@@ -42,6 +54,8 @@ export const googleResponseParser: ResponseParser = {
onToken,
'Google'
);
+
+ return result;
},
extractContentFromResponse(data: any): string {
@@ -65,6 +79,10 @@ export const googleResponseParser: ResponseParser = {
return text || data.candidates?.[0]?.content?.parts?.[0]?.text || '';
},
+ extractWebSourcesFromResponse(data: any): ChatSource[] {
+ return extractGeminiSources(data);
+ },
+
parseModelsResponse(data: any): ModelSetting[] {
if (!Array.isArray(data.models)) return [];
return data.models.map(
diff --git a/src/services/providers/parsers/ollamaResponseParser.ts b/src/services/providers/parsers/ollamaResponseParser.ts
index 44ad4ab..7e95b8b 100644
--- a/src/services/providers/parsers/ollamaResponseParser.ts
+++ b/src/services/providers/parsers/ollamaResponseParser.ts
@@ -2,29 +2,76 @@
//
// Key differences from all other providers:
// - NDJSON streaming (not SSE)
-// - Response: message.content
+// - Response: message.content; with `think` enabled also message.thinking (reasoning trace)
// - Model listing: /api/tags with models[] array
import { ModelSetting } from '@/types/setting';
+import { ChatSource } from '@/types/chat';
import { ResponseParser } from '@/types/providers';
import { parseNDJSONStream } from '../core/ndjsonStreamParser';
export const ollamaResponseParser: ResponseParser = {
- async parseStreamResponse(res: Response, onToken?: (token: string) => void): Promise {
- return parseNDJSONStream(res, (obj) => obj?.message?.content || null, onToken, {
- logTag: 'Ollama',
- extractError: (obj) => {
- if (obj?.error) {
- return typeof obj.error === 'string' ? obj.error : JSON.stringify(obj.error);
+ async parseStreamResponse(
+ res: Response,
+ onToken?: (token: string) => void,
+ _onWebSources?: (sources: ChatSource[]) => void
+ ): Promise {
+ let inThinking = false;
+
+ const full = await parseNDJSONStream(
+ res,
+ (obj) => {
+ const thinking = obj?.message?.thinking;
+ const content = obj?.message?.content;
+ let token = '';
+
+ if (thinking) {
+ if (!inThinking) {
+ inThinking = true;
+ token += '\n';
+ }
+ token += thinking;
+ }
+
+ if (content) {
+ if (inThinking) {
+ inThinking = false;
+ token += '\n\n\n';
+ }
+ token += content;
}
- return null;
+
+ return token || null;
},
- isDone: (obj) => obj?.done === true,
- });
+ onToken,
+ {
+ logTag: 'Ollama',
+ extractError: (obj) => {
+ if (obj?.error) {
+ return typeof obj.error === 'string' ? obj.error : JSON.stringify(obj.error);
+ }
+ return null;
+ },
+ isDone: (obj) => obj?.done === true,
+ }
+ );
+
+ if (inThinking) {
+ const close = '\n\n\n';
+ if (typeof onToken === 'function') onToken(close);
+ return full + close;
+ }
+
+ return full;
},
extractContentFromResponse(data: any): string {
- return data?.message?.content || '';
+ const thinking = data?.message?.thinking;
+ const content = data?.message?.content || '';
+ if (thinking) {
+ return `\n${thinking}\n\n\n${content}`;
+ }
+ return content;
},
parseModelsResponse(data: any): ModelSetting[] {
diff --git a/src/services/providers/parsers/openaiResponseParser.ts b/src/services/providers/parsers/openaiResponseParser.ts
index 472eaa8..d68697c 100644
--- a/src/services/providers/parsers/openaiResponseParser.ts
+++ b/src/services/providers/parsers/openaiResponseParser.ts
@@ -3,8 +3,10 @@
// Parameterized to support OpenAI, DeepSeek, Qwen, and Azure with shared logic.
import { ModelSetting } from '@/types/setting';
+import { ChatSource } from '@/types/chat';
import { ResponseParser } from '@/types/providers';
import { parseSSEStream } from '../core/sseStreamParser';
+import { extractOpenAISources, deduplicateSources } from './extractWebSources';
export interface OpenAIResponseParserOptions {
/** Human-readable name used as SSE log tag. */
@@ -31,14 +33,26 @@ export function createOpenAIResponseParser(opts: OpenAIResponseParserOptions): R
} = opts;
return {
- async parseStreamResponse(res: Response, onToken?: (token: string) => void): Promise {
- // Track whether we're inside a reasoning block
+ async parseStreamResponse(
+ res: Response,
+ onToken?: (token: string) => void,
+ onWebSources?: (sources: ChatSource[]) => void
+ ): Promise {
let inReasoning = false;
+ let collectedSources: ChatSource[] = [];
- return parseSSEStream(
+ const result = await parseSSEStream(
res,
(data) => {
- // Handle reasoning_content (DeepSeek-R1, Qwen QwQ, OpenAI o-series)
+ // Collect web search citations from streaming chunks
+ if (onWebSources) {
+ const chunkSources = extractOpenAISources(data);
+ if (chunkSources.length > 0) {
+ collectedSources = deduplicateSources([...collectedSources, ...chunkSources]);
+ onWebSources(collectedSources);
+ }
+ }
+
const reasoningContent = data.choices?.[0]?.delta?.reasoning_content;
const content = data.choices?.[0]?.delta?.content;
@@ -65,6 +79,8 @@ export function createOpenAIResponseParser(opts: OpenAIResponseParserOptions): R
onToken,
providerName
);
+
+ return result;
},
extractContentFromResponse(data: any): string {
@@ -76,6 +92,10 @@ export function createOpenAIResponseParser(opts: OpenAIResponseParserOptions): R
return content;
},
+ extractWebSourcesFromResponse(data: any): ChatSource[] {
+ return extractOpenAISources(data);
+ },
+
parseModelsResponse(data: any): ModelSetting[] {
if (!Array.isArray(data.data)) return [];
return data.data.filter(modelFilter).map(
diff --git a/src/types/chat.d.ts b/src/types/chat.d.ts
index 813133c..a66a053 100644
--- a/src/types/chat.d.ts
+++ b/src/types/chat.d.ts
@@ -1,4 +1,10 @@
+export type ChatSource = {
+ url: string;
+ title?: string;
+};
+
export type Message = {
role: 'user' | 'assistant' | 'system';
content: string;
+ sources?: ChatSource[];
};
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index bc47f18..a8065b7 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -69,6 +69,8 @@ interface ElectronAPI {
// Open the installed application folder in the OS file explorer
openInstallFolder?: () => Promise<{ success: boolean; error?: string }>;
+ // Open external URL in default browser (http/https only)
+ openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
// General app events
quitApp: () => void;
diff --git a/src/types/providers.d.ts b/src/types/providers.d.ts
index 6f9df55..2c256c9 100644
--- a/src/types/providers.d.ts
+++ b/src/types/providers.d.ts
@@ -1,4 +1,4 @@
-import { Message } from './chat';
+import { Message, ChatSource } from './chat';
import { ModelSetting } from './setting';
export interface ProviderOptions {
@@ -9,6 +9,8 @@ export interface ProviderOptions {
top_p?: number;
signal?: AbortSignal;
reasoning?: boolean;
+ webSearch?: boolean;
+ onWebSources?: (sources: ChatSource[]) => void;
}
export interface BaseProviderConfig {
@@ -108,11 +110,18 @@ export interface RequestBuilder {
*/
export interface ResponseParser {
/** Parse a streaming response, calling onToken for each incremental token. */
- parseStreamResponse(res: Response, onToken?: (token: string) => void): Promise;
+ parseStreamResponse(
+ res: Response,
+ onToken?: (token: string) => void,
+ onWebSources?: (sources: ChatSource[]) => void
+ ): Promise;
/** Extract content from a non-streaming response JSON. */
extractContentFromResponse(data: any): string;
+ /** Extract web search sources from a non-streaming response JSON. */
+ extractWebSourcesFromResponse?(data: any): ChatSource[];
+
/** Parse the models list API response into ModelSetting[]. */
parseModelsResponse(data: any, config: BaseProviderConfig): ModelSetting[];
}
diff --git a/src/types/setting.d.ts b/src/types/setting.d.ts
index a615b2b..7c37d0a 100644
--- a/src/types/setting.d.ts
+++ b/src/types/setting.d.ts
@@ -37,6 +37,7 @@ export interface ChatSetting {
top_p: number;
streamingEnabled: boolean;
reasoningEnabled: boolean;
+ webSearchEnabled: boolean;
defaultModel: string;
defaultProvider: string;
}