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
15 changes: 15 additions & 0 deletions main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions settings.default.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"top_p": 0.95,
"streamingEnabled": true,
"reasoningEnabled": false,
"webSearchEnabled": false,
"defaultModel": null,
"defaultProvider": null
},
Expand Down
6 changes: 5 additions & 1 deletion src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
LuArrowUp,
LuLightbulb,
LuLightbulbOff,
LuGlobe,
} from 'react-icons/lu';
import { MdOutlineCleaningServices } from 'react-icons/md';

Expand Down Expand Up @@ -66,7 +67,8 @@ type IconType =
| 'paint-roller'
| 'arrow-up'
| 'lightbulb'
| 'lightbulb-off';
| 'lightbulb-off'
| 'globe';

interface IconProps {
className?: string;
Expand Down Expand Up @@ -153,6 +155,8 @@ function Icon({
return <LuLightbulb className={svgClassName} color={color} size={size} />;
case 'lightbulb-off':
return <LuLightbulbOff className={svgClassName} color={color} size={size} />;
case 'globe':
return <LuGlobe className={svgClassName} color={color} size={size} />;
default:
return null;
}
Expand Down
126 changes: 126 additions & 0 deletions src/components/MessageWebSources.tsx
Original file line number Diff line number Diff line change
@@ -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 */}
<button
type="button"
onClick={() => setIsOpen(true)}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full
bg-default-100 hover:bg-default-200 transition-colors
text-xs text-default-600 cursor-pointer select-none"
aria-label={t('chat.sources')}
>
{/* Stacked host indicators */}
<span className="flex -space-x-1.5">
{uniqueHosts.slice(0, previewCount).map((host, i) => (
<span
key={host}
className={`inline-flex items-center justify-center w-4 h-4 rounded-full
ring-1 ring-background text-[8px] font-bold text-white
${colorForIndex(i)}`}
title={host}
>
{host.charAt(0).toUpperCase()}
</span>
))}
</span>
<span className="font-medium">
{t('chat.sources')} · {sources.length}
</span>
</button>

{/* Sources drawer */}
<Drawer isOpen={isOpen} onClose={() => setIsOpen(false)} placement="right" size="sm">
<DrawerContent>
<DrawerHeader className="flex items-center gap-2 text-base font-semibold">
<LuGlobe size={16} />
{t('chat.sources')}
</DrawerHeader>
<DrawerBody className="px-4 pb-6">
<ul className="flex flex-col gap-2">
{sources.map((source, i) => {
const host = extractHostname(source.url);
return (
<li key={`${source.url}-${i}`}>
<button
type="button"
onClick={() => openUrl(source.url)}
className="w-full text-left flex items-start gap-3 p-3 rounded-xl
bg-default-50 hover:bg-default-100 transition-colors
cursor-pointer group"
>
<span
className={`mt-0.5 flex-shrink-0 inline-flex items-center justify-center
w-6 h-6 rounded-lg text-[10px] font-bold text-white
${colorForIndex(i)}`}
>
{host.charAt(0).toUpperCase()}
</span>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-foreground truncate">
{source.title || host}
</span>
<span className="block text-xs text-default-400 truncate">{host}</span>
</span>
<LuExternalLink
size={14}
className="mt-1 flex-shrink-0 text-default-300 group-hover:text-default-500 transition-colors"
/>
</button>
</li>
);
})}
</ul>
</DrawerBody>
</DrawerContent>
</Drawer>
</>
);
}
35 changes: 35 additions & 0 deletions src/components/WebSearchToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-2">
<Component {...getBaseProps()}>
<VisuallyHidden>
<input {...getInputProps()} aria-label={props['aria-label']} />
</VisuallyHidden>
<div
{...getWrapperProps()}
className={slots.wrapper({
class: [
'w-8 h-8',
'flex items-center justify-center',
'rounded-lg bg-default-100 hover:bg-default-200',
],
})}
>
<Icon icon="globe" />
</div>
</Component>
</div>
);
}
7 changes: 5 additions & 2 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -105,7 +107,8 @@
"send": "Send",
"sendMessage": "Send a message...",
"thinking": "Thinking",
"thought": "Thought"
"thought": "Thought",
"sources": "Sources"
},
"common": {
"brand": "SnapMind",
Expand Down
7 changes: 5 additions & 2 deletions src/i18n/locales/zh-hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@
"streaming": "响应流式传输",
"streamingDescription": "实时显示生成的回应",
"reasoning": "推理模式",
"reasoningDescription": "为支持的模型启用深度思考 / 推理模式"
"reasoningDescription": "为支持的模型启用深度思考 / 推理模式",
"webSearch": "联网搜索",
"webSearchDescription": "在支持的厂商中启用原生联网搜索(如 Google Gemini 搜索增强、OpenAI 搜索模型)"
},
"hotkeys": {
"title": "快捷键",
Expand Down Expand Up @@ -105,7 +107,8 @@
"send": "发送",
"sendMessage": "发送消息...",
"thinking": "思考过程",
"thought": "思考过程"
"thought": "思考过程",
"sources": "来源"
},
"common": {
"brand": "SnapMind",
Expand Down
7 changes: 5 additions & 2 deletions src/i18n/locales/zh-hant.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@
"streaming": "響應串流",
"streamingDescription": "即時顯示生成的回應",
"reasoning": "推理模式",
"reasoningDescription": "為支援的模型啟用深度思考 / 推理模式"
"reasoningDescription": "為支援的模型啟用深度思考 / 推理模式",
"webSearch": "聯網搜尋",
"webSearchDescription": "在支援的廠商中啟用原生聯網搜尋(如 Google Gemini 搜尋增強、OpenAI 搜尋模型)"
},
"hotkeys": {
"title": "快捷鍵",
Expand Down Expand Up @@ -105,7 +107,8 @@
"send": "傳送",
"sendMessage": "傳送訊息...",
"thinking": "思考過程",
"thought": "思考過程"
"thought": "思考過程",
"sources": "來源"
},
"common": {
"brand": "SnapMind",
Expand Down
4 changes: 4 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 } from '@/types/chat';
import ThinkingMessage from '@/components/ThinkingMessage';
import MessageWebSources from '@/components/MessageWebSources';
import { useChatMessage } from '@/hooks/useChatMessage';

interface ChatMessageProps {
Expand Down Expand Up @@ -39,6 +40,9 @@ export default function ChatMessage({ message }: ChatMessageProps) {
>
{main}
</ReactMarkdown>
{!isUser && message.sources && message.sources.length > 0 && (
<MessageWebSources sources={message.sources} />
)}
</div>
</div>
);
Expand Down
28 changes: 25 additions & 3 deletions src/pages/ChatPopup/ChatPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,10 +34,12 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
const lastScrollTopRef = useRef<number>(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
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
}
Comment thread
Louis-7 marked this conversation as resolved.
return updatedMsgs;
});
},
}
);
} catch (error) {
if (error && error.name === 'AbortError') {
Expand Down Expand Up @@ -293,6 +307,14 @@ export default function ChatPopup({ initialMessage }: ChatPopupProps) {
ref={inputRef}
/>
<div className="flex-shrink-0 flex flex-row justify-end gap-2 items-center">
<WebSearchToggle
aria-label={t('settings.chat.webSearch')}
isSelected={webSearchEnabled}
onValueChange={(checked) => {
setWebSearchEnabled(checked);
setSettings(['chat', 'webSearchEnabled'], checked);
}}
/>
<ReasoningToggle
aria-label={t('settings.chat.reasoning')}
isSelected={reasoningEnabled}
Expand Down
Loading
Loading