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 ( +
+ + + + +
+ +
+
+
+ ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 58503b3..67961cd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -72,7 +72,9 @@ "streaming": "Response Streaming", "streamingDescription": "Show responses as they are being generated", "reasoning": "Reasoning", - "reasoningDescription": "Enable extended thinking / reasoning mode for supported models" + "reasoningDescription": "Enable extended thinking / reasoning mode for supported models", + "webSearch": "Web search", + "webSearchDescription": "Use provider-native web search when supported (e.g. Google Gemini grounding, OpenAI search models)" }, "hotkeys": { "title": "Hotkeys", @@ -105,7 +107,8 @@ "send": "Send", "sendMessage": "Send a message...", "thinking": "Thinking", - "thought": "Thought" + "thought": "Thought", + "sources": "Sources" }, "common": { "brand": "SnapMind", diff --git a/src/i18n/locales/zh-hans.json b/src/i18n/locales/zh-hans.json index 109c4a3..72929f5 100644 --- a/src/i18n/locales/zh-hans.json +++ b/src/i18n/locales/zh-hans.json @@ -72,7 +72,9 @@ "streaming": "响应流式传输", "streamingDescription": "实时显示生成的回应", "reasoning": "推理模式", - "reasoningDescription": "为支持的模型启用深度思考 / 推理模式" + "reasoningDescription": "为支持的模型启用深度思考 / 推理模式", + "webSearch": "联网搜索", + "webSearchDescription": "在支持的厂商中启用原生联网搜索(如 Google Gemini 搜索增强、OpenAI 搜索模型)" }, "hotkeys": { "title": "快捷键", @@ -105,7 +107,8 @@ "send": "发送", "sendMessage": "发送消息...", "thinking": "思考过程", - "thought": "思考过程" + "thought": "思考过程", + "sources": "来源" }, "common": { "brand": "SnapMind", diff --git a/src/i18n/locales/zh-hant.json b/src/i18n/locales/zh-hant.json index 8a42da2..cc524d8 100644 --- a/src/i18n/locales/zh-hant.json +++ b/src/i18n/locales/zh-hant.json @@ -72,7 +72,9 @@ "streaming": "響應串流", "streamingDescription": "即時顯示生成的回應", "reasoning": "推理模式", - "reasoningDescription": "為支援的模型啟用深度思考 / 推理模式" + "reasoningDescription": "為支援的模型啟用深度思考 / 推理模式", + "webSearch": "聯網搜尋", + "webSearchDescription": "在支援的廠商中啟用原生聯網搜尋(如 Google Gemini 搜尋增強、OpenAI 搜尋模型)" }, "hotkeys": { "title": "快捷鍵", @@ -105,7 +107,8 @@ "send": "傳送", "sendMessage": "傳送訊息...", "thinking": "思考過程", - "thought": "思考過程" + "thought": "思考過程", + "sources": "來源" }, "common": { "brand": "SnapMind", diff --git a/src/pages/ChatMessage/ChatMessage.tsx b/src/pages/ChatMessage/ChatMessage.tsx index d61341a..39b2b38 100644 --- a/src/pages/ChatMessage/ChatMessage.tsx +++ b/src/pages/ChatMessage/ChatMessage.tsx @@ -6,6 +6,7 @@ import rehypeHighlight from 'rehype-highlight'; import { Message } from '@/types/chat'; import ThinkingMessage from '@/components/ThinkingMessage'; +import MessageWebSources from '@/components/MessageWebSources'; import { useChatMessage } from '@/hooks/useChatMessage'; interface ChatMessageProps { @@ -39,6 +40,9 @@ export default function ChatMessage({ message }: ChatMessageProps) { > {main} + {!isUser && message.sources && message.sources.length > 0 && ( + + )} ); diff --git a/src/pages/ChatPopup/ChatPopup.tsx b/src/pages/ChatPopup/ChatPopup.tsx index d76d52a..2cf4b32 100644 --- a/src/pages/ChatPopup/ChatPopup.tsx +++ b/src/pages/ChatPopup/ChatPopup.tsx @@ -7,10 +7,11 @@ import { useSettings } from '../../hooks/useSettings'; import { AIService } from '../../services/AIService'; import ChatMessage from '../ChatMessage/ChatMessage'; -import { Message } from '@/types/chat'; +import { Message, ChatSource } from '@/types/chat'; import { useTranslation } from 'react-i18next'; import Icon from '../../components/Icon'; import ReasoningToggle from '@/components/ReasoningToggle'; +import WebSearchToggle from '@/components/WebSearchToggle'; import { BaseProviderConfig, ProviderType } from '@/types/providers'; interface ChatPopupProps { @@ -33,10 +34,12 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) { const lastScrollTopRef = useRef(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; }