From de141c5944bcb8c6f08be5ccd853bb0ae187ba75 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Sun, 15 Mar 2026 17:49:56 -0700 Subject: [PATCH 01/91] feat: add agentic tool-calling system to Agent Mode Transform Agent Mode from text-only chat into a full agentic experience with native tool/function calling. The agent can now search notes, copy to clipboard, search the web (via cloud API), and check calendar events. - Tool registry with OpenAI-compatible function calling format - ReAct execution loop with parallel read-only tool execution - SSE streaming with incremental tool call argument accumulation - Inline tool execution UI (compact pills with status animations) - Text input field alongside voice input for tool-heavy workflows - Dynamic system prompt with tool usage instructions - IPC handler for web search via OpenWhispr cloud API - Database migration for tool message metadata - i18n strings for all 10 supported locales --- preload.js | 1 + src/components/AgentOverlay.tsx | 274 ++++++++++++++++++-------- src/components/agent/AgentChat.tsx | 32 ++- src/components/agent/AgentInput.tsx | 106 +++++++--- src/components/agent/AgentMessage.tsx | 71 ++++++- src/config/prompts.ts | 25 ++- src/helpers/database.js | 6 + src/helpers/ipcHandlers.js | 37 ++++ src/locales/de/translation.json | 19 +- src/locales/en/translation.json | 19 +- src/locales/es/translation.json | 19 +- src/locales/fr/translation.json | 19 +- src/locales/it/translation.json | 19 +- src/locales/ja/translation.json | 19 +- src/locales/pt/translation.json | 19 +- src/locales/ru/translation.json | 19 +- src/locales/zh-CN/translation.json | 19 +- src/locales/zh-TW/translation.json | 19 +- src/services/AgentLoop.ts | 185 +++++++++++++++++ src/services/ReasoningService.ts | 228 +++++++++++++++++++++ src/services/tools/ToolRegistry.ts | 51 +++++ src/services/tools/calendarTool.ts | 77 ++++++++ src/services/tools/clipboardTool.ts | 39 ++++ src/services/tools/index.ts | 30 +++ src/services/tools/searchNotesTool.ts | 61 ++++++ src/services/tools/webSearchTool.ts | 44 +++++ src/types/electron.ts | 13 ++ 27 files changed, 1344 insertions(+), 126 deletions(-) create mode 100644 src/services/AgentLoop.ts create mode 100644 src/services/tools/ToolRegistry.ts create mode 100644 src/services/tools/calendarTool.ts create mode 100644 src/services/tools/clipboardTool.ts create mode 100644 src/services/tools/index.ts create mode 100644 src/services/tools/searchNotesTool.ts create mode 100644 src/services/tools/webSearchTool.ts diff --git a/preload.js b/preload.js index 5575aec20..4ba03b1d1 100644 --- a/preload.js +++ b/preload.js @@ -585,6 +585,7 @@ contextBridge.exposeInMainWorld("electronAPI", { (callback) => (_event, chunk) => callback(chunk) ), onAgentStreamDone: registerListener("agent-stream-done", (callback) => () => callback()), + agentWebSearch: (query, numResults) => ipcRenderer.invoke("agent-web-search", query, numResults), // Agent conversation persistence createAgentConversation: (title) => ipcRenderer.invoke("db-create-agent-conversation", title), diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index ec0e44329..0687de051 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "./lib/utils"; import { AgentTitleBar } from "./agent/AgentTitleBar"; import { AgentChat } from "./agent/AgentChat"; @@ -7,23 +8,42 @@ import AudioManager from "../helpers/audioManager"; import ReasoningService from "../services/ReasoningService"; import { getSettings } from "../stores/settingsStore"; import { getAgentSystemPrompt } from "../config/prompts"; - -type AgentState = "idle" | "listening" | "transcribing" | "thinking" | "streaming"; +import { run as runAgentLoop } from "../services/AgentLoop"; +import { createToolRegistry } from "../services/tools"; + +type AgentState = + | "idle" + | "listening" + | "transcribing" + | "thinking" + | "streaming" + | "tool-executing"; + +interface ToolCallEntry { + id: string; + name: string; + arguments: string; + status: "executing" | "completed" | "error"; + result?: string; +} interface Message { id: string; - role: "user" | "assistant"; + role: "user" | "assistant" | "tool"; content: string; isStreaming: boolean; + toolCalls?: ToolCallEntry[]; } const MIN_HEIGHT = 200; const MIN_WIDTH = 360; export default function AgentOverlay() { + const { t } = useTranslation(); const [messages, setMessages] = useState([]); const [agentState, setAgentState] = useState("idle"); const [partialTranscript, setPartialTranscript] = useState(""); + const [toolStatus, setToolStatus] = useState(""); const audioManagerRef = useRef | null>(null); const messagesRef = useRef([]); const agentStateRef = useRef("idle"); @@ -44,94 +64,175 @@ export default function AgentOverlay() { ]); }, []); - const handleTranscriptionComplete = useCallback(async (text: string) => { - if (!text.trim()) { - setAgentState("idle"); - return; - } - - // Create conversation on first message - if (!conversationIdRef.current) { - const conv = await window.electronAPI?.createAgentConversation?.("New conversation"); - conversationIdRef.current = conv?.id ?? null; - } - - const userMsg: Message = { - id: crypto.randomUUID(), - role: "user", - content: text, - isStreaming: false, - }; - setMessages((prev) => [...prev, userMsg]); - - if (conversationIdRef.current) { - window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "user", text); - } - - // Auto-title after first user message - const allMessages = messagesRef.current; - if (conversationIdRef.current && allMessages.length === 0) { - const title = text.slice(0, 50) + (text.length > 50 ? "..." : ""); - window.electronAPI?.updateAgentConversationTitle?.(conversationIdRef.current, title); - } + const handleTranscriptionComplete = useCallback( + async (text: string) => { + if (!text.trim()) { + setAgentState("idle"); + return; + } - setAgentState("thinking"); + // Create conversation on first message + if (!conversationIdRef.current) { + const conv = await window.electronAPI?.createAgentConversation?.("New conversation"); + conversationIdRef.current = conv?.id ?? null; + } - const settings = getSettings(); - const systemPrompt = getAgentSystemPrompt(); + const userMsg: Message = { + id: crypto.randomUUID(), + role: "user", + content: text, + isStreaming: false, + }; + setMessages((prev) => [...prev, userMsg]); - const llmMessages = [ - { role: "system", content: systemPrompt }, - ...[...allMessages, userMsg].slice(-20).map((m) => ({ role: m.role, content: m.content })), - ]; + if (conversationIdRef.current) { + window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "user", text); + } - const assistantId = crypto.randomUUID(); - setMessages((prev) => [ - ...prev, - { id: assistantId, role: "assistant", content: "", isStreaming: true }, - ]); - setAgentState("streaming"); + // Auto-title after first user message + const allMessages = messagesRef.current; + if (conversationIdRef.current && allMessages.length === 0) { + const title = text.slice(0, 50) + (text.length > 50 ? "..." : ""); + window.electronAPI?.updateAgentConversationTitle?.(conversationIdRef.current, title); + } - const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; + setAgentState("thinking"); + + const settings = getSettings(); + + const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; + const toolSupportedProviders = ["openai", "groq", "custom"]; + const supportsTools = + !isCloudAgent && toolSupportedProviders.includes(settings.agentProvider); + + const registry = supportsTools + ? createToolRegistry({ + isSignedIn: settings.isSignedIn, + gcalConnected: settings.gcalConnected, + }) + : null; + const systemPrompt = getAgentSystemPrompt(registry?.getAll().map((t) => t.name)); + + const llmMessages = [ + { role: "system", content: systemPrompt }, + ...[...allMessages, userMsg].slice(-20).map((m) => ({ role: m.role, content: m.content })), + ]; + + const assistantId = crypto.randomUUID(); + setMessages((prev) => [ + ...prev, + { id: assistantId, role: "assistant", content: "", isStreaming: true }, + ]); + setAgentState("streaming"); + + try { + let fullContent = ""; + + if (supportsTools && registry) { + await runAgentLoop({ + messages: llmMessages, + model: settings.agentModel, + provider: settings.agentProvider, + systemPrompt, + tools: registry, + callbacks: { + onContentChunk: (text) => { + fullContent += text; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + }, + onToolStart: (toolName) => { + setAgentState("tool-executing"); + setToolStatus( + t(`agentMode.tools.${toolName}Status`, { defaultValue: `Using ${toolName}...` }) + ); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + toolCalls: [ + ...(m.toolCalls || []), + { + id: crypto.randomUUID(), + name: toolName, + arguments: "", + status: "executing" as const, + }, + ], + } + : m + ) + ); + }, + onToolComplete: (toolName, result) => { + setAgentState("streaming"); + setToolStatus(""); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + toolCalls: m.toolCalls?.map((tc) => + tc.name === toolName && tc.status === "executing" + ? { ...tc, status: "completed" as const, result: result.displayText } + : tc + ), + } + : m + ) + ); + }, + onError: (error) => { + console.error("Agent loop error:", error); + }, + }, + maxIterations: 5, + }); + } else { + const streamSource = isCloudAgent + ? ReasoningService.processTextStreamingCloud(llmMessages, { systemPrompt }) + : ReasoningService.processTextStreaming( + llmMessages, + settings.agentModel, + settings.agentProvider, + { systemPrompt } + ); + + for await (const chunk of streamSource) { + fullContent += chunk; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + } + } - try { - let fullContent = ""; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) + ); - const streamSource = isCloudAgent - ? ReasoningService.processTextStreamingCloud(llmMessages, { systemPrompt }) - : ReasoningService.processTextStreaming( - llmMessages, - settings.agentModel, - settings.agentProvider, - { systemPrompt } + if (conversationIdRef.current) { + window.electronAPI?.addAgentMessage?.( + conversationIdRef.current, + "assistant", + fullContent ); - - for await (const chunk of streamSource) { - fullContent += chunk; + } + } catch (error) { setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + prev.map((m) => + m.id === assistantId + ? { ...m, content: `Error: ${(error as Error).message}`, isStreaming: false } + : m + ) ); } - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) - ); - - if (conversationIdRef.current) { - window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "assistant", fullContent); - } - } catch (error) { - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { ...m, content: `Error: ${(error as Error).message}`, isStreaming: false } - : m - ) - ); - } - - setAgentState("idle"); - }, []); + setAgentState("idle"); + }, + [t] + ); useEffect(() => { const am = new AudioManager(); @@ -244,10 +345,18 @@ export default function AgentOverlay() { }; }, []); + const handleTextSubmit = useCallback( + (text: string) => { + handleTranscriptionComplete(text); + }, + [handleTranscriptionComplete] + ); + const handleNewChat = useCallback(() => { setMessages([]); setAgentState("idle"); setPartialTranscript(""); + setToolStatus(""); conversationIdRef.current = null; }, []); @@ -268,7 +377,12 @@ export default function AgentOverlay() { > - + {/* Resize handles — edges */} diff --git a/src/components/agent/AgentChat.tsx b/src/components/agent/AgentChat.tsx index 923b5d54f..b91991e14 100644 --- a/src/components/agent/AgentChat.tsx +++ b/src/components/agent/AgentChat.tsx @@ -5,11 +5,20 @@ import { AgentMessage } from "./AgentMessage"; import { useSettingsStore } from "../../stores/settingsStore"; import { formatHotkeyLabel } from "../../utils/hotkeys"; -interface Message { +export interface ToolCallInfo { id: string; - role: "user" | "assistant"; + name: string; + arguments: string; + status: "executing" | "completed" | "error"; + result?: string; +} + +export interface Message { + id: string; + role: "user" | "assistant" | "tool"; content: string; isStreaming: boolean; + toolCalls?: ToolCallInfo[]; } interface AgentChatProps { @@ -39,14 +48,17 @@ export function AgentChat({ messages }: AgentChatProps) { ) : (
- {messages.map((msg) => ( - - ))} + {messages + .filter((msg) => msg.role !== "tool") + .map((msg) => ( + + ))}
)} diff --git a/src/components/agent/AgentInput.tsx b/src/components/agent/AgentInput.tsx index a55c7b5cf..f0f86807a 100644 --- a/src/components/agent/AgentInput.tsx +++ b/src/components/agent/AgentInput.tsx @@ -1,14 +1,23 @@ -import { Mic } from "lucide-react"; +import { useState, useRef, useCallback } from "react"; +import { Mic, SendHorizontal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; import { useSettingsStore } from "../../stores/settingsStore"; import { formatHotkeyLabel, isGlobeLikeHotkey } from "../../utils/hotkeys"; -type AgentState = "idle" | "listening" | "transcribing" | "thinking" | "streaming"; +type AgentState = + | "idle" + | "listening" + | "transcribing" + | "thinking" + | "streaming" + | "tool-executing"; interface AgentInputProps { agentState: AgentState; partialTranscript: string; + toolStatus?: string; + onTextSubmit?: (text: string) => void; } function Kbd({ children }: { children: React.ReactNode }) { @@ -80,37 +89,81 @@ function InputLoadingDots() { ); } -export function AgentInput({ agentState, partialTranscript }: AgentInputProps) { +export function AgentInput({ + agentState, + partialTranscript, + toolStatus, + onTextSubmit, +}: AgentInputProps) { const { t } = useTranslation(); const agentKey = useSettingsStore((s) => s.agentKey); + const [inputText, setInputText] = useState(""); + const inputRef = useRef(null); + + const handleSubmit = useCallback(() => { + const text = inputText.trim(); + if (!text || !onTextSubmit) return; + onTextSubmit(text); + setInputText(""); + }, [inputText, onTextSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + const isIdle = agentState === "idle"; return (
- {agentState === "idle" && ( -
-
-
+ setInputText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t("agentMode.input.typeMessage")} + className={cn( + "bg-transparent border-none outline-none flex-1", + "text-[13px] text-foreground placeholder:text-muted-foreground/40", + "min-w-0" + )} + /> + {inputText.trim() ? ( + + ) : ( +
+
+ +
+
- - {t("agentMode.input.holdToSpeak")} - - -
-
- Esc - - {t("agentMode.input.toClose")} - -
+ )}
)} @@ -140,6 +193,15 @@ export function AgentInput({ agentState, partialTranscript }: AgentInputProps) { )} + + {agentState === "tool-executing" && ( + <> + + + {toolStatus || t("agentMode.input.thinking")} + + + )}
); } diff --git a/src/components/agent/AgentMessage.tsx b/src/components/agent/AgentMessage.tsx index 1a569c06a..ecfe55eb9 100644 --- a/src/components/agent/AgentMessage.tsx +++ b/src/components/agent/AgentMessage.tsx @@ -1,15 +1,74 @@ import { useState } from "react"; -import { Copy, Check } from "lucide-react"; +import { Copy, Check, Search, Globe, ClipboardCopy, Calendar } from "lucide-react"; import { cn } from "../lib/utils"; import { MarkdownRenderer } from "../ui/MarkdownRenderer"; +import type { ToolCallInfo } from "./AgentChat"; interface AgentMessageProps { role: "user" | "assistant"; content: string; isStreaming: boolean; + toolCalls?: ToolCallInfo[]; } -export function AgentMessage({ role, content, isStreaming }: AgentMessageProps) { +const toolIcons: Record = { + search_notes: Search, + web_search: Globe, + copy_to_clipboard: ClipboardCopy, + get_calendar_events: Calendar, +}; + +function ToolCallPill({ toolCall }: { toolCall: ToolCallInfo }) { + const [expanded, setExpanded] = useState(false); + const Icon = toolIcons[toolCall.name] || Search; + const isExecuting = toolCall.status === "executing"; + const isError = toolCall.status === "error"; + const resultLines = toolCall.result?.split("\n") ?? []; + const isExpandable = resultLines.length > 3; + + return ( +
setExpanded((v) => !v) : undefined} + > +
+ + {isExecuting ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : ( + + {toolCall.result || toolCall.name} + + )} +
+ {!isExecuting && isExpandable && ( +
+
+            {toolCall.result}
+          
+
+ )} +
+ ); +} + +export function AgentMessage({ role, content, isStreaming, toolCalls }: AgentMessageProps) { const [copied, setCopied] = useState(false); const handleCopy = async () => { @@ -65,6 +124,14 @@ export function AgentMessage({ role, content, isStreaming }: AgentMessageProps) {copied ? : } + {toolCalls && toolCalls.length > 0 && ( +
+ {toolCalls.map((tc) => ( + + ))} +
+ )} + = { + search_notes: + "Use search_notes to find information from the user's past meetings, discussions, or personal notes before answering from memory.", + web_search: + "Use web_search for questions about current events, facts you're unsure about, or anything requiring up-to-date information.", + copy_to_clipboard: + "Use copy_to_clipboard when the user asks you to copy something to their clipboard.", + get_calendar_events: + "Use get_calendar_events to check the user's schedule, upcoming meetings, or calendar events.", +}; + +export function getAgentSystemPrompt(availableTools?: string[]): string { if (typeof window !== "undefined" && window.localStorage) { const custom = window.localStorage.getItem("agentSystemPrompt"); if (custom) return custom; } - return DEFAULT_AGENT_SYSTEM_PROMPT; + + let prompt = DEFAULT_AGENT_SYSTEM_PROMPT; + + if (availableTools && availableTools.length > 0) { + const toolLines = availableTools.map((name) => TOOL_INSTRUCTIONS[name]).filter(Boolean); + if (toolLines.length > 0) { + prompt += "\n\nYou have access to tools. " + toolLines.join(" "); + } + } + + return prompt; } diff --git a/src/helpers/database.js b/src/helpers/database.js index 32284dfc1..b4dc348d4 100644 --- a/src/helpers/database.js +++ b/src/helpers/database.js @@ -230,6 +230,12 @@ class DatabaseManager { "CREATE INDEX IF NOT EXISTS idx_agent_messages_conversation ON agent_messages(conversation_id)" ); + try { + this.db.exec("ALTER TABLE agent_messages ADD COLUMN metadata TEXT"); + } catch (err) { + if (!err.message.includes("duplicate column")) throw err; + } + const actionCount = this.db.prepare("SELECT COUNT(*) as count FROM actions").get(); if (actionCount.count === 0) { this.db diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index d7adf6364..6f1b20801 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -2712,6 +2712,43 @@ class IPCHandlers { } }); + ipcMain.handle("agent-web-search", async (event, query, numResults = 5) => { + try { + const apiUrl = getApiUrl(); + if (!apiUrl) throw new Error("OpenWhispr API URL not configured"); + + const cookieHeader = await getSessionCookies(event); + if (!cookieHeader) throw new Error("No session cookies available"); + + debugLogger.debug("Agent web search request", { query, numResults }, "cloud-api"); + + const response = await fetch(`${apiUrl}/api/agent/web-search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + }, + body: JSON.stringify({ query, numResults }), + }); + + if (!response.ok) { + if (response.status === 401) { + return { success: false, error: "Session expired", code: "AUTH_EXPIRED" }; + } + const errorData = await response.json().catch(() => ({})); + return { + success: false, + error: errorData.error || `API error: ${response.status}`, + }; + } + + return await response.json(); + } catch (error) { + debugLogger.error("Agent web search error:", error); + return { success: false, error: error.message }; + } + }); + ipcMain.handle( "cloud-streaming-usage", async (event, text, audioDurationSeconds, opts = {}) => { diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index f737682f2..6802ee828 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1812,7 +1812,24 @@ "toClose": "schließen", "listening": "Hört zu...", "transcribing": "Transkribiert...", - "thinking": "Denkt nach..." + "thinking": "Denkt nach...", + "typeMessage": "Nachricht eingeben...", + "send": "Senden" + }, + "tools": { + "search_notesStatus": "Notizen werden durchsucht...", + "web_searchStatus": "Websuche...", + "copy_to_clipboardStatus": "In Zwischenablage kopieren...", + "get_calendar_eventsStatus": "Kalender wird geprüft...", + "foundNotes": "{{count}} Notiz gefunden", + "foundNotes_plural": "{{count}} Notizen gefunden", + "searchResults": "{{count}} Ergebnis gefunden", + "searchResults_plural": "{{count}} Ergebnisse gefunden", + "copiedToClipboard": "In Zwischenablage kopiert", + "calendarEvents": "{{count}} Termin gefunden", + "calendarEvents_plural": "{{count}} Termine gefunden", + "noResults": "Keine Ergebnisse", + "error": "Werkzeugfehler" } }, "integrations": { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index e18649dd2..6f151f837 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1882,7 +1882,24 @@ "toClose": "close", "listening": "Listening...", "transcribing": "Transcribing...", - "thinking": "Thinking..." + "thinking": "Thinking...", + "typeMessage": "Type a message...", + "send": "Send" + }, + "tools": { + "search_notesStatus": "Searching notes...", + "web_searchStatus": "Searching the web...", + "copy_to_clipboardStatus": "Copying to clipboard...", + "get_calendar_eventsStatus": "Checking calendar...", + "foundNotes": "Found {{count}} note", + "foundNotes_plural": "Found {{count}} notes", + "searchResults": "Found {{count}} result", + "searchResults_plural": "Found {{count}} results", + "copiedToClipboard": "Copied to clipboard", + "calendarEvents": "Found {{count}} event", + "calendarEvents_plural": "Found {{count}} events", + "noResults": "No results found", + "error": "Tool error" } }, "integrations": { diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 272560202..b49d92978 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1812,7 +1812,24 @@ "toClose": "cerrar", "listening": "Escuchando...", "transcribing": "Transcribiendo...", - "thinking": "Pensando..." + "thinking": "Pensando...", + "typeMessage": "Escribe un mensaje...", + "send": "Enviar" + }, + "tools": { + "search_notesStatus": "Buscando notas...", + "web_searchStatus": "Buscando en la web...", + "copy_to_clipboardStatus": "Copiando al portapapeles...", + "get_calendar_eventsStatus": "Consultando calendario...", + "foundNotes": "{{count}} nota encontrada", + "foundNotes_plural": "{{count}} notas encontradas", + "searchResults": "{{count}} resultado encontrado", + "searchResults_plural": "{{count}} resultados encontrados", + "copiedToClipboard": "Copiado al portapapeles", + "calendarEvents": "{{count}} evento encontrado", + "calendarEvents_plural": "{{count}} eventos encontrados", + "noResults": "Sin resultados", + "error": "Error de herramienta" } }, "integrations": { diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 9ddd4e6fb..f50dac182 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1812,7 +1812,24 @@ "toClose": "fermer", "listening": "Écoute...", "transcribing": "Transcription...", - "thinking": "Réflexion..." + "thinking": "Réflexion...", + "typeMessage": "Écrivez un message...", + "send": "Envoyer" + }, + "tools": { + "search_notesStatus": "Recherche de notes...", + "web_searchStatus": "Recherche sur le web...", + "copy_to_clipboardStatus": "Copie dans le presse-papiers...", + "get_calendar_eventsStatus": "Vérification du calendrier...", + "foundNotes": "{{count}} note trouvée", + "foundNotes_plural": "{{count}} notes trouvées", + "searchResults": "{{count}} résultat trouvé", + "searchResults_plural": "{{count}} résultats trouvés", + "copiedToClipboard": "Copié dans le presse-papiers", + "calendarEvents": "{{count}} événement trouvé", + "calendarEvents_plural": "{{count}} événements trouvés", + "noResults": "Aucun résultat", + "error": "Erreur d'outil" } }, "integrations": { diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 107aaa675..835c5e5a1 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1812,7 +1812,24 @@ "toClose": "chiudi", "listening": "Ascolto...", "transcribing": "Trascrizione...", - "thinking": "Pensiero..." + "thinking": "Pensiero...", + "typeMessage": "Scrivi un messaggio...", + "send": "Invia" + }, + "tools": { + "search_notesStatus": "Ricerca nelle note...", + "web_searchStatus": "Ricerca sul web...", + "copy_to_clipboardStatus": "Copia negli appunti...", + "get_calendar_eventsStatus": "Controllo del calendario...", + "foundNotes": "{{count}} nota trovata", + "foundNotes_plural": "{{count}} note trovate", + "searchResults": "{{count}} risultato trovato", + "searchResults_plural": "{{count}} risultati trovati", + "copiedToClipboard": "Copiato negli appunti", + "calendarEvents": "{{count}} evento trovato", + "calendarEvents_plural": "{{count}} eventi trovati", + "noResults": "Nessun risultato", + "error": "Errore dello strumento" } }, "integrations": { diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index c75fa6c78..4250f34f7 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1812,7 +1812,24 @@ "toClose": "閉じる", "listening": "聞いています...", "transcribing": "文字起こし中...", - "thinking": "考え中..." + "thinking": "考え中...", + "typeMessage": "メッセージを入力...", + "send": "送信" + }, + "tools": { + "search_notesStatus": "ノートを検索中...", + "web_searchStatus": "ウェブを検索中...", + "copy_to_clipboardStatus": "クリップボードにコピー中...", + "get_calendar_eventsStatus": "カレンダーを確認中...", + "foundNotes": "{{count}}件のノートが見つかりました", + "foundNotes_plural": "{{count}}件のノートが見つかりました", + "searchResults": "{{count}}件の結果が見つかりました", + "searchResults_plural": "{{count}}件の結果が見つかりました", + "copiedToClipboard": "クリップボードにコピーしました", + "calendarEvents": "{{count}}件の予定が見つかりました", + "calendarEvents_plural": "{{count}}件の予定が見つかりました", + "noResults": "結果が見つかりません", + "error": "ツールエラー" } }, "integrations": { diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 2fdece6cf..9cd5a679c 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1812,7 +1812,24 @@ "toClose": "fechar", "listening": "Ouvindo...", "transcribing": "Transcrevendo...", - "thinking": "Pensando..." + "thinking": "Pensando...", + "typeMessage": "Digite uma mensagem...", + "send": "Enviar" + }, + "tools": { + "search_notesStatus": "Pesquisando notas...", + "web_searchStatus": "Pesquisando na web...", + "copy_to_clipboardStatus": "Copiando para a área de transferência...", + "get_calendar_eventsStatus": "Verificando calendário...", + "foundNotes": "{{count}} nota encontrada", + "foundNotes_plural": "{{count}} notas encontradas", + "searchResults": "{{count}} resultado encontrado", + "searchResults_plural": "{{count}} resultados encontrados", + "copiedToClipboard": "Copiado para a área de transferência", + "calendarEvents": "{{count}} evento encontrado", + "calendarEvents_plural": "{{count}} eventos encontrados", + "noResults": "Nenhum resultado", + "error": "Erro de ferramenta" } }, "integrations": { diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index fc14875ec..88fa191d1 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1812,7 +1812,24 @@ "toClose": "закрыть", "listening": "Слушаю...", "transcribing": "Транскрибирую...", - "thinking": "Думаю..." + "thinking": "Думаю...", + "typeMessage": "Введите сообщение...", + "send": "Отправить" + }, + "tools": { + "search_notesStatus": "Поиск в заметках...", + "web_searchStatus": "Поиск в интернете...", + "copy_to_clipboardStatus": "Копирование в буфер обмена...", + "get_calendar_eventsStatus": "Проверка календаря...", + "foundNotes": "Найдена {{count}} заметка", + "foundNotes_plural": "Найдено {{count}} заметок", + "searchResults": "Найден {{count}} результат", + "searchResults_plural": "Найдено {{count}} результатов", + "copiedToClipboard": "Скопировано в буфер обмена", + "calendarEvents": "Найдено {{count}} событие", + "calendarEvents_plural": "Найдено {{count}} событий", + "noResults": "Ничего не найдено", + "error": "Ошибка инструмента" } }, "integrations": { diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index f11cef94b..bb8c79b3e 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1812,7 +1812,24 @@ "toClose": "关闭", "listening": "正在聆听...", "transcribing": "正在转录...", - "thinking": "正在思考..." + "thinking": "正在思考...", + "typeMessage": "输入消息...", + "send": "发送" + }, + "tools": { + "search_notesStatus": "正在搜索笔记...", + "web_searchStatus": "正在搜索网页...", + "copy_to_clipboardStatus": "正在复制到剪贴板...", + "get_calendar_eventsStatus": "正在查看日历...", + "foundNotes": "找到 {{count}} 条笔记", + "foundNotes_plural": "找到 {{count}} 条笔记", + "searchResults": "找到 {{count}} 条结果", + "searchResults_plural": "找到 {{count}} 条结果", + "copiedToClipboard": "已复制到剪贴板", + "calendarEvents": "找到 {{count}} 个日程", + "calendarEvents_plural": "找到 {{count}} 个日程", + "noResults": "未找到结果", + "error": "工具错误" } }, "integrations": { diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 8353a2505..7631b3517 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1812,7 +1812,24 @@ "toClose": "關閉", "listening": "正在聆聽...", "transcribing": "正在轉錄...", - "thinking": "正在思考..." + "thinking": "正在思考...", + "typeMessage": "輸入訊息...", + "send": "傳送" + }, + "tools": { + "search_notesStatus": "正在搜尋筆記...", + "web_searchStatus": "正在搜尋網頁...", + "copy_to_clipboardStatus": "正在複製到剪貼簿...", + "get_calendar_eventsStatus": "正在查看行事曆...", + "foundNotes": "找到 {{count}} 則筆記", + "foundNotes_plural": "找到 {{count}} 則筆記", + "searchResults": "找到 {{count}} 項結果", + "searchResults_plural": "找到 {{count}} 項結果", + "copiedToClipboard": "已複製到剪貼簿", + "calendarEvents": "找到 {{count}} 個行程", + "calendarEvents_plural": "找到 {{count}} 個行程", + "noResults": "未找到結果", + "error": "工具錯誤" } }, "integrations": { diff --git a/src/services/AgentLoop.ts b/src/services/AgentLoop.ts new file mode 100644 index 000000000..6b6f37ab6 --- /dev/null +++ b/src/services/AgentLoop.ts @@ -0,0 +1,185 @@ +import ReasoningService, { type AgentStreamChunk } from "./ReasoningService"; +import type { ToolRegistry, ToolResult } from "./tools"; +import logger from "../utils/logger"; + +export interface AgentLoopCallbacks { + onContentChunk: (text: string) => void; + onToolStart: (toolName: string, args: Record) => void; + onToolComplete: (toolName: string, result: ToolResult) => void; + onError: (error: Error) => void; +} + +export interface AgentLoopOptions { + messages: Array<{ role: string; content: string }>; + model: string; + provider: string; + systemPrompt: string; + tools: ToolRegistry; + callbacks: AgentLoopCallbacks; + isCloud?: boolean; + maxIterations?: number; +} + +interface ToolCallMessage { + role: "assistant"; + tool_calls: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }>; +} + +interface ToolResultMessage { + role: "tool"; + tool_call_id: string; + content: string; +} + +type Message = { role: string; content: string } | ToolCallMessage | ToolResultMessage; + +async function executeTool( + tools: ToolRegistry, + name: string, + rawArgs: string, + callbacks: AgentLoopCallbacks +): Promise { + const tool = tools.get(name); + if (!tool) { + const result: ToolResult = { + success: false, + data: null, + displayText: `Unknown tool: ${name}`, + }; + return result; + } + + let args: Record; + try { + args = JSON.parse(rawArgs); + } catch { + const result: ToolResult = { + success: false, + data: null, + displayText: `Invalid arguments for ${name}: failed to parse JSON`, + }; + return result; + } + + callbacks.onToolStart(name, args); + + try { + const result = await tool.execute(args); + callbacks.onToolComplete(name, result); + return result; + } catch (error) { + const result: ToolResult = { + success: false, + data: null, + displayText: `Tool ${name} error: ${(error as Error).message}`, + }; + callbacks.onToolComplete(name, result); + return result; + } +} + +export async function run(options: AgentLoopOptions): Promise { + const { + messages: initialMessages, + model, + provider, + systemPrompt, + tools, + callbacks, + maxIterations = 5, + } = options; + + const conversationMessages: Message[] = [ + { role: "system", content: systemPrompt }, + ...initialMessages, + ]; + + const toolDefs = tools.toOpenAIFormat(); + + for (let iteration = 0; iteration < maxIterations; iteration++) { + let pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = []; + + try { + const stream = ReasoningService.processTextStreamingWithTools( + conversationMessages as Array<{ role: string; content: string }>, + model, + provider, + { systemPrompt }, + toolDefs + ) as AsyncGenerator; + + for await (const chunk of stream) { + if (chunk.type === "content") { + callbacks.onContentChunk(chunk.text); + } else if (chunk.type === "tool_calls") { + pendingToolCalls = chunk.calls; + } else if (chunk.type === "done") { + if (pendingToolCalls.length === 0) return; + } + } + } catch (error) { + callbacks.onError(error as Error); + return; + } + + if (pendingToolCalls.length === 0) return; + + // Append assistant message with tool_calls + const assistantMessage: ToolCallMessage = { + role: "assistant", + tool_calls: pendingToolCalls.map((call) => ({ + id: call.id, + type: "function", + function: { name: call.name, arguments: call.arguments }, + })), + }; + conversationMessages.push(assistantMessage); + + // Classify calls: read-only tools run in parallel, write tools run sequentially + const readOnlyCalls = pendingToolCalls.filter((c) => tools.get(c.name)?.readOnly); + const writeCalls = pendingToolCalls.filter((c) => !tools.get(c.name)?.readOnly); + + const toolResults: ToolResultMessage[] = []; + + // Execute read-only tools in parallel + if (readOnlyCalls.length > 0) { + const results = await Promise.all( + readOnlyCalls.map(async (call) => { + const result = await executeTool(tools, call.name, call.arguments, callbacks); + return { id: call.id, result }; + }) + ); + for (const { id, result } of results) { + toolResults.push({ + role: "tool", + tool_call_id: id, + content: JSON.stringify({ success: result.success, data: result.data }), + }); + } + } + + // Execute write tools sequentially + for (const call of writeCalls) { + const result = await executeTool(tools, call.name, call.arguments, callbacks); + toolResults.push({ + role: "tool", + tool_call_id: call.id, + content: JSON.stringify({ success: result.success, data: result.data }), + }); + } + + conversationMessages.push(...toolResults); + + logger.logReasoning("AGENT_LOOP_ITERATION", { + iteration: iteration + 1, + toolCallCount: pendingToolCalls.length, + toolNames: pendingToolCalls.map((c) => c.name), + }); + } + + logger.warn("Agent loop reached max iterations", { maxIterations }, "agent"); +} diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index f822d99e7..de79c9e88 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -8,6 +8,19 @@ import { isSecureEndpoint } from "../utils/urlUtils"; import { withSessionRefresh } from "../lib/neonAuth"; import { getSettings, isCloudReasoningMode } from "../stores/settingsStore"; +export type AgentStreamChunk = + | { type: "content"; text: string } + | { type: "tool_calls"; calls: Array<{ id: string; name: string; arguments: string }> } + | { type: "done"; finishReason?: string }; + +interface ResolvedEndpoint { + endpoint: string; + apiKey: string; + isLocalProvider: boolean; + apiConfig: ReturnType; + useOldTokenParam: boolean; +} + class ReasoningService extends BaseReasoningService { private apiKeyCache: SecureCache; private openAiEndpointPreference = new Map(); @@ -250,6 +263,43 @@ class ReasoningService extends BaseReasoningService { return apiKey; } + private async resolveEndpoint(model: string, provider: string): Promise { + const cloudProviders = ["openai", "groq", "gemini", "anthropic", "custom"]; + const isLocalProvider = !cloudProviders.includes(provider); + + let endpoint: string; + let apiKey = ""; + + if (isLocalProvider) { + const serverResult = await window.electronAPI.llamaServerStart(model); + if (!serverResult.success || !serverResult.port) { + throw new Error(serverResult.error || "Failed to start local model server"); + } + endpoint = `http://127.0.0.1:${serverResult.port}/v1/chat/completions`; + } else { + const providerKey = provider as "openai" | "groq" | "gemini" | "anthropic" | "custom"; + apiKey = await this.getApiKey(providerKey); + + switch (providerKey) { + case "groq": + endpoint = buildApiUrl(API_ENDPOINTS.GROQ_BASE, "/chat/completions"); + break; + case "openai": + case "custom": + endpoint = buildApiUrl(this.getConfiguredOpenAIBase(), "/chat/completions"); + break; + default: + endpoint = buildApiUrl(API_ENDPOINTS.OPENAI_BASE, "/chat/completions"); + break; + } + } + + const apiConfig = getOpenAiApiConfig(model); + const useOldTokenParam = isLocalProvider || provider === "groq"; + + return { endpoint, apiKey, isLocalProvider, apiConfig, useOldTokenParam }; + } + private async callChatCompletionsApi( endpoint: string, apiKey: string, @@ -1262,6 +1312,184 @@ class ReasoningService extends BaseReasoningService { } } + async *processTextStreamingWithTools( + messages: Array<{ role: string; content: string }>, + model: string, + provider: string, + config: ReasoningConfig & { systemPrompt: string }, + tools: Array<{ + type: "function"; + function: { name: string; description: string; parameters: Record }; + }> + ): AsyncGenerator { + const { endpoint, apiKey, isLocalProvider, apiConfig, useOldTokenParam } = + await this.resolveEndpoint(model, provider); + + // Local providers don't support tools — fall back to content-only streaming + if (isLocalProvider) { + const contentGen = this.processTextStreaming(messages, model, provider, config); + for await (const text of contentGen) { + yield { type: "content", text }; + } + yield { type: "done", finishReason: "stop" }; + return; + } + + const maxTokens = config.maxTokens || Math.max(4096, TOKEN_LIMITS.MAX_TOKENS); + + const requestBody: Record = { + model, + messages, + stream: true, + tools, + tool_choice: "auto", + }; + + if (useOldTokenParam) { + requestBody.temperature = config.temperature ?? 0.3; + requestBody.max_tokens = maxTokens; + } else { + requestBody[apiConfig.tokenParam] = maxTokens; + if (apiConfig.supportsTemperature) { + requestBody.temperature = config.temperature ?? 0.3; + } + } + + logger.logReasoning("AGENT_TOOL_STREAM_REQUEST", { + endpoint, + model, + provider, + toolCount: tools.length, + messageCount: messages.length, + }); + + const headers: Record = { + "Content-Type": "application/json", + }; + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}`; + } + + const response = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage: string; + try { + const errorData = JSON.parse(errorText); + errorMessage = + errorData.error?.message || + errorData.message || + errorData.error || + `API error: ${response.status}`; + } catch { + errorMessage = errorText || `API error: ${response.status}`; + } + logger.logReasoning("AGENT_TOOL_STREAM_ERROR", { status: response.status, errorMessage }); + throw new Error(errorMessage); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("No response body"); + + const decoder = new TextDecoder(); + let buffer = ""; + // Accumulate tool calls by index — OpenAI streams arguments incrementally + const toolCallAccumulator = new Map(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith("data: ")) continue; + + const data = trimmed.slice(6); + if (data === "[DONE]") { + // Flush any remaining accumulated tool calls + if (toolCallAccumulator.size > 0) { + const sortedCalls = Array.from(toolCallAccumulator.entries()) + .sort(([a], [b]) => a - b) + .map(([, call]) => call); + yield { type: "tool_calls", calls: sortedCalls }; + toolCallAccumulator.clear(); + } + yield { type: "done" }; + return; + } + + try { + const parsed = JSON.parse(data); + const choice = parsed.choices?.[0]; + if (!choice) continue; + + const delta = choice.delta; + const finishReason = choice.finish_reason; + + // Accumulate text content + if (delta?.content) { + yield { type: "content", text: delta.content }; + } + + // Accumulate tool call deltas + if (delta?.tool_calls) { + for (const toolCallDelta of delta.tool_calls) { + const idx = toolCallDelta.index; + const existing = toolCallAccumulator.get(idx); + + if (existing) { + // Append incremental argument fragments + if (toolCallDelta.function?.arguments) { + existing.arguments += toolCallDelta.function.arguments; + } + } else { + // First chunk for this tool call — initialize + toolCallAccumulator.set(idx, { + id: toolCallDelta.id || "", + name: toolCallDelta.function?.name || "", + arguments: toolCallDelta.function?.arguments || "", + }); + } + } + } + + // When finish_reason arrives, yield accumulated state + if (finishReason === "tool_calls") { + if (toolCallAccumulator.size > 0) { + const sortedCalls = Array.from(toolCallAccumulator.entries()) + .sort(([a], [b]) => a - b) + .map(([, call]) => call); + yield { type: "tool_calls", calls: sortedCalls }; + toolCallAccumulator.clear(); + } + yield { type: "done", finishReason: "tool_calls" }; + return; + } + + if (finishReason === "stop") { + yield { type: "done", finishReason: "stop" }; + return; + } + } catch { + // skip malformed SSE chunks + } + } + } + } finally { + reader.releaseLock(); + } + } + async *processTextStreamingCloud( messages: Array<{ role: string; content: string }>, config: { systemPrompt: string } diff --git a/src/services/tools/ToolRegistry.ts b/src/services/tools/ToolRegistry.ts new file mode 100644 index 000000000..80f8a0039 --- /dev/null +++ b/src/services/tools/ToolRegistry.ts @@ -0,0 +1,51 @@ +export interface ToolResult { + success: boolean; + data: unknown; + displayText: string; +} + +export interface ToolDefinition { + name: string; + description: string; + parameters: Record; + readOnly: boolean; + execute: (args: Record) => Promise; +} + +interface OpenAIFunctionTool { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + strict: true; + }; +} + +export class ToolRegistry { + private tools = new Map(); + + register(tool: ToolDefinition): void { + this.tools.set(tool.name, tool); + } + + get(name: string): ToolDefinition | undefined { + return this.tools.get(name); + } + + getAll(): ToolDefinition[] { + return Array.from(this.tools.values()); + } + + toOpenAIFormat(): OpenAIFunctionTool[] { + return this.getAll().map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + strict: true, + }, + })); + } +} diff --git a/src/services/tools/calendarTool.ts b/src/services/tools/calendarTool.ts new file mode 100644 index 000000000..56a665748 --- /dev/null +++ b/src/services/tools/calendarTool.ts @@ -0,0 +1,77 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; + +type TimeRange = "today" | "tomorrow" | "week"; + +function getWindowMinutes(timeRange: TimeRange): number { + if (timeRange === "week") return 10080; + if (timeRange === "tomorrow") return 2880; + + // "today": remaining minutes until midnight + const now = new Date(); + const midnight = new Date(now); + midnight.setHours(23, 59, 59, 999); + return Math.max(1, Math.ceil((midnight.getTime() - now.getTime()) / 60000)); +} + +export const calendarTool: ToolDefinition = { + name: "get_calendar_events", + description: + "Get upcoming Google Calendar events for a given time range. Returns event summaries, times, and locations.", + parameters: { + type: "object", + properties: { + timeRange: { + type: "string", + enum: ["today", "tomorrow", "week"], + description: 'Time range to fetch events for (default "today")', + }, + }, + required: [], + additionalProperties: false, + }, + readOnly: true, + + async execute(args: Record): Promise { + const timeRange = (args.timeRange as TimeRange) || "today"; + const windowMinutes = getWindowMinutes(timeRange); + + try { + const response = await window.electronAPI.gcalGetUpcomingEvents!(windowMinutes); + + if (!response.success) { + return { + success: false, + data: null, + displayText: "Failed to fetch calendar events", + }; + } + + const events = response.events.map((event: Record) => ({ + summary: event.summary || "(No title)", + start: event.start, + end: event.end, + location: event.location || null, + })); + + if (events.length === 0) { + return { + success: true, + data: [], + displayText: `No events found for ${timeRange}`, + }; + } + + return { + success: true, + data: events, + displayText: `Found ${events.length} event${events.length === 1 ? "" : "s"} for ${timeRange}`, + }; + } catch (error) { + return { + success: false, + data: null, + displayText: `Failed to fetch calendar events: ${(error as Error).message}`, + }; + } + }, +}; diff --git a/src/services/tools/clipboardTool.ts b/src/services/tools/clipboardTool.ts new file mode 100644 index 000000000..09328e096 --- /dev/null +++ b/src/services/tools/clipboardTool.ts @@ -0,0 +1,39 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; + +export const clipboardTool: ToolDefinition = { + name: "copy_to_clipboard", + description: "Copy text to the user's system clipboard.", + parameters: { + type: "object", + properties: { + text: { + type: "string", + description: "The text to copy to the clipboard", + }, + }, + required: ["text"], + additionalProperties: false, + }, + readOnly: false, + + async execute(args: Record): Promise { + const text = args.text as string; + + try { + await window.electronAPI.writeClipboard(text); + + const preview = text.length > 100 ? text.slice(0, 100) + "..." : text; + return { + success: true, + data: null, + displayText: `Copied to clipboard: "${preview}"`, + }; + } catch (error) { + return { + success: false, + data: null, + displayText: `Failed to copy to clipboard: ${(error as Error).message}`, + }; + } + }, +}; diff --git a/src/services/tools/index.ts b/src/services/tools/index.ts new file mode 100644 index 000000000..ea782ed7b --- /dev/null +++ b/src/services/tools/index.ts @@ -0,0 +1,30 @@ +import { ToolRegistry } from "./ToolRegistry"; +import { searchNotesTool } from "./searchNotesTool"; +import { clipboardTool } from "./clipboardTool"; +import { webSearchTool } from "./webSearchTool"; +import { calendarTool } from "./calendarTool"; + +export { ToolRegistry } from "./ToolRegistry"; +export type { ToolDefinition, ToolResult } from "./ToolRegistry"; + +interface ToolRegistrySettings { + isSignedIn: boolean; + gcalConnected: boolean; +} + +export function createToolRegistry(settings: ToolRegistrySettings): ToolRegistry { + const registry = new ToolRegistry(); + + registry.register(searchNotesTool); + registry.register(clipboardTool); + + if (settings.isSignedIn) { + registry.register(webSearchTool); + } + + if (settings.gcalConnected) { + registry.register(calendarTool); + } + + return registry; +} diff --git a/src/services/tools/searchNotesTool.ts b/src/services/tools/searchNotesTool.ts new file mode 100644 index 000000000..fd4cd59be --- /dev/null +++ b/src/services/tools/searchNotesTool.ts @@ -0,0 +1,61 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; + +const MAX_CONTENT_LENGTH = 500; + +export const searchNotesTool: ToolDefinition = { + name: "search_notes", + description: + "Search the user's notes by keyword or phrase. Returns matching notes with title, date, and a preview of content.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "The search query to find relevant notes", + }, + limit: { + type: "number", + description: "Maximum number of results to return (default 5)", + }, + }, + required: ["query"], + additionalProperties: false, + }, + readOnly: true, + + async execute(args: Record): Promise { + const query = args.query as string; + const limit = typeof args.limit === "number" ? args.limit : 5; + + try { + const notes = await window.electronAPI.searchNotes(query, limit); + + if (notes.length === 0) { + return { + success: true, + data: [], + displayText: `No notes found for "${query}"`, + }; + } + + const results = notes.map((note) => ({ + title: note.title, + date: note.created_at, + type: note.note_type, + content: (note.enhanced_content || note.content).slice(0, MAX_CONTENT_LENGTH), + })); + + return { + success: true, + data: results, + displayText: `Found ${results.length} note${results.length === 1 ? "" : "s"} for "${query}"`, + }; + } catch (error) { + return { + success: false, + data: null, + displayText: `Failed to search notes: ${(error as Error).message}`, + }; + } + }, +}; diff --git a/src/services/tools/webSearchTool.ts b/src/services/tools/webSearchTool.ts new file mode 100644 index 000000000..b5d681760 --- /dev/null +++ b/src/services/tools/webSearchTool.ts @@ -0,0 +1,44 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; + +export const webSearchTool: ToolDefinition = { + name: "web_search", + description: + "Search the web for current information. Returns relevant web results with titles and snippets.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "The search query", + }, + numResults: { + type: "number", + description: "Number of results to return (default 5)", + }, + }, + required: ["query"], + additionalProperties: false, + }, + readOnly: true, + + async execute(args: Record): Promise { + const query = args.query as string; + const numResults = typeof args.numResults === "number" ? args.numResults : 5; + + try { + const results = await window.electronAPI.agentWebSearch!(query, numResults); + + return { + success: true, + data: results, + displayText: `Found web results for "${query}"`, + }; + } catch (error) { + return { + success: false, + data: null, + displayText: `Web search failed: ${(error as Error).message}`, + }; + } + }, +}; diff --git a/src/types/electron.ts b/src/types/electron.ts index 663f423d7..b0dfb125e 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1078,6 +1078,19 @@ declare global { ) => Promise<{ success: boolean; error?: string; code?: string }>; onAgentStreamChunk?: (callback: (chunk: string) => void) => () => void; onAgentStreamDone?: (callback: () => void) => () => void; + agentWebSearch?: ( + query: string, + numResults?: number + ) => Promise<{ + success: boolean; + results?: Array<{ + title: string; + url: string; + text: string; + publishedDate?: string; + }>; + error?: string; + }>; // Google Calendar gcalStartOAuth?: () => Promise<{ success: boolean; email?: string; error?: string }>; From 8413080abf5c883cf44622161c74cf209c987957 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Sun, 15 Mar 2026 18:24:10 -0700 Subject: [PATCH 02/91] feat: migrate agent mode to Vercel AI SDK Replace manual SSE parsing and AgentLoop with AI SDK streamText + stepCountIs for tool-calling agent mode. Add unified provider factory supporting OpenAI, Groq, Anthropic, Gemini, and custom endpoints. - Add ai, @ai-sdk/openai, @ai-sdk/groq, @ai-sdk/anthropic, @ai-sdk/google - Add src/services/ai/providers.ts with getAIModel factory - Add ToolRegistry.toAISDKFormat() using jsonSchema wrapper - Add ReasoningService.processTextStreamingAI() with full tool support - Remove AgentLoop.ts (replaced by stepCountIs) - Remove dead toOpenAIFormat/OpenAIFunctionTool from ToolRegistry - Simplify AgentOverlay to 3 streaming paths (tools/cloud/BYOK) --- package-lock.json | 146 +++++++++++++++++- package.json | 5 + src/components/AgentOverlay.tsx | 98 ++++++------ src/services/AgentLoop.ts | 185 ---------------------- src/services/ReasoningService.ts | 236 +++++------------------------ src/services/ai/providers.ts | 27 ++++ src/services/tools/ToolRegistry.ts | 36 ++--- 7 files changed, 277 insertions(+), 456 deletions(-) delete mode 100644 src/services/AgentLoop.ts create mode 100644 src/services/ai/providers.ts diff --git a/package-lock.json b/package-lock.json index 6487b0348..c6ba9fab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,10 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/google": "^3.0.43", + "@ai-sdk/groq": "^3.0.29", + "@ai-sdk/openai": "^3.0.41", "@neondatabase/auth": "^0.1.0-beta.21", "@neondatabase/neon-js": "^0.1.0-beta.22", "@radix-ui/react-accordion": "^1.1.2", @@ -21,6 +25,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@vercel/blob": "^2.3.0", + "ai": "^6.0.116", "better-sqlite3": "^12.4.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -73,6 +78,116 @@ "node": ">=22" } }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.58", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.58.tgz", + "integrity": "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.66", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz", + "integrity": "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.43", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-3.0.43.tgz", + "integrity": "sha512-NGCgP5g8HBxrNdxvF8Dhww+UKfqAkZAmyYBvbu9YLoBkzAmGKDBGhVptN/oXPB5Vm0jggMdoLycZ8JReQM8Zqg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-3.0.29.tgz", + "integrity": "sha512-I/tUoHuOvGXbIr1dJ0CLRLA7W0UPDMtrYT5mgeb3O+P+6I5BAm/7riPwr22Xw5YTzpwQxcoDQlIczOU9XDXBpA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.41", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.41.tgz", + "integrity": "sha512-IZ42A+FO+vuEQCVNqlnAPYQnnUpUfdJIwn1BEDOBywiEHa23fw7PahxVtlX9zm3/zMvTW4JKPzWyvAgDu+SQ2A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -7073,6 +7188,15 @@ "node": ">=20.0.0" } }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -7190,6 +7314,24 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.116", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.116.tgz", + "integrity": "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.66", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -10019,7 +10161,6 @@ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" } @@ -11760,8 +11901,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)", - "optional": true + "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-ref-resolver": { "version": "1.0.1", diff --git a/package.json b/package.json index 9b8d20615..94d832c74 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,10 @@ "vite": "^6.3.5" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/google": "^3.0.43", + "@ai-sdk/groq": "^3.0.29", + "@ai-sdk/openai": "^3.0.41", "@neondatabase/auth": "^0.1.0-beta.21", "@neondatabase/neon-js": "^0.1.0-beta.22", "@radix-ui/react-accordion": "^1.1.2", @@ -113,6 +117,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", "@vercel/blob": "^2.3.0", + "ai": "^6.0.116", "better-sqlite3": "^12.4.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 0687de051..0c5af4e02 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -8,7 +8,6 @@ import AudioManager from "../helpers/audioManager"; import ReasoningService from "../services/ReasoningService"; import { getSettings } from "../stores/settingsStore"; import { getAgentSystemPrompt } from "../config/prompts"; -import { run as runAgentLoop } from "../services/AgentLoop"; import { createToolRegistry } from "../services/tools"; type AgentState = @@ -101,7 +100,7 @@ export default function AgentOverlay() { const settings = getSettings(); const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; - const toolSupportedProviders = ["openai", "groq", "custom"]; + const toolSupportedProviders = ["openai", "groq", "custom", "anthropic", "gemini"]; const supportsTools = !isCloudAgent && toolSupportedProviders.includes(settings.agentProvider); @@ -129,23 +128,26 @@ export default function AgentOverlay() { let fullContent = ""; if (supportsTools && registry) { - await runAgentLoop({ - messages: llmMessages, - model: settings.agentModel, - provider: settings.agentProvider, - systemPrompt, - tools: registry, - callbacks: { - onContentChunk: (text) => { - fullContent += text; - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) - ); - }, - onToolStart: (toolName) => { + const aiTools = registry.toAISDKFormat(); + const stream = ReasoningService.processTextStreamingAI( + llmMessages, + settings.agentModel, + settings.agentProvider, + { systemPrompt }, + aiTools + ); + + for await (const chunk of stream) { + if (chunk.type === "content") { + fullContent += chunk.text; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + } else if (chunk.type === "tool_calls") { + for (const call of chunk.calls) { setAgentState("tool-executing"); setToolStatus( - t(`agentMode.tools.${toolName}Status`, { defaultValue: `Using ${toolName}...` }) + t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) ); setMessages((prev) => prev.map((m) => @@ -155,57 +157,47 @@ export default function AgentOverlay() { toolCalls: [ ...(m.toolCalls || []), { - id: crypto.randomUUID(), - name: toolName, - arguments: "", - status: "executing" as const, + id: call.id, + name: call.name, + arguments: call.arguments, + status: "completed" as const, + result: "Done", }, ], } : m ) ); - }, - onToolComplete: (toolName, result) => { setAgentState("streaming"); setToolStatus(""); - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - toolCalls: m.toolCalls?.map((tc) => - tc.name === toolName && tc.status === "executing" - ? { ...tc, status: "completed" as const, result: result.displayText } - : tc - ), - } - : m - ) - ); - }, - onError: (error) => { - console.error("Agent loop error:", error); - }, - }, - maxIterations: 5, + } + } + } + } else if (isCloudAgent) { + const streamSource = ReasoningService.processTextStreamingCloud(llmMessages, { + systemPrompt, }); - } else { - const streamSource = isCloudAgent - ? ReasoningService.processTextStreamingCloud(llmMessages, { systemPrompt }) - : ReasoningService.processTextStreaming( - llmMessages, - settings.agentModel, - settings.agentProvider, - { systemPrompt } - ); - for await (const chunk of streamSource) { fullContent += chunk; setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) ); } + } else { + const stream = ReasoningService.processTextStreamingAI( + llmMessages, + settings.agentModel, + settings.agentProvider, + { systemPrompt } + ); + for await (const chunk of stream) { + if (chunk.type === "content") { + fullContent += chunk.text; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + } + } } setMessages((prev) => diff --git a/src/services/AgentLoop.ts b/src/services/AgentLoop.ts deleted file mode 100644 index 6b6f37ab6..000000000 --- a/src/services/AgentLoop.ts +++ /dev/null @@ -1,185 +0,0 @@ -import ReasoningService, { type AgentStreamChunk } from "./ReasoningService"; -import type { ToolRegistry, ToolResult } from "./tools"; -import logger from "../utils/logger"; - -export interface AgentLoopCallbacks { - onContentChunk: (text: string) => void; - onToolStart: (toolName: string, args: Record) => void; - onToolComplete: (toolName: string, result: ToolResult) => void; - onError: (error: Error) => void; -} - -export interface AgentLoopOptions { - messages: Array<{ role: string; content: string }>; - model: string; - provider: string; - systemPrompt: string; - tools: ToolRegistry; - callbacks: AgentLoopCallbacks; - isCloud?: boolean; - maxIterations?: number; -} - -interface ToolCallMessage { - role: "assistant"; - tool_calls: Array<{ - id: string; - type: "function"; - function: { name: string; arguments: string }; - }>; -} - -interface ToolResultMessage { - role: "tool"; - tool_call_id: string; - content: string; -} - -type Message = { role: string; content: string } | ToolCallMessage | ToolResultMessage; - -async function executeTool( - tools: ToolRegistry, - name: string, - rawArgs: string, - callbacks: AgentLoopCallbacks -): Promise { - const tool = tools.get(name); - if (!tool) { - const result: ToolResult = { - success: false, - data: null, - displayText: `Unknown tool: ${name}`, - }; - return result; - } - - let args: Record; - try { - args = JSON.parse(rawArgs); - } catch { - const result: ToolResult = { - success: false, - data: null, - displayText: `Invalid arguments for ${name}: failed to parse JSON`, - }; - return result; - } - - callbacks.onToolStart(name, args); - - try { - const result = await tool.execute(args); - callbacks.onToolComplete(name, result); - return result; - } catch (error) { - const result: ToolResult = { - success: false, - data: null, - displayText: `Tool ${name} error: ${(error as Error).message}`, - }; - callbacks.onToolComplete(name, result); - return result; - } -} - -export async function run(options: AgentLoopOptions): Promise { - const { - messages: initialMessages, - model, - provider, - systemPrompt, - tools, - callbacks, - maxIterations = 5, - } = options; - - const conversationMessages: Message[] = [ - { role: "system", content: systemPrompt }, - ...initialMessages, - ]; - - const toolDefs = tools.toOpenAIFormat(); - - for (let iteration = 0; iteration < maxIterations; iteration++) { - let pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = []; - - try { - const stream = ReasoningService.processTextStreamingWithTools( - conversationMessages as Array<{ role: string; content: string }>, - model, - provider, - { systemPrompt }, - toolDefs - ) as AsyncGenerator; - - for await (const chunk of stream) { - if (chunk.type === "content") { - callbacks.onContentChunk(chunk.text); - } else if (chunk.type === "tool_calls") { - pendingToolCalls = chunk.calls; - } else if (chunk.type === "done") { - if (pendingToolCalls.length === 0) return; - } - } - } catch (error) { - callbacks.onError(error as Error); - return; - } - - if (pendingToolCalls.length === 0) return; - - // Append assistant message with tool_calls - const assistantMessage: ToolCallMessage = { - role: "assistant", - tool_calls: pendingToolCalls.map((call) => ({ - id: call.id, - type: "function", - function: { name: call.name, arguments: call.arguments }, - })), - }; - conversationMessages.push(assistantMessage); - - // Classify calls: read-only tools run in parallel, write tools run sequentially - const readOnlyCalls = pendingToolCalls.filter((c) => tools.get(c.name)?.readOnly); - const writeCalls = pendingToolCalls.filter((c) => !tools.get(c.name)?.readOnly); - - const toolResults: ToolResultMessage[] = []; - - // Execute read-only tools in parallel - if (readOnlyCalls.length > 0) { - const results = await Promise.all( - readOnlyCalls.map(async (call) => { - const result = await executeTool(tools, call.name, call.arguments, callbacks); - return { id: call.id, result }; - }) - ); - for (const { id, result } of results) { - toolResults.push({ - role: "tool", - tool_call_id: id, - content: JSON.stringify({ success: result.success, data: result.data }), - }); - } - } - - // Execute write tools sequentially - for (const call of writeCalls) { - const result = await executeTool(tools, call.name, call.arguments, callbacks); - toolResults.push({ - role: "tool", - tool_call_id: call.id, - content: JSON.stringify({ success: result.success, data: result.data }), - }); - } - - conversationMessages.push(...toolResults); - - logger.logReasoning("AGENT_LOOP_ITERATION", { - iteration: iteration + 1, - toolCallCount: pendingToolCalls.length, - toolNames: pendingToolCalls.map((c) => c.name), - }); - } - - logger.warn("Agent loop reached max iterations", { maxIterations }, "agent"); -} diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index de79c9e88..76625ad85 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -7,20 +7,14 @@ import logger from "../utils/logger"; import { isSecureEndpoint } from "../utils/urlUtils"; import { withSessionRefresh } from "../lib/neonAuth"; import { getSettings, isCloudReasoningMode } from "../stores/settingsStore"; +import { streamText, stepCountIs } from "ai"; +import { getAIModel } from "./ai/providers"; export type AgentStreamChunk = | { type: "content"; text: string } | { type: "tool_calls"; calls: Array<{ id: string; name: string; arguments: string }> } | { type: "done"; finishReason?: string }; -interface ResolvedEndpoint { - endpoint: string; - apiKey: string; - isLocalProvider: boolean; - apiConfig: ReturnType; - useOldTokenParam: boolean; -} - class ReasoningService extends BaseReasoningService { private apiKeyCache: SecureCache; private openAiEndpointPreference = new Map(); @@ -263,43 +257,6 @@ class ReasoningService extends BaseReasoningService { return apiKey; } - private async resolveEndpoint(model: string, provider: string): Promise { - const cloudProviders = ["openai", "groq", "gemini", "anthropic", "custom"]; - const isLocalProvider = !cloudProviders.includes(provider); - - let endpoint: string; - let apiKey = ""; - - if (isLocalProvider) { - const serverResult = await window.electronAPI.llamaServerStart(model); - if (!serverResult.success || !serverResult.port) { - throw new Error(serverResult.error || "Failed to start local model server"); - } - endpoint = `http://127.0.0.1:${serverResult.port}/v1/chat/completions`; - } else { - const providerKey = provider as "openai" | "groq" | "gemini" | "anthropic" | "custom"; - apiKey = await this.getApiKey(providerKey); - - switch (providerKey) { - case "groq": - endpoint = buildApiUrl(API_ENDPOINTS.GROQ_BASE, "/chat/completions"); - break; - case "openai": - case "custom": - endpoint = buildApiUrl(this.getConfiguredOpenAIBase(), "/chat/completions"); - break; - default: - endpoint = buildApiUrl(API_ENDPOINTS.OPENAI_BASE, "/chat/completions"); - break; - } - } - - const apiConfig = getOpenAiApiConfig(model); - const useOldTokenParam = isLocalProvider || provider === "groq"; - - return { endpoint, apiKey, isLocalProvider, apiConfig, useOldTokenParam }; - } - private async callChatCompletionsApi( endpoint: string, apiKey: string, @@ -1312,20 +1269,17 @@ class ReasoningService extends BaseReasoningService { } } - async *processTextStreamingWithTools( + async *processTextStreamingAI( messages: Array<{ role: string; content: string }>, model: string, provider: string, config: ReasoningConfig & { systemPrompt: string }, - tools: Array<{ - type: "function"; - function: { name: string; description: string; parameters: Record }; - }> + tools?: Record ): AsyncGenerator { - const { endpoint, apiKey, isLocalProvider, apiConfig, useOldTokenParam } = - await this.resolveEndpoint(model, provider); + const cloudProviders = ["openai", "groq", "gemini", "anthropic", "custom"]; + const isLocalProvider = !cloudProviders.includes(provider); - // Local providers don't support tools — fall back to content-only streaming + // Local models don't work with AI SDK — fall back to content-only streaming if (isLocalProvider) { const contentGen = this.processTextStreaming(messages, model, provider, config); for await (const text of contentGen) { @@ -1335,158 +1289,50 @@ class ReasoningService extends BaseReasoningService { return; } - const maxTokens = config.maxTokens || Math.max(4096, TOKEN_LIMITS.MAX_TOKENS); - - const requestBody: Record = { - model, - messages, - stream: true, - tools, - tool_choice: "auto", - }; + const providerKey = provider as "openai" | "groq" | "gemini" | "anthropic" | "custom"; + const apiKey = await this.getApiKey(providerKey); + const baseURL = provider === "custom" ? this.getConfiguredOpenAIBase() : undefined; + const apiConfig = getOpenAiApiConfig(model); - if (useOldTokenParam) { - requestBody.temperature = config.temperature ?? 0.3; - requestBody.max_tokens = maxTokens; - } else { - requestBody[apiConfig.tokenParam] = maxTokens; - if (apiConfig.supportsTemperature) { - requestBody.temperature = config.temperature ?? 0.3; - } - } + const aiModel = getAIModel(provider, model, apiKey, baseURL); - logger.logReasoning("AGENT_TOOL_STREAM_REQUEST", { - endpoint, + logger.logReasoning("AGENT_AI_SDK_STREAM_REQUEST", { model, provider, - toolCount: tools.length, + hasTools: !!tools, + toolCount: tools ? Object.keys(tools).length : 0, messageCount: messages.length, }); - const headers: Record = { - "Content-Type": "application/json", - }; - if (apiKey) { - headers["Authorization"] = `Bearer ${apiKey}`; - } - - const response = await fetch(endpoint, { - method: "POST", - headers, - body: JSON.stringify(requestBody), + const result = streamText({ + model: aiModel, + messages: messages.map((m) => ({ + role: m.role as "system" | "user" | "assistant", + content: m.content, + })), + tools: tools || undefined, + stopWhen: stepCountIs(tools ? 5 : 1), + ...(apiConfig.supportsTemperature ? { temperature: config.temperature ?? 0.3 } : {}), + maxOutputTokens: config.maxTokens || 4096, }); - if (!response.ok) { - const errorText = await response.text(); - let errorMessage: string; - try { - const errorData = JSON.parse(errorText); - errorMessage = - errorData.error?.message || - errorData.message || - errorData.error || - `API error: ${response.status}`; - } catch { - errorMessage = errorText || `API error: ${response.status}`; - } - logger.logReasoning("AGENT_TOOL_STREAM_ERROR", { status: response.status, errorMessage }); - throw new Error(errorMessage); - } - - const reader = response.body?.getReader(); - if (!reader) throw new Error("No response body"); - - const decoder = new TextDecoder(); - let buffer = ""; - // Accumulate tool calls by index — OpenAI streams arguments incrementally - const toolCallAccumulator = new Map(); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.startsWith("data: ")) continue; - - const data = trimmed.slice(6); - if (data === "[DONE]") { - // Flush any remaining accumulated tool calls - if (toolCallAccumulator.size > 0) { - const sortedCalls = Array.from(toolCallAccumulator.entries()) - .sort(([a], [b]) => a - b) - .map(([, call]) => call); - yield { type: "tool_calls", calls: sortedCalls }; - toolCallAccumulator.clear(); - } - yield { type: "done" }; - return; - } - - try { - const parsed = JSON.parse(data); - const choice = parsed.choices?.[0]; - if (!choice) continue; - - const delta = choice.delta; - const finishReason = choice.finish_reason; - - // Accumulate text content - if (delta?.content) { - yield { type: "content", text: delta.content }; - } - - // Accumulate tool call deltas - if (delta?.tool_calls) { - for (const toolCallDelta of delta.tool_calls) { - const idx = toolCallDelta.index; - const existing = toolCallAccumulator.get(idx); - - if (existing) { - // Append incremental argument fragments - if (toolCallDelta.function?.arguments) { - existing.arguments += toolCallDelta.function.arguments; - } - } else { - // First chunk for this tool call — initialize - toolCallAccumulator.set(idx, { - id: toolCallDelta.id || "", - name: toolCallDelta.function?.name || "", - arguments: toolCallDelta.function?.arguments || "", - }); - } - } - } - - // When finish_reason arrives, yield accumulated state - if (finishReason === "tool_calls") { - if (toolCallAccumulator.size > 0) { - const sortedCalls = Array.from(toolCallAccumulator.entries()) - .sort(([a], [b]) => a - b) - .map(([, call]) => call); - yield { type: "tool_calls", calls: sortedCalls }; - toolCallAccumulator.clear(); - } - yield { type: "done", finishReason: "tool_calls" }; - return; - } - - if (finishReason === "stop") { - yield { type: "done", finishReason: "stop" }; - return; - } - } catch { - // skip malformed SSE chunks - } - } + for await (const chunk of result.fullStream) { + if (chunk.type === "text-delta") { + yield { type: "content", text: chunk.text }; + } else if (chunk.type === "tool-call") { + yield { + type: "tool_calls", + calls: [ + { + id: chunk.toolCallId, + name: chunk.toolName, + arguments: JSON.stringify(chunk.input), + }, + ], + }; + } else if (chunk.type === "finish") { + yield { type: "done", finishReason: chunk.finishReason }; } - } finally { - reader.releaseLock(); } } diff --git a/src/services/ai/providers.ts b/src/services/ai/providers.ts new file mode 100644 index 000000000..b760cf958 --- /dev/null +++ b/src/services/ai/providers.ts @@ -0,0 +1,27 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { createGroq } from "@ai-sdk/groq"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import type { LanguageModel } from "ai"; + +export function getAIModel( + provider: string, + model: string, + apiKey: string, + baseURL?: string +): LanguageModel { + switch (provider) { + case "openai": + return createOpenAI({ apiKey })(model); + case "groq": + return createGroq({ apiKey })(model); + case "anthropic": + return createAnthropic({ apiKey })(model); + case "gemini": + return createGoogleGenerativeAI({ apiKey })(model); + case "custom": + return createOpenAI({ apiKey, baseURL })(model); + default: + throw new Error(`Unsupported AI SDK provider: ${provider}`); + } +} diff --git a/src/services/tools/ToolRegistry.ts b/src/services/tools/ToolRegistry.ts index 80f8a0039..7f6c2e612 100644 --- a/src/services/tools/ToolRegistry.ts +++ b/src/services/tools/ToolRegistry.ts @@ -1,3 +1,6 @@ +import { jsonSchema } from "ai"; +import type { Tool } from "ai"; + export interface ToolResult { success: boolean; data: unknown; @@ -12,16 +15,6 @@ export interface ToolDefinition { execute: (args: Record) => Promise; } -interface OpenAIFunctionTool { - type: "function"; - function: { - name: string; - description: string; - parameters: Record; - strict: true; - }; -} - export class ToolRegistry { private tools = new Map(); @@ -37,15 +30,18 @@ export class ToolRegistry { return Array.from(this.tools.values()); } - toOpenAIFormat(): OpenAIFunctionTool[] { - return this.getAll().map((tool) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - strict: true, - }, - })); + toAISDKFormat(): Record { + const result: Record = {}; + for (const def of this.getAll()) { + result[def.name] = { + description: def.description, + inputSchema: jsonSchema(def.parameters), + execute: async (args: unknown) => { + const toolResult = await def.execute(args as Record); + return toolResult.data; + }, + } as Tool; + } + return result; } } From e6f0cdab01192e34e59a1c51ab7d36e9da6252e5 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Sun, 15 Mar 2026 18:45:07 -0700 Subject: [PATCH 03/91] fix: pass reasoning_effort via providerOptions for Groq models with disableThinking Uses AI SDK's providerOptions API to send reasoning_effort: "none" to Groq for models flagged with disableThinking in the model registry. --- src/services/ReasoningService.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 76625ad85..46e5f59f5 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -1296,6 +1296,9 @@ class ReasoningService extends BaseReasoningService { const aiModel = getAIModel(provider, model, apiKey, baseURL); + const modelDef = getCloudModel(model); + const needsDisableThinking = provider === "groq" && modelDef?.disableThinking; + logger.logReasoning("AGENT_AI_SDK_STREAM_REQUEST", { model, provider, @@ -1314,6 +1317,7 @@ class ReasoningService extends BaseReasoningService { stopWhen: stepCountIs(tools ? 5 : 1), ...(apiConfig.supportsTemperature ? { temperature: config.temperature ?? 0.3 } : {}), maxOutputTokens: config.maxTokens || 4096, + ...(needsDisableThinking ? { providerOptions: { groq: { reasoningEffort: "none" } } } : {}), }); for await (const chunk of result.fullStream) { From edcae987caaba03207ab7b4d084007278cbd1f94 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Sun, 15 Mar 2026 19:16:24 -0700 Subject: [PATCH 04/91] fix: harden agent tool-calling with proper cleanup and error handling - Add mounted ref to guard state updates after overlay unmount - Add AudioManager.cleanup() call on unmount - Handle tool-result stream chunks to show actual results in UI - Set tool status to "executing" until result arrives (fix state thrashing) - Add try/catch in ToolRegistry.toAISDKFormat() execute wrapper - Extend AgentStreamChunk type with tool_result variant - Add success field to IPC web-search response for contract consistency --- src/components/AgentOverlay.tsx | 33 ++++++++++++++++++++++++++---- src/helpers/ipcHandlers.js | 3 ++- src/services/ReasoningService.ts | 10 +++++++++ src/services/tools/ToolRegistry.ts | 8 ++++++-- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 0c5af4e02..451037862 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -47,6 +47,7 @@ export default function AgentOverlay() { const messagesRef = useRef([]); const agentStateRef = useRef("idle"); const conversationIdRef = useRef(null); + const mountedRef = useRef(true); useEffect(() => { messagesRef.current = messages; @@ -56,6 +57,12 @@ export default function AgentOverlay() { agentStateRef.current = agentState; }, [agentState]); + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + const addSystemMessage = useCallback((content: string) => { setMessages((prev) => [ ...prev, @@ -138,6 +145,7 @@ export default function AgentOverlay() { ); for await (const chunk of stream) { + if (!mountedRef.current) break; if (chunk.type === "content") { fullContent += chunk.text; setMessages((prev) => @@ -160,17 +168,31 @@ export default function AgentOverlay() { id: call.id, name: call.name, arguments: call.arguments, - status: "completed" as const, - result: "Done", + status: "executing" as const, }, ], } : m ) ); - setAgentState("streaming"); - setToolStatus(""); } + } else if (chunk.type === "tool_result") { + setMessages((prev) => + prev.map((m) => + m.id === assistantId && m.toolCalls + ? { + ...m, + toolCalls: m.toolCalls.map((tc) => + tc.id === chunk.callId + ? { ...tc, status: "completed" as const, result: chunk.displayText } + : tc + ), + } + : m + ) + ); + setAgentState("streaming"); + setToolStatus(""); } } } else if (isCloudAgent) { @@ -178,6 +200,7 @@ export default function AgentOverlay() { systemPrompt, }); for await (const chunk of streamSource) { + if (!mountedRef.current) break; fullContent += chunk; setMessages((prev) => prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) @@ -191,6 +214,7 @@ export default function AgentOverlay() { { systemPrompt } ); for await (const chunk of stream) { + if (!mountedRef.current) break; if (chunk.type === "content") { fullContent += chunk.text; setMessages((prev) => @@ -256,6 +280,7 @@ export default function AgentOverlay() { }); audioManagerRef.current = am; return () => { + am.cleanup?.(); window.removeEventListener("api-key-changed", (am as any)._onApiKeyChanged); }; }, [addSystemMessage, handleTranscriptionComplete]); diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 6f1b20801..0721414cb 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -2742,7 +2742,8 @@ class IPCHandlers { }; } - return await response.json(); + const data = await response.json(); + return { success: true, ...data }; } catch (error) { debugLogger.error("Agent web search error:", error); return { success: false, error: error.message }; diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 46e5f59f5..804afa1e6 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -13,6 +13,7 @@ import { getAIModel } from "./ai/providers"; export type AgentStreamChunk = | { type: "content"; text: string } | { type: "tool_calls"; calls: Array<{ id: string; name: string; arguments: string }> } + | { type: "tool_result"; callId: string; toolName: string; displayText: string } | { type: "done"; finishReason?: string }; class ReasoningService extends BaseReasoningService { @@ -1334,6 +1335,15 @@ class ReasoningService extends BaseReasoningService { }, ], }; + } else if (chunk.type === "tool-result") { + const output = chunk.output; + const displayText = + typeof output === "string" + ? output + : output?.error + ? String(output.error) + : "Done"; + yield { type: "tool_result", callId: chunk.toolCallId, toolName: chunk.toolName, displayText }; } else if (chunk.type === "finish") { yield { type: "done", finishReason: chunk.finishReason }; } diff --git a/src/services/tools/ToolRegistry.ts b/src/services/tools/ToolRegistry.ts index 7f6c2e612..948fdb78a 100644 --- a/src/services/tools/ToolRegistry.ts +++ b/src/services/tools/ToolRegistry.ts @@ -37,8 +37,12 @@ export class ToolRegistry { description: def.description, inputSchema: jsonSchema(def.parameters), execute: async (args: unknown) => { - const toolResult = await def.execute(args as Record); - return toolResult.data; + try { + const toolResult = await def.execute(args as Record); + return toolResult.success ? toolResult.data : { error: toolResult.displayText }; + } catch (error) { + return { error: (error as Error).message || "Tool execution failed" }; + } }, } as Tool; } From ecda3049b4340dc420f78963b7a86b9486d2d0f7 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 16 Mar 2026 13:16:24 -0700 Subject: [PATCH 05/91] feat: add note management tools (get, create, update) to agent mode - Add get_note tool to fetch full note content by ID - Add create_note tool with folder resolution and cloud sync - Add update_note tool for title, content, and folder changes - Add shared resolveFolderId utility for folder name lookup - Include note ID in search_notes results for cross-tool reference - Register tools and add system prompt instructions - Add translation keys for all 10 locales --- src/config/prompts.ts | 5 ++ src/locales/de/translation.json | 3 + src/locales/en/translation.json | 3 + src/locales/es/translation.json | 3 + src/locales/fr/translation.json | 3 + src/locales/it/translation.json | 3 + src/locales/ja/translation.json | 3 + src/locales/pt/translation.json | 3 + src/locales/ru/translation.json | 3 + src/locales/zh-CN/translation.json | 3 + src/locales/zh-TW/translation.json | 3 + src/services/ReasoningService.ts | 13 ++-- src/services/tools/createNoteTool.ts | 73 ++++++++++++++++++++++ src/services/tools/getNoteTool.ts | 55 +++++++++++++++++ src/services/tools/index.ts | 6 ++ src/services/tools/searchNotesTool.ts | 1 + src/services/tools/updateNoteTool.ts | 87 +++++++++++++++++++++++++++ src/services/tools/utils.ts | 11 ++++ 18 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 src/services/tools/createNoteTool.ts create mode 100644 src/services/tools/getNoteTool.ts create mode 100644 src/services/tools/updateNoteTool.ts create mode 100644 src/services/tools/utils.ts diff --git a/src/config/prompts.ts b/src/config/prompts.ts index ab1b37b86..1a5cbc9d9 100644 --- a/src/config/prompts.ts +++ b/src/config/prompts.ts @@ -147,6 +147,11 @@ const DEFAULT_AGENT_SYSTEM_PROMPT = const TOOL_INSTRUCTIONS: Record = { search_notes: "Use search_notes to find information from the user's past meetings, discussions, or personal notes before answering from memory.", + get_note: + "Use get_note to fetch the full content of a specific note by ID. Use search_notes first to find the note ID.", + create_note: "Use create_note when the user asks you to create, write, or draft a new note.", + update_note: + "Use update_note to modify an existing note's title, content, or move it to a different folder. Use search_notes first to find the note ID.", web_search: "Use web_search for questions about current events, facts you're unsure about, or anything requiring up-to-date information.", copy_to_clipboard: diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 6802ee828..973ef066f 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "Websuche...", "copy_to_clipboardStatus": "In Zwischenablage kopieren...", "get_calendar_eventsStatus": "Kalender wird geprüft...", + "get_noteStatus": "Notiz wird gelesen...", + "create_noteStatus": "Notiz wird erstellt...", + "update_noteStatus": "Notiz wird aktualisiert...", "foundNotes": "{{count}} Notiz gefunden", "foundNotes_plural": "{{count}} Notizen gefunden", "searchResults": "{{count}} Ergebnis gefunden", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6f151f837..71d1b7b84 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1891,6 +1891,9 @@ "web_searchStatus": "Searching the web...", "copy_to_clipboardStatus": "Copying to clipboard...", "get_calendar_eventsStatus": "Checking calendar...", + "get_noteStatus": "Reading note...", + "create_noteStatus": "Creating note...", + "update_noteStatus": "Updating note...", "foundNotes": "Found {{count}} note", "foundNotes_plural": "Found {{count}} notes", "searchResults": "Found {{count}} result", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index b49d92978..8067bf17a 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "Buscando en la web...", "copy_to_clipboardStatus": "Copiando al portapapeles...", "get_calendar_eventsStatus": "Consultando calendario...", + "get_noteStatus": "Leyendo nota...", + "create_noteStatus": "Creando nota...", + "update_noteStatus": "Actualizando nota...", "foundNotes": "{{count}} nota encontrada", "foundNotes_plural": "{{count}} notas encontradas", "searchResults": "{{count}} resultado encontrado", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index f50dac182..77c00d367 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "Recherche sur le web...", "copy_to_clipboardStatus": "Copie dans le presse-papiers...", "get_calendar_eventsStatus": "Vérification du calendrier...", + "get_noteStatus": "Lecture de la note...", + "create_noteStatus": "Création de la note...", + "update_noteStatus": "Mise à jour de la note...", "foundNotes": "{{count}} note trouvée", "foundNotes_plural": "{{count}} notes trouvées", "searchResults": "{{count}} résultat trouvé", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 835c5e5a1..0698edb09 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "Ricerca sul web...", "copy_to_clipboardStatus": "Copia negli appunti...", "get_calendar_eventsStatus": "Controllo del calendario...", + "get_noteStatus": "Lettura della nota...", + "create_noteStatus": "Creazione della nota...", + "update_noteStatus": "Aggiornamento della nota...", "foundNotes": "{{count}} nota trovata", "foundNotes_plural": "{{count}} note trovate", "searchResults": "{{count}} risultato trovato", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 4250f34f7..81acc6304 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "ウェブを検索中...", "copy_to_clipboardStatus": "クリップボードにコピー中...", "get_calendar_eventsStatus": "カレンダーを確認中...", + "get_noteStatus": "ノートを読み込み中...", + "create_noteStatus": "ノートを作成中...", + "update_noteStatus": "ノートを更新中...", "foundNotes": "{{count}}件のノートが見つかりました", "foundNotes_plural": "{{count}}件のノートが見つかりました", "searchResults": "{{count}}件の結果が見つかりました", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 9cd5a679c..025eb77ed 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "Pesquisando na web...", "copy_to_clipboardStatus": "Copiando para a área de transferência...", "get_calendar_eventsStatus": "Verificando calendário...", + "get_noteStatus": "Lendo nota...", + "create_noteStatus": "Criando nota...", + "update_noteStatus": "Atualizando nota...", "foundNotes": "{{count}} nota encontrada", "foundNotes_plural": "{{count}} notas encontradas", "searchResults": "{{count}} resultado encontrado", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 88fa191d1..c431d0d38 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "Поиск в интернете...", "copy_to_clipboardStatus": "Копирование в буфер обмена...", "get_calendar_eventsStatus": "Проверка календаря...", + "get_noteStatus": "Чтение заметки...", + "create_noteStatus": "Создание заметки...", + "update_noteStatus": "Обновление заметки...", "foundNotes": "Найдена {{count}} заметка", "foundNotes_plural": "Найдено {{count}} заметок", "searchResults": "Найден {{count}} результат", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index bb8c79b3e..2c223a3d1 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "正在搜索网页...", "copy_to_clipboardStatus": "正在复制到剪贴板...", "get_calendar_eventsStatus": "正在查看日历...", + "get_noteStatus": "正在读取笔记...", + "create_noteStatus": "正在创建笔记...", + "update_noteStatus": "正在更新笔记...", "foundNotes": "找到 {{count}} 条笔记", "foundNotes_plural": "找到 {{count}} 条笔记", "searchResults": "找到 {{count}} 条结果", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 7631b3517..777093828 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1821,6 +1821,9 @@ "web_searchStatus": "正在搜尋網頁...", "copy_to_clipboardStatus": "正在複製到剪貼簿...", "get_calendar_eventsStatus": "正在查看行事曆...", + "get_noteStatus": "正在讀取筆記...", + "create_noteStatus": "正在建立筆記...", + "update_noteStatus": "正在更新筆記...", "foundNotes": "找到 {{count}} 則筆記", "foundNotes_plural": "找到 {{count}} 則筆記", "searchResults": "找到 {{count}} 項結果", diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 804afa1e6..1704dc0c2 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -1338,12 +1338,13 @@ class ReasoningService extends BaseReasoningService { } else if (chunk.type === "tool-result") { const output = chunk.output; const displayText = - typeof output === "string" - ? output - : output?.error - ? String(output.error) - : "Done"; - yield { type: "tool_result", callId: chunk.toolCallId, toolName: chunk.toolName, displayText }; + typeof output === "string" ? output : output?.error ? String(output.error) : "Done"; + yield { + type: "tool_result", + callId: chunk.toolCallId, + toolName: chunk.toolName, + displayText, + }; } else if (chunk.type === "finish") { yield { type: "done", finishReason: chunk.finishReason }; } diff --git a/src/services/tools/createNoteTool.ts b/src/services/tools/createNoteTool.ts new file mode 100644 index 000000000..003a594b9 --- /dev/null +++ b/src/services/tools/createNoteTool.ts @@ -0,0 +1,73 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; +import { resolveFolderId } from "./utils"; +import { syncNoteToCloud } from "../../stores/noteStore"; + +export const createNoteTool: ToolDefinition = { + name: "create_note", + description: "Create a new note with a title and content, optionally in a specific folder.", + parameters: { + type: "object", + properties: { + title: { + type: "string", + description: "The title of the note", + }, + content: { + type: "string", + description: "The content of the note", + }, + folder: { + type: "string", + description: "The folder name to create the note in (optional)", + }, + }, + required: ["title", "content"], + additionalProperties: false, + }, + readOnly: false, + + async execute(args: Record): Promise { + const title = args.title as string; + const content = args.content as string; + const folderName = args.folder as string | undefined; + + try { + let folderId: number | null = null; + + if (folderName) { + const resolved = await resolveFolderId(folderName); + if (resolved.error) { + return { success: false, data: null, displayText: resolved.error }; + } + folderId = resolved.folderId; + } + + const result = await window.electronAPI.saveNote( + title, + content, + "personal", + null, + null, + folderId + ); + + if (!result.success || !result.note) { + return { success: false, data: null, displayText: "Failed to create note" }; + } + + syncNoteToCloud(result.note).catch(() => {}); + + return { + success: true, + data: { id: result.note.id, title: result.note.title }, + displayText: `Created note: "${title}"`, + }; + } catch (error) { + return { + success: false, + data: null, + displayText: `Failed to create note: ${(error as Error).message}`, + }; + } + }, +}; diff --git a/src/services/tools/getNoteTool.ts b/src/services/tools/getNoteTool.ts new file mode 100644 index 000000000..1219759fc --- /dev/null +++ b/src/services/tools/getNoteTool.ts @@ -0,0 +1,55 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; + +export const getNoteTool: ToolDefinition = { + name: "get_note", + description: + "Get the full content of a specific note by ID. Use search_notes first to find the note ID.", + parameters: { + type: "object", + properties: { + id: { + type: "number", + description: "The note ID to retrieve", + }, + }, + required: ["id"], + additionalProperties: false, + }, + readOnly: true, + + async execute(args: Record): Promise { + const id = args.id as number; + + try { + const note = await window.electronAPI.getNote(id); + + if (!note) { + return { + success: false, + data: null, + displayText: `Note with ID ${id} not found`, + }; + } + + return { + success: true, + data: { + id: note.id, + title: note.title, + content: note.enhanced_content || note.content, + type: note.note_type, + folder_id: note.folder_id, + created_at: note.created_at, + updated_at: note.updated_at, + }, + displayText: `Retrieved note: "${note.title}"`, + }; + } catch (error) { + return { + success: false, + data: null, + displayText: `Failed to get note: ${(error as Error).message}`, + }; + } + }, +}; diff --git a/src/services/tools/index.ts b/src/services/tools/index.ts index ea782ed7b..d45d10f35 100644 --- a/src/services/tools/index.ts +++ b/src/services/tools/index.ts @@ -1,5 +1,8 @@ import { ToolRegistry } from "./ToolRegistry"; import { searchNotesTool } from "./searchNotesTool"; +import { getNoteTool } from "./getNoteTool"; +import { createNoteTool } from "./createNoteTool"; +import { updateNoteTool } from "./updateNoteTool"; import { clipboardTool } from "./clipboardTool"; import { webSearchTool } from "./webSearchTool"; import { calendarTool } from "./calendarTool"; @@ -16,6 +19,9 @@ export function createToolRegistry(settings: ToolRegistrySettings): ToolRegistry const registry = new ToolRegistry(); registry.register(searchNotesTool); + registry.register(getNoteTool); + registry.register(createNoteTool); + registry.register(updateNoteTool); registry.register(clipboardTool); if (settings.isSignedIn) { diff --git a/src/services/tools/searchNotesTool.ts b/src/services/tools/searchNotesTool.ts index fd4cd59be..2e8f1bae4 100644 --- a/src/services/tools/searchNotesTool.ts +++ b/src/services/tools/searchNotesTool.ts @@ -39,6 +39,7 @@ export const searchNotesTool: ToolDefinition = { } const results = notes.map((note) => ({ + id: note.id, title: note.title, date: note.created_at, type: note.note_type, diff --git a/src/services/tools/updateNoteTool.ts b/src/services/tools/updateNoteTool.ts new file mode 100644 index 000000000..e41b8a266 --- /dev/null +++ b/src/services/tools/updateNoteTool.ts @@ -0,0 +1,87 @@ +import type { ToolDefinition, ToolResult } from "./ToolRegistry"; +import { resolveFolderId } from "./utils"; +import { syncNoteUpdateToCloud } from "../../stores/noteStore"; + +export const updateNoteTool: ToolDefinition = { + name: "update_note", + description: + "Update an existing note's title, content, or move it to a different folder. Use search_notes first to find the note ID.", + parameters: { + type: "object", + properties: { + id: { + type: "number", + description: "The note ID to update", + }, + title: { + type: "string", + description: "New title for the note (optional)", + }, + content: { + type: "string", + description: "New content for the note (optional)", + }, + folder: { + type: "string", + description: "Folder name to move the note to (optional)", + }, + }, + required: ["id"], + additionalProperties: false, + }, + readOnly: false, + + async execute(args: Record): Promise { + const id = args.id as number; + const title = args.title as string | undefined; + const content = args.content as string | undefined; + const folderName = args.folder as string | undefined; + + if (!title && !content && !folderName) { + return { + success: false, + data: null, + displayText: "At least one of title, content, or folder must be provided", + }; + } + + try { + const note = await window.electronAPI.getNote(id); + if (!note) { + return { success: false, data: null, displayText: `Note with ID ${id} not found` }; + } + + const updates: Record = {}; + if (title) updates.title = title; + if (content) updates.content = content; + + if (folderName) { + const resolved = await resolveFolderId(folderName); + if (resolved.error) { + return { success: false, data: null, displayText: resolved.error }; + } + updates.folder_id = resolved.folderId; + } + + const result = await window.electronAPI.updateNote(id, updates); + + if (!result.success) { + return { success: false, data: null, displayText: "Failed to update note" }; + } + + syncNoteUpdateToCloud(note, updates).catch(() => {}); + + return { + success: true, + data: { id, title: title || note.title }, + displayText: `Updated note: "${title || note.title}"`, + }; + } catch (error) { + return { + success: false, + data: null, + displayText: `Failed to update note: ${(error as Error).message}`, + }; + } + }, +}; diff --git a/src/services/tools/utils.ts b/src/services/tools/utils.ts new file mode 100644 index 000000000..ea4917971 --- /dev/null +++ b/src/services/tools/utils.ts @@ -0,0 +1,11 @@ +export async function resolveFolderId( + folderName: string +): Promise<{ folderId: number; error?: undefined } | { folderId?: undefined; error: string }> { + const folders = await window.electronAPI.getFolders(); + + const match = folders.find((f) => f.name.toLowerCase() === folderName.toLowerCase()); + if (match) return { folderId: match.id }; + + const available = folders.map((f) => f.name).join(", "); + return { error: `Folder "${folderName}" not found. Available folders: ${available}` }; +} From bbc7748f4af5f30afde928c7318fa00539e03e01 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 08:06:21 -0700 Subject: [PATCH 06/91] feat: add cloud agent tool support with NDJSON streaming - Enable tool calling for cloud agent mode (clipboard, notes, web search, calendar) - Implement NDJSON streaming via IPC batch approach for reliable event delivery - Add multi-step tool-calling loop with AI SDK v6 message format - Fix mountedRef StrictMode bug causing empty renders - Return actual tool result data to LLM instead of just display text - Extract MAX_TOOL_STEPS constant, remove redundant comments --- preload.js | 5 - src/components/AgentOverlay.tsx | 137 ++++++++++++++-------------- src/helpers/ipcHandlers.js | 31 +++++-- src/services/ReasoningService.ts | 110 +++++++++++++++------- src/services/tools/webSearchTool.ts | 2 +- src/types/electron.ts | 23 ++++- 6 files changed, 188 insertions(+), 120 deletions(-) diff --git a/preload.js b/preload.js index 4ba03b1d1..d5545f15b 100644 --- a/preload.js +++ b/preload.js @@ -580,11 +580,6 @@ contextBridge.exposeInMainWorld("electronAPI", { // Agent cloud streaming cloudAgentStream: (messages, opts) => ipcRenderer.invoke("cloud-agent-stream", messages, opts), - onAgentStreamChunk: registerListener( - "agent-stream-chunk", - (callback) => (_event, chunk) => callback(chunk) - ), - onAgentStreamDone: registerListener("agent-stream-done", (callback) => () => callback()), agentWebSearch: (query, numResults) => ipcRenderer.invoke("agent-web-search", query, numResults), // Agent conversation persistence diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 451037862..a3975f101 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -5,7 +5,7 @@ import { AgentTitleBar } from "./agent/AgentTitleBar"; import { AgentChat } from "./agent/AgentChat"; import { AgentInput } from "./agent/AgentInput"; import AudioManager from "../helpers/audioManager"; -import ReasoningService from "../services/ReasoningService"; +import ReasoningService, { type AgentStreamChunk } from "../services/ReasoningService"; import { getSettings } from "../stores/settingsStore"; import { getAgentSystemPrompt } from "../config/prompts"; import { createToolRegistry } from "../services/tools"; @@ -58,6 +58,7 @@ export default function AgentOverlay() { }, [agentState]); useEffect(() => { + mountedRef.current = true; return () => { mountedRef.current = false; }; @@ -108,8 +109,7 @@ export default function AgentOverlay() { const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; const toolSupportedProviders = ["openai", "groq", "custom", "anthropic", "gemini"]; - const supportsTools = - !isCloudAgent && toolSupportedProviders.includes(settings.agentProvider); + const supportsTools = isCloudAgent || toolSupportedProviders.includes(settings.agentProvider); const registry = supportsTools ? createToolRegistry({ @@ -133,94 +133,89 @@ export default function AgentOverlay() { try { let fullContent = ""; + let stream: AsyncGenerator; + + if (isCloudAgent) { + const executeToolCall = registry + ? async (name: string, argsJson: string) => { + const tool = registry.get(name); + if (!tool) return `Unknown tool: ${name}`; + const args = JSON.parse(argsJson); + const result = await tool.execute(args); + if (!result.success) return result.displayText; + return typeof result.data === "string" ? result.data : JSON.stringify(result.data); + } + : undefined; - if (supportsTools && registry) { - const aiTools = registry.toAISDKFormat(); - const stream = ReasoningService.processTextStreamingAI( + stream = ReasoningService.processTextStreamingCloud(llmMessages, { + systemPrompt, + tools: registry?.getAll().map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + })), + executeToolCall, + }); + } else { + const aiTools = registry?.toAISDKFormat(); + stream = ReasoningService.processTextStreamingAI( llmMessages, settings.agentModel, settings.agentProvider, { systemPrompt }, aiTools ); + } - for await (const chunk of stream) { - if (!mountedRef.current) break; - if (chunk.type === "content") { - fullContent += chunk.text; - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + for await (const chunk of stream) { + if (!mountedRef.current) break; + if (chunk.type === "content") { + fullContent += chunk.text; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + } else if (chunk.type === "tool_calls") { + for (const call of chunk.calls) { + setAgentState("tool-executing"); + setToolStatus( + t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) ); - } else if (chunk.type === "tool_calls") { - for (const call of chunk.calls) { - setAgentState("tool-executing"); - setToolStatus( - t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) - ); - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - toolCalls: [ - ...(m.toolCalls || []), - { - id: call.id, - name: call.name, - arguments: call.arguments, - status: "executing" as const, - }, - ], - } - : m - ) - ); - } - } else if (chunk.type === "tool_result") { setMessages((prev) => prev.map((m) => - m.id === assistantId && m.toolCalls + m.id === assistantId ? { ...m, - toolCalls: m.toolCalls.map((tc) => - tc.id === chunk.callId - ? { ...tc, status: "completed" as const, result: chunk.displayText } - : tc - ), + toolCalls: [ + ...(m.toolCalls || []), + { + id: call.id, + name: call.name, + arguments: call.arguments, + status: "executing" as const, + }, + ], } : m ) ); - setAgentState("streaming"); - setToolStatus(""); } - } - } else if (isCloudAgent) { - const streamSource = ReasoningService.processTextStreamingCloud(llmMessages, { - systemPrompt, - }); - for await (const chunk of streamSource) { - if (!mountedRef.current) break; - fullContent += chunk; + } else if (chunk.type === "tool_result") { setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + prev.map((m) => + m.id === assistantId && m.toolCalls + ? { + ...m, + toolCalls: m.toolCalls.map((tc) => + tc.id === chunk.callId + ? { ...tc, status: "completed" as const, result: chunk.displayText } + : tc + ), + } + : m + ) ); - } - } else { - const stream = ReasoningService.processTextStreamingAI( - llmMessages, - settings.agentModel, - settings.agentProvider, - { systemPrompt } - ); - for await (const chunk of stream) { - if (!mountedRef.current) break; - if (chunk.type === "content") { - fullContent += chunk.text; - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) - ); - } + setAgentState("streaming"); + setToolStatus(""); } } diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 0721414cb..bfe0e08d7 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -2658,7 +2658,7 @@ class IPCHandlers { debugLogger.debug( "Cloud agent stream request", - { messageCount: messages?.length || 0 }, + { messageCount: messages?.length || 0, toolCount: opts.tools?.length || 0 }, "cloud-api" ); @@ -2671,6 +2671,7 @@ class IPCHandlers { body: JSON.stringify({ messages, systemPrompt: opts.systemPrompt, + tools: opts.tools, sessionId: this.sessionId, clientType: "desktop", appVersion: app.getVersion(), @@ -2690,24 +2691,42 @@ class IPCHandlers { const reader = response.body.getReader(); const decoder = new TextDecoder(); + let buffer = ""; + const events = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; - const text = decoder.decode(value, { stream: true }); - if (text) event.sender.send("agent-stream-chunk", text); + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + events.push(JSON.parse(line)); + } catch { + // Non-JSON line, skip + } + } + } + if (buffer.trim()) { + try { + events.push(JSON.parse(buffer)); + } catch { + // Non-JSON remainder, skip + } } } finally { reader.releaseLock(); } - event.sender.send("agent-stream-done"); - return { success: true }; + debugLogger.debug("Agent stream complete", { eventCount: events.length }, "cloud-api"); + return { success: true, events }; } catch (error) { debugLogger.error("Cloud agent stream error:", error); - event.sender.send("agent-stream-done"); return { success: false, error: error.message }; } }); diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 1704dc0c2..c6bffdf53 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -20,6 +20,7 @@ class ReasoningService extends BaseReasoningService { private apiKeyCache: SecureCache; private openAiEndpointPreference = new Map(); private static readonly OPENAI_ENDPOINT_PREF_STORAGE_KEY = "openAiEndpointPreference"; + private static readonly MAX_TOOL_STEPS = 5; private cacheCleanupStop: (() => void) | undefined; constructor() { @@ -1280,7 +1281,6 @@ class ReasoningService extends BaseReasoningService { const cloudProviders = ["openai", "groq", "gemini", "anthropic", "custom"]; const isLocalProvider = !cloudProviders.includes(provider); - // Local models don't work with AI SDK — fall back to content-only streaming if (isLocalProvider) { const contentGen = this.processTextStreaming(messages, model, provider, config); for await (const text of contentGen) { @@ -1315,7 +1315,7 @@ class ReasoningService extends BaseReasoningService { content: m.content, })), tools: tools || undefined, - stopWhen: stepCountIs(tools ? 5 : 1), + stopWhen: stepCountIs(tools ? ReasoningService.MAX_TOOL_STEPS : 1), ...(apiConfig.supportsTemperature ? { temperature: config.temperature ?? 0.3 } : {}), maxOutputTokens: config.maxTokens || 4096, ...(needsDisableThinking ? { providerOptions: { groq: { reasoningEffort: "none" } } } : {}), @@ -1352,45 +1352,91 @@ class ReasoningService extends BaseReasoningService { } async *processTextStreamingCloud( - messages: Array<{ role: string; content: string }>, - config: { systemPrompt: string } - ): AsyncGenerator { - const chunks: string[] = []; - let done = false; - let waiting: (() => void) | null = null; - - const unsubChunk = window.electronAPI?.onAgentStreamChunk?.((chunk: string) => { - chunks.push(chunk); - waiting?.(); - }); - const unsubDone = window.electronAPI?.onAgentStreamDone?.(() => { - done = true; - waiting?.(); - }); + messages: Array<{ role: string; content: string | Array }>, + config: { + systemPrompt: string; + tools?: Array<{ name: string; description: string; parameters: Record }>; + executeToolCall?: (name: string, args: string) => Promise; + } + ): AsyncGenerator { + const maxSteps = config.tools?.length ? ReasoningService.MAX_TOOL_STEPS : 1; + let currentMessages = [...messages]; - try { - const result = await window.electronAPI?.cloudAgentStream?.(messages, { + for (let step = 0; step < maxSteps; step++) { + const result = await window.electronAPI?.cloudAgentStream?.(currentMessages, { systemPrompt: config.systemPrompt, + tools: config.tools, }); - if (result && !result.success) { - throw new Error(result.error || "Cloud agent streaming failed"); + if (!result || !result.success) { + throw new Error(result?.error || "Cloud agent streaming failed"); } - while (!done || chunks.length > 0) { - if (chunks.length > 0) { - yield chunks.shift()!; - } else if (!done) { - await new Promise((r) => { - waiting = r; - }); - waiting = null; + const events = result.events || []; + const pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = []; + + for (const ev of events) { + if (ev.type === "content") { + yield { type: "content", text: ev.text as string }; + } else if (ev.type === "tool_call") { + const call = { + id: ev.id as string, + name: ev.name as string, + arguments: ev.arguments as string, + }; + pendingToolCalls.push(call); + yield { type: "tool_calls", calls: [call] }; } } - } finally { - unsubChunk?.(); - unsubDone?.(); + + if (pendingToolCalls.length === 0 || !config.executeToolCall) { + yield { type: "done", finishReason: "stop" }; + return; + } + + for (const call of pendingToolCalls) { + let toolResult: string; + try { + toolResult = await config.executeToolCall(call.name, call.arguments); + } catch (error) { + toolResult = `Error: ${(error as Error).message}`; + } + yield { + type: "tool_result", + callId: call.id, + toolName: call.name, + displayText: toolResult, + }; + + currentMessages = [ + ...currentMessages, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: call.id, + toolName: call.name, + input: JSON.parse(call.arguments), + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: call.id, + toolName: call.name, + output: { type: "text", value: toolResult }, + }, + ], + }, + ]; + } } + + yield { type: "done", finishReason: "stop" }; } async isAvailable(): Promise { diff --git a/src/services/tools/webSearchTool.ts b/src/services/tools/webSearchTool.ts index b5d681760..42db80cca 100644 --- a/src/services/tools/webSearchTool.ts +++ b/src/services/tools/webSearchTool.ts @@ -3,7 +3,7 @@ import type { ToolDefinition, ToolResult } from "./ToolRegistry"; export const webSearchTool: ToolDefinition = { name: "web_search", description: - "Search the web for current information. Returns relevant web results with titles and snippets.", + "Search the web for current information. Returns relevant web results with titles, URLs, and article text.", parameters: { type: "object", properties: { diff --git a/src/types/electron.ts b/src/types/electron.ts index b0dfb125e..9106971ba 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1073,11 +1073,24 @@ declare global { // Agent cloud streaming cloudAgentStream?: ( - messages: Array<{ role: string; content: string }>, - opts?: { systemPrompt?: string } - ) => Promise<{ success: boolean; error?: string; code?: string }>; - onAgentStreamChunk?: (callback: (chunk: string) => void) => () => void; - onAgentStreamDone?: (callback: () => void) => () => void; + messages: Array<{ role: string; content: string | Array }>, + opts?: { + systemPrompt?: string; + tools?: Array<{ name: string; description: string; parameters: Record }>; + } + ) => Promise<{ + success: boolean; + error?: string; + code?: string; + events?: Array<{ + type: "content" | "tool_call" | "done"; + text?: string; + id?: string; + name?: string; + arguments?: string; + finishReason?: string; + }>; + }>; agentWebSearch?: ( query: string, numResults?: number From 500c6f91ac5d086e85a788a6cd2494b7cc69507a Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 16:00:46 -0700 Subject: [PATCH 07/91] feat: use cloud semantic search in agent search_notes tool When cloud backup is enabled and user is signed in, the search_notes agent tool now uses the cloud hybrid search (pgvector + FTS) instead of local SQLite FTS5 keyword search. Falls back to local search transparently on cloud failure. --- src/components/AgentOverlay.tsx | 1 + src/services/tools/index.ts | 6 +- src/services/tools/searchNotesTool.ts | 151 +++++++++++++++++--------- 3 files changed, 106 insertions(+), 52 deletions(-) diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index a3975f101..f6a3dfe4d 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -115,6 +115,7 @@ export default function AgentOverlay() { ? createToolRegistry({ isSignedIn: settings.isSignedIn, gcalConnected: settings.gcalConnected, + cloudBackupEnabled: settings.cloudBackupEnabled, }) : null; const systemPrompt = getAgentSystemPrompt(registry?.getAll().map((t) => t.name)); diff --git a/src/services/tools/index.ts b/src/services/tools/index.ts index d45d10f35..35552f94a 100644 --- a/src/services/tools/index.ts +++ b/src/services/tools/index.ts @@ -1,5 +1,5 @@ import { ToolRegistry } from "./ToolRegistry"; -import { searchNotesTool } from "./searchNotesTool"; +import { createSearchNotesTool } from "./searchNotesTool"; import { getNoteTool } from "./getNoteTool"; import { createNoteTool } from "./createNoteTool"; import { updateNoteTool } from "./updateNoteTool"; @@ -13,12 +13,14 @@ export type { ToolDefinition, ToolResult } from "./ToolRegistry"; interface ToolRegistrySettings { isSignedIn: boolean; gcalConnected: boolean; + cloudBackupEnabled: boolean; } export function createToolRegistry(settings: ToolRegistrySettings): ToolRegistry { const registry = new ToolRegistry(); - registry.register(searchNotesTool); + const useCloudSearch = settings.isSignedIn && settings.cloudBackupEnabled; + registry.register(createSearchNotesTool({ useCloudSearch })); registry.register(getNoteTool); registry.register(createNoteTool); registry.register(updateNoteTool); diff --git a/src/services/tools/searchNotesTool.ts b/src/services/tools/searchNotesTool.ts index 2e8f1bae4..70e7e1539 100644 --- a/src/services/tools/searchNotesTool.ts +++ b/src/services/tools/searchNotesTool.ts @@ -2,61 +2,112 @@ import type { ToolDefinition, ToolResult } from "./ToolRegistry"; const MAX_CONTENT_LENGTH = 500; -export const searchNotesTool: ToolDefinition = { - name: "search_notes", - description: - "Search the user's notes by keyword or phrase. Returns matching notes with title, date, and a preview of content.", - parameters: { - type: "object", - properties: { - query: { - type: "string", - description: "The search query to find relevant notes", - }, - limit: { - type: "number", - description: "Maximum number of results to return (default 5)", +interface SearchToolOptions { + useCloudSearch: boolean; +} + +export function createSearchNotesTool(options: SearchToolOptions): ToolDefinition { + const { useCloudSearch } = options; + + return { + name: "search_notes", + description: useCloudSearch + ? "Search the user's notes using semantic search. Understands meaning and context, not just keywords. Returns matching notes with title, date, relevance score, and a preview of content." + : "Search the user's notes by keyword or phrase. Returns matching notes with title, date, and a preview of content.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "The search query to find relevant notes", + }, + limit: { + type: "number", + description: "Maximum number of results to return (default 5)", + }, }, + required: ["query"], + additionalProperties: false, }, - required: ["query"], - additionalProperties: false, - }, - readOnly: true, + readOnly: true, - async execute(args: Record): Promise { - const query = args.query as string; - const limit = typeof args.limit === "number" ? args.limit : 5; + async execute(args: Record): Promise { + const query = args.query as string; + const limit = typeof args.limit === "number" ? args.limit : 5; - try { - const notes = await window.electronAPI.searchNotes(query, limit); - - if (notes.length === 0) { + try { + if (useCloudSearch) { + return await executeCloudSearch(query, limit); + } + return await executeLocalSearch(query, limit); + } catch (error) { + if (useCloudSearch) { + try { + return await executeLocalSearch(query, limit); + } catch { + // Both failed + } + } return { - success: true, - data: [], - displayText: `No notes found for "${query}"`, + success: false, + data: null, + displayText: `Failed to search notes: ${(error as Error).message}`, }; } + }, + }; +} + +async function executeLocalSearch(query: string, limit: number): Promise { + const notes = await window.electronAPI.searchNotes(query, limit); + + if (notes.length === 0) { + return { + success: true, + data: [], + displayText: `No notes found for "${query}"`, + }; + } + + const results = notes.map((note) => ({ + id: note.id, + title: note.title, + date: note.created_at, + type: note.note_type, + content: (note.enhanced_content || note.content).slice(0, MAX_CONTENT_LENGTH), + })); + + return { + success: true, + data: results, + displayText: `Found ${results.length} note${results.length === 1 ? "" : "s"} for "${query}"`, + }; +} + +async function executeCloudSearch(query: string, limit: number): Promise { + const { NotesService } = await import("../../services/NotesService.js"); + const { notes: cloudNotes } = await NotesService.search(query, limit); + + if (cloudNotes.length === 0) { + return { + success: true, + data: [], + displayText: `No notes found for "${query}"`, + }; + } + + const results = cloudNotes.map((cn) => ({ + id: cn.client_note_id ? parseInt(cn.client_note_id, 10) : null, + title: cn.title, + date: cn.created_at, + type: cn.note_type, + score: cn.score, + content: (cn.enhanced_content || cn.content).slice(0, MAX_CONTENT_LENGTH), + })); - const results = notes.map((note) => ({ - id: note.id, - title: note.title, - date: note.created_at, - type: note.note_type, - content: (note.enhanced_content || note.content).slice(0, MAX_CONTENT_LENGTH), - })); - - return { - success: true, - data: results, - displayText: `Found ${results.length} note${results.length === 1 ? "" : "s"} for "${query}"`, - }; - } catch (error) { - return { - success: false, - data: null, - displayText: `Failed to search notes: ${(error as Error).message}`, - }; - } - }, -}; + return { + success: true, + data: results, + displayText: `Found ${results.length} note${results.length === 1 ? "" : "s"} for "${query}" (semantic search)`, + }; +} From 589947d2f2e78275c3f2e3596987496d0f29ed0f Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 18:23:28 -0700 Subject: [PATCH 08/91] feat: stream cloud agent responses directly from renderer Replace the buffered IPC middleman with direct fetch from the renderer to the API. Text now streams token-by-token instead of arriving all at once after the full response completes. Adds AbortController support for instant cancellation on unmount or user stop. --- preload.js | 3 +- src/components/AgentOverlay.tsx | 6 ++- src/helpers/ipcHandlers.js | 83 ------------------------------- src/services/ReasoningService.ts | 85 +++++++++++++++++++++++++------- src/types/electron.ts | 21 +------- 5 files changed, 74 insertions(+), 124 deletions(-) diff --git a/preload.js b/preload.js index d5545f15b..d06231efb 100644 --- a/preload.js +++ b/preload.js @@ -578,8 +578,7 @@ contextBridge.exposeInMainWorld("electronAPI", { acquireRecordingLock: (pipeline) => ipcRenderer.invoke("acquire-recording-lock", pipeline), releaseRecordingLock: (pipeline) => ipcRenderer.invoke("release-recording-lock", pipeline), - // Agent cloud streaming - cloudAgentStream: (messages, opts) => ipcRenderer.invoke("cloud-agent-stream", messages, opts), + // Agent cloud tools agentWebSearch: (query, numResults) => ipcRenderer.invoke("agent-web-search", query, numResults), // Agent conversation persistence diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index f6a3dfe4d..73d2f5cdc 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -61,6 +61,7 @@ export default function AgentOverlay() { mountedRef.current = true; return () => { mountedRef.current = false; + ReasoningService.cancelActiveStream(); }; }, []); @@ -169,7 +170,10 @@ export default function AgentOverlay() { } for await (const chunk of stream) { - if (!mountedRef.current) break; + if (!mountedRef.current) { + ReasoningService.cancelActiveStream(); + break; + } if (chunk.type === "content") { fullContent += chunk.text; setMessages((prev) => diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index bfe0e08d7..f78af753d 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -2648,89 +2648,6 @@ class IPCHandlers { } }); - ipcMain.handle("cloud-agent-stream", async (event, messages, opts = {}) => { - try { - const apiUrl = getApiUrl(); - if (!apiUrl) throw new Error("OpenWhispr API URL not configured"); - - const cookieHeader = await getSessionCookies(event); - if (!cookieHeader) throw new Error("No session cookies available"); - - debugLogger.debug( - "Cloud agent stream request", - { messageCount: messages?.length || 0, toolCount: opts.tools?.length || 0 }, - "cloud-api" - ); - - const response = await fetch(`${apiUrl}/api/agent/stream`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Cookie: cookieHeader, - }, - body: JSON.stringify({ - messages, - systemPrompt: opts.systemPrompt, - tools: opts.tools, - sessionId: this.sessionId, - clientType: "desktop", - appVersion: app.getVersion(), - }), - }); - - if (!response.ok) { - if (response.status === 401) { - return { success: false, error: "Session expired", code: "AUTH_EXPIRED" }; - } - const errorData = await response.json().catch(() => ({})); - return { - success: false, - error: errorData.error || `API error: ${response.status}`, - }; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const events = []; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - events.push(JSON.parse(line)); - } catch { - // Non-JSON line, skip - } - } - } - if (buffer.trim()) { - try { - events.push(JSON.parse(buffer)); - } catch { - // Non-JSON remainder, skip - } - } - } finally { - reader.releaseLock(); - } - - debugLogger.debug("Agent stream complete", { eventCount: events.length }, "cloud-api"); - return { success: true, events }; - } catch (error) { - debugLogger.error("Cloud agent stream error:", error); - return { success: false, error: error.message }; - } - }); - ipcMain.handle("agent-web-search", async (event, query, numResults = 5) => { try { const apiUrl = getApiUrl(); diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index c6bffdf53..7d67c85b4 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -2,7 +2,13 @@ import { getModelProvider, getCloudModel, getOpenAiApiConfig } from "../models/M import { BaseReasoningService, ReasoningConfig } from "./BaseReasoningService"; import { SecureCache } from "../utils/SecureCache"; import { withRetry, createApiRetryStrategy } from "../utils/retry"; -import { API_ENDPOINTS, TOKEN_LIMITS, buildApiUrl, normalizeBaseUrl } from "../config/constants"; +import { + API_ENDPOINTS, + TOKEN_LIMITS, + buildApiUrl, + normalizeBaseUrl, + OPENWHISPR_API_URL, +} from "../config/constants"; import logger from "../utils/logger"; import { isSecureEndpoint } from "../utils/urlUtils"; import { withSessionRefresh } from "../lib/neonAuth"; @@ -22,6 +28,7 @@ class ReasoningService extends BaseReasoningService { private static readonly OPENAI_ENDPOINT_PREF_STORAGE_KEY = "openAiEndpointPreference"; private static readonly MAX_TOOL_STEPS = 5; private cacheCleanupStop: (() => void) | undefined; + private activeAbortController: AbortController | null = null; constructor() { super(); @@ -1351,6 +1358,11 @@ class ReasoningService extends BaseReasoningService { } } + cancelActiveStream(): void { + this.activeAbortController?.abort(); + this.activeAbortController = null; + } + async *processTextStreamingCloud( messages: Array<{ role: string; content: string | Array }>, config: { @@ -1363,30 +1375,66 @@ class ReasoningService extends BaseReasoningService { let currentMessages = [...messages]; for (let step = 0; step < maxSteps; step++) { - const result = await window.electronAPI?.cloudAgentStream?.(currentMessages, { - systemPrompt: config.systemPrompt, - tools: config.tools, + const controller = new AbortController(); + this.activeAbortController = controller; + + const response = await fetch(`${OPENWHISPR_API_URL}/api/agent/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + signal: controller.signal, + body: JSON.stringify({ + messages: currentMessages, + systemPrompt: config.systemPrompt, + tools: config.tools, + clientType: "desktop", + }), }); - if (!result || !result.success) { - throw new Error(result?.error || "Cloud agent streaming failed"); + if (!response.ok) { + if (response.status === 401) { + throw new Error("Session expired"); + } + const err = await response.json().catch(() => ({})); + throw new Error(err.error || `API error: ${response.status}`); } - const events = result.events || []; + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; const pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = []; - for (const ev of events) { - if (ev.type === "content") { - yield { type: "content", text: ev.text as string }; - } else if (ev.type === "tool_call") { - const call = { - id: ev.id as string, - name: ev.name as string, - arguments: ev.arguments as string, - }; - pendingToolCalls.push(call); - yield { type: "tool_calls", calls: [call] }; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const ev = JSON.parse(line); + if (ev.type === "content") { + yield { type: "content", text: ev.text }; + } else if (ev.type === "tool_call") { + const call = { + id: ev.id as string, + name: ev.name as string, + arguments: ev.arguments as string, + }; + pendingToolCalls.push(call); + yield { type: "tool_calls", calls: [call] }; + } + } catch { + // skip malformed NDJSON line + } + } } + } finally { + reader.releaseLock(); } if (pendingToolCalls.length === 0 || !config.executeToolCall) { @@ -1495,6 +1543,7 @@ class ReasoningService extends BaseReasoningService { } destroy(): void { + this.cancelActiveStream(); if (this.cacheCleanupStop) { this.cacheCleanupStop(); } diff --git a/src/types/electron.ts b/src/types/electron.ts index 9106971ba..d42b8b40c 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1071,26 +1071,7 @@ declare global { onAgentStopRecording?: (callback: () => void) => () => void; onAgentToggleRecording?: (callback: () => void) => () => void; - // Agent cloud streaming - cloudAgentStream?: ( - messages: Array<{ role: string; content: string | Array }>, - opts?: { - systemPrompt?: string; - tools?: Array<{ name: string; description: string; parameters: Record }>; - } - ) => Promise<{ - success: boolean; - error?: string; - code?: string; - events?: Array<{ - type: "content" | "tool_call" | "done"; - text?: string; - id?: string; - name?: string; - arguments?: string; - finishReason?: string; - }>; - }>; + // Agent cloud tools agentWebSearch?: ( query: string, numResults?: number From 0921eec6a31b3b5956c7e62cc460448061d55932 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 21:25:11 -0700 Subject: [PATCH 09/91] fix: forward session cookies via IPC for direct agent streaming The renderer's fetch() can't access auth cookies stored on the Neon Auth domain. Add a get-session-cookies IPC handler to retrieve them from the main process cookie jar and forward as an explicit Cookie header. --- preload.js | 3 +++ src/helpers/ipcHandlers.js | 4 ++++ src/services/ReasoningService.ts | 11 +++++++++-- src/types/electron.ts | 3 +++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/preload.js b/preload.js index d06231efb..389644ae6 100644 --- a/preload.js +++ b/preload.js @@ -578,6 +578,9 @@ contextBridge.exposeInMainWorld("electronAPI", { acquireRecordingLock: (pipeline) => ipcRenderer.invoke("acquire-recording-lock", pipeline), releaseRecordingLock: (pipeline) => ipcRenderer.invoke("release-recording-lock", pipeline), + // Auth cookies for direct renderer-to-API fetch + getSessionCookies: () => ipcRenderer.invoke("get-session-cookies"), + // Agent cloud tools agentWebSearch: (query, numResults) => ipcRenderer.invoke("agent-web-search", query, numResults), diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index f78af753d..dde6011ff 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -2146,6 +2146,10 @@ class IPCHandlers { return getSessionCookiesFromWindow(win); }; + ipcMain.handle("get-session-cookies", async (event) => { + return getSessionCookies(event); + }); + ipcMain.handle("cloud-transcribe", async (event, audioBuffer, opts = {}) => { try { const apiUrl = getApiUrl(); diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 7d67c85b4..c982cec3f 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -1371,6 +1371,11 @@ class ReasoningService extends BaseReasoningService { executeToolCall?: (name: string, args: string) => Promise; } ): AsyncGenerator { + const cookieHeader = await window.electronAPI?.getSessionCookies?.(); + if (!cookieHeader) { + throw new Error("No session cookies available"); + } + const maxSteps = config.tools?.length ? ReasoningService.MAX_TOOL_STEPS : 1; let currentMessages = [...messages]; @@ -1380,8 +1385,10 @@ class ReasoningService extends BaseReasoningService { const response = await fetch(`${OPENWHISPR_API_URL}/api/agent/stream`, { method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + }, signal: controller.signal, body: JSON.stringify({ messages: currentMessages, diff --git a/src/types/electron.ts b/src/types/electron.ts index d42b8b40c..771138b93 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1071,6 +1071,9 @@ declare global { onAgentStopRecording?: (callback: () => void) => () => void; onAgentToggleRecording?: (callback: () => void) => () => void; + // Auth cookies for direct renderer-to-API fetch + getSessionCookies?: () => Promise; + // Agent cloud tools agentWebSearch?: ( query: string, From 4a1d3e2c5862749324c6747f47d86548cc7b7960 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 21:34:55 -0700 Subject: [PATCH 10/91] fix: use event-based IPC streaming instead of direct fetch Browser fetch() forbids setting the Cookie header, so direct renderer-to- API streaming can't authenticate. Switch to event-based IPC: main process reads the API stream and forwards each chunk via webContents.send() as it arrives. This matches the pattern used for AssemblyAI/Deepgram streaming. --- preload.js | 7 +- src/helpers/ipcHandlers.js | 77 ++++++++++++++- src/services/ReasoningService.ts | 163 +++++++++++++++++-------------- src/types/electron.ts | 24 ++++- 4 files changed, 191 insertions(+), 80 deletions(-) diff --git a/preload.js b/preload.js index 389644ae6..2e500d855 100644 --- a/preload.js +++ b/preload.js @@ -578,8 +578,11 @@ contextBridge.exposeInMainWorld("electronAPI", { acquireRecordingLock: (pipeline) => ipcRenderer.invoke("acquire-recording-lock", pipeline), releaseRecordingLock: (pipeline) => ipcRenderer.invoke("release-recording-lock", pipeline), - // Auth cookies for direct renderer-to-API fetch - getSessionCookies: () => ipcRenderer.invoke("get-session-cookies"), + // Agent cloud streaming (event-based for real-time chunks) + startAgentStream: (messages, opts) => ipcRenderer.send("cloud-agent-stream-start", messages, opts), + onAgentStreamChunk: registerListener("cloud-agent-stream-chunk", (callback) => (_event, chunk) => callback(chunk)), + onAgentStreamError: registerListener("cloud-agent-stream-error", (callback) => (_event, error) => callback(error)), + onAgentStreamEnd: registerListener("cloud-agent-stream-end", (callback) => () => callback()), // Agent cloud tools agentWebSearch: (query, numResults) => ipcRenderer.invoke("agent-web-search", query, numResults), diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index dde6011ff..54e82b345 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -2146,10 +2146,6 @@ class IPCHandlers { return getSessionCookiesFromWindow(win); }; - ipcMain.handle("get-session-cookies", async (event) => { - return getSessionCookies(event); - }); - ipcMain.handle("cloud-transcribe", async (event, audioBuffer, opts = {}) => { try { const apiUrl = getApiUrl(); @@ -2652,6 +2648,79 @@ class IPCHandlers { } }); + ipcMain.on("cloud-agent-stream-start", async (event, messages, opts = {}) => { + try { + const apiUrl = getApiUrl(); + if (!apiUrl) throw new Error("OpenWhispr API URL not configured"); + + const cookieHeader = await getSessionCookies(event); + if (!cookieHeader) throw new Error("No session cookies available"); + + const response = await fetch(`${apiUrl}/api/agent/stream`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + }, + body: JSON.stringify({ + messages, + systemPrompt: opts.systemPrompt, + tools: opts.tools, + sessionId: this.sessionId, + clientType: "desktop", + appVersion: app.getVersion(), + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + event.sender.send("cloud-agent-stream-error", { + error: errorData.error || `API error: ${response.status}`, + code: response.status === 401 ? "AUTH_EXPIRED" : undefined, + }); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + event.sender.send("cloud-agent-stream-chunk", JSON.parse(line)); + } catch { + // skip malformed NDJSON line + } + } + } + if (buffer.trim()) { + try { + event.sender.send("cloud-agent-stream-chunk", JSON.parse(buffer)); + } catch { + // skip malformed remainder + } + } + } finally { + reader.releaseLock(); + } + + event.sender.send("cloud-agent-stream-end"); + } catch (error) { + debugLogger.error("Cloud agent stream error:", error); + event.sender.send("cloud-agent-stream-error", { error: error.message }); + } + }); + ipcMain.handle("agent-web-search", async (event, query, numResults = 5) => { try { const apiUrl = getApiUrl(); diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index c982cec3f..f704baf22 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -2,13 +2,7 @@ import { getModelProvider, getCloudModel, getOpenAiApiConfig } from "../models/M import { BaseReasoningService, ReasoningConfig } from "./BaseReasoningService"; import { SecureCache } from "../utils/SecureCache"; import { withRetry, createApiRetryStrategy } from "../utils/retry"; -import { - API_ENDPOINTS, - TOKEN_LIMITS, - buildApiUrl, - normalizeBaseUrl, - OPENWHISPR_API_URL, -} from "../config/constants"; +import { API_ENDPOINTS, TOKEN_LIMITS, buildApiUrl, normalizeBaseUrl } from "../config/constants"; import logger from "../utils/logger"; import { isSecureEndpoint } from "../utils/urlUtils"; import { withSessionRefresh } from "../lib/neonAuth"; @@ -28,7 +22,6 @@ class ReasoningService extends BaseReasoningService { private static readonly OPENAI_ENDPOINT_PREF_STORAGE_KEY = "openAiEndpointPreference"; private static readonly MAX_TOOL_STEPS = 5; private cacheCleanupStop: (() => void) | undefined; - private activeAbortController: AbortController | null = null; constructor() { super(); @@ -1359,8 +1352,82 @@ class ReasoningService extends BaseReasoningService { } cancelActiveStream(): void { - this.activeAbortController?.abort(); - this.activeAbortController = null; + // No-op — event-based stream cleanup is handled by listener removal + } + + private streamFromIPC( + messages: Array<{ role: string; content: string | Array }>, + opts: { + systemPrompt?: string; + tools?: Array<{ name: string; description: string; parameters: Record }>; + } + ): AsyncGenerator< + { + type: string; + text?: string; + id?: string; + name?: string; + arguments?: string; + finishReason?: string; + }, + void, + unknown + > { + type StreamEvent = { + type: string; + text?: string; + id?: string; + name?: string; + arguments?: string; + finishReason?: string; + }; + const queue: Array = []; + let resolve: (() => void) | null = null; + + const cleanupChunk = window.electronAPI?.onAgentStreamChunk?.((chunk) => { + queue.push(chunk); + resolve?.(); + }); + const cleanupError = window.electronAPI?.onAgentStreamError?.((err) => { + queue.push({ type: "__error", error: err.error }); + resolve?.(); + }); + const cleanupEnd = window.electronAPI?.onAgentStreamEnd?.(() => { + queue.push({ type: "__end" }); + resolve?.(); + }); + + const cleanup = () => { + cleanupChunk?.(); + cleanupError?.(); + cleanupEnd?.(); + }; + + window.electronAPI?.startAgentStream?.(messages, opts); + + const generator = async function* () { + try { + while (true) { + if (queue.length === 0) { + await new Promise((r) => { + resolve = r; + }); + resolve = null; + } + + while (queue.length > 0) { + const item = queue.shift()!; + if (item.type === "__end") return; + if (item.type === "__error") throw new Error((item as { error: string }).error); + yield item as StreamEvent; + } + } + } finally { + cleanup(); + } + }; + + return generator(); } async *processTextStreamingCloud( @@ -1371,77 +1438,29 @@ class ReasoningService extends BaseReasoningService { executeToolCall?: (name: string, args: string) => Promise; } ): AsyncGenerator { - const cookieHeader = await window.electronAPI?.getSessionCookies?.(); - if (!cookieHeader) { - throw new Error("No session cookies available"); - } - const maxSteps = config.tools?.length ? ReasoningService.MAX_TOOL_STEPS : 1; let currentMessages = [...messages]; for (let step = 0; step < maxSteps; step++) { - const controller = new AbortController(); - this.activeAbortController = controller; - - const response = await fetch(`${OPENWHISPR_API_URL}/api/agent/stream`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Cookie: cookieHeader, - }, - signal: controller.signal, - body: JSON.stringify({ - messages: currentMessages, - systemPrompt: config.systemPrompt, - tools: config.tools, - clientType: "desktop", - }), + const stream = this.streamFromIPC(currentMessages, { + systemPrompt: config.systemPrompt, + tools: config.tools, }); - if (!response.ok) { - if (response.status === 401) { - throw new Error("Session expired"); - } - const err = await response.json().catch(() => ({})); - throw new Error(err.error || `API error: ${response.status}`); - } - - const reader = response.body!.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; const pendingToolCalls: Array<{ id: string; name: string; arguments: string }> = []; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - if (!line.trim()) continue; - try { - const ev = JSON.parse(line); - if (ev.type === "content") { - yield { type: "content", text: ev.text }; - } else if (ev.type === "tool_call") { - const call = { - id: ev.id as string, - name: ev.name as string, - arguments: ev.arguments as string, - }; - pendingToolCalls.push(call); - yield { type: "tool_calls", calls: [call] }; - } - } catch { - // skip malformed NDJSON line - } - } + for await (const ev of stream) { + if (ev.type === "content") { + yield { type: "content", text: ev.text as string }; + } else if (ev.type === "tool_call") { + const call = { + id: ev.id as string, + name: ev.name as string, + arguments: ev.arguments as string, + }; + pendingToolCalls.push(call); + yield { type: "tool_calls", calls: [call] }; } - } finally { - reader.releaseLock(); } if (pendingToolCalls.length === 0 || !config.executeToolCall) { diff --git a/src/types/electron.ts b/src/types/electron.ts index 771138b93..e5e0f7e1f 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1071,8 +1071,28 @@ declare global { onAgentStopRecording?: (callback: () => void) => () => void; onAgentToggleRecording?: (callback: () => void) => () => void; - // Auth cookies for direct renderer-to-API fetch - getSessionCookies?: () => Promise; + // Agent cloud streaming (event-based) + startAgentStream?: ( + messages: Array<{ role: string; content: string | Array }>, + opts?: { + systemPrompt?: string; + tools?: Array<{ name: string; description: string; parameters: Record }>; + } + ) => void; + onAgentStreamChunk?: ( + callback: (chunk: { + type: "content" | "tool_call" | "done"; + text?: string; + id?: string; + name?: string; + arguments?: string; + finishReason?: string; + }) => void + ) => () => void; + onAgentStreamError?: ( + callback: (error: { error: string; code?: string }) => void + ) => () => void; + onAgentStreamEnd?: (callback: () => void) => () => void; // Agent cloud tools agentWebSearch?: ( From c4222c7d82830a62fe0f71bdff2be4c95a035550 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 21:55:55 -0700 Subject: [PATCH 11/91] feat: polish agent chat UX with rich tool step visualization Replace basic tool pills with step-based visualization showing the full tool lifecycle: shimmer accent while executing, checkmark pop on completion, expandable detail, and contextual clipboard confirmation. - ToolCallStep: left-border accent, per-tool icons, shimmer animation - Clipboard: inline "Copied to your clipboard" with green check - Input bar: thinking shimmer bar, tool icon in executing state - Empty state: mic icon with dual-line CTA - Title bar: shows agent name, softer shadow - Tighter spacing throughout (gaps, heights, bubble widths) - New CSS: tool-step-shimmer, tool-check-pop, tool-status-sweep - i18n: copiedToClipboard + orType in all 10 locales --- src/components/AgentOverlay.tsx | 5 + src/components/agent/AgentChat.tsx | 22 +++- src/components/agent/AgentInput.tsx | 66 ++++++---- src/components/agent/AgentMessage.tsx | 163 ++++++++++++++++++------- src/components/agent/AgentTitleBar.tsx | 8 +- src/index.css | 41 ++++++- src/locales/de/translation.json | 2 + src/locales/en/translation.json | 2 + src/locales/es/translation.json | 2 + src/locales/fr/translation.json | 2 + src/locales/it/translation.json | 2 + src/locales/ja/translation.json | 2 + src/locales/pt/translation.json | 2 + src/locales/ru/translation.json | 2 + src/locales/zh-CN/translation.json | 2 + src/locales/zh-TW/translation.json | 2 + 16 files changed, 250 insertions(+), 75 deletions(-) diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 73d2f5cdc..516f9a1e5 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -43,6 +43,7 @@ export default function AgentOverlay() { const [agentState, setAgentState] = useState("idle"); const [partialTranscript, setPartialTranscript] = useState(""); const [toolStatus, setToolStatus] = useState(""); + const [activeToolName, setActiveToolName] = useState(""); const audioManagerRef = useRef | null>(null); const messagesRef = useRef([]); const agentStateRef = useRef("idle"); @@ -182,6 +183,7 @@ export default function AgentOverlay() { } else if (chunk.type === "tool_calls") { for (const call of chunk.calls) { setAgentState("tool-executing"); + setActiveToolName(call.name); setToolStatus( t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) ); @@ -221,6 +223,7 @@ export default function AgentOverlay() { ); setAgentState("streaming"); setToolStatus(""); + setActiveToolName(""); } } @@ -374,6 +377,7 @@ export default function AgentOverlay() { setAgentState("idle"); setPartialTranscript(""); setToolStatus(""); + setActiveToolName(""); conversationIdRef.current = null; }, []); @@ -398,6 +402,7 @@ export default function AgentOverlay() { agentState={agentState} partialTranscript={partialTranscript} toolStatus={toolStatus} + activeToolName={activeToolName} onTextSubmit={handleTextSubmit} />
diff --git a/src/components/agent/AgentChat.tsx b/src/components/agent/AgentChat.tsx index b91991e14..162647ae7 100644 --- a/src/components/agent/AgentChat.tsx +++ b/src/components/agent/AgentChat.tsx @@ -1,4 +1,5 @@ import { useRef, useEffect } from "react"; +import { Mic } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; import { AgentMessage } from "./AgentMessage"; @@ -41,13 +42,24 @@ export function AgentChat({ messages }: AgentChatProps) { return (
{messages.length === 0 ? ( -
-

- {t("agentMode.chat.emptyState", { hotkey: hotkeyLabel })} -

+
+
+ +
+
+

+ {t("agentMode.chat.emptyState", { hotkey: hotkeyLabel })} +

+

+ {t("agentMode.chat.orType")} +

+
) : ( -
+
{messages .filter((msg) => msg.role !== "tool") .map((msg) => ( diff --git a/src/components/agent/AgentInput.tsx b/src/components/agent/AgentInput.tsx index f0f86807a..b8ce2d6a4 100644 --- a/src/components/agent/AgentInput.tsx +++ b/src/components/agent/AgentInput.tsx @@ -1,5 +1,15 @@ import { useState, useRef, useCallback } from "react"; -import { Mic, SendHorizontal } from "lucide-react"; +import { + Mic, + SendHorizontal, + Search, + Globe, + ClipboardCheck, + Calendar, + FileText, + FilePlus, + FilePen, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; import { useSettingsStore } from "../../stores/settingsStore"; @@ -17,9 +27,20 @@ interface AgentInputProps { agentState: AgentState; partialTranscript: string; toolStatus?: string; + activeToolName?: string; onTextSubmit?: (text: string) => void; } +const toolIcons: Record = { + search_notes: Search, + web_search: Globe, + copy_to_clipboard: ClipboardCheck, + get_calendar_events: Calendar, + get_note: FileText, + create_note: FilePlus, + update_note: FilePen, +}; + function Kbd({ children }: { children: React.ReactNode }) { return ( - {[0, 1, 2, 3].map((i) => ( +
+ {[0, 1, 2].map((i) => (
))} @@ -73,19 +94,16 @@ function WaveBars() { ); } -function InputLoadingDots() { +function ThinkingBar() { return ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
+
); } @@ -93,6 +111,7 @@ export function AgentInput({ agentState, partialTranscript, toolStatus, + activeToolName, onTextSubmit, }: AgentInputProps) { const { t } = useTranslation(); @@ -118,11 +137,12 @@ export function AgentInput({ ); const isIdle = agentState === "idle"; + const ToolIcon = activeToolName ? toolIcons[activeToolName] : null; return (
@@ -178,7 +198,7 @@ export function AgentInput({ {agentState === "transcribing" && ( <> - + {t("agentMode.input.transcribing")} @@ -187,7 +207,7 @@ export function AgentInput({ {(agentState === "thinking" || agentState === "streaming") && ( <> - + {t("agentMode.input.thinking")} @@ -196,8 +216,8 @@ export function AgentInput({ {agentState === "tool-executing" && ( <> - - + {ToolIcon ? : } + {toolStatus || t("agentMode.input.thinking")} diff --git a/src/components/agent/AgentMessage.tsx b/src/components/agent/AgentMessage.tsx index ecfe55eb9..6347f5346 100644 --- a/src/components/agent/AgentMessage.tsx +++ b/src/components/agent/AgentMessage.tsx @@ -1,5 +1,18 @@ import { useState } from "react"; -import { Copy, Check, Search, Globe, ClipboardCopy, Calendar } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { + Copy, + Check, + Search, + Globe, + ClipboardCheck, + Calendar, + FileText, + FilePlus, + FilePen, + ChevronDown, + CircleAlert, +} from "lucide-react"; import { cn } from "../lib/utils"; import { MarkdownRenderer } from "../ui/MarkdownRenderer"; import type { ToolCallInfo } from "./AgentChat"; @@ -14,52 +27,107 @@ interface AgentMessageProps { const toolIcons: Record = { search_notes: Search, web_search: Globe, - copy_to_clipboard: ClipboardCopy, + copy_to_clipboard: ClipboardCheck, get_calendar_events: Calendar, + get_note: FileText, + create_note: FilePlus, + update_note: FilePen, }; -function ToolCallPill({ toolCall }: { toolCall: ToolCallInfo }) { +function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const Icon = toolIcons[toolCall.name] || Search; const isExecuting = toolCall.status === "executing"; const isError = toolCall.status === "error"; + const isCompleted = toolCall.status === "completed"; + const isClipboard = toolCall.name === "copy_to_clipboard" && isCompleted; + const resultLines = toolCall.result?.split("\n") ?? []; - const isExpandable = resultLines.length > 3; + const hasDetail = resultLines.length > 1 && !isClipboard; return (
setExpanded((v) => !v) : undefined} > -
- + {isExecuting && ( +
+ )} + +
setExpanded((v) => !v) : undefined} + > + + {isExecuting ? ( + + {t(`agentMode.tools.${toolCall.name}Status`, { defaultValue: toolCall.name })} + + ) : isError ? (
- {[0, 1, 2].map((i) => ( -
- ))} + + + {toolCall.result || toolCall.name} + +
+ ) : isClipboard ? ( +
+ + {t("agentMode.tools.copiedToClipboard")} + +
) : ( - + {toolCall.result || toolCall.name} )} + + {hasDetail && !isExecuting && ( + + )}
- {!isExecuting && isExpandable && ( + + {hasDetail && !isExecuting && (
-
+          
             {toolCall.result}
           
@@ -89,7 +157,7 @@ export function AgentMessage({ role, content, isStreaming, toolCalls }: AgentMes >
0; + const hasContent = content.length > 0; + return (
- - - {toolCalls && toolCalls.length > 0 && ( -
+ {hasToolCalls && ( +
{toolCalls.map((tc) => ( - + ))}
)} - + {hasContent && ( + + )} {isStreaming && ( )} + + {hasContent && !isStreaming && ( + + )}
); diff --git a/src/components/agent/AgentTitleBar.tsx b/src/components/agent/AgentTitleBar.tsx index 7409a4e6e..d6e868376 100644 --- a/src/components/agent/AgentTitleBar.tsx +++ b/src/components/agent/AgentTitleBar.tsx @@ -1,6 +1,7 @@ import { Plus, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; +import { getAgentName } from "../../utils/agentName"; interface AgentTitleBarProps { onNewChat: () => void; @@ -9,18 +10,21 @@ interface AgentTitleBarProps { export function AgentTitleBar({ onNewChat, onClose }: AgentTitleBarProps) { const { t } = useTranslation(); + const agentName = getAgentName(); return (
- {t("agentMode.titleBar.label")} + {agentName}
Date: Wed, 18 Mar 2026 21:58:55 -0700 Subject: [PATCH 12/91] fix: separate tool displayText from LLM data, truncate web search results The executeToolCall callback was returning raw data (full JSON) which was displayed in the tool step UI. Now returns { data, displayText } so the LLM gets structured data while the UI shows a human-readable summary. Also truncates Exa web search article text to 500 chars per result to prevent massive payloads. --- src/components/AgentOverlay.tsx | 11 ++++++++--- src/services/ReasoningService.ts | 14 +++++++++----- src/services/tools/webSearchTool.ts | 13 ++++++++++++- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 516f9a1e5..f5554dedc 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -142,11 +142,16 @@ export default function AgentOverlay() { const executeToolCall = registry ? async (name: string, argsJson: string) => { const tool = registry.get(name); - if (!tool) return `Unknown tool: ${name}`; + if (!tool) + return { data: `Unknown tool: ${name}`, displayText: `Unknown tool: ${name}` }; const args = JSON.parse(argsJson); const result = await tool.execute(args); - if (!result.success) return result.displayText; - return typeof result.data === "string" ? result.data : JSON.stringify(result.data); + const data = result.success + ? typeof result.data === "string" + ? result.data + : JSON.stringify(result.data) + : result.displayText; + return { data, displayText: result.displayText }; } : undefined; diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index f704baf22..2a8801ad1 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -1435,7 +1435,10 @@ class ReasoningService extends BaseReasoningService { config: { systemPrompt: string; tools?: Array<{ name: string; description: string; parameters: Record }>; - executeToolCall?: (name: string, args: string) => Promise; + executeToolCall?: ( + name: string, + args: string + ) => Promise<{ data: string; displayText: string }>; } ): AsyncGenerator { const maxSteps = config.tools?.length ? ReasoningService.MAX_TOOL_STEPS : 1; @@ -1469,17 +1472,18 @@ class ReasoningService extends BaseReasoningService { } for (const call of pendingToolCalls) { - let toolResult: string; + let toolResult: { data: string; displayText: string }; try { toolResult = await config.executeToolCall(call.name, call.arguments); } catch (error) { - toolResult = `Error: ${(error as Error).message}`; + const errMsg = `Error: ${(error as Error).message}`; + toolResult = { data: errMsg, displayText: errMsg }; } yield { type: "tool_result", callId: call.id, toolName: call.name, - displayText: toolResult, + displayText: toolResult.displayText, }; currentMessages = [ @@ -1502,7 +1506,7 @@ class ReasoningService extends BaseReasoningService { type: "tool-result", toolCallId: call.id, toolName: call.name, - output: { type: "text", value: toolResult }, + output: { type: "text", value: toolResult.data }, }, ], }, diff --git a/src/services/tools/webSearchTool.ts b/src/services/tools/webSearchTool.ts index 42db80cca..a6454367b 100644 --- a/src/services/tools/webSearchTool.ts +++ b/src/services/tools/webSearchTool.ts @@ -26,7 +26,18 @@ export const webSearchTool: ToolDefinition = { const numResults = typeof args.numResults === "number" ? args.numResults : 5; try { - const results = await window.electronAPI.agentWebSearch!(query, numResults); + const raw = await window.electronAPI.agentWebSearch!(query, numResults); + + const results = Array.isArray(raw?.results) + ? raw.results.map( + (r: { title?: string; url?: string; text?: string; publishedDate?: string }) => ({ + title: r.title || "", + url: r.url || "", + text: r.text ? r.text.slice(0, 500) : "", + publishedDate: r.publishedDate || null, + }) + ) + : raw; return { success: true, From df1511192d78113b0672675c36ec35a9cc579baa Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 22:21:34 -0700 Subject: [PATCH 13/91] feat: clickable created notes, bump max tool steps to 20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase MAX_TOOL_STEPS from 5 to 20 to prevent agent from getting stuck on multi-step workflows - Add metadata field to ToolCallInfo so tool results can carry structured data (e.g. note ID) to the UI without mixing it into displayText - Created/updated/fetched notes show as clickable steps with a primary accent — clicking opens the note in the control panel - New agent-open-note IPC handler navigates the control panel to the note --- preload.js | 1 + src/components/AgentOverlay.tsx | 13 ++++++++-- src/components/agent/AgentChat.tsx | 1 + src/components/agent/AgentMessage.tsx | 35 +++++++++++++++++++++++---- src/helpers/ipcHandlers.js | 14 +++++++++++ src/services/ReasoningService.ts | 15 +++++++++--- src/types/electron.ts | 1 + 7 files changed, 69 insertions(+), 11 deletions(-) diff --git a/preload.js b/preload.js index 2e500d855..65e83b52a 100644 --- a/preload.js +++ b/preload.js @@ -586,6 +586,7 @@ contextBridge.exposeInMainWorld("electronAPI", { // Agent cloud tools agentWebSearch: (query, numResults) => ipcRenderer.invoke("agent-web-search", query, numResults), + agentOpenNote: (noteId) => ipcRenderer.invoke("agent-open-note", noteId), // Agent conversation persistence createAgentConversation: (title) => ipcRenderer.invoke("db-create-agent-conversation", title), diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index f5554dedc..50a9cf9a0 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -151,7 +151,11 @@ export default function AgentOverlay() { ? result.data : JSON.stringify(result.data) : result.displayText; - return { data, displayText: result.displayText }; + const metadata = + result.success && result.data && typeof result.data === "object" + ? (result.data as Record) + : undefined; + return { data, displayText: result.displayText, metadata }; } : undefined; @@ -219,7 +223,12 @@ export default function AgentOverlay() { ...m, toolCalls: m.toolCalls.map((tc) => tc.id === chunk.callId - ? { ...tc, status: "completed" as const, result: chunk.displayText } + ? { + ...tc, + status: "completed" as const, + result: chunk.displayText, + ...(chunk.metadata ? { metadata: chunk.metadata } : {}), + } : tc ), } diff --git a/src/components/agent/AgentChat.tsx b/src/components/agent/AgentChat.tsx index 162647ae7..5c37222e7 100644 --- a/src/components/agent/AgentChat.tsx +++ b/src/components/agent/AgentChat.tsx @@ -12,6 +12,7 @@ export interface ToolCallInfo { arguments: string; status: "executing" | "completed" | "error"; result?: string; + metadata?: Record; } export interface Message { diff --git a/src/components/agent/AgentMessage.tsx b/src/components/agent/AgentMessage.tsx index 6347f5346..992cebb42 100644 --- a/src/components/agent/AgentMessage.tsx +++ b/src/components/agent/AgentMessage.tsx @@ -11,6 +11,7 @@ import { FilePlus, FilePen, ChevronDown, + ChevronRight, CircleAlert, } from "lucide-react"; import { cn } from "../lib/utils"; @@ -34,6 +35,8 @@ const toolIcons: Record = { update_note: FilePen, }; +const NOTE_TOOLS = new Set(["create_note", "update_note", "get_note"]); + function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); @@ -43,16 +46,32 @@ function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { const isCompleted = toolCall.status === "completed"; const isClipboard = toolCall.name === "copy_to_clipboard" && isCompleted; + const noteId = + isCompleted && NOTE_TOOLS.has(toolCall.name) && toolCall.metadata?.id + ? Number(toolCall.metadata.id) + : null; + const resultLines = toolCall.result?.split("\n") ?? []; const hasDetail = resultLines.length > 1 && !isClipboard; + const handleClick = () => { + if (noteId) { + window.electronAPI?.agentOpenNote?.(noteId); + } else if (hasDetail && !isExecuting) { + setExpanded((v) => !v); + } + }; + + const isClickable = (noteId || hasDetail) && !isExecuting; + return (
setExpanded((v) => !v) : undefined} + onClick={isClickable ? handleClick : undefined} >
) : ( - + {toolCall.result || toolCall.name} )} - {hasDetail && !isExecuting && ( + {noteId && !isExecuting && ( + + )} + {hasDetail && !noteId && !isExecuting && ( { + try { + await this.windowManager.createControlPanelWindow(); + this.windowManager.sendToControlPanel("navigate-to-meeting-note", { + noteId, + folderId: null, + }); + return { success: true }; + } catch (error) { + debugLogger.error("Failed to open note from agent:", error); + return { success: false, error: error.message }; + } + }); + ipcMain.handle("agent-web-search", async (event, query, numResults = 5) => { try { const apiUrl = getApiUrl(); diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 2a8801ad1..98017c3e0 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -13,14 +13,20 @@ import { getAIModel } from "./ai/providers"; export type AgentStreamChunk = | { type: "content"; text: string } | { type: "tool_calls"; calls: Array<{ id: string; name: string; arguments: string }> } - | { type: "tool_result"; callId: string; toolName: string; displayText: string } + | { + type: "tool_result"; + callId: string; + toolName: string; + displayText: string; + metadata?: Record; + } | { type: "done"; finishReason?: string }; class ReasoningService extends BaseReasoningService { private apiKeyCache: SecureCache; private openAiEndpointPreference = new Map(); private static readonly OPENAI_ENDPOINT_PREF_STORAGE_KEY = "openAiEndpointPreference"; - private static readonly MAX_TOOL_STEPS = 5; + private static readonly MAX_TOOL_STEPS = 20; private cacheCleanupStop: (() => void) | undefined; constructor() { @@ -1438,7 +1444,7 @@ class ReasoningService extends BaseReasoningService { executeToolCall?: ( name: string, args: string - ) => Promise<{ data: string; displayText: string }>; + ) => Promise<{ data: string; displayText: string; metadata?: Record }>; } ): AsyncGenerator { const maxSteps = config.tools?.length ? ReasoningService.MAX_TOOL_STEPS : 1; @@ -1472,7 +1478,7 @@ class ReasoningService extends BaseReasoningService { } for (const call of pendingToolCalls) { - let toolResult: { data: string; displayText: string }; + let toolResult: { data: string; displayText: string; metadata?: Record }; try { toolResult = await config.executeToolCall(call.name, call.arguments); } catch (error) { @@ -1484,6 +1490,7 @@ class ReasoningService extends BaseReasoningService { callId: call.id, toolName: call.name, displayText: toolResult.displayText, + ...(toolResult.metadata ? { metadata: toolResult.metadata } : {}), }; currentMessages = [ diff --git a/src/types/electron.ts b/src/types/electron.ts index e5e0f7e1f..fb6d33143 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1095,6 +1095,7 @@ declare global { onAgentStreamEnd?: (callback: () => void) => () => void; // Agent cloud tools + agentOpenNote?: (noteId: number) => Promise<{ success: boolean; error?: string }>; agentWebSearch?: ( query: string, numResults?: number From 84dca04a8174b7747fb1efffb27fcf1ff5387f1e Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 22:33:07 -0700 Subject: [PATCH 14/91] feat: inline note cards in chat, recording-style input indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Note cards: when create/update/get_note completes, render a compact clickable card at the bottom of the message bubble with title, icon, and "Open note" label. Clicking opens the note in the control panel. - Input bar: replace generic loading dots with dictation-panel-inspired indicators — pulsing blue circle for listening, accent wave bars for transcribing, shimmer sweep for thinking. - Fix duplicate copiedToClipboard keys in all locale files. --- src/components/agent/AgentInput.tsx | 51 +++++++++----- src/components/agent/AgentMessage.tsx | 96 +++++++++++++++++++-------- src/locales/de/translation.json | 2 +- src/locales/en/translation.json | 2 +- src/locales/es/translation.json | 2 +- src/locales/fr/translation.json | 2 +- src/locales/it/translation.json | 2 +- src/locales/ja/translation.json | 2 +- src/locales/pt/translation.json | 2 +- src/locales/ru/translation.json | 2 +- src/locales/zh-CN/translation.json | 2 +- src/locales/zh-TW/translation.json | 2 +- 12 files changed, 111 insertions(+), 56 deletions(-) diff --git a/src/components/agent/AgentInput.tsx b/src/components/agent/AgentInput.tsx index b8ce2d6a4..99aebc208 100644 --- a/src/components/agent/AgentInput.tsx +++ b/src/components/agent/AgentInput.tsx @@ -77,27 +77,38 @@ function HotkeyKeys({ hotkey }: { hotkey: string }) { ); } -function WaveBars() { +function RecordingIndicator() { return ( -
- {[0, 1, 2].map((i) => ( -
- ))} +
+
+
); } -function ThinkingBar() { +function ProcessingIndicator() { + return ( +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); +} + +function ThinkingIndicator() { return (
- + {partialTranscript || t("agentMode.input.listening")} @@ -198,7 +209,7 @@ export function AgentInput({ {agentState === "transcribing" && ( <> - + {t("agentMode.input.transcribing")} @@ -207,7 +218,7 @@ export function AgentInput({ {(agentState === "thinking" || agentState === "streaming") && ( <> - + {t("agentMode.input.thinking")} @@ -216,7 +227,11 @@ export function AgentInput({ {agentState === "tool-executing" && ( <> - {ToolIcon ? : } + {ToolIcon ? ( + + ) : ( + + )} {toolStatus || t("agentMode.input.thinking")} diff --git a/src/components/agent/AgentMessage.tsx b/src/components/agent/AgentMessage.tsx index 992cebb42..73524d0c9 100644 --- a/src/components/agent/AgentMessage.tsx +++ b/src/components/agent/AgentMessage.tsx @@ -46,32 +46,16 @@ function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { const isCompleted = toolCall.status === "completed"; const isClipboard = toolCall.name === "copy_to_clipboard" && isCompleted; - const noteId = - isCompleted && NOTE_TOOLS.has(toolCall.name) && toolCall.metadata?.id - ? Number(toolCall.metadata.id) - : null; - const resultLines = toolCall.result?.split("\n") ?? []; const hasDetail = resultLines.length > 1 && !isClipboard; - const handleClick = () => { - if (noteId) { - window.electronAPI?.agentOpenNote?.(noteId); - } else if (hasDetail && !isExecuting) { - setExpanded((v) => !v); - } - }; - - const isClickable = (noteId || hasDetail) && !isExecuting; - return (
setExpanded((v) => !v) : undefined} >
) : ( - + {toolCall.result || toolCall.name} )} - {noteId && !isExecuting && ( - - )} - {hasDetail && !noteId && !isExecuting && ( + {hasDetail && !isExecuting && ( window.electronAPI?.agentOpenNote?.(noteId)} + className={cn( + "flex items-center gap-2 w-full mt-2 px-2.5 py-2 rounded-md", + "bg-primary/6 border border-primary/12", + "hover:bg-primary/10 hover:border-primary/20", + "active:scale-[0.99]", + "transition-all duration-150", + "text-left group/note" + )} + > +
+ +
+
+

{title}

+

{t("agentMode.tools.openNote")}

+
+ + + ); +} + +function extractNoteCards(toolCalls?: ToolCallInfo[]): Array<{ noteId: number; title: string }> { + if (!toolCalls) return []; + const cards: Array<{ noteId: number; title: string }> = []; + const seen = new Set(); + + for (const tc of toolCalls) { + if (tc.status !== "completed" || !NOTE_TOOLS.has(tc.name) || !tc.metadata?.id) continue; + const noteId = Number(tc.metadata.id); + if (seen.has(noteId)) continue; + seen.add(noteId); + const title = + (tc.metadata.title as string) || + tc.result?.replace(/^(Created|Updated|Retrieved) note: "(.+)"$/, "$2") || + "Note"; + cards.push({ noteId, title }); + } + return cards; +} + export function AgentMessage({ role, content, isStreaming, toolCalls }: AgentMessageProps) { const [copied, setCopied] = useState(false); @@ -195,6 +222,7 @@ export function AgentMessage({ role, content, isStreaming, toolCalls }: AgentMes const hasToolCalls = toolCalls && toolCalls.length > 0; const hasContent = content.length > 0; + const noteCards = extractNoteCards(toolCalls); return (
{hasToolCalls && ( -
+
0) && "mb-2 pb-1.5 border-b border-border/15" + )} + > {toolCalls.map((tc) => ( ))} @@ -230,6 +262,14 @@ export function AgentMessage({ role, content, isStreaming, toolCalls }: AgentMes /> )} + {noteCards.length > 0 && !isStreaming && ( +
+ {noteCards.map((card) => ( + + ))} +
+ )} + {hasContent && !isStreaming && (
diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index e940220f1..97725d29d 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -104,6 +104,7 @@ class IPCHandlers { this.googleCalendarManager = managers.googleCalendarManager; this.meetingDetectionEngine = managers.meetingDetectionEngine; this.audioTapManager = managers.audioTapManager; + this.qdrantManager = managers.qdrantManager; this.sessionId = crypto.randomUUID(); this.assemblyAiStreaming = null; this.deepgramStreaming = null; @@ -128,6 +129,24 @@ class IPCHandlers { } } + _asyncVectorUpsert(note) { + setImmediate(() => { + const vectorIndex = require("./vectorIndex"); + if (!vectorIndex.isReady()) return; + const { LocalEmbeddings } = require("./localEmbeddings"); + const text = LocalEmbeddings.noteEmbedText(note.title, note.content, note.enhanced_content); + vectorIndex.upsertNote(note.id, text).catch(() => {}); + }); + } + + _asyncVectorDelete(noteId) { + setImmediate(() => { + const vectorIndex = require("./vectorIndex"); + if (!vectorIndex.isReady()) return; + vectorIndex.deleteNote(noteId).catch(() => {}); + }); + } + _getDictionarySafe() { try { return this.databaseManager.getDictionary(); @@ -526,9 +545,8 @@ class IPCHandlers { folderId ); if (result?.success && result?.note) { - setImmediate(() => { - this.broadcastToWindows("note-added", result.note); - }); + setImmediate(() => this.broadcastToWindows("note-added", result.note)); + this._asyncVectorUpsert(result.note); } return result; } @@ -545,9 +563,8 @@ class IPCHandlers { ipcMain.handle("db-update-note", async (event, id, updates) => { const result = this.databaseManager.updateNote(id, updates); if (result?.success && result?.note) { - setImmediate(() => { - this.broadcastToWindows("note-updated", result.note); - }); + setImmediate(() => this.broadcastToWindows("note-updated", result.note)); + this._asyncVectorUpsert(result.note); } return result; }); @@ -555,9 +572,8 @@ class IPCHandlers { ipcMain.handle("db-delete-note", async (event, id) => { const result = this.databaseManager.deleteNote(id); if (result?.success) { - setImmediate(() => { - this.broadcastToWindows("note-deleted", { id }); - }); + setImmediate(() => this.broadcastToWindows("note-deleted", { id })); + this._asyncVectorDelete(id); } return result; }); @@ -566,6 +582,102 @@ class IPCHandlers { return this.databaseManager.searchNotes(query, limit); }); + ipcMain.handle("db-semantic-search-notes", async (event, query, limit = 5) => { + const vectorIndex = require("./vectorIndex"); + if (!vectorIndex.isReady()) { + return this.databaseManager.searchNotes(query, limit); + } + + try { + const [ftsResults, vectorResults] = await Promise.all([ + this.databaseManager.searchNotes(query, limit * 2), + vectorIndex.search(query, limit * 2), + ]); + + // Filter low-confidence semantic matches before RRF + const filteredVectorResults = vectorResults.filter(({ score }) => score > 0.3); + + // Reciprocal Rank Fusion (K=60, matching cloud implementation) + const scores = new Map(); + ftsResults.forEach((note, i) => { + scores.set(note.id, (scores.get(note.id) || 0) + 1 / (60 + i)); + }); + filteredVectorResults.forEach(({ noteId }, i) => { + scores.set(noteId, (scores.get(noteId) || 0) + 1 / (60 + i)); + }); + + const rankedIds = [...scores.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([id]) => id); + + const noteMap = new Map(); + ftsResults.forEach((n) => noteMap.set(n.id, n)); + for (const id of rankedIds) { + if (!noteMap.has(id)) { + const note = this.databaseManager.getNote(id); + if (note) noteMap.set(id, note); + } + } + + return rankedIds.map((id) => noteMap.get(id)).filter(Boolean); + } catch (error) { + debugLogger.error("Semantic search failed, falling back to FTS5", { error: error.message }); + return this.databaseManager.searchNotes(query, limit); + } + }); + + ipcMain.handle("db-semantic-reindex-all", async () => { + const vectorIndex = require("./vectorIndex"); + if (!vectorIndex.isReady()) return { success: false, error: "Vector index not ready" }; + + const notes = this.databaseManager.getNotes(null, 100000); + let done = 0; + await vectorIndex.reindexAll(notes, (completed, total) => { + done = completed; + this.broadcastToWindows("semantic-reindex-progress", { done: completed, total }); + }); + return { success: true, indexed: done }; + }); + + ipcMain.handle("semantic-search-enable", async () => { + try { + const QdrantManager = require("./qdrantManager"); + const vectorIndex = require("./vectorIndex"); + + if (!this.qdrantManager) { + this.qdrantManager = new QdrantManager(); + } + await this.qdrantManager.start(); + + if (this.qdrantManager.isReady()) { + vectorIndex.init(this.qdrantManager.getPort()); + await vectorIndex.ensureCollection(); + } + + process.env.LOCAL_SEMANTIC_SEARCH = "true"; + await this.environmentManager.saveAllKeysToEnvFile(); + return { success: true }; + } catch (error) { + debugLogger.error("Failed to enable semantic search", { error: error.message }); + return { success: false, error: error.message }; + } + }); + + ipcMain.handle("semantic-search-disable", async () => { + try { + if (this.qdrantManager) { + await this.qdrantManager.stop(); + } + delete process.env.LOCAL_SEMANTIC_SEARCH; + await this.environmentManager.saveAllKeysToEnvFile(); + return { success: true }; + } catch (error) { + debugLogger.error("Failed to disable semantic search", { error: error.message }); + return { success: false, error: error.message }; + } + }); + ipcMain.handle("db-update-note-cloud-id", async (event, id, cloudId) => { return this.databaseManager.updateNoteCloudId(id, cloudId); }); diff --git a/src/helpers/localEmbeddings.js b/src/helpers/localEmbeddings.js new file mode 100644 index 000000000..358ef92b1 --- /dev/null +++ b/src/helpers/localEmbeddings.js @@ -0,0 +1,181 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const debugLogger = require("./debugLogger"); + +const MAX_TOKENS = 256; +const EMBEDDING_DIM = 384; + +class LocalEmbeddings { + constructor() { + this.session = null; + this.tokenizer = null; + this.modelDir = path.join( + os.homedir(), + ".cache", + "openwhispr", + "embedding-models", + "all-MiniLM-L6-v2" + ); + } + + isAvailable() { + return ( + fs.existsSync(path.join(this.modelDir, "model.onnx")) && + fs.existsSync(path.join(this.modelDir, "tokenizer.json")) + ); + } + + async _ensureLoaded() { + if (this.session && this.tokenizer) return; + + if (!this.isAvailable()) { + throw new Error("Embedding model not found. Run: node scripts/download-minilm.js"); + } + + debugLogger.debug("local-embeddings loading model", { modelDir: this.modelDir }); + + const tokenizerPath = path.join(this.modelDir, "tokenizer.json"); + const tokenizerData = JSON.parse(fs.readFileSync(tokenizerPath, "utf-8")); + this.tokenizer = this._buildTokenizer(tokenizerData); + + const ort = require("onnxruntime-node"); + const modelPath = path.join(this.modelDir, "model.onnx"); + this.session = await ort.InferenceSession.create(modelPath); + + debugLogger.debug("local-embeddings model loaded"); + } + + _buildTokenizer(tokenizerData) { + const tokenToId = new Map(); + for (const [token, id] of Object.entries(tokenizerData.model.vocab)) { + tokenToId.set(token, id); + } + + return { + tokenToId, + clsId: tokenToId.get("[CLS]") ?? 101, + sepId: tokenToId.get("[SEP]") ?? 102, + unkId: tokenToId.get("[UNK]") ?? 100, + }; + } + + _tokenize(text) { + const { tokenToId, clsId, sepId, unkId } = this.tokenizer; + const words = text.toLowerCase().match(/[a-z0-9]+|[^\s\w]/g) || []; + const tokenIds = [clsId]; + + for (const word of words) { + if (tokenIds.length >= MAX_TOKENS - 1) break; + + if (tokenToId.has(word)) { + tokenIds.push(tokenToId.get(word)); + continue; + } + + // WordPiece: greedily match longest subword + let start = 0; + while (start < word.length) { + if (tokenIds.length >= MAX_TOKENS - 1) break; + + let end = word.length; + let matched = false; + + while (end > start) { + const subword = start === 0 ? word.slice(start, end) : `##${word.slice(start, end)}`; + if (tokenToId.has(subword)) { + tokenIds.push(tokenToId.get(subword)); + start = end; + matched = true; + break; + } + end--; + } + + if (!matched) { + tokenIds.push(unkId); + start++; + } + } + } + + tokenIds.push(sepId); + + const length = tokenIds.length; + const inputIds = new BigInt64Array(length); + const attentionMask = new BigInt64Array(length); + const tokenTypeIds = new BigInt64Array(length); + + for (let i = 0; i < length; i++) { + inputIds[i] = BigInt(tokenIds[i]); + attentionMask[i] = 1n; + tokenTypeIds[i] = 0n; + } + + return { inputIds, attentionMask, tokenTypeIds, length }; + } + + async embedText(text) { + await this._ensureLoaded(); + + const { inputIds, attentionMask, tokenTypeIds, length } = this._tokenize(text); + + const ort = require("onnxruntime-node"); + const feeds = { + input_ids: new ort.Tensor("int64", inputIds, [1, length]), + attention_mask: new ort.Tensor("int64", attentionMask, [1, length]), + token_type_ids: new ort.Tensor("int64", tokenTypeIds, [1, length]), + }; + + const results = await this.session.run(feeds); + const output = results.last_hidden_state ?? results.output_0; + + return this._meanPoolAndNormalize(output.data, length, EMBEDDING_DIM); + } + + async embedTexts(texts) { + const results = []; + for (const text of texts) { + results.push(await this.embedText(text)); + } + return results; + } + + // Mean pooling over token embeddings, then L2 normalize to unit vector + _meanPoolAndNormalize(data, tokenCount, dim) { + const embedding = new Float32Array(dim); + + for (let t = 0; t < tokenCount; t++) { + const offset = t * dim; + for (let d = 0; d < dim; d++) { + embedding[d] += data[offset + d]; + } + } + + for (let d = 0; d < dim; d++) { + embedding[d] /= tokenCount; + } + + let norm = 0; + for (let d = 0; d < dim; d++) { + norm += embedding[d] * embedding[d]; + } + norm = Math.sqrt(norm); + + if (norm > 0) { + for (let d = 0; d < dim; d++) { + embedding[d] /= norm; + } + } + + return embedding; + } + + static noteEmbedText(title, content, enhancedContent) { + return `${title}\n${enhancedContent || content}`.slice(0, 1500); + } +} + +const instance = new LocalEmbeddings(); +module.exports = instance; +module.exports.LocalEmbeddings = LocalEmbeddings; diff --git a/src/helpers/qdrantManager.js b/src/helpers/qdrantManager.js new file mode 100644 index 000000000..e5e7f2ad1 --- /dev/null +++ b/src/helpers/qdrantManager.js @@ -0,0 +1,238 @@ +const { spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const http = require("http"); +const os = require("os"); +const debugLogger = require("./debugLogger"); +const { + findAvailablePort, + resolveBinaryPath, + gracefulStopProcess, +} = require("../utils/serverUtils"); + +const PORT_RANGE_START = 6333; +const PORT_RANGE_END = 6350; +const STARTUP_TIMEOUT_MS = 30000; +const STARTUP_POLL_INTERVAL_MS = 100; +const HEALTH_CHECK_INTERVAL_MS = 5000; +const HEALTH_CHECK_TIMEOUT_MS = 2000; + +const STORAGE_DIR = path.join(os.homedir(), ".cache", "openwhispr", "qdrant-data"); + +class QdrantManager { + constructor() { + this.process = null; + this.port = null; + this.ready = false; + this.startupPromise = null; + this.healthCheckInterval = null; + this.cachedBinaryPath = null; + } + + getBinaryPath() { + if (this.cachedBinaryPath) return this.cachedBinaryPath; + + const platformArch = `${process.platform}-${process.arch}`; + const binaryName = + process.platform === "win32" ? `qdrant-${platformArch}.exe` : `qdrant-${platformArch}`; + + const resolved = resolveBinaryPath(binaryName); + if (resolved) this.cachedBinaryPath = resolved; + return resolved; + } + + isAvailable() { + return this.getBinaryPath() !== null; + } + + async start() { + if (this.startupPromise) return this.startupPromise; + if (this.ready) return; + if (this.process) await this.stop(); + + this.startupPromise = this._doStart(); + try { + await this.startupPromise; + } finally { + this.startupPromise = null; + } + } + + async _doStart() { + const binaryPath = this.getBinaryPath(); + if (!binaryPath) throw new Error("qdrant binary not found"); + + this.port = await findAvailablePort(PORT_RANGE_START, PORT_RANGE_END); + + fs.mkdirSync(STORAGE_DIR, { recursive: true }); + + const configPath = path.join(STORAGE_DIR, "config.yaml"); + const storagePath = path.join(STORAGE_DIR, "storage"); + const configContent = [ + "storage:", + ` storage_path: ${storagePath}`, + "service:", + " host: 127.0.0.1", + ` http_port: ${this.port}`, + ` grpc_port: ${this.port + 1}`, + "", + ].join("\n"); + + fs.writeFileSync(configPath, configContent, "utf-8"); + + debugLogger.debug("Starting qdrant", { + port: this.port, + binaryPath, + configPath, + storagePath, + }); + + this.process = spawn(binaryPath, ["--config-path", configPath], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + let stderrBuffer = ""; + let exitCode = null; + + this.process.stdout.on("data", (data) => { + debugLogger.debug("qdrant stdout", { data: data.toString().trim() }); + }); + + this.process.stderr.on("data", (data) => { + stderrBuffer += data.toString(); + debugLogger.debug("qdrant stderr", { data: data.toString().trim() }); + }); + + this.process.on("error", (error) => { + debugLogger.error("qdrant process error", { error: error.message }); + this.ready = false; + }); + + this.process.on("close", (code) => { + exitCode = code; + debugLogger.debug("qdrant process exited", { code }); + this.ready = false; + this.process = null; + this._stopHealthCheck(); + }); + + await this._waitForReady(() => ({ stderr: stderrBuffer, exitCode })); + this._startHealthCheck(); + + debugLogger.info("qdrant started successfully", { port: this.port }); + } + + async _waitForReady(getProcessInfo) { + const startTime = Date.now(); + let pollCount = 0; + + while (Date.now() - startTime < STARTUP_TIMEOUT_MS) { + if (!this.process || this.process.killed) { + const info = getProcessInfo ? getProcessInfo() : {}; + const stderr = info.stderr ? info.stderr.trim().slice(0, 200) : ""; + const details = stderr || (info.exitCode !== null ? `exit code: ${info.exitCode}` : ""); + throw new Error(`qdrant process died during startup${details ? `: ${details}` : ""}`); + } + + pollCount++; + if (await this._checkHealth()) { + this.ready = true; + debugLogger.debug("qdrant ready", { + startupTimeMs: Date.now() - startTime, + pollCount, + }); + return; + } + + await new Promise((resolve) => setTimeout(resolve, STARTUP_POLL_INTERVAL_MS)); + } + + throw new Error(`qdrant failed to start within ${STARTUP_TIMEOUT_MS}ms`); + } + + _checkHealth() { + return new Promise((resolve) => { + const req = http.request( + { + hostname: "127.0.0.1", + port: this.port, + path: "/healthz", + method: "GET", + timeout: HEALTH_CHECK_TIMEOUT_MS, + }, + (res) => { + resolve(true); + res.resume(); + } + ); + + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + req.end(); + }); + } + + _startHealthCheck() { + this._stopHealthCheck(); + this.healthCheckInterval = setInterval(async () => { + if (!this.process) { + this._stopHealthCheck(); + return; + } + if (!(await this._checkHealth())) { + debugLogger.warn("qdrant health check failed"); + this.ready = false; + } + }, HEALTH_CHECK_INTERVAL_MS); + } + + _stopHealthCheck() { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + } + + async stop() { + this._stopHealthCheck(); + + if (!this.process) { + this.ready = false; + return; + } + + debugLogger.debug("Stopping qdrant"); + + try { + await gracefulStopProcess(this.process); + } catch (error) { + debugLogger.error("Error stopping qdrant", { error: error.message }); + } + + this.process = null; + this.ready = false; + this.port = null; + } + + isReady() { + return this.ready; + } + + getPort() { + return this.port; + } + + getStatus() { + return { + available: this.isAvailable(), + running: this.ready && this.process !== null, + port: this.port, + }; + } +} + +module.exports = QdrantManager; diff --git a/src/helpers/vectorIndex.js b/src/helpers/vectorIndex.js new file mode 100644 index 000000000..da53038cb --- /dev/null +++ b/src/helpers/vectorIndex.js @@ -0,0 +1,95 @@ +const { QdrantClient } = require("@qdrant/js-client-rest"); +const localEmbeddings = require("./localEmbeddings"); +const { LocalEmbeddings } = localEmbeddings; +const debugLogger = require("./debugLogger"); + +class VectorIndex { + constructor() { + this.client = null; + this.collectionName = "notes"; + } + + init(port) { + this.client = new QdrantClient({ host: "127.0.0.1", port }); + } + + async ensureCollection() { + if (!this.client) return; + try { + await this.client.getCollection(this.collectionName); + } catch { + try { + await this.client.createCollection(this.collectionName, { + vectors: { size: 384, distance: "Cosine" }, + }); + } catch (err) { + debugLogger.error("Failed to create Qdrant collection", { error: err.message }); + } + } + } + + async upsertNote(noteId, text) { + if (!this.client) return; + try { + const vector = await localEmbeddings.embedText(text); + await this.client.upsert(this.collectionName, { + points: [{ id: noteId, vector: Array.from(vector), payload: {} }], + }); + } catch (err) { + debugLogger.debug("Vector index upsert failed", { noteId, error: err.message }); + } + } + + async deleteNote(noteId) { + if (!this.client) return; + try { + await this.client.delete(this.collectionName, { points: [noteId] }); + } catch (err) { + debugLogger.debug("Vector index delete failed", { noteId, error: err.message }); + } + } + + async search(queryText, limit = 5) { + if (!this.client) return []; + try { + const vector = await localEmbeddings.embedText(queryText); + const results = await this.client.search(this.collectionName, { + vector: Array.from(vector), + limit, + }); + return results.map((r) => ({ noteId: r.id, score: r.score })); + } catch (err) { + debugLogger.debug("Vector search failed", { error: err.message }); + return []; + } + } + + async reindexAll(notes, onProgress) { + if (!this.client) return; + const BATCH_SIZE = 50; + for (let i = 0; i < notes.length; i += BATCH_SIZE) { + const batch = notes.slice(i, i + BATCH_SIZE); + const texts = batch.map((n) => + LocalEmbeddings.noteEmbedText(n.title, n.content, n.enhanced_content) + ); + try { + const vectors = await localEmbeddings.embedTexts(texts); + const points = batch.map((n, j) => ({ + id: n.id, + vector: Array.from(vectors[j]), + payload: {}, + })); + await this.client.upsert(this.collectionName, { points }); + } catch (err) { + debugLogger.debug("Vector reindex batch failed", { offset: i, error: err.message }); + } + if (onProgress) onProgress(Math.min(i + BATCH_SIZE, notes.length), notes.length); + } + } + + isReady() { + return this.client !== null; + } +} + +module.exports = new VectorIndex(); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 7c563be18..a3a23234b 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -53,6 +53,7 @@ export interface ApiKeySettings { export interface PrivacySettings { cloudBackupEnabled: boolean; + localSemanticSearchEnabled: boolean; telemetryEnabled: boolean; audioRetentionDays: number; dataRetentionEnabled: boolean; @@ -245,6 +246,8 @@ function useSettingsInternal() { setKeepTranscriptionInClipboard: store.setKeepTranscriptionInClipboard, cloudBackupEnabled: store.cloudBackupEnabled, setCloudBackupEnabled: store.setCloudBackupEnabled, + localSemanticSearchEnabled: store.localSemanticSearchEnabled, + setLocalSemanticSearchEnabled: store.setLocalSemanticSearchEnabled, telemetryEnabled: store.telemetryEnabled, setTelemetryEnabled: store.setTelemetryEnabled, audioRetentionDays: store.audioRetentionDays, diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 46514273d..e4eb51561 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1352,6 +1352,8 @@ "title": "Datenschutz", "usageAnalytics": "Nutzungsanalysen", "usageAnalyticsDescription": "Helfen Sie uns, OpenWhispr zu verbessern, indem Sie anonyme Leistungsdaten teilen. Wir senden niemals Transkriptionsinhalte – nur Timing- und Fehlerdaten.", + "localSemanticSearch": "Lokale semantische Suche", + "localSemanticSearchDescription": "Notizen nach Bedeutung finden, nicht nur nach Stichwörtern. Benötigt ca. 52 MB für das Einbettungsmodell und den Suchindex. Funktioniert vollständig offline.", "dataRetention": "Datenaufbewahrung", "dataRetentionDescription": "Transkriptionen und Audio lokal im Verlauf speichern. Wenn deaktiviert, werden Transkriptionen eingefügt, aber nicht gespeichert.", "audioRetention": "Audio-Aufbewahrung", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c2a10f910..9f2bcac02 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1400,6 +1400,8 @@ "title": "Privacy", "usageAnalytics": "Usage analytics", "usageAnalyticsDescription": "Help us improve OpenWhispr by sharing anonymous performance metrics. We never send transcription content — only timing and error data.", + "localSemanticSearch": "Local semantic search", + "localSemanticSearchDescription": "Find notes by meaning, not just keywords. Uses ~52 MB for the embedding model and search index. Works fully offline.", "audioRetention": "Audio Retention", "audioRetentionDescription": "Store audio recordings locally for re-transcription and download. Files are automatically deleted after the retention period.", "audioRetentionDisabled": "Disabled", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 106b7e71d..a55ec447e 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1352,6 +1352,8 @@ "title": "Privacidad", "usageAnalytics": "Analíticas de uso", "usageAnalyticsDescription": "Ayúdanos a mejorar OpenWhispr compartiendo métricas de rendimiento anónimas. Nunca enviamos contenido de transcripciones, solo datos de tiempo y errores.", + "localSemanticSearch": "Búsqueda semántica local", + "localSemanticSearchDescription": "Encuentra notas por significado, no solo por palabras clave. Usa ~52 MB para el modelo de embeddings y el índice de búsqueda. Funciona completamente sin conexión.", "dataRetention": "Retención de datos", "dataRetentionDescription": "Almacenar transcripciones y audio localmente en tu historial. Cuando está desactivado, las transcripciones se pegan pero no se guardan.", "audioRetention": "Retención de audio", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index d73e7dc33..c59b4a07a 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1352,6 +1352,8 @@ "title": "Confidentialité", "usageAnalytics": "Analyse d'utilisation", "usageAnalyticsDescription": "Aidez-nous à améliorer OpenWhispr en partageant des métriques de performance anonymes. Nous n'envoyons jamais le contenu de vos transcriptions — uniquement des données de performance et d'erreurs.", + "localSemanticSearch": "Recherche sémantique locale", + "localSemanticSearchDescription": "Trouvez des notes par sens, pas seulement par mots-clés. Utilise environ 52 Mo pour le modèle d'embeddings et l'index de recherche. Fonctionne entièrement hors ligne.", "dataRetention": "Conservation des données", "dataRetentionDescription": "Stocker les transcriptions et l'audio localement dans votre historique. Lorsque désactivé, les transcriptions sont collées mais pas enregistrées.", "audioRetention": "Conservation de l'audio", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 05536bcf9..a3802b7ca 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1352,6 +1352,8 @@ "title": "Privacy", "usageAnalytics": "Analisi dell'utilizzo", "usageAnalyticsDescription": "Aiutaci a migliorare OpenWhispr condividendo metriche di prestazione anonime. Non inviamo mai il contenuto delle trascrizioni, solo dati su tempi ed errori.", + "localSemanticSearch": "Ricerca semantica locale", + "localSemanticSearchDescription": "Trova le note per significato, non solo per parole chiave. Usa circa 52 MB per il modello di embedding e l'indice di ricerca. Funziona completamente offline.", "dataRetention": "Conservazione dei dati", "dataRetentionDescription": "Salva trascrizioni e audio localmente nella cronologia. Se disattivato, le trascrizioni vengono incollate ma non salvate.", "audioRetention": "Conservazione audio", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 6b153204c..c4eb3bc4a 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1352,6 +1352,8 @@ "title": "プライバシー", "usageAnalytics": "使用状況の分析", "usageAnalyticsDescription": "匿名のパフォーマンスデータを共有して OpenWhispr の改善にご協力ください。文字起こしの内容は送信されません。タイミングとエラーデータのみです。", + "localSemanticSearch": "ローカルセマンティック検索", + "localSemanticSearchDescription": "キーワードだけでなく、意味でノートを検索します。埋め込みモデルと検索インデックスに約52 MBを使用します。完全にオフラインで動作します。", "dataRetention": "データ保持", "dataRetentionDescription": "文字起こしと音声を履歴にローカル保存します。無効にすると、文字起こしは貼り付けられますが保存されません。", "audioRetention": "音声の保持", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index e0e34e5b0..b36dbf535 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1324,6 +1324,8 @@ "title": "Privacidade", "usageAnalytics": "Análise de uso", "usageAnalyticsDescription": "Ajude-nos a melhorar o OpenWhispr compartilhando métricas anônimas de desempenho. Nunca enviamos conteúdo de transcrição — apenas dados de tempo e erros.", + "localSemanticSearch": "Pesquisa semântica local", + "localSemanticSearchDescription": "Encontre notas por significado, não apenas por palavras-chave. Usa ~52 MB para o modelo de embeddings e o índice de pesquisa. Funciona totalmente offline.", "dataRetention": "Retenção de dados", "dataRetentionDescription": "Armazenar transcrições e áudio localmente no seu histórico. Quando desativado, as transcrições são coladas mas não salvas.", "audioRetention": "Retenção de áudio", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index e367dc180..a1b06f0e7 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1352,6 +1352,8 @@ "title": "Конфиденциальность", "usageAnalytics": "Аналитика использования", "usageAnalyticsDescription": "Помогите нам улучшить OpenWhispr, делясь анонимными метриками производительности. Мы никогда не отправляем содержимое транскрипций — только данные о времени и ошибках.", + "localSemanticSearch": "Локальный семантический поиск", + "localSemanticSearchDescription": "Находите заметки по смыслу, а не только по ключевым словам. Использует ~52 МБ для модели эмбеддингов и поискового индекса. Работает полностью офлайн.", "dataRetention": "Хранение данных", "dataRetentionDescription": "Сохранять транскрипции и аудио локально в истории. При отключении транскрипции вставляются, но не сохраняются.", "audioRetention": "Хранение аудио", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 93c3ec54a..d5c0f95ba 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1347,6 +1347,8 @@ "title": "隐私", "usageAnalytics": "使用分析", "usageAnalyticsDescription": "通过分享匿名性能数据帮助我们改进 OpenWhispr。我们绝不发送转录内容——仅发送计时和错误数据。", + "localSemanticSearch": "本地语义搜索", + "localSemanticSearchDescription": "按含义查找笔记,不仅仅是关键词。嵌入模型和搜索索引约需 52 MB 空间。完全离线运行。", "dataRetention": "数据保留", "dataRetentionDescription": "将转录和音频本地保存到历史记录中。禁用后,转录内容会被粘贴但不会被保存。", "audioRetention": "音频保留", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index e3811b4da..f1c7260bd 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1347,6 +1347,8 @@ "title": "隱私權", "usageAnalytics": "使用分析", "usageAnalyticsDescription": "分享匿名的效能指標,協助我們改善 OpenWhispr。我們絕不會傳送轉錄內容——只有計時和錯誤資料。", + "localSemanticSearch": "本地語義搜尋", + "localSemanticSearchDescription": "依含義尋找筆記,不僅僅是關鍵字。嵌入模型和搜尋索引約需 52 MB 空間。完全離線運作。", "dataRetention": "資料保留", "dataRetentionDescription": "將轉錄和音訊本地儲存至歷史記錄。停用後,轉錄內容會被貼上但不會被儲存。", "audioRetention": "音訊保留", diff --git a/src/services/tools/index.ts b/src/services/tools/index.ts index 35552f94a..9b840dcb6 100644 --- a/src/services/tools/index.ts +++ b/src/services/tools/index.ts @@ -14,13 +14,15 @@ interface ToolRegistrySettings { isSignedIn: boolean; gcalConnected: boolean; cloudBackupEnabled: boolean; + localSemanticSearchEnabled?: boolean; } export function createToolRegistry(settings: ToolRegistrySettings): ToolRegistry { const registry = new ToolRegistry(); const useCloudSearch = settings.isSignedIn && settings.cloudBackupEnabled; - registry.register(createSearchNotesTool({ useCloudSearch })); + const useLocalSemanticSearch = !useCloudSearch && !!settings.localSemanticSearchEnabled; + registry.register(createSearchNotesTool({ useCloudSearch, useLocalSemanticSearch })); registry.register(getNoteTool); registry.register(createNoteTool); registry.register(updateNoteTool); diff --git a/src/services/tools/searchNotesTool.ts b/src/services/tools/searchNotesTool.ts index 70e7e1539..1a30cd4a6 100644 --- a/src/services/tools/searchNotesTool.ts +++ b/src/services/tools/searchNotesTool.ts @@ -4,14 +4,17 @@ const MAX_CONTENT_LENGTH = 500; interface SearchToolOptions { useCloudSearch: boolean; + useLocalSemanticSearch: boolean; } export function createSearchNotesTool(options: SearchToolOptions): ToolDefinition { - const { useCloudSearch } = options; + const { useCloudSearch, useLocalSemanticSearch } = options; + + const hasSemanticSearch = useCloudSearch || useLocalSemanticSearch; return { name: "search_notes", - description: useCloudSearch + description: hasSemanticSearch ? "Search the user's notes using semantic search. Understands meaning and context, not just keywords. Returns matching notes with title, date, relevance score, and a preview of content." : "Search the user's notes by keyword or phrase. Returns matching notes with title, date, and a preview of content.", parameters: { @@ -35,31 +38,39 @@ export function createSearchNotesTool(options: SearchToolOptions): ToolDefinitio const query = args.query as string; const limit = typeof args.limit === "number" ? args.limit : 5; - try { - if (useCloudSearch) { - return await executeCloudSearch(query, limit); - } - return await executeLocalSearch(query, limit); - } catch (error) { - if (useCloudSearch) { - try { - return await executeLocalSearch(query, limit); - } catch { - // Both failed + // Build fallback chain: cloud → local semantic → FTS5 + const strategies: Array<() => Promise> = []; + if (useCloudSearch) strategies.push(() => executeCloudSearch(query, limit)); + if (useLocalSemanticSearch) strategies.push(() => executeLocalSearch(query, limit, true)); + strategies.push(() => executeLocalSearch(query, limit, false)); + + for (let i = 0; i < strategies.length; i++) { + try { + return await strategies[i](); + } catch (error) { + if (i === strategies.length - 1) { + return { + success: false, + data: null, + displayText: `Failed to search notes: ${(error as Error).message}`, + }; } } - return { - success: false, - data: null, - displayText: `Failed to search notes: ${(error as Error).message}`, - }; } + + return { success: false, data: null, displayText: "No search strategies available" }; }, }; } -async function executeLocalSearch(query: string, limit: number): Promise { - const notes = await window.electronAPI.searchNotes(query, limit); +async function executeLocalSearch( + query: string, + limit: number, + semantic: boolean +): Promise { + const notes = semantic + ? await window.electronAPI.semanticSearchNotes(query, limit) + : await window.electronAPI.searchNotes(query, limit); if (notes.length === 0) { return { @@ -80,7 +91,7 @@ async function executeLocalSearch(query: string, limit: number): Promise void; setCloudBackupEnabled: (value: boolean) => void; + setLocalSemanticSearchEnabled: (value: boolean) => void; setTelemetryEnabled: (value: boolean) => void; setAudioRetentionDays: (days: number) => void; setDataRetentionEnabled: (value: boolean) => void; @@ -273,6 +275,7 @@ export const useSettingsStore = create()((set, get) => ({ return "auto" as const; })(), cloudBackupEnabled: readBoolean("cloudBackupEnabled", false), + localSemanticSearchEnabled: readBoolean("localSemanticSearchEnabled", false), telemetryEnabled: readBoolean("telemetryEnabled", false), audioRetentionDays: (() => { if (!isBrowser) return 30; @@ -442,6 +445,7 @@ export const useSettingsStore = create()((set, get) => ({ }, setCloudBackupEnabled: createBooleanSetter("cloudBackupEnabled"), + setLocalSemanticSearchEnabled: createBooleanSetter("localSemanticSearchEnabled"), setTelemetryEnabled: createBooleanSetter("telemetryEnabled"), setAudioRetentionDays: (days: number) => { if (isBrowser) localStorage.setItem("audioRetentionDays", String(days)); diff --git a/src/types/electron.ts b/src/types/electron.ts index 078350eb6..82aef3395 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -371,6 +371,13 @@ declare global { format: "txt" | "md" ) => Promise<{ success: boolean; error?: string }>; searchNotes: (query: string, limit?: number) => Promise; + semanticSearchNotes: (query: string, limit?: number) => Promise; + semanticReindexAll: () => Promise<{ success: boolean; indexed?: number; error?: string }>; + onSemanticReindexProgress: ( + callback: (data: { done: number; total: number }) => void + ) => () => void; + enableSemanticSearch: () => Promise<{ success: boolean; error?: string }>; + disableSemanticSearch: () => Promise<{ success: boolean; error?: string }>; updateNoteCloudId: (id: number, cloudId: string) => Promise; // Folder operations From 4353f75337d569a5363b0a8e6a62be68d1405df5 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 18:54:42 -0700 Subject: [PATCH 22/91] docs: add local semantic search to CLAUDE.md Add architecture docs, dev setup instructions, troubleshooting guide, settings reference, and testing checklist for the Qdrant sidecar and MiniLM embedding pipeline. --- CLAUDE.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index b556ee40a..92c74dbff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,9 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - **whisper.js**: Local whisper.cpp integration and model management - **parakeet.js**: NVIDIA Parakeet model management via sherpa-onnx - **parakeetServer.js**: sherpa-onnx CLI wrapper for transcription +- **qdrantManager.js**: Qdrant vector DB sidecar process lifecycle (spawn, health check, shutdown) +- **localEmbeddings.js**: Local text embedding via ONNX Runtime + all-MiniLM-L6-v2 (384-dim vectors) +- **vectorIndex.js**: Qdrant collection management — upsert, delete, search, batch reindex - **windowConfig.js**: Centralized window configuration - **windowManager.js**: Window creation and lifecycle management @@ -161,6 +164,33 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - **Download URLs**: Models from sherpa-onnx ASR models release on GitHub +### Local Semantic Search (Qdrant + MiniLM) + +Offline semantic search that finds notes by meaning, not just keywords. Gated behind a settings toggle (Settings → Privacy & Data → Local Semantic Search). Only used by the AI agent's `search_notes` tool — not the manual note search UI. + +**Architecture**: +- **Qdrant sidecar**: Rust binary spawned as child process (`qdrantManager.js`), port 6333–6350 +- **Embedding model**: `all-MiniLM-L6-v2` via ONNX Runtime (`localEmbeddings.js`), 384-dim vectors +- **Vector index**: Qdrant collection management (`vectorIndex.js`), cosine distance +- **Hybrid search**: FTS5 + Qdrant in parallel → Reciprocal Rank Fusion (K=60) with 0.3 cosine score threshold + +**Pipeline**: +1. User enables toggle → `semantic-search-enable` IPC → starts Qdrant → reindexes all notes +2. Note create/update/delete → SQLite write → background vector upsert/delete via `_asyncVectorUpsert()`/`_asyncVectorDelete()` +3. Agent searches → `db-semantic-search-notes` IPC → parallel FTS5 + vector search → RRF merge → ranked results + +**Search fallback chain** (in `searchNotesTool.ts`): cloud search → local semantic → FTS5 keyword + +**Storage**: +- Qdrant data: `~/.cache/openwhispr/qdrant-data/` +- Qdrant binary: `resources/bin/qdrant-{platform}-{arch}` +- Embedding model: `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/` +- Setting persisted as `LOCAL_SEMANTIC_SEARCH=true` in `.env` + +**Dependencies**: `@qdrant/js-client-rest`, `onnxruntime-node` + +**Dev setup**: Run `npm run download:qdrant` and `npm run download:embedding-model` before testing. The Qdrant binary is also downloaded automatically during `prebuild`. + ### Build Scripts (scripts/) - **download-whisper-cpp.js**: Downloads whisper.cpp binaries from GitHub releases @@ -169,6 +199,8 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. - **download-windows-key-listener.js**: Downloads prebuilt Windows key listener binary - **download-windows-mic-listener.js**: Downloads prebuilt Windows mic listener binary - **download-sherpa-onnx.js**: Downloads sherpa-onnx binaries for Parakeet support +- **download-qdrant.js**: Downloads Qdrant vector DB binary for local semantic search +- **download-minilm.js**: Downloads all-MiniLM-L6-v2 ONNX model + tokenizer for local embeddings - **build-globe-listener.js**: Compiles macOS Globe key listener from Swift source - **build-macos-mic-listener.js**: Compiles macOS mic listener from Swift source - **build-windows-key-listener.js**: Compiles Windows key listener (for local development) @@ -240,10 +272,12 @@ Settings stored in localStorage with these keys: - `hotkey`: Custom hotkey configuration - `hasCompletedOnboarding`: Onboarding completion flag - `customDictionary`: JSON array of words/phrases for improved transcription accuracy +- `localSemanticSearchEnabled`: Boolean for local semantic search (Qdrant + MiniLM) Environment variables persisted to `.env` (via `saveAllKeysToEnvFile()`): - `LOCAL_TRANSCRIPTION_PROVIDER`: Transcription engine (`nvidia` for Parakeet) - `PARAKEET_MODEL`: Selected Parakeet model name (e.g., `parakeet-tdt-0.6b-v3`) +- `LOCAL_SEMANTIC_SEARCH`: Set to `true` to auto-start Qdrant sidecar on app launch ### 6. Language Support @@ -520,7 +554,8 @@ const { t } = useTranslation(); 2. **New Setting**: Update useSettings.ts and SettingsPage.tsx 3. **New UI Component**: Follow shadcn/ui patterns in src/components/ui 4. **New Manager**: Create in src/helpers/, initialize in main.js -5. **New UI Strings**: Add translation keys to all 9 language files (see i18n section above) +5. **New UI Strings**: Add translation keys to all 10 language files (see i18n section above) +6. **New Sidecar Binary**: Add download script in `scripts/`, add to `prebuild*` scripts in package.json, add manager in `src/helpers/`, initialize in `main.js`, shutdown in `will-quit` handler ### Testing Checklist @@ -539,6 +574,10 @@ const { t } = useTranslation(); - [ ] Verify meeting detection works with event-driven mode (check debug logs for "event-driven") - [ ] Test meeting notification suppression during recording - [ ] Test post-recording cooldown (notifications shouldn't flash immediately) +- [ ] Enable local semantic search in Settings → Privacy & Data +- [ ] Create a note about "quarterly revenue projections", search via agent for "financial forecast" +- [ ] Disable local semantic search — verify Qdrant process stops +- [ ] Verify FTS5 keyword search still works when semantic search is disabled ### Common Issues and Solutions @@ -583,6 +622,15 @@ const { t } = useTranslation(); - Linux: Verify `pactl` is installed (`pulseaudio-utils` or `pipewire-pulse` package) - If event-driven binary is missing, detection falls back to polling automatically +7. **Local Semantic Search Not Working**: + - Run `npm run download:qdrant` to download the Qdrant binary for your platform + - Run `npm run download:embedding-model` to download the MiniLM ONNX model + - Check that `resources/bin/qdrant-{platform}-{arch}` exists + - Check that `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/model.onnx` exists + - Check debug logs for "qdrant" entries (port, health check, errors) + - If Qdrant fails to start, search still works via FTS5 keyword fallback + - Semantic search is only available through the AI agent's `search_notes` tool, not the manual search UI + ### Platform-Specific Notes **macOS**: From 29bf31f901b29eab575c420308053a27241a0281 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 19:02:00 -0700 Subject: [PATCH 23/91] refactor: make local semantic search always-on, remove toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic search is no longer optional — Qdrant starts unconditionally on app launch if the binary is available. Embedding model auto-downloads on first run. Qdrant binary downloads automatically in predev/prestart. Removes: settings toggle, localSemanticSearchEnabled setting, i18n keys, enable/disable IPC handlers, LOCAL_SEMANTIC_SEARCH env var. --- CLAUDE.md | 27 ++++++++----------- main.js | 17 +++++++----- package.json | 6 ++--- preload.js | 2 -- src/components/AgentOverlay.tsx | 3 +-- src/components/SettingsPage.tsx | 23 ---------------- src/helpers/ipcHandlers.js | 39 --------------------------- src/helpers/localEmbeddings.js | 27 +++++++++++++++++++ src/hooks/useSettings.ts | 3 --- src/locales/de/translation.json | 2 -- src/locales/en/translation.json | 2 -- src/locales/es/translation.json | 2 -- src/locales/fr/translation.json | 2 -- src/locales/it/translation.json | 2 -- src/locales/ja/translation.json | 2 -- src/locales/pt/translation.json | 2 -- src/locales/ru/translation.json | 2 -- src/locales/zh-CN/translation.json | 2 -- src/locales/zh-TW/translation.json | 2 -- src/services/tools/index.ts | 4 +-- src/services/tools/searchNotesTool.ts | 14 ++++------ src/stores/settingsStore.ts | 4 --- src/types/electron.ts | 2 -- 23 files changed, 59 insertions(+), 132 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92c74dbff..70e1887aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ OpenWhispr is an Electron-based desktop dictation application that uses whisper. ### Local Semantic Search (Qdrant + MiniLM) -Offline semantic search that finds notes by meaning, not just keywords. Gated behind a settings toggle (Settings → Privacy & Data → Local Semantic Search). Only used by the AI agent's `search_notes` tool — not the manual note search UI. +Always-on offline semantic search that finds notes by meaning, not just keywords. Used by the AI agent's `search_notes` tool. Qdrant starts automatically on app launch; embedding model auto-downloads on first run if missing. **Architecture**: - **Qdrant sidecar**: Rust binary spawned as child process (`qdrantManager.js`), port 6333–6350 @@ -175,7 +175,7 @@ Offline semantic search that finds notes by meaning, not just keywords. Gated be - **Hybrid search**: FTS5 + Qdrant in parallel → Reciprocal Rank Fusion (K=60) with 0.3 cosine score threshold **Pipeline**: -1. User enables toggle → `semantic-search-enable` IPC → starts Qdrant → reindexes all notes +1. App launches → Qdrant binary starts → collection created. Embedding model auto-downloads if missing (~22MB) 2. Note create/update/delete → SQLite write → background vector upsert/delete via `_asyncVectorUpsert()`/`_asyncVectorDelete()` 3. Agent searches → `db-semantic-search-notes` IPC → parallel FTS5 + vector search → RRF merge → ranked results @@ -183,13 +183,12 @@ Offline semantic search that finds notes by meaning, not just keywords. Gated be **Storage**: - Qdrant data: `~/.cache/openwhispr/qdrant-data/` -- Qdrant binary: `resources/bin/qdrant-{platform}-{arch}` -- Embedding model: `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/` -- Setting persisted as `LOCAL_SEMANTIC_SEARCH=true` in `.env` +- Qdrant binary: `resources/bin/qdrant-{platform}-{arch}` (bundled — downloaded during `prebuild` / `predev`) +- Embedding model: `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/` (auto-downloaded on first launch) **Dependencies**: `@qdrant/js-client-rest`, `onnxruntime-node` -**Dev setup**: Run `npm run download:qdrant` and `npm run download:embedding-model` before testing. The Qdrant binary is also downloaded automatically during `prebuild`. +**Dev setup**: The Qdrant binary downloads automatically via `predev`/`prestart`. The embedding model auto-downloads on first app launch. To manually download: `npm run download:qdrant` and `npm run download:embedding-model`. ### Build Scripts (scripts/) @@ -272,12 +271,10 @@ Settings stored in localStorage with these keys: - `hotkey`: Custom hotkey configuration - `hasCompletedOnboarding`: Onboarding completion flag - `customDictionary`: JSON array of words/phrases for improved transcription accuracy -- `localSemanticSearchEnabled`: Boolean for local semantic search (Qdrant + MiniLM) Environment variables persisted to `.env` (via `saveAllKeysToEnvFile()`): - `LOCAL_TRANSCRIPTION_PROVIDER`: Transcription engine (`nvidia` for Parakeet) - `PARAKEET_MODEL`: Selected Parakeet model name (e.g., `parakeet-tdt-0.6b-v3`) -- `LOCAL_SEMANTIC_SEARCH`: Set to `true` to auto-start Qdrant sidecar on app launch ### 6. Language Support @@ -574,10 +571,9 @@ const { t } = useTranslation(); - [ ] Verify meeting detection works with event-driven mode (check debug logs for "event-driven") - [ ] Test meeting notification suppression during recording - [ ] Test post-recording cooldown (notifications shouldn't flash immediately) -- [ ] Enable local semantic search in Settings → Privacy & Data -- [ ] Create a note about "quarterly revenue projections", search via agent for "financial forecast" -- [ ] Disable local semantic search — verify Qdrant process stops -- [ ] Verify FTS5 keyword search still works when semantic search is disabled +- [ ] Create a note about "quarterly revenue projections", search via agent for "financial forecast" — should match semantically +- [ ] Verify Qdrant starts on app launch (check debug logs for "qdrant started successfully") +- [ ] Kill Qdrant process manually — verify FTS5 keyword search still works as fallback ### Common Issues and Solutions @@ -623,10 +619,9 @@ const { t } = useTranslation(); - If event-driven binary is missing, detection falls back to polling automatically 7. **Local Semantic Search Not Working**: - - Run `npm run download:qdrant` to download the Qdrant binary for your platform - - Run `npm run download:embedding-model` to download the MiniLM ONNX model - - Check that `resources/bin/qdrant-{platform}-{arch}` exists - - Check that `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/model.onnx` exists + - Qdrant binary should be in `resources/bin/qdrant-{platform}-{arch}` (auto-downloaded during `predev`/`prebuild`) + - Embedding model should be in `~/.cache/openwhispr/embedding-models/all-MiniLM-L6-v2/model.onnx` (auto-downloaded on first app launch) + - Run `npm run download:qdrant` and `npm run download:embedding-model` manually if missing - Check debug logs for "qdrant" entries (port, health check, errors) - If Qdrant fails to start, search still works via FTS5 keyword fallback - Semantic search is only available through the AI agent's `search_notes` tool, not the manual search UI diff --git a/main.js b/main.js index 0a1fe4281..68836d50b 100644 --- a/main.js +++ b/main.js @@ -666,10 +666,10 @@ async function startApp() { }); } - if (process.env.LOCAL_SEMANTIC_SEARCH === "true") { - const QdrantManager = require("./src/helpers/qdrantManager"); - qdrantManager = new QdrantManager(); - ipcHandlers.qdrantManager = qdrantManager; + // Local semantic search: start Qdrant sidecar + ensure embedding model is available + const QdrantManager = require("./src/helpers/qdrantManager"); + qdrantManager = new QdrantManager(); + if (qdrantManager.isAvailable()) { qdrantManager.start().then(() => { if (qdrantManager.isReady()) { const vectorIndex = require("./src/helpers/vectorIndex"); @@ -683,6 +683,13 @@ async function startApp() { }); } + const localEmbeddings = require("./src/helpers/localEmbeddings"); + if (!localEmbeddings.isAvailable()) { + localEmbeddings.downloadModel().catch((err) => { + debugLogger.debug("Embedding model download error (non-fatal)", { error: err.message }); + }); + } + if (process.platform === "win32") { const nircmdStatus = clipboardManager.getNircmdStatus(); debugLogger.debug("Windows paste tool status", nircmdStatus); @@ -1165,8 +1172,6 @@ if (gotSingleInstanceLock) { modelManager.stopServer().catch(() => {}); if (qdrantManager) { qdrantManager.stop().catch(() => {}); - } else if (ipcHandlers?.qdrantManager) { - ipcHandlers.qdrantManager.stop().catch(() => {}); } }); } diff --git a/package.json b/package.json index 212906375..c7bb3e111 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,11 @@ "compile:mic-listener": "node scripts/build-macos-mic-listener.js", "compile:audio-tap": "node scripts/build-macos-audio-tap.js", "compile:native": "npm run compile:globe && npm run compile:fast-paste && npm run compile:winkeys && npm run compile:winpaste && npm run compile:linux-paste && npm run compile:text-monitor && npm run compile:media-remote && npm run compile:mic-listener && npm run compile:audio-tap", - "prestart": "npm run compile:native", + "prestart": "npm run compile:native && npm run download:qdrant", "start": "electron .", - "predev": "npm run compile:native", + "predev": "npm run compile:native && npm run download:qdrant", "dev": "concurrently -k -r \"npm:dev:renderer\" \"npm:dev:main\"", - "predev:main": "npm run compile:native", + "predev:main": "npm run compile:native && npm run download:qdrant", "dev:main": "cross-env NODE_ENV=development node scripts/run-electron.js --dev", "dev:renderer": "cd src && vite", "prebuild": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant", diff --git a/preload.js b/preload.js index 3289b4e5a..4d18e6a63 100644 --- a/preload.js +++ b/preload.js @@ -92,8 +92,6 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("semantic-reindex-progress", listener); return () => ipcRenderer.removeListener("semantic-reindex-progress", listener); }, - enableSemanticSearch: () => ipcRenderer.invoke("semantic-search-enable"), - disableSemanticSearch: () => ipcRenderer.invoke("semantic-search-disable"), updateNoteCloudId: (id, cloudId) => ipcRenderer.invoke("db-update-note-cloud-id", id, cloudId), // Folder functions diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 17011abe3..938587fba 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -103,7 +103,7 @@ export default function AgentOverlay() { let registry: ToolRegistry | null = null; if (supportsTools) { - const cacheKey = `${settings.isSignedIn}-${settings.gcalConnected}-${settings.cloudBackupEnabled}-${settings.localSemanticSearchEnabled}`; + const cacheKey = `${settings.isSignedIn}-${settings.gcalConnected}-${settings.cloudBackupEnabled}`; if (toolRegistryRef.current?.key === cacheKey) { registry = toolRegistryRef.current.registry; } else { @@ -111,7 +111,6 @@ export default function AgentOverlay() { isSignedIn: settings.isSignedIn, gcalConnected: settings.gcalConnected, cloudBackupEnabled: settings.cloudBackupEnabled, - localSemanticSearchEnabled: settings.localSemanticSearchEnabled, }); toolRegistryRef.current = { key: cacheKey, registry }; } diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 4b6cb277f..4a06bb1f9 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -707,8 +707,6 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage setPanelStartPosition, cloudBackupEnabled, setCloudBackupEnabled, - localSemanticSearchEnabled, - setLocalSemanticSearchEnabled, telemetryEnabled, setTelemetryEnabled, audioRetentionDays, @@ -3232,27 +3230,6 @@ EOF`, - - - { - setLocalSemanticSearchEnabled(v); - if (v) { - window.electronAPI - .enableSemanticSearch?.() - .then(() => window.electronAPI.semanticReindexAll?.()) - .catch(console.error); - } else { - window.electronAPI.disableSemanticSearch?.().catch(console.error); - } - }} - /> - -
diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 97725d29d..15deec0d8 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -104,7 +104,6 @@ class IPCHandlers { this.googleCalendarManager = managers.googleCalendarManager; this.meetingDetectionEngine = managers.meetingDetectionEngine; this.audioTapManager = managers.audioTapManager; - this.qdrantManager = managers.qdrantManager; this.sessionId = crypto.randomUUID(); this.assemblyAiStreaming = null; this.deepgramStreaming = null; @@ -640,44 +639,6 @@ class IPCHandlers { return { success: true, indexed: done }; }); - ipcMain.handle("semantic-search-enable", async () => { - try { - const QdrantManager = require("./qdrantManager"); - const vectorIndex = require("./vectorIndex"); - - if (!this.qdrantManager) { - this.qdrantManager = new QdrantManager(); - } - await this.qdrantManager.start(); - - if (this.qdrantManager.isReady()) { - vectorIndex.init(this.qdrantManager.getPort()); - await vectorIndex.ensureCollection(); - } - - process.env.LOCAL_SEMANTIC_SEARCH = "true"; - await this.environmentManager.saveAllKeysToEnvFile(); - return { success: true }; - } catch (error) { - debugLogger.error("Failed to enable semantic search", { error: error.message }); - return { success: false, error: error.message }; - } - }); - - ipcMain.handle("semantic-search-disable", async () => { - try { - if (this.qdrantManager) { - await this.qdrantManager.stop(); - } - delete process.env.LOCAL_SEMANTIC_SEARCH; - await this.environmentManager.saveAllKeysToEnvFile(); - return { success: true }; - } catch (error) { - debugLogger.error("Failed to disable semantic search", { error: error.message }); - return { success: false, error: error.message }; - } - }); - ipcMain.handle("db-update-note-cloud-id", async (event, id, cloudId) => { return this.databaseManager.updateNoteCloudId(id, cloudId); }); diff --git a/src/helpers/localEmbeddings.js b/src/helpers/localEmbeddings.js index 358ef92b1..a071ed363 100644 --- a/src/helpers/localEmbeddings.js +++ b/src/helpers/localEmbeddings.js @@ -174,6 +174,33 @@ class LocalEmbeddings { static noteEmbedText(title, content, enhancedContent) { return `${title}\n${enhancedContent || content}`.slice(0, 1500); } + + async downloadModel() { + if (this.isAvailable()) return; + + const { downloadFile } = require("../../scripts/lib/download-utils"); + const files = [ + { + name: "model.onnx", + url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx", + }, + { + name: "tokenizer.json", + url: "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main/tokenizer.json", + }, + ]; + + fs.mkdirSync(this.modelDir, { recursive: true }); + + for (const file of files) { + const dest = path.join(this.modelDir, file.name); + if (fs.existsSync(dest)) continue; + debugLogger.debug("local-embeddings downloading", { file: file.name }); + await downloadFile(file.url, dest); + } + + debugLogger.info("local-embeddings model downloaded", { modelDir: this.modelDir }); + } } const instance = new LocalEmbeddings(); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index a3a23234b..7c563be18 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -53,7 +53,6 @@ export interface ApiKeySettings { export interface PrivacySettings { cloudBackupEnabled: boolean; - localSemanticSearchEnabled: boolean; telemetryEnabled: boolean; audioRetentionDays: number; dataRetentionEnabled: boolean; @@ -246,8 +245,6 @@ function useSettingsInternal() { setKeepTranscriptionInClipboard: store.setKeepTranscriptionInClipboard, cloudBackupEnabled: store.cloudBackupEnabled, setCloudBackupEnabled: store.setCloudBackupEnabled, - localSemanticSearchEnabled: store.localSemanticSearchEnabled, - setLocalSemanticSearchEnabled: store.setLocalSemanticSearchEnabled, telemetryEnabled: store.telemetryEnabled, setTelemetryEnabled: store.setTelemetryEnabled, audioRetentionDays: store.audioRetentionDays, diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index e4eb51561..46514273d 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1352,8 +1352,6 @@ "title": "Datenschutz", "usageAnalytics": "Nutzungsanalysen", "usageAnalyticsDescription": "Helfen Sie uns, OpenWhispr zu verbessern, indem Sie anonyme Leistungsdaten teilen. Wir senden niemals Transkriptionsinhalte – nur Timing- und Fehlerdaten.", - "localSemanticSearch": "Lokale semantische Suche", - "localSemanticSearchDescription": "Notizen nach Bedeutung finden, nicht nur nach Stichwörtern. Benötigt ca. 52 MB für das Einbettungsmodell und den Suchindex. Funktioniert vollständig offline.", "dataRetention": "Datenaufbewahrung", "dataRetentionDescription": "Transkriptionen und Audio lokal im Verlauf speichern. Wenn deaktiviert, werden Transkriptionen eingefügt, aber nicht gespeichert.", "audioRetention": "Audio-Aufbewahrung", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9f2bcac02..c2a10f910 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1400,8 +1400,6 @@ "title": "Privacy", "usageAnalytics": "Usage analytics", "usageAnalyticsDescription": "Help us improve OpenWhispr by sharing anonymous performance metrics. We never send transcription content — only timing and error data.", - "localSemanticSearch": "Local semantic search", - "localSemanticSearchDescription": "Find notes by meaning, not just keywords. Uses ~52 MB for the embedding model and search index. Works fully offline.", "audioRetention": "Audio Retention", "audioRetentionDescription": "Store audio recordings locally for re-transcription and download. Files are automatically deleted after the retention period.", "audioRetentionDisabled": "Disabled", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index a55ec447e..106b7e71d 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1352,8 +1352,6 @@ "title": "Privacidad", "usageAnalytics": "Analíticas de uso", "usageAnalyticsDescription": "Ayúdanos a mejorar OpenWhispr compartiendo métricas de rendimiento anónimas. Nunca enviamos contenido de transcripciones, solo datos de tiempo y errores.", - "localSemanticSearch": "Búsqueda semántica local", - "localSemanticSearchDescription": "Encuentra notas por significado, no solo por palabras clave. Usa ~52 MB para el modelo de embeddings y el índice de búsqueda. Funciona completamente sin conexión.", "dataRetention": "Retención de datos", "dataRetentionDescription": "Almacenar transcripciones y audio localmente en tu historial. Cuando está desactivado, las transcripciones se pegan pero no se guardan.", "audioRetention": "Retención de audio", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index c59b4a07a..d73e7dc33 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1352,8 +1352,6 @@ "title": "Confidentialité", "usageAnalytics": "Analyse d'utilisation", "usageAnalyticsDescription": "Aidez-nous à améliorer OpenWhispr en partageant des métriques de performance anonymes. Nous n'envoyons jamais le contenu de vos transcriptions — uniquement des données de performance et d'erreurs.", - "localSemanticSearch": "Recherche sémantique locale", - "localSemanticSearchDescription": "Trouvez des notes par sens, pas seulement par mots-clés. Utilise environ 52 Mo pour le modèle d'embeddings et l'index de recherche. Fonctionne entièrement hors ligne.", "dataRetention": "Conservation des données", "dataRetentionDescription": "Stocker les transcriptions et l'audio localement dans votre historique. Lorsque désactivé, les transcriptions sont collées mais pas enregistrées.", "audioRetention": "Conservation de l'audio", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index a3802b7ca..05536bcf9 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1352,8 +1352,6 @@ "title": "Privacy", "usageAnalytics": "Analisi dell'utilizzo", "usageAnalyticsDescription": "Aiutaci a migliorare OpenWhispr condividendo metriche di prestazione anonime. Non inviamo mai il contenuto delle trascrizioni, solo dati su tempi ed errori.", - "localSemanticSearch": "Ricerca semantica locale", - "localSemanticSearchDescription": "Trova le note per significato, non solo per parole chiave. Usa circa 52 MB per il modello di embedding e l'indice di ricerca. Funziona completamente offline.", "dataRetention": "Conservazione dei dati", "dataRetentionDescription": "Salva trascrizioni e audio localmente nella cronologia. Se disattivato, le trascrizioni vengono incollate ma non salvate.", "audioRetention": "Conservazione audio", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index c4eb3bc4a..6b153204c 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1352,8 +1352,6 @@ "title": "プライバシー", "usageAnalytics": "使用状況の分析", "usageAnalyticsDescription": "匿名のパフォーマンスデータを共有して OpenWhispr の改善にご協力ください。文字起こしの内容は送信されません。タイミングとエラーデータのみです。", - "localSemanticSearch": "ローカルセマンティック検索", - "localSemanticSearchDescription": "キーワードだけでなく、意味でノートを検索します。埋め込みモデルと検索インデックスに約52 MBを使用します。完全にオフラインで動作します。", "dataRetention": "データ保持", "dataRetentionDescription": "文字起こしと音声を履歴にローカル保存します。無効にすると、文字起こしは貼り付けられますが保存されません。", "audioRetention": "音声の保持", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index b36dbf535..e0e34e5b0 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1324,8 +1324,6 @@ "title": "Privacidade", "usageAnalytics": "Análise de uso", "usageAnalyticsDescription": "Ajude-nos a melhorar o OpenWhispr compartilhando métricas anônimas de desempenho. Nunca enviamos conteúdo de transcrição — apenas dados de tempo e erros.", - "localSemanticSearch": "Pesquisa semântica local", - "localSemanticSearchDescription": "Encontre notas por significado, não apenas por palavras-chave. Usa ~52 MB para o modelo de embeddings e o índice de pesquisa. Funciona totalmente offline.", "dataRetention": "Retenção de dados", "dataRetentionDescription": "Armazenar transcrições e áudio localmente no seu histórico. Quando desativado, as transcrições são coladas mas não salvas.", "audioRetention": "Retenção de áudio", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index a1b06f0e7..e367dc180 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1352,8 +1352,6 @@ "title": "Конфиденциальность", "usageAnalytics": "Аналитика использования", "usageAnalyticsDescription": "Помогите нам улучшить OpenWhispr, делясь анонимными метриками производительности. Мы никогда не отправляем содержимое транскрипций — только данные о времени и ошибках.", - "localSemanticSearch": "Локальный семантический поиск", - "localSemanticSearchDescription": "Находите заметки по смыслу, а не только по ключевым словам. Использует ~52 МБ для модели эмбеддингов и поискового индекса. Работает полностью офлайн.", "dataRetention": "Хранение данных", "dataRetentionDescription": "Сохранять транскрипции и аудио локально в истории. При отключении транскрипции вставляются, но не сохраняются.", "audioRetention": "Хранение аудио", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index d5c0f95ba..93c3ec54a 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1347,8 +1347,6 @@ "title": "隐私", "usageAnalytics": "使用分析", "usageAnalyticsDescription": "通过分享匿名性能数据帮助我们改进 OpenWhispr。我们绝不发送转录内容——仅发送计时和错误数据。", - "localSemanticSearch": "本地语义搜索", - "localSemanticSearchDescription": "按含义查找笔记,不仅仅是关键词。嵌入模型和搜索索引约需 52 MB 空间。完全离线运行。", "dataRetention": "数据保留", "dataRetentionDescription": "将转录和音频本地保存到历史记录中。禁用后,转录内容会被粘贴但不会被保存。", "audioRetention": "音频保留", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index f1c7260bd..e3811b4da 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1347,8 +1347,6 @@ "title": "隱私權", "usageAnalytics": "使用分析", "usageAnalyticsDescription": "分享匿名的效能指標,協助我們改善 OpenWhispr。我們絕不會傳送轉錄內容——只有計時和錯誤資料。", - "localSemanticSearch": "本地語義搜尋", - "localSemanticSearchDescription": "依含義尋找筆記,不僅僅是關鍵字。嵌入模型和搜尋索引約需 52 MB 空間。完全離線運作。", "dataRetention": "資料保留", "dataRetentionDescription": "將轉錄和音訊本地儲存至歷史記錄。停用後,轉錄內容會被貼上但不會被儲存。", "audioRetention": "音訊保留", diff --git a/src/services/tools/index.ts b/src/services/tools/index.ts index 9b840dcb6..35552f94a 100644 --- a/src/services/tools/index.ts +++ b/src/services/tools/index.ts @@ -14,15 +14,13 @@ interface ToolRegistrySettings { isSignedIn: boolean; gcalConnected: boolean; cloudBackupEnabled: boolean; - localSemanticSearchEnabled?: boolean; } export function createToolRegistry(settings: ToolRegistrySettings): ToolRegistry { const registry = new ToolRegistry(); const useCloudSearch = settings.isSignedIn && settings.cloudBackupEnabled; - const useLocalSemanticSearch = !useCloudSearch && !!settings.localSemanticSearchEnabled; - registry.register(createSearchNotesTool({ useCloudSearch, useLocalSemanticSearch })); + registry.register(createSearchNotesTool({ useCloudSearch })); registry.register(getNoteTool); registry.register(createNoteTool); registry.register(updateNoteTool); diff --git a/src/services/tools/searchNotesTool.ts b/src/services/tools/searchNotesTool.ts index 1a30cd4a6..d01b9adf3 100644 --- a/src/services/tools/searchNotesTool.ts +++ b/src/services/tools/searchNotesTool.ts @@ -4,19 +4,15 @@ const MAX_CONTENT_LENGTH = 500; interface SearchToolOptions { useCloudSearch: boolean; - useLocalSemanticSearch: boolean; } export function createSearchNotesTool(options: SearchToolOptions): ToolDefinition { - const { useCloudSearch, useLocalSemanticSearch } = options; - - const hasSemanticSearch = useCloudSearch || useLocalSemanticSearch; + const { useCloudSearch } = options; return { name: "search_notes", - description: hasSemanticSearch - ? "Search the user's notes using semantic search. Understands meaning and context, not just keywords. Returns matching notes with title, date, relevance score, and a preview of content." - : "Search the user's notes by keyword or phrase. Returns matching notes with title, date, and a preview of content.", + description: + "Search the user's notes using semantic search. Understands meaning and context, not just keywords. Returns matching notes with title, date, relevance score, and a preview of content.", parameters: { type: "object", properties: { @@ -38,10 +34,10 @@ export function createSearchNotesTool(options: SearchToolOptions): ToolDefinitio const query = args.query as string; const limit = typeof args.limit === "number" ? args.limit : 5; - // Build fallback chain: cloud → local semantic → FTS5 + // Fallback chain: cloud → local semantic (hybrid RRF) → FTS5 keyword const strategies: Array<() => Promise> = []; if (useCloudSearch) strategies.push(() => executeCloudSearch(query, limit)); - if (useLocalSemanticSearch) strategies.push(() => executeLocalSearch(query, limit, true)); + strategies.push(() => executeLocalSearch(query, limit, true)); strategies.push(() => executeLocalSearch(query, limit, false)); for (let i = 0; i < strategies.length; i++) { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 067f5445b..85321b4cc 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -54,7 +54,6 @@ const BOOLEAN_SETTINGS = new Set([ "useReasoningModel", "preferBuiltInMic", "cloudBackupEnabled", - "localSemanticSearchEnabled", "telemetryEnabled", "audioCuesEnabled", "pauseMediaOnDictation", @@ -145,7 +144,6 @@ export interface SettingsState setTheme: (value: "light" | "dark" | "auto") => void; setCloudBackupEnabled: (value: boolean) => void; - setLocalSemanticSearchEnabled: (value: boolean) => void; setTelemetryEnabled: (value: boolean) => void; setAudioRetentionDays: (days: number) => void; setDataRetentionEnabled: (value: boolean) => void; @@ -275,7 +273,6 @@ export const useSettingsStore = create()((set, get) => ({ return "auto" as const; })(), cloudBackupEnabled: readBoolean("cloudBackupEnabled", false), - localSemanticSearchEnabled: readBoolean("localSemanticSearchEnabled", false), telemetryEnabled: readBoolean("telemetryEnabled", false), audioRetentionDays: (() => { if (!isBrowser) return 30; @@ -445,7 +442,6 @@ export const useSettingsStore = create()((set, get) => ({ }, setCloudBackupEnabled: createBooleanSetter("cloudBackupEnabled"), - setLocalSemanticSearchEnabled: createBooleanSetter("localSemanticSearchEnabled"), setTelemetryEnabled: createBooleanSetter("telemetryEnabled"), setAudioRetentionDays: (days: number) => { if (isBrowser) localStorage.setItem("audioRetentionDays", String(days)); diff --git a/src/types/electron.ts b/src/types/electron.ts index 82aef3395..fd7c551b3 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -376,8 +376,6 @@ declare global { onSemanticReindexProgress: ( callback: (data: { done: number; total: number }) => void ) => () => void; - enableSemanticSearch: () => Promise<{ success: boolean; error?: string }>; - disableSemanticSearch: () => Promise<{ success: boolean; error?: string }>; updateNoteCloudId: (id: number, cloudId: string) => Promise; // Folder operations From 436643d9ef1c146bad6dac78c154db1d08633a15 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 19:04:56 -0700 Subject: [PATCH 24/91] fix: add qdrant to electron-builder packaging, fix runtime download path - Add qdrant-* to extraResources filter so binary is bundled in release - Add onnxruntime-node to asarUnpack (native module needs unpacking) - Fix localEmbeddings.downloadModel() to use runtime downloadUtils.js instead of build-time scripts/lib/download-utils (not packaged in ASAR) --- electron-builder.json | 3 ++- main.js | 1 - src/helpers/localEmbeddings.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electron-builder.json b/electron-builder.json index e743b9309..2818e5bb1 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -82,7 +82,7 @@ "!**/node_modules/**/CHANGELOG*", "!**/node_modules/**/.github/**" ], - "asarUnpack": ["**/node_modules/ffmpeg-static/**/*", "**/node_modules/better-sqlite3/**/*"], + "asarUnpack": ["**/node_modules/ffmpeg-static/**/*", "**/node_modules/better-sqlite3/**/*", "**/node_modules/onnxruntime-node/**/*"], "extraResources": [ ".env", "src/assets/**/*", @@ -102,6 +102,7 @@ "whisper-server-*", "llama-server-*", "sherpa-onnx-*", + "qdrant-*", "windows-key-listener*", "windows-mic-listener*", "windows-text-monitor*", diff --git a/main.js b/main.js index 68836d50b..b28c064d1 100644 --- a/main.js +++ b/main.js @@ -666,7 +666,6 @@ async function startApp() { }); } - // Local semantic search: start Qdrant sidecar + ensure embedding model is available const QdrantManager = require("./src/helpers/qdrantManager"); qdrantManager = new QdrantManager(); if (qdrantManager.isAvailable()) { diff --git a/src/helpers/localEmbeddings.js b/src/helpers/localEmbeddings.js index a071ed363..343a42191 100644 --- a/src/helpers/localEmbeddings.js +++ b/src/helpers/localEmbeddings.js @@ -178,7 +178,7 @@ class LocalEmbeddings { async downloadModel() { if (this.isAvailable()) return; - const { downloadFile } = require("../../scripts/lib/download-utils"); + const { downloadFile } = require("./downloadUtils"); const files = [ { name: "model.onnx", From 7966210a7c1bd613f34acc98acae36b4369432a8 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 19:07:58 -0700 Subject: [PATCH 25/91] ci: add Qdrant binary download to all CI/release workflows Add download-qdrant.js step to linux, windows, and macOS jobs in both build-and-notarize.yml and release.yml. Passes --platform/--arch for macOS cross-compilation (same pattern as whisper-cpp and sherpa-onnx). --- .github/workflows/build-and-notarize.yml | 15 +++++++++++++++ .github/workflows/release.yml | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/build-and-notarize.yml b/.github/workflows/build-and-notarize.yml index 39b8e1ba7..dc758b683 100644 --- a/.github/workflows/build-and-notarize.yml +++ b/.github/workflows/build-and-notarize.yml @@ -54,6 +54,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Cache Electron uses: actions/cache@v4 with: @@ -124,6 +129,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download nircmd.exe run: node scripts/download-nircmd.js env: @@ -212,6 +222,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current --platform darwin --arch ${{ matrix.arch }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + - name: Cache Electron uses: actions/cache@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d691d9737..524249387 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Cache Electron uses: actions/cache@v4 with: @@ -116,6 +121,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download nircmd.exe run: node scripts/download-nircmd.js env: @@ -197,6 +207,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Download Qdrant binary + run: node scripts/download-qdrant.js --current --platform darwin --arch ${{ matrix.arch }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - name: Cache Electron uses: actions/cache@v4 with: From c1848d1e538567e2116e6983590e73c475527c31 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 19:13:00 -0700 Subject: [PATCH 26/91] chore: add .qdrant-initialized to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0e3eb2931..d57c43b08 100644 --- a/.gitignore +++ b/.gitignore @@ -314,3 +314,4 @@ Thumbs.db .macos-fast-paste.hash .macos-fast-paste.*.hash .linux-fast-paste.hash +.qdrant-initialized From c45a037fd371a400e2abb02702387ac23f0fee84 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 19:24:05 -0700 Subject: [PATCH 27/91] fix(agent): skip LLM cleanup for all transcription paths in agent mode processTranscription() was missing the skipReasoning check, so BYOK transcription paths (local whisper, parakeet, OpenAI API, Mistral) still ran LLM cleanup before sending text to the agent chat. Only the OpenWhispr Cloud and streaming paths had the guard. Add early return in processTranscription() so agent mode always gets raw transcription. --- src/helpers/audioManager.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/helpers/audioManager.js b/src/helpers/audioManager.js index 2caa64657..07466006b 100644 --- a/src/helpers/audioManager.js +++ b/src/helpers/audioManager.js @@ -931,6 +931,14 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor); return normalizedText; } + if (this.skipReasoning) { + logger.logReasoning("REASONING_SKIPPED_AGENT_MODE", { + source, + reason: "skipReasoning is set (agent mode) — returning raw transcription", + }); + return normalizedText; + } + logger.logReasoning("TRANSCRIPTION_RECEIVED", { source, textLength: normalizedText.length, From 4648a7ae0aa75363b910a08d7b0233d4811da29b Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 21:03:17 -0700 Subject: [PATCH 28/91] feat(chat): add sidebar chat tab with conversation history, cloud sync, and semantic search - Extract shared chat components from agent overlay into reusable src/components/chat/ (ChatMessages, ChatMessage, ChatInput, useChatStreaming, useChatPersistence hooks) - Refactor AgentOverlay, AgentChat, AgentInput, AgentMessage to thin wrappers - Add "Chat" tab to control panel sidebar below Home with two-panel layout - Add virtualized conversation list with date grouping (@tanstack/react-virtual) - Add conversation search, archive/unarchive, delete with confirmation - Add editable chat titles, empty states, loading skeletons - Extend local DB: getAgentConversationsWithPreview, searchAgentConversations, archive/unarchive methods, archived_at and cloud_id column migrations - Add cloud sync: ConversationsService, chatStore (Zustand), conversation CRUD IPC - Add chunked conversation embeddings for semantic search (conversationChunker.js, vectorIndex.js conversation_chunks collection) - Add i18n keys for all 10 languages --- package-lock.json | 135 +++++--- package.json | 1 + preload.js | 10 + src/components/AgentOverlay.tsx | 302 +++------------- src/components/ControlPanel.tsx | 6 + src/components/ControlPanelSidebar.tsx | 10 +- src/components/agent/AgentChat.tsx | 53 +-- src/components/agent/AgentInput.tsx | 236 +------------ src/components/agent/AgentMessage.tsx | 290 +--------------- src/components/chat/ChatHeader.tsx | 86 +++++ src/components/chat/ChatInput.tsx | 237 +++++++++++++ src/components/chat/ChatMessage.tsx | 289 ++++++++++++++++ src/components/chat/ChatMessages.tsx | 42 +++ src/components/chat/ChatView.tsx | 171 ++++++++++ src/components/chat/ConversationDateGroup.tsx | 19 ++ src/components/chat/ConversationItem.tsx | 133 ++++++++ src/components/chat/ConversationList.tsx | 323 ++++++++++++++++++ src/components/chat/EmptyChatState.tsx | 11 + src/components/chat/EmptyConversationList.tsx | 29 ++ src/components/chat/types.ts | 24 ++ src/components/chat/useChatPersistence.ts | 94 +++++ src/components/chat/useChatStreaming.ts | 245 +++++++++++++ src/helpers/conversationChunker.js | 26 ++ src/helpers/database.js | 110 ++++++ src/helpers/ipcHandlers.js | 31 ++ src/helpers/vectorIndex.js | 114 +++++++ src/locales/de/translation.json | 20 +- src/locales/en/translation.json | 20 +- src/locales/es/translation.json | 20 +- src/locales/fr/translation.json | 20 +- src/locales/it/translation.json | 20 +- src/locales/ja/translation.json | 20 +- src/locales/pt/translation.json | 20 +- src/locales/ru/translation.json | 20 +- src/locales/zh-CN/translation.json | 20 +- src/locales/zh-TW/translation.json | 20 +- src/services/ConversationsService.ts | 141 ++++++++ src/stores/chatStore.ts | 173 ++++++++++ src/types/electron.ts | 30 ++ 39 files changed, 2693 insertions(+), 878 deletions(-) create mode 100644 src/components/chat/ChatHeader.tsx create mode 100644 src/components/chat/ChatInput.tsx create mode 100644 src/components/chat/ChatMessage.tsx create mode 100644 src/components/chat/ChatMessages.tsx create mode 100644 src/components/chat/ChatView.tsx create mode 100644 src/components/chat/ConversationDateGroup.tsx create mode 100644 src/components/chat/ConversationItem.tsx create mode 100644 src/components/chat/ConversationList.tsx create mode 100644 src/components/chat/EmptyChatState.tsx create mode 100644 src/components/chat/EmptyConversationList.tsx create mode 100644 src/components/chat/types.ts create mode 100644 src/components/chat/useChatPersistence.ts create mode 100644 src/components/chat/useChatStreaming.ts create mode 100644 src/helpers/conversationChunker.js create mode 100644 src/services/ConversationsService.ts create mode 100644 src/stores/chatStore.ts diff --git a/package-lock.json b/package-lock.json index 0c3ec9e38..b4666fa6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@ai-sdk/openai": "^3.0.41", "@neondatabase/auth": "^0.1.0-beta.21", "@neondatabase/neon-js": "^0.1.0-beta.22", + "@qdrant/js-client-rest": "^1.12.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -24,6 +25,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.12", + "@tanstack/react-virtual": "^3.13.2", "@tiptap/core": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-task-item": "^3.20.4", @@ -42,6 +44,7 @@ "i18next": "^25.8.4", "lucide-react": "^0.518.0", "object-assign": "^4.1.1", + "onnxruntime-node": "^1.21.0", "ps-list": "^9.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -3615,6 +3618,33 @@ "@opentelemetry/api": "^1.8" } }, + "node_modules/@qdrant/js-client-rest": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.17.0.tgz", + "integrity": "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A==", + "license": "Apache-2.0", + "dependencies": { + "@qdrant/openapi-typescript-fetch": "1.2.6", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=18.17.0", + "pnpm": ">=8" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@qdrant/openapi-typescript-fetch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@qdrant/openapi-typescript-fetch/-/openapi-typescript-fetch-1.2.6.tgz", + "integrity": "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -6653,6 +6683,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz", + "integrity": "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.23" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.23", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.23.tgz", + "integrity": "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "3.20.4", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.4.tgz", @@ -7929,6 +7986,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -8716,9 +8782,7 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/brace-expansion": { "version": "2.0.2", @@ -9664,9 +9728,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -9683,9 +9745,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -9763,9 +9823,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/detect-node-es": { "version": "1.1.0", @@ -10430,7 +10488,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10440,7 +10497,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10479,9 +10535,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/esbuild": { "version": "0.25.12", @@ -11601,9 +11655,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", @@ -11633,9 +11685,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" @@ -11651,7 +11701,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11731,9 +11780,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -12586,8 +12633,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -13206,9 +13252,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "escape-string-regexp": "^4.0.0" }, @@ -14479,9 +14523,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -14526,6 +14568,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, "node_modules/openapi-types": { "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", @@ -16261,9 +16326,7 @@ "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", @@ -16447,17 +16510,13 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "type-fest": "^0.13.1" }, @@ -16778,9 +16837,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/sql-formatter": { "version": "14.0.0", @@ -17472,9 +17529,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, "license": "(MIT OR CC0-1.0)", - "optional": true, "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index c7bb3e111..684fef926 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "@qdrant/js-client-rest": "^1.12.0", "@neondatabase/auth": "^0.1.0-beta.21", "@neondatabase/neon-js": "^0.1.0-beta.22", + "@tanstack/react-virtual": "^3.13.2", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", diff --git a/preload.js b/preload.js index 4d18e6a63..8c943cec0 100644 --- a/preload.js +++ b/preload.js @@ -612,6 +612,16 @@ contextBridge.exposeInMainWorld("electronAPI", { addAgentMessage: (conversationId, role, content, metadata) => ipcRenderer.invoke("db-add-agent-message", conversationId, role, content, metadata), getAgentMessages: (conversationId) => ipcRenderer.invoke("db-get-agent-messages", conversationId), + getAgentConversationsWithPreview: (limit, offset, includeArchived) => + ipcRenderer.invoke("db-get-agent-conversations-with-preview", limit, offset, includeArchived), + searchAgentConversations: (query, limit) => + ipcRenderer.invoke("db-search-agent-conversations", query, limit), + archiveAgentConversation: (id) => ipcRenderer.invoke("db-archive-agent-conversation", id), + unarchiveAgentConversation: (id) => ipcRenderer.invoke("db-unarchive-agent-conversation", id), + updateAgentConversationCloudId: (id, cloudId) => + ipcRenderer.invoke("db-update-agent-conversation-cloud-id", id, cloudId), + semanticSearchConversations: (query, limit) => + ipcRenderer.invoke("db-semantic-search-conversations", query, limit), // Google Calendar gcalStartOAuth: () => ipcRenderer.invoke("gcal-start-oauth"), diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 938587fba..a0ed30b21 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -2,76 +2,55 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "./lib/utils"; import { AgentTitleBar } from "./agent/AgentTitleBar"; -import { AgentChat, type Message } from "./agent/AgentChat"; +import { AgentChat } from "./agent/AgentChat"; import { AgentInput } from "./agent/AgentInput"; import AudioManager from "../helpers/audioManager"; -import ReasoningService, { type AgentStreamChunk } from "../services/ReasoningService"; -import { getSettings } from "../stores/settingsStore"; -import { getAgentSystemPrompt } from "../config/prompts"; -import { createToolRegistry } from "../services/tools"; -import type { ToolRegistry } from "../services/tools/ToolRegistry"; - -type AgentState = - | "idle" - | "listening" - | "transcribing" - | "thinking" - | "streaming" - | "tool-executing"; +import { useChatPersistence } from "./chat/useChatPersistence"; +import { useChatStreaming } from "./chat/useChatStreaming"; +import type { Message } from "./chat/types"; const MIN_HEIGHT = 200; const MIN_WIDTH = 360; export default function AgentOverlay() { const { t } = useTranslation(); - const [messages, setMessages] = useState([]); - const [agentState, setAgentState] = useState("idle"); const [partialTranscript, setPartialTranscript] = useState(""); - const [toolStatus, setToolStatus] = useState(""); - const [activeToolName, setActiveToolName] = useState(""); const audioManagerRef = useRef | null>(null); - const messagesRef = useRef([]); - const agentStateRef = useRef("idle"); - const conversationIdRef = useRef(null); - const mountedRef = useRef(true); - const toolRegistryRef = useRef<{ key: string; registry: ToolRegistry } | null>(null); + const agentStateRef = useRef("idle"); - useEffect(() => { - messagesRef.current = messages; - }, [messages]); + const persistence = useChatPersistence(); + const { messages, setMessages, handleNewChat: persistenceNewChat } = persistence; + + const streaming = useChatStreaming({ + messages, + setMessages, + onStreamComplete: (_assistantId, content, toolCalls) => { + persistence.saveAssistantMessage(content, toolCalls); + }, + }); + + const { agentState, toolStatus, activeToolName } = streaming; useEffect(() => { agentStateRef.current = agentState; }, [agentState]); - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - ReasoningService.cancelActiveStream(); - }; - }, []); - - const addSystemMessage = useCallback((content: string) => { - setMessages((prev) => [ - ...prev, - { id: crypto.randomUUID(), role: "assistant" as const, content, isStreaming: false }, - ]); - }, []); + const addSystemMessage = useCallback( + (content: string) => { + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: "assistant" as const, content, isStreaming: false }, + ]); + }, + [setMessages] + ); const handleTranscriptionComplete = useCallback( async (text: string) => { - if (!text.trim()) { - setAgentState("idle"); - return; - } + if (!text.trim()) return; - // Create conversation on first message - if (!conversationIdRef.current) { - const conv = await window.electronAPI?.createAgentConversation?.( - t("agentMode.titleBar.newChat") - ); - conversationIdRef.current = conv?.id ?? null; + if (!persistence.conversationId) { + await persistence.createConversation(t("agentMode.titleBar.newChat")); } const userMsg: Message = { @@ -82,202 +61,16 @@ export default function AgentOverlay() { }; setMessages((prev) => [...prev, userMsg]); - if (conversationIdRef.current) { - window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "user", text); - } + await persistence.saveUserMessage(text); - // Auto-title after first user message - const allMessages = messagesRef.current; - if (conversationIdRef.current && allMessages.length === 0) { + if (persistence.conversationId && messages.length === 0) { const title = text.slice(0, 50) + (text.length > 50 ? "..." : ""); - window.electronAPI?.updateAgentConversationTitle?.(conversationIdRef.current, title); + window.electronAPI?.updateAgentConversationTitle?.(persistence.conversationId, title); } - setAgentState("thinking"); - - const settings = getSettings(); - - const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; - const toolSupportedProviders = ["openai", "groq", "custom", "anthropic", "gemini"]; - const supportsTools = isCloudAgent || toolSupportedProviders.includes(settings.agentProvider); - - let registry: ToolRegistry | null = null; - if (supportsTools) { - const cacheKey = `${settings.isSignedIn}-${settings.gcalConnected}-${settings.cloudBackupEnabled}`; - if (toolRegistryRef.current?.key === cacheKey) { - registry = toolRegistryRef.current.registry; - } else { - registry = createToolRegistry({ - isSignedIn: settings.isSignedIn, - gcalConnected: settings.gcalConnected, - cloudBackupEnabled: settings.cloudBackupEnabled, - }); - toolRegistryRef.current = { key: cacheKey, registry }; - } - } - const systemPrompt = getAgentSystemPrompt(registry?.getAll().map((t) => t.name)); - - const llmMessages = [ - { role: "system", content: systemPrompt }, - ...[...allMessages, userMsg].slice(-20).map((m) => ({ role: m.role, content: m.content })), - ]; - - const assistantId = crypto.randomUUID(); - setMessages((prev) => [ - ...prev, - { id: assistantId, role: "assistant", content: "", isStreaming: true }, - ]); - setAgentState("streaming"); - - try { - let fullContent = ""; - let stream: AsyncGenerator; - - if (isCloudAgent) { - const executeToolCall = registry - ? async (name: string, argsJson: string) => { - const tool = registry.get(name); - if (!tool) - return { - data: `Unknown tool: ${name}`, - displayText: t("agentMode.tools.unknownTool", { name }), - }; - let args: Record; - try { - args = JSON.parse(argsJson); - } catch { - return { - data: `Invalid tool arguments for ${name}`, - displayText: t("agentMode.tools.invalidArgs", { name }), - }; - } - const result = await tool.execute(args); - const data = result.success - ? typeof result.data === "string" - ? result.data - : JSON.stringify(result.data) - : result.displayText; - const metadata = - result.success && result.data && typeof result.data === "object" - ? (result.data as Record) - : undefined; - return { data, displayText: result.displayText, metadata }; - } - : undefined; - - stream = ReasoningService.processTextStreamingCloud(llmMessages, { - systemPrompt, - tools: registry?.getAll().map((t) => ({ - name: t.name, - description: t.description, - parameters: t.parameters, - })), - executeToolCall, - }); - } else { - const aiTools = registry?.toAISDKFormat(); - stream = ReasoningService.processTextStreamingAI( - llmMessages, - settings.agentModel, - settings.agentProvider, - { systemPrompt }, - aiTools - ); - } - - for await (const chunk of stream) { - if (!mountedRef.current) { - ReasoningService.cancelActiveStream(); - break; - } - if (chunk.type === "content") { - fullContent += chunk.text; - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) - ); - } else if (chunk.type === "tool_calls") { - for (const call of chunk.calls) { - setAgentState("tool-executing"); - setActiveToolName(call.name); - setToolStatus( - t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) - ); - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - toolCalls: [ - ...(m.toolCalls || []), - { - id: call.id, - name: call.name, - arguments: call.arguments, - status: "executing" as const, - }, - ], - } - : m - ) - ); - } - } else if (chunk.type === "tool_result") { - setMessages((prev) => - prev.map((m) => - m.id === assistantId && m.toolCalls - ? { - ...m, - toolCalls: m.toolCalls.map((tc) => - tc.id === chunk.callId - ? { - ...tc, - status: "completed" as const, - result: chunk.displayText, - ...(chunk.metadata ? { metadata: chunk.metadata } : {}), - } - : tc - ), - } - : m - ) - ); - setAgentState("streaming"); - setToolStatus(""); - setActiveToolName(""); - } - } - - setMessages((prev) => - prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) - ); - - if (conversationIdRef.current) { - const finalMsg = messagesRef.current.find((m) => m.id === assistantId); - const toolCalls = finalMsg?.toolCalls; - window.electronAPI?.addAgentMessage?.( - conversationIdRef.current, - "assistant", - fullContent, - toolCalls?.length ? { toolCalls } : undefined - ); - } - } catch (error) { - setMessages((prev) => - prev.map((m) => - m.id === assistantId - ? { - ...m, - content: `${t("agentMode.chat.errorPrefix")}: ${(error as Error).message}`, - isStreaming: false, - } - : m - ) - ); - } - - setAgentState("idle"); + await streaming.sendToAI(text, [...messages, userMsg]); }, - [t] + [t, messages, setMessages, persistence, streaming] ); useEffect(() => { @@ -285,20 +78,10 @@ export default function AgentOverlay() { am.setSkipReasoning(true); am.setContext("agent"); am.setCallbacks({ - onStateChange: ({ - isRecording, - isProcessing, - }: { - isRecording: boolean; - isProcessing: boolean; - }) => { - if (isRecording) setAgentState("listening"); - else if (isProcessing) setAgentState("transcribing"); - }, + onStateChange: () => {}, onError: (error: { message?: string }) => { const msg = error?.message || (typeof error === "string" ? error : "Transcription failed"); addSystemMessage(`${t("agentMode.chat.errorPrefix")}: ${msg}`); - setAgentState("idle"); }, onTranscriptionComplete: (result: { text: string }) => { handleTranscriptionComplete(result.text); @@ -313,6 +96,7 @@ export default function AgentOverlay() { am.cleanup?.(); window.removeEventListener("api-key-changed", (am as any)._onApiKeyChanged); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [addSystemMessage, handleTranscriptionComplete]); const handleResizeStart = useCallback((e: React.MouseEvent, direction: string) => { @@ -400,14 +184,10 @@ export default function AgentOverlay() { ); const handleNewChat = useCallback(() => { - setMessages([]); - setAgentState("idle"); + persistenceNewChat(); setPartialTranscript(""); - setToolStatus(""); - setActiveToolName(""); - conversationIdRef.current = null; - toolRegistryRef.current = null; - }, []); + streaming.cancelStream(); + }, [persistenceNewChat, streaming]); const handleClose = useCallback(() => { window.electronAPI?.hideAgentOverlay?.(); @@ -435,7 +215,7 @@ export default function AgentOverlay() { />
- {/* Resize handles — edges */} + {/* Resize handles -- edges */}
handleResizeStart(e, "n")} @@ -453,7 +233,7 @@ export default function AgentOverlay() { onMouseDown={(e) => handleResizeStart(e, "e")} /> - {/* Resize handles — corners */} + {/* Resize handles -- corners */}
handleResizeStart(e, "nw")} diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index bf79438a7..a39ba3b32 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -33,6 +33,7 @@ const PersonalNotesView = React.lazy(() => import("./notes/PersonalNotesView")); const DictionaryView = React.lazy(() => import("./DictionaryView")); const UploadAudioView = React.lazy(() => import("./notes/UploadAudioView")); const IntegrationsView = React.lazy(() => import("./IntegrationsView")); +const ChatView = React.lazy(() => import("./chat/ChatView")); const CommandSearch = React.lazy(() => import("./CommandSearch")); export default function ControlPanel() { @@ -732,6 +733,11 @@ export default function ControlPanel() { }} /> )} + {activeView === "chat" && ( + + + + )} {activeView === "personal-notes" && ( ; }[] = [ { id: "home", label: t("sidebar.home"), icon: Home }, + { id: "chat", label: t("sidebar.chat"), icon: MessageSquare }, { id: "personal-notes", label: t("sidebar.notes"), icon: NotebookPen }, { id: "upload", label: t("sidebar.upload"), icon: Upload }, { id: "dictionary", label: t("sidebar.dictionary"), icon: BookOpen }, diff --git a/src/components/agent/AgentChat.tsx b/src/components/agent/AgentChat.tsx index 5c37222e7..398b58a38 100644 --- a/src/components/agent/AgentChat.tsx +++ b/src/components/agent/AgentChat.tsx @@ -1,27 +1,11 @@ -import { useRef, useEffect } from "react"; import { Mic } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { cn } from "../lib/utils"; -import { AgentMessage } from "./AgentMessage"; import { useSettingsStore } from "../../stores/settingsStore"; import { formatHotkeyLabel } from "../../utils/hotkeys"; +import { ChatMessages } from "../chat/ChatMessages"; +import type { Message } from "../chat/types"; -export interface ToolCallInfo { - id: string; - name: string; - arguments: string; - status: "executing" | "completed" | "error"; - result?: string; - metadata?: Record; -} - -export interface Message { - id: string; - role: "user" | "assistant" | "tool"; - content: string; - isStreaming: boolean; - toolCalls?: ToolCallInfo[]; -} +export type { Message, ToolCallInfo } from "../chat/types"; interface AgentChatProps { messages: Message[]; @@ -29,20 +13,13 @@ interface AgentChatProps { export function AgentChat({ messages }: AgentChatProps) { const { t } = useTranslation(); - const scrollRef = useRef(null); const agentKey = useSettingsStore((s) => s.agentKey); const hotkeyLabel = formatHotkeyLabel(agentKey); - useEffect(() => { - const el = scrollRef.current; - if (el) { - el.scrollTop = el.scrollHeight; - } - }, [messages]); - return ( -
- {messages.length === 0 ? ( +
- ) : ( -
- {messages - .filter((msg) => msg.role !== "tool") - .map((msg) => ( - - ))} -
- )} -
+ } + /> ); } diff --git a/src/components/agent/AgentInput.tsx b/src/components/agent/AgentInput.tsx index 99aebc208..211e52a0b 100644 --- a/src/components/agent/AgentInput.tsx +++ b/src/components/agent/AgentInput.tsx @@ -1,27 +1,5 @@ -import { useState, useRef, useCallback } from "react"; -import { - Mic, - SendHorizontal, - Search, - Globe, - ClipboardCheck, - Calendar, - FileText, - FilePlus, - FilePen, -} from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { cn } from "../lib/utils"; -import { useSettingsStore } from "../../stores/settingsStore"; -import { formatHotkeyLabel, isGlobeLikeHotkey } from "../../utils/hotkeys"; - -type AgentState = - | "idle" - | "listening" - | "transcribing" - | "thinking" - | "streaming" - | "tool-executing"; +import { ChatInput } from "../chat/ChatInput"; +import type { AgentState } from "../chat/types"; interface AgentInputProps { agentState: AgentState; @@ -31,212 +9,6 @@ interface AgentInputProps { onTextSubmit?: (text: string) => void; } -const toolIcons: Record = { - search_notes: Search, - web_search: Globe, - copy_to_clipboard: ClipboardCheck, - get_calendar_events: Calendar, - get_note: FileText, - create_note: FilePlus, - update_note: FilePen, -}; - -function Kbd({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -function HotkeyKeys({ hotkey }: { hotkey: string }) { - const label = formatHotkeyLabel(hotkey); - - if (isGlobeLikeHotkey(hotkey) || !label.includes("+")) { - return {label}; - } - - const parts = label.split("+"); - - return ( - - {parts.map((part, i) => ( - {part} - ))} - - ); -} - -function RecordingIndicator() { - return ( -
-
-
-
- ); -} - -function ProcessingIndicator() { - return ( -
-
- {[0, 1, 2, 3].map((i) => ( -
- ))} -
-
- ); -} - -function ThinkingIndicator() { - return ( -
- ); -} - -export function AgentInput({ - agentState, - partialTranscript, - toolStatus, - activeToolName, - onTextSubmit, -}: AgentInputProps) { - const { t } = useTranslation(); - const agentKey = useSettingsStore((s) => s.agentKey); - const [inputText, setInputText] = useState(""); - const inputRef = useRef(null); - - const handleSubmit = useCallback(() => { - const text = inputText.trim(); - if (!text || !onTextSubmit) return; - onTextSubmit(text); - setInputText(""); - }, [inputText, onTextSubmit]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }, - [handleSubmit] - ); - - const isIdle = agentState === "idle"; - const ToolIcon = activeToolName ? toolIcons[activeToolName] : null; - - return ( -
- {isIdle && ( -
- setInputText(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={t("agentMode.input.typeMessage")} - className={cn( - "bg-transparent border-none outline-none flex-1", - "text-[13px] text-foreground placeholder:text-muted-foreground/40", - "min-w-0" - )} - /> - {inputText.trim() ? ( - - ) : ( -
-
- -
- -
- )} -
- )} - - {agentState === "listening" && ( - <> - - - {partialTranscript || t("agentMode.input.listening")} - - - )} - - {agentState === "transcribing" && ( - <> - - - {t("agentMode.input.transcribing")} - - - )} - - {(agentState === "thinking" || agentState === "streaming") && ( - <> - - - {t("agentMode.input.thinking")} - - - )} - - {agentState === "tool-executing" && ( - <> - {ToolIcon ? ( - - ) : ( - - )} - - {toolStatus || t("agentMode.input.thinking")} - - - )} -
- ); +export function AgentInput(props: AgentInputProps) { + return ; } diff --git a/src/components/agent/AgentMessage.tsx b/src/components/agent/AgentMessage.tsx index 73524d0c9..306d0252c 100644 --- a/src/components/agent/AgentMessage.tsx +++ b/src/components/agent/AgentMessage.tsx @@ -1,289 +1 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Copy, - Check, - Search, - Globe, - ClipboardCheck, - Calendar, - FileText, - FilePlus, - FilePen, - ChevronDown, - ChevronRight, - CircleAlert, -} from "lucide-react"; -import { cn } from "../lib/utils"; -import { MarkdownRenderer } from "../ui/MarkdownRenderer"; -import type { ToolCallInfo } from "./AgentChat"; - -interface AgentMessageProps { - role: "user" | "assistant"; - content: string; - isStreaming: boolean; - toolCalls?: ToolCallInfo[]; -} - -const toolIcons: Record = { - search_notes: Search, - web_search: Globe, - copy_to_clipboard: ClipboardCheck, - get_calendar_events: Calendar, - get_note: FileText, - create_note: FilePlus, - update_note: FilePen, -}; - -const NOTE_TOOLS = new Set(["create_note", "update_note", "get_note"]); - -function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { - const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - const Icon = toolIcons[toolCall.name] || Search; - const isExecuting = toolCall.status === "executing"; - const isError = toolCall.status === "error"; - const isCompleted = toolCall.status === "completed"; - const isClipboard = toolCall.name === "copy_to_clipboard" && isCompleted; - - const resultLines = toolCall.result?.split("\n") ?? []; - const hasDetail = resultLines.length > 1 && !isClipboard; - - return ( -
- {isExecuting && ( -
- )} - -
setExpanded((v) => !v) : undefined} - > - - - {isExecuting ? ( - - {t(`agentMode.tools.${toolCall.name}Status`, { defaultValue: toolCall.name })} - - ) : isError ? ( -
- - - {toolCall.result || toolCall.name} - -
- ) : isClipboard ? ( -
- - {t("agentMode.tools.copiedToClipboard")} - - -
- ) : ( - - {toolCall.result || toolCall.name} - - )} - - {hasDetail && !isExecuting && ( - - )} -
- - {hasDetail && !isExecuting && ( -
-
-            {toolCall.result}
-          
-
- )} -
- ); -} - -function NoteCard({ noteId, title }: { noteId: number; title: string }) { - const { t } = useTranslation(); - - return ( - - ); -} - -function extractNoteCards(toolCalls?: ToolCallInfo[]): Array<{ noteId: number; title: string }> { - if (!toolCalls) return []; - const cards: Array<{ noteId: number; title: string }> = []; - const seen = new Set(); - - for (const tc of toolCalls) { - if (tc.status !== "completed" || !NOTE_TOOLS.has(tc.name) || !tc.metadata?.id) continue; - const noteId = Number(tc.metadata.id); - if (seen.has(noteId)) continue; - seen.add(noteId); - const title = - (tc.metadata.title as string) || - tc.result?.replace(/^(Created|Updated|Retrieved) note: "(.+)"$/, "$2") || - "Note"; - cards.push({ noteId, title }); - } - return cards; -} - -export function AgentMessage({ role, content, isStreaming, toolCalls }: AgentMessageProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(content); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - // clipboard unavailable - } - }; - - if (role === "user") { - return ( -
-
- {content} -
-
- ); - } - - const hasToolCalls = toolCalls && toolCalls.length > 0; - const hasContent = content.length > 0; - const noteCards = extractNoteCards(toolCalls); - - return ( -
-
- {hasToolCalls && ( -
0) && "mb-2 pb-1.5 border-b border-border/15" - )} - > - {toolCalls.map((tc) => ( - - ))} -
- )} - - {hasContent && ( - - )} - - {isStreaming && ( - - )} - - {noteCards.length > 0 && !isStreaming && ( -
- {noteCards.map((card) => ( - - ))} -
- )} - - {hasContent && !isStreaming && ( - - )} -
-
- ); -} +export { ChatMessage as AgentMessage } from "../chat/ChatMessage"; diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx new file mode 100644 index 000000000..e61963faa --- /dev/null +++ b/src/components/chat/ChatHeader.tsx @@ -0,0 +1,86 @@ +import { useState, useRef, useCallback } from "react"; +import { Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "../lib/utils"; + +interface ChatHeaderProps { + title: string; + onTitleChange: (title: string) => void; + onNewChat: () => void; +} + +export default function ChatHeader({ title, onTitleChange, onNewChat }: ChatHeaderProps) { + const { t } = useTranslation(); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(title); + const inputRef = useRef(null); + + const startEditing = useCallback(() => { + setEditValue(title); + setIsEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }, [title]); + + const commitEdit = useCallback(() => { + setIsEditing(false); + const trimmed = editValue.trim(); + if (trimmed && trimmed !== title) { + onTitleChange(trimmed); + } + }, [editValue, title, onTitleChange]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + commitEdit(); + } + if (e.key === "Escape") { + setIsEditing(false); + setEditValue(title); + } + }, + [commitEdit, title] + ); + + return ( +
+
+ {isEditing ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} + onKeyDown={handleKeyDown} + className={cn( + "w-full bg-transparent text-xs font-medium text-foreground", + "outline-none border-none appearance-none", + "rounded-sm focus-visible:ring-1 focus-visible:ring-primary/30 px-1 -mx-1" + )} + /> + ) : ( +

+ {title} +

+ )} +
+ +
+ ); +} diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx new file mode 100644 index 000000000..53ba4d2aa --- /dev/null +++ b/src/components/chat/ChatInput.tsx @@ -0,0 +1,237 @@ +import { useState, useRef, useCallback } from "react"; +import { + Mic, + SendHorizontal, + Search, + Globe, + ClipboardCheck, + Calendar, + FileText, + FilePlus, + FilePen, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "../lib/utils"; +import { useSettingsStore } from "../../stores/settingsStore"; +import { formatHotkeyLabel, isGlobeLikeHotkey } from "../../utils/hotkeys"; +import type { AgentState } from "./types"; + +interface ChatInputProps { + agentState: AgentState; + partialTranscript: string; + toolStatus?: string; + activeToolName?: string; + onTextSubmit?: (text: string) => void; + showHotkey?: boolean; +} + +const toolIcons: Record = { + search_notes: Search, + web_search: Globe, + copy_to_clipboard: ClipboardCheck, + get_calendar_events: Calendar, + get_note: FileText, + create_note: FilePlus, + update_note: FilePen, +}; + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function HotkeyKeys({ hotkey }: { hotkey: string }) { + const label = formatHotkeyLabel(hotkey); + + if (isGlobeLikeHotkey(hotkey) || !label.includes("+")) { + return {label}; + } + + const parts = label.split("+"); + + return ( + + {parts.map((part, i) => ( + {part} + ))} + + ); +} + +function RecordingIndicator() { + return ( +
+
+
+
+ ); +} + +function ProcessingIndicator() { + return ( +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); +} + +function ThinkingIndicator() { + return ( +
+ ); +} + +export function ChatInput({ + agentState, + partialTranscript, + toolStatus, + activeToolName, + onTextSubmit, + showHotkey = false, +}: ChatInputProps) { + const { t } = useTranslation(); + const agentKey = useSettingsStore((s) => s.agentKey); + const [inputText, setInputText] = useState(""); + const inputRef = useRef(null); + + const handleSubmit = useCallback(() => { + const text = inputText.trim(); + if (!text || !onTextSubmit) return; + onTextSubmit(text); + setInputText(""); + }, [inputText, onTextSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + const isIdle = agentState === "idle"; + const ToolIcon = activeToolName ? toolIcons[activeToolName] : null; + + return ( +
+ {isIdle && ( +
+ setInputText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t("agentMode.input.typeMessage")} + className={cn( + "bg-transparent border-none outline-none flex-1", + "text-[13px] text-foreground placeholder:text-muted-foreground/40", + "min-w-0" + )} + /> + {inputText.trim() ? ( + + ) : showHotkey ? ( +
+
+ +
+ +
+ ) : null} +
+ )} + + {agentState === "listening" && ( + <> + + + {partialTranscript || t("agentMode.input.listening")} + + + )} + + {agentState === "transcribing" && ( + <> + + + {t("agentMode.input.transcribing")} + + + )} + + {(agentState === "thinking" || agentState === "streaming") && ( + <> + + + {t("agentMode.input.thinking")} + + + )} + + {agentState === "tool-executing" && ( + <> + {ToolIcon ? ( + + ) : ( + + )} + + {toolStatus || t("agentMode.input.thinking")} + + + )} +
+ ); +} diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx new file mode 100644 index 000000000..a1b50606e --- /dev/null +++ b/src/components/chat/ChatMessage.tsx @@ -0,0 +1,289 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Copy, + Check, + Search, + Globe, + ClipboardCheck, + Calendar, + FileText, + FilePlus, + FilePen, + ChevronDown, + ChevronRight, + CircleAlert, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import { MarkdownRenderer } from "../ui/MarkdownRenderer"; +import type { ToolCallInfo } from "./types"; + +interface ChatMessageProps { + role: "user" | "assistant"; + content: string; + isStreaming: boolean; + toolCalls?: ToolCallInfo[]; +} + +const toolIcons: Record = { + search_notes: Search, + web_search: Globe, + copy_to_clipboard: ClipboardCheck, + get_calendar_events: Calendar, + get_note: FileText, + create_note: FilePlus, + update_note: FilePen, +}; + +const NOTE_TOOLS = new Set(["create_note", "update_note", "get_note"]); + +function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const Icon = toolIcons[toolCall.name] || Search; + const isExecuting = toolCall.status === "executing"; + const isError = toolCall.status === "error"; + const isCompleted = toolCall.status === "completed"; + const isClipboard = toolCall.name === "copy_to_clipboard" && isCompleted; + + const resultLines = toolCall.result?.split("\n") ?? []; + const hasDetail = resultLines.length > 1 && !isClipboard; + + return ( +
+ {isExecuting && ( +
+ )} + +
setExpanded((v) => !v) : undefined} + > + + + {isExecuting ? ( + + {t(`agentMode.tools.${toolCall.name}Status`, { defaultValue: toolCall.name })} + + ) : isError ? ( +
+ + + {toolCall.result || toolCall.name} + +
+ ) : isClipboard ? ( +
+ + {t("agentMode.tools.copiedToClipboard")} + + +
+ ) : ( + + {toolCall.result || toolCall.name} + + )} + + {hasDetail && !isExecuting && ( + + )} +
+ + {hasDetail && !isExecuting && ( +
+
+            {toolCall.result}
+          
+
+ )} +
+ ); +} + +function NoteCard({ noteId, title }: { noteId: number; title: string }) { + const { t } = useTranslation(); + + return ( + + ); +} + +function extractNoteCards(toolCalls?: ToolCallInfo[]): Array<{ noteId: number; title: string }> { + if (!toolCalls) return []; + const cards: Array<{ noteId: number; title: string }> = []; + const seen = new Set(); + + for (const tc of toolCalls) { + if (tc.status !== "completed" || !NOTE_TOOLS.has(tc.name) || !tc.metadata?.id) continue; + const noteId = Number(tc.metadata.id); + if (seen.has(noteId)) continue; + seen.add(noteId); + const title = + (tc.metadata.title as string) || + tc.result?.replace(/^(Created|Updated|Retrieved) note: "(.+)"$/, "$2") || + "Note"; + cards.push({ noteId, title }); + } + return cards; +} + +export function ChatMessage({ role, content, isStreaming, toolCalls }: ChatMessageProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // clipboard unavailable + } + }; + + if (role === "user") { + return ( +
+
+ {content} +
+
+ ); + } + + const hasToolCalls = toolCalls && toolCalls.length > 0; + const hasContent = content.length > 0; + const noteCards = extractNoteCards(toolCalls); + + return ( +
+
+ {hasToolCalls && ( +
0) && "mb-2 pb-1.5 border-b border-border/15" + )} + > + {toolCalls.map((tc) => ( + + ))} +
+ )} + + {hasContent && ( + + )} + + {isStreaming && ( + + )} + + {noteCards.length > 0 && !isStreaming && ( +
+ {noteCards.map((card) => ( + + ))} +
+ )} + + {hasContent && !isStreaming && ( + + )} +
+
+ ); +} diff --git a/src/components/chat/ChatMessages.tsx b/src/components/chat/ChatMessages.tsx new file mode 100644 index 000000000..297990eed --- /dev/null +++ b/src/components/chat/ChatMessages.tsx @@ -0,0 +1,42 @@ +import { useRef, useEffect } from "react"; +import { cn } from "../lib/utils"; +import { ChatMessage } from "./ChatMessage"; +import type { Message } from "./types"; + +interface ChatMessagesProps { + messages: Message[]; + emptyState?: React.ReactNode; +} + +export function ChatMessages({ messages, emptyState }: ChatMessagesProps) { + const scrollRef = useRef(null); + + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + } + }, [messages]); + + return ( +
+ {messages.length === 0 ? ( + (emptyState ?? null) + ) : ( +
+ {messages + .filter((msg) => msg.role !== "tool") + .map((msg) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx new file mode 100644 index 000000000..9216ebdfd --- /dev/null +++ b/src/components/chat/ChatView.tsx @@ -0,0 +1,171 @@ +import { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useChatPersistence } from "./useChatPersistence"; +import { useChatStreaming } from "./useChatStreaming"; +import { ChatMessages } from "./ChatMessages"; +import { ChatInput } from "./ChatInput"; +import ChatHeader from "./ChatHeader"; +import ConversationList from "./ConversationList"; +import EmptyChatState from "./EmptyChatState"; +import { ConfirmDialog } from "../ui/dialog"; +import { useDialogs } from "../../hooks/useDialogs"; +import { getCachedPlatform } from "../../utils/platform"; + +const platform = getCachedPlatform(); + +export default function ChatView() { + const { t } = useTranslation(); + const [activeConversationId, setActiveConversationId] = useState(null); + const [activeTitle, setActiveTitle] = useState("Untitled"); + const [refreshKey, setRefreshKey] = useState(0); + const { confirmDialog, showConfirmDialog, hideConfirmDialog } = useDialogs(); + + const persistence = useChatPersistence({ + conversationId: activeConversationId, + onConversationCreated: (id, title) => { + setActiveConversationId(id); + setActiveTitle(title); + setRefreshKey((k) => k + 1); + }, + }); + + const streaming = useChatStreaming({ + messages: persistence.messages, + setMessages: persistence.setMessages, + onStreamComplete: (_id, content, toolCalls) => { + persistence.saveAssistantMessage(content, toolCalls); + }, + }); + + const handleSelectConversation = useCallback( + async (id: number, title: string) => { + if (id === activeConversationId) return; + setActiveConversationId(id); + setActiveTitle(title); + await persistence.loadConversation(id); + }, + [activeConversationId, persistence] + ); + + const handleNewChat = useCallback(() => { + setActiveConversationId(null); + setActiveTitle("Untitled"); + persistence.handleNewChat(); + }, [persistence]); + + const handleTextSubmit = useCallback( + async (text: string) => { + let convId = activeConversationId; + if (!convId) { + const title = text.length > 50 ? `${text.slice(0, 50)}...` : text; + convId = await persistence.createConversation(title); + setActiveTitle(title); + } + + const userMsg = { + id: crypto.randomUUID(), + role: "user" as const, + content: text, + isStreaming: false, + }; + persistence.setMessages((prev) => [...prev, userMsg]); + persistence.saveUserMessage(text); + + const allMessages = [...persistence.messages, userMsg]; + streaming.sendToAI(text, allMessages); + }, + [activeConversationId, persistence, streaming] + ); + + const handleTitleChange = useCallback( + async (title: string) => { + if (!activeConversationId) return; + setActiveTitle(title); + await window.electronAPI?.updateAgentConversationTitle?.(activeConversationId, title); + setRefreshKey((k) => k + 1); + }, + [activeConversationId] + ); + + const handleArchive = useCallback(async (_id: number) => { + // Archive not yet supported in the DB API + }, []); + + const handleDelete = useCallback( + (id: number) => { + showConfirmDialog({ + title: t("chat.delete"), + description: t("chat.deleteConfirm"), + onConfirm: async () => { + await window.electronAPI?.deleteAgentConversation?.(id); + if (activeConversationId === id) { + handleNewChat(); + } + setRefreshKey((k) => k + 1); + }, + variant: "destructive", + }); + }, + [activeConversationId, handleNewChat, showConfirmDialog, t] + ); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const mod = platform === "darwin" ? e.metaKey : e.ctrlKey; + if (mod && e.key === "n") { + e.preventDefault(); + handleNewChat(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleNewChat]); + + const hasActiveChat = activeConversationId !== null || persistence.messages.length > 0; + + return ( + <> + +
+
+ +
+
+ {hasActiveChat ? ( + <> + + + + + ) : ( + + )} +
+
+ + ); +} diff --git a/src/components/chat/ConversationDateGroup.tsx b/src/components/chat/ConversationDateGroup.tsx new file mode 100644 index 000000000..b73c720dc --- /dev/null +++ b/src/components/chat/ConversationDateGroup.tsx @@ -0,0 +1,19 @@ +import { cn } from "../lib/utils"; + +interface ConversationDateGroupProps { + label: string; +} + +export default function ConversationDateGroup({ label }: ConversationDateGroupProps) { + return ( +
+ {label} +
+ ); +} diff --git a/src/components/chat/ConversationItem.tsx b/src/components/chat/ConversationItem.tsx new file mode 100644 index 000000000..f0c2a7036 --- /dev/null +++ b/src/components/chat/ConversationItem.tsx @@ -0,0 +1,133 @@ +import { useTranslation } from "react-i18next"; +import { MoreHorizontal, Archive, ArchiveRestore, Trash2 } from "lucide-react"; +import { Button } from "../ui/button"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from "../ui/dropdown-menu"; +import { cn } from "../lib/utils"; +import { normalizeDbDate } from "../../utils/dateFormatting"; + +export interface ConversationPreview { + id: number; + title: string; + preview?: string; + created_at: string; + updated_at: string; + is_archived?: boolean; +} + +interface ConversationItemProps { + conversation: ConversationPreview; + isActive: boolean; + onClick: () => void; + onArchive: (id: number) => void; + onDelete: (id: number) => void; +} + +function formatTimestamp(dateStr: string): string { + const date = normalizeDbDate(dateStr); + if (Number.isNaN(date.getTime())) return ""; + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "now"; + if (minutes < 60) return `${minutes}m`; + if (hours < 24) return `${hours}h`; + if (days < 7) return `${days}d`; + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +export default function ConversationItem({ + conversation, + isActive, + onClick, + onArchive, + onDelete, +}: ConversationItemProps) { + const { t } = useTranslation(); + const isArchived = !!conversation.is_archived; + + return ( + + + + { + e.stopPropagation(); + onArchive(conversation.id); + }} + className="text-xs gap-2 rounded-lg px-2.5 py-1.5 cursor-pointer focus:bg-foreground/5" + > + {isArchived ? ( + <> + + {t("chat.unarchive")} + + ) : ( + <> + + {t("chat.archive")} + + )} + + + { + e.stopPropagation(); + onDelete(conversation.id); + }} + className="text-xs gap-2 rounded-lg px-2.5 py-1.5 text-destructive focus:text-destructive focus:bg-destructive/10 cursor-pointer" + > + + {t("chat.delete")} + + + +
+
+ {conversation.preview && ( +

+ {conversation.preview} +

+ )} +
+ + ); +} diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx new file mode 100644 index 000000000..f7d18c984 --- /dev/null +++ b/src/components/chat/ConversationList.tsx @@ -0,0 +1,323 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { Plus, Search, Archive as ArchiveIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { cn } from "../lib/utils"; +import { useDebouncedCallback } from "../../hooks/useDebouncedCallback"; +import { normalizeDbDate } from "../../utils/dateFormatting"; +import ConversationItem, { type ConversationPreview } from "./ConversationItem"; +import ConversationDateGroup from "./ConversationDateGroup"; +import EmptyConversationList from "./EmptyConversationList"; + +type FlatItem = + | { type: "header"; label: string } + | { type: "conversation"; data: ConversationPreview }; + +interface ConversationListProps { + activeConversationId: number | null; + onSelectConversation: (id: number, title: string) => void; + onNewChat: () => void; + onArchive: (id: number) => void; + onDelete: (id: number) => void; + refreshKey: number; +} + +function groupByDate(conversations: ConversationPreview[], t: (key: string) => string): FlatItem[] { + if (conversations.length === 0) return []; + + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + + const items: FlatItem[] = []; + let currentGroup: string | null = null; + + for (const conv of conversations) { + const date = normalizeDbDate(conv.updated_at); + const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); + + let group: string; + if (target.getTime() >= today.getTime()) { + group = t("chat.today"); + } else if (target.getTime() >= yesterday.getTime()) { + group = t("chat.yesterday"); + } else if (target.getTime() >= weekAgo.getTime()) { + group = t("chat.previousWeek"); + } else { + group = t("chat.older"); + } + + if (group !== currentGroup) { + items.push({ type: "header", label: group }); + currentGroup = group; + } + items.push({ type: "conversation", data: conv }); + } + + return items; +} + +function SkeletonRows() { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ); +} + +export default function ConversationList({ + activeConversationId, + onSelectConversation, + onNewChat, + onArchive, + onDelete, + refreshKey, +}: ConversationListProps) { + const { t } = useTranslation(); + const [conversations, setConversations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [showArchived, setShowArchived] = useState(false); + const scrollRef = useRef(null); + const showSkeletonTimer = useRef | null>(null); + const [showSkeleton, setShowSkeleton] = useState(false); + + const loadConversations = useCallback(async () => { + try { + const result = await window.electronAPI.getAgentConversations?.(200); + if (result) { + setConversations( + result.map((c) => ({ + ...c, + preview: undefined, + is_archived: false, + })) + ); + } + } catch { + // silently fail + } finally { + setIsLoading(false); + setShowSkeleton(false); + if (showSkeletonTimer.current) { + clearTimeout(showSkeletonTimer.current); + showSkeletonTimer.current = null; + } + } + }, []); + + useEffect(() => { + setIsLoading(true); + showSkeletonTimer.current = setTimeout(() => setShowSkeleton(true), 150); + loadConversations(); + return () => { + if (showSkeletonTimer.current) clearTimeout(showSkeletonTimer.current); + }; + }, [loadConversations, refreshKey]); + + const filtered = useMemo(() => { + let list = conversations; + if (!showArchived) { + list = list.filter((c) => !c.is_archived); + } else { + list = list.filter((c) => c.is_archived); + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + list = list.filter((c) => c.title.toLowerCase().includes(q)); + } + return list; + }, [conversations, searchQuery, showArchived]); + + const flatItems = useMemo(() => groupByDate(filtered, t), [filtered, t]); + + const debouncedSearch = useDebouncedCallback((value: string) => { + setSearchQuery(value); + }, 200); + + const virtualizer = useVirtualizer({ + count: flatItems.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => (flatItems[index].type === "header" ? 28 : 52), + overscan: 5, + }); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (flatItems.length === 0) return; + + const convItems = flatItems + .map((item, index) => ({ item, index })) + .filter((entry) => entry.item.type === "conversation"); + if (convItems.length === 0) return; + + const currentIdx = convItems.findIndex( + (entry) => entry.item.type === "conversation" && entry.item.data.id === activeConversationId + ); + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = currentIdx < convItems.length - 1 ? currentIdx + 1 : 0; + const item = convItems[next].item; + if (item.type === "conversation") onSelectConversation(item.data.id, item.data.title); + virtualizer.scrollToIndex(convItems[next].index); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prev = currentIdx > 0 ? currentIdx - 1 : convItems.length - 1; + const item = convItems[prev].item; + if (item.type === "conversation") onSelectConversation(item.data.id, item.data.title); + virtualizer.scrollToIndex(convItems[prev].index); + } else if (e.key === "Enter" && activeConversationId) { + e.preventDefault(); + } + }, + [flatItems, activeConversationId, onSelectConversation, virtualizer] + ); + + if (isLoading && showSkeleton) { + return ( +
+
+

{t("sidebar.chat")}

+
+ +
+ ); + } + + if (isLoading && !showSkeleton) { + return ( +
+
+

{t("sidebar.chat")}

+
+
+ ); + } + + return ( +
+
+
+

{t("sidebar.chat")}

+ + +
+
+ + debouncedSearch(e.target.value)} + placeholder={t("chat.search")} + className={cn( + "w-full h-6 pl-6 pr-2 rounded-md text-[11px]", + "bg-foreground/3 dark:bg-white/3 border border-border/25 dark:border-white/8", + "text-foreground placeholder:text-muted-foreground/40", + "outline-none focus-visible:ring-1 focus-visible:ring-primary/30", + "transition-colors" + )} + /> +
+
+ + {flatItems.length === 0 ? ( + searchQuery.trim() ? ( +
+

{t("chat.noResults")}

+
+ ) : ( + + ) + ) : ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const item = flatItems[virtualItem.index]; + return ( +
+ {item.type === "header" ? ( + + ) : ( + onSelectConversation(item.data.id, item.data.title)} + onArchive={onArchive} + onDelete={onDelete} + /> + )} +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/chat/EmptyChatState.tsx b/src/components/chat/EmptyChatState.tsx new file mode 100644 index 000000000..f7a9f0d0b --- /dev/null +++ b/src/components/chat/EmptyChatState.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export default function EmptyChatState() { + const { t } = useTranslation(); + + return ( +
+

{t("chat.selectChat")}

+
+ ); +} diff --git a/src/components/chat/EmptyConversationList.tsx b/src/components/chat/EmptyConversationList.tsx new file mode 100644 index 000000000..cf0fae566 --- /dev/null +++ b/src/components/chat/EmptyConversationList.tsx @@ -0,0 +1,29 @@ +import { Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "../lib/utils"; + +interface EmptyConversationListProps { + onNewChat: () => void; +} + +export default function EmptyConversationList({ onNewChat }: EmptyConversationListProps) { + const { t } = useTranslation(); + + return ( +
+

{t("chat.noConversations")}

+ +
+ ); +} diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts new file mode 100644 index 000000000..b2ca6a8c4 --- /dev/null +++ b/src/components/chat/types.ts @@ -0,0 +1,24 @@ +export interface ToolCallInfo { + id: string; + name: string; + arguments: string; + status: "executing" | "completed" | "error"; + result?: string; + metadata?: Record; +} + +export interface Message { + id: string; + role: "user" | "assistant" | "tool"; + content: string; + isStreaming: boolean; + toolCalls?: ToolCallInfo[]; +} + +export type AgentState = + | "idle" + | "listening" + | "transcribing" + | "thinking" + | "streaming" + | "tool-executing"; diff --git a/src/components/chat/useChatPersistence.ts b/src/components/chat/useChatPersistence.ts new file mode 100644 index 000000000..1ed040015 --- /dev/null +++ b/src/components/chat/useChatPersistence.ts @@ -0,0 +1,94 @@ +import { useState, useRef, useCallback } from "react"; +import type { Message, ToolCallInfo } from "./types"; + +interface UseChatPersistenceOptions { + conversationId?: number | null; + onConversationCreated?: (id: number, title: string) => void; +} + +export interface ChatPersistence { + messages: Message[]; + setMessages: React.Dispatch>; + conversationId: number | null; + createConversation: (title: string) => Promise; + loadConversation: (id: number) => Promise; + saveUserMessage: (text: string) => Promise; + saveAssistantMessage: (content: string, toolCalls?: ToolCallInfo[]) => Promise; + handleNewChat: () => void; +} + +export function useChatPersistence(options: UseChatPersistenceOptions = {}): ChatPersistence { + const [messages, setMessages] = useState([]); + const conversationIdRef = useRef(options.conversationId ?? null); + + const createConversation = useCallback( + async (title: string): Promise => { + const conv = await window.electronAPI?.createAgentConversation?.(title); + const id = conv?.id ?? 0; + conversationIdRef.current = id; + options.onConversationCreated?.(id, title); + return id; + }, + [options] + ); + + const loadConversation = useCallback(async (id: number) => { + const conv = await window.electronAPI?.getAgentConversation?.(id); + if (!conv) return; + conversationIdRef.current = id; + const loaded: Message[] = conv.messages.map((m) => { + const parsed = m.metadata ? tryParseMetadata(m.metadata) : undefined; + const toolCalls = parsed?.toolCalls as ToolCallInfo[] | undefined; + return { + id: crypto.randomUUID(), + role: m.role as Message["role"], + content: m.content, + isStreaming: false, + ...(toolCalls ? { toolCalls } : {}), + }; + }); + setMessages(loaded); + }, []); + + const saveUserMessage = useCallback(async (text: string) => { + if (conversationIdRef.current) { + window.electronAPI?.addAgentMessage?.(conversationIdRef.current, "user", text); + } + }, []); + + const saveAssistantMessage = useCallback(async (content: string, toolCalls?: ToolCallInfo[]) => { + if (conversationIdRef.current) { + window.electronAPI?.addAgentMessage?.( + conversationIdRef.current, + "assistant", + content, + toolCalls?.length ? { toolCalls } : undefined + ); + } + }, []); + + const handleNewChat = useCallback(() => { + setMessages([]); + conversationIdRef.current = null; + }, []); + + return { + messages, + setMessages, + conversationId: conversationIdRef.current, + createConversation, + loadConversation, + saveUserMessage, + saveAssistantMessage, + handleNewChat, + }; +} + +function tryParseMetadata(raw: string | undefined): Record | undefined { + if (!raw) return undefined; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} diff --git a/src/components/chat/useChatStreaming.ts b/src/components/chat/useChatStreaming.ts new file mode 100644 index 000000000..17702e7ef --- /dev/null +++ b/src/components/chat/useChatStreaming.ts @@ -0,0 +1,245 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import ReasoningService, { type AgentStreamChunk } from "../../services/ReasoningService"; +import { getSettings } from "../../stores/settingsStore"; +import { getAgentSystemPrompt } from "../../config/prompts"; +import { createToolRegistry } from "../../services/tools"; +import type { ToolRegistry } from "../../services/tools/ToolRegistry"; +import type { Message, AgentState, ToolCallInfo } from "./types"; + +interface UseChatStreamingOptions { + messages: Message[]; + setMessages: React.Dispatch>; + onStreamComplete?: (assistantId: string, content: string, toolCalls?: ToolCallInfo[]) => void; +} + +export interface ChatStreaming { + agentState: AgentState; + toolStatus: string; + activeToolName: string; + sendToAI: (userText: string, allMessages: Message[]) => Promise; + cancelStream: () => void; +} + +export function useChatStreaming({ + messages, + setMessages, + onStreamComplete, +}: UseChatStreamingOptions): ChatStreaming { + const { t } = useTranslation(); + const [agentState, setAgentState] = useState("idle"); + const [toolStatus, setToolStatus] = useState(""); + const [activeToolName, setActiveToolName] = useState(""); + const mountedRef = useRef(true); + const messagesRef = useRef([]); + const toolRegistryRef = useRef<{ key: string; registry: ToolRegistry } | null>(null); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + ReasoningService.cancelActiveStream(); + }; + }, []); + + const cancelStream = useCallback(() => { + ReasoningService.cancelActiveStream(); + setAgentState("idle"); + setToolStatus(""); + setActiveToolName(""); + }, []); + + const sendToAI = useCallback( + async (userText: string, allMessages: Message[]) => { + setAgentState("thinking"); + + const settings = getSettings(); + const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; + const toolSupportedProviders = ["openai", "groq", "custom", "anthropic", "gemini"]; + const supportsTools = isCloudAgent || toolSupportedProviders.includes(settings.agentProvider); + + let registry: ToolRegistry | null = null; + if (supportsTools) { + const cacheKey = `${settings.isSignedIn}-${settings.gcalConnected}-${settings.cloudBackupEnabled}`; + if (toolRegistryRef.current?.key === cacheKey) { + registry = toolRegistryRef.current.registry; + } else { + registry = createToolRegistry({ + isSignedIn: settings.isSignedIn, + gcalConnected: settings.gcalConnected, + cloudBackupEnabled: settings.cloudBackupEnabled, + }); + toolRegistryRef.current = { key: cacheKey, registry }; + } + } + const systemPrompt = getAgentSystemPrompt(registry?.getAll().map((t) => t.name)); + + const llmMessages = [ + { role: "system", content: systemPrompt }, + ...allMessages.slice(-20).map((m) => ({ role: m.role, content: m.content })), + ]; + + const assistantId = crypto.randomUUID(); + setMessages((prev) => [ + ...prev, + { id: assistantId, role: "assistant", content: "", isStreaming: true }, + ]); + setAgentState("streaming"); + + try { + let fullContent = ""; + let stream: AsyncGenerator; + + if (isCloudAgent) { + const executeToolCall = registry + ? async (name: string, argsJson: string) => { + const tool = registry.get(name); + if (!tool) + return { + data: `Unknown tool: ${name}`, + displayText: t("agentMode.tools.unknownTool", { name }), + }; + let args: Record; + try { + args = JSON.parse(argsJson); + } catch { + return { + data: `Invalid tool arguments for ${name}`, + displayText: t("agentMode.tools.invalidArgs", { name }), + }; + } + const result = await tool.execute(args); + const data = result.success + ? typeof result.data === "string" + ? result.data + : JSON.stringify(result.data) + : result.displayText; + const metadata = + result.success && result.data && typeof result.data === "object" + ? (result.data as Record) + : undefined; + return { data, displayText: result.displayText, metadata }; + } + : undefined; + + stream = ReasoningService.processTextStreamingCloud(llmMessages, { + systemPrompt, + tools: registry?.getAll().map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters, + })), + executeToolCall, + }); + } else { + const aiTools = registry?.toAISDKFormat(); + stream = ReasoningService.processTextStreamingAI( + llmMessages, + settings.agentModel, + settings.agentProvider, + { systemPrompt }, + aiTools + ); + } + + for await (const chunk of stream) { + if (!mountedRef.current) { + ReasoningService.cancelActiveStream(); + break; + } + if (chunk.type === "content") { + fullContent += chunk.text; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: fullContent } : m)) + ); + } else if (chunk.type === "tool_calls") { + for (const call of chunk.calls) { + setAgentState("tool-executing"); + setActiveToolName(call.name); + setToolStatus( + t(`agentMode.tools.${call.name}Status`, { defaultValue: `Using ${call.name}...` }) + ); + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + toolCalls: [ + ...(m.toolCalls || []), + { + id: call.id, + name: call.name, + arguments: call.arguments, + status: "executing" as const, + }, + ], + } + : m + ) + ); + } + } else if (chunk.type === "tool_result") { + setMessages((prev) => + prev.map((m) => + m.id === assistantId && m.toolCalls + ? { + ...m, + toolCalls: m.toolCalls.map((tc) => + tc.id === chunk.callId + ? { + ...tc, + status: "completed" as const, + result: chunk.displayText, + ...(chunk.metadata ? { metadata: chunk.metadata } : {}), + } + : tc + ), + } + : m + ) + ); + setAgentState("streaming"); + setToolStatus(""); + setActiveToolName(""); + } + } + + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, isStreaming: false } : m)) + ); + + const finalMsg = messagesRef.current.find((m) => m.id === assistantId); + onStreamComplete?.(assistantId, fullContent, finalMsg?.toolCalls); + } catch (error) { + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: `${t("agentMode.chat.errorPrefix")}: ${(error as Error).message}`, + isStreaming: false, + } + : m + ) + ); + } + + setAgentState("idle"); + setToolStatus(""); + setActiveToolName(""); + }, + [t, setMessages, onStreamComplete] + ); + + return { + agentState, + toolStatus, + activeToolName, + sendToAI, + cancelStream, + }; +} diff --git a/src/helpers/conversationChunker.js b/src/helpers/conversationChunker.js new file mode 100644 index 000000000..76548acf7 --- /dev/null +++ b/src/helpers/conversationChunker.js @@ -0,0 +1,26 @@ +const CHUNK_SIZE = 5; +const CHUNK_OVERLAP = 2; + +function chunkConversation(title, messages) { + const relevant = messages.filter((m) => m.role !== "system"); + if (relevant.length === 0) return []; + + if (relevant.length <= CHUNK_SIZE) { + return [{ chunkIndex: 0, text: formatChunkText(title, relevant) }]; + } + + const chunks = []; + for (let i = 0; i < relevant.length; i += CHUNK_SIZE - CHUNK_OVERLAP) { + const window = relevant.slice(i, i + CHUNK_SIZE); + if (window.length < 2) break; + chunks.push({ chunkIndex: chunks.length, text: formatChunkText(title, window) }); + } + return chunks; +} + +function formatChunkText(title, messages) { + const body = messages.map((m) => `${m.role}: ${m.content}`).join("\n"); + return `${title}\n${body}`.slice(0, 1500); +} + +module.exports = { chunkConversation, CHUNK_SIZE }; diff --git a/src/helpers/database.js b/src/helpers/database.js index f0ae9bc31..c729c9435 100644 --- a/src/helpers/database.js +++ b/src/helpers/database.js @@ -235,6 +235,16 @@ class DatabaseManager { } catch (err) { if (!err.message.includes("duplicate column")) throw err; } + try { + this.db.exec("ALTER TABLE agent_conversations ADD COLUMN archived_at DATETIME"); + } catch (err) { + if (!err.message.includes("duplicate column")) throw err; + } + try { + this.db.exec("ALTER TABLE agent_conversations ADD COLUMN cloud_id TEXT"); + } catch (err) { + if (!err.message.includes("duplicate column")) throw err; + } const actionCount = this.db.prepare("SELECT COUNT(*) as count FROM actions").get(); if (actionCount.count === 0) { @@ -1271,6 +1281,106 @@ class DatabaseManager { debugLogger.error("Error deleting database file", { error: error.message }, "database"); } } + getAgentConversationsWithPreview(limit = 50, offset = 0, includeArchived = false) { + try { + if (!this.db) throw new Error("Database not initialized"); + const archiveFilter = includeArchived ? "" : "WHERE c.archived_at IS NULL"; + return this.db + .prepare( + `SELECT c.id, c.title, c.created_at, c.updated_at, c.archived_at, c.cloud_id, + COUNT(m.id) AS message_count, + (SELECT content FROM agent_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message, + (SELECT role FROM agent_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_role + FROM agent_conversations c + LEFT JOIN agent_messages m ON m.conversation_id = c.id + ${archiveFilter} + GROUP BY c.id + ORDER BY c.updated_at DESC + LIMIT ? OFFSET ?` + ) + .all(limit, offset); + } catch (error) { + debugLogger.error( + "Error getting agent conversations with preview", + { error: error.message }, + "database" + ); + throw error; + } + } + + searchAgentConversations(query, limit = 20) { + try { + if (!this.db) throw new Error("Database not initialized"); + const pattern = `%${query}%`; + return this.db + .prepare( + `SELECT DISTINCT c.id, c.title, c.created_at, c.updated_at, c.archived_at, c.cloud_id, + COUNT(m.id) AS message_count, + (SELECT content FROM agent_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message, + (SELECT role FROM agent_messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_role + FROM agent_conversations c + LEFT JOIN agent_messages m ON m.conversation_id = c.id + LEFT JOIN agent_messages ms ON ms.conversation_id = c.id + WHERE c.archived_at IS NULL + AND (c.title LIKE ? OR ms.content LIKE ?) + GROUP BY c.id + ORDER BY c.updated_at DESC + LIMIT ?` + ) + .all(pattern, pattern, limit); + } catch (error) { + debugLogger.error( + "Error searching agent conversations", + { error: error.message }, + "database" + ); + throw error; + } + } + + archiveAgentConversation(id) { + try { + if (!this.db) throw new Error("Database not initialized"); + this.db + .prepare("UPDATE agent_conversations SET archived_at = CURRENT_TIMESTAMP WHERE id = ?") + .run(id); + return { success: true }; + } catch (error) { + debugLogger.error("Error archiving agent conversation", { error: error.message }, "database"); + throw error; + } + } + + unarchiveAgentConversation(id) { + try { + if (!this.db) throw new Error("Database not initialized"); + this.db.prepare("UPDATE agent_conversations SET archived_at = NULL WHERE id = ?").run(id); + return { success: true }; + } catch (error) { + debugLogger.error( + "Error unarchiving agent conversation", + { error: error.message }, + "database" + ); + throw error; + } + } + + updateAgentConversationCloudId(id, cloudId) { + try { + if (!this.db) throw new Error("Database not initialized"); + this.db.prepare("UPDATE agent_conversations SET cloud_id = ? WHERE id = ?").run(cloudId, id); + return { success: true }; + } catch (error) { + debugLogger.error( + "Error updating agent conversation cloud_id", + { error: error.message }, + "database" + ); + throw error; + } + } } module.exports = DatabaseManager; diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 15deec0d8..3fc7aa46f 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -751,6 +751,37 @@ class IPCHandlers { return this.databaseManager.getAgentMessages(conversationId); }); + ipcMain.handle( + "db-get-agent-conversations-with-preview", + async (event, limit, offset, includeArchived) => { + return this.databaseManager.getAgentConversationsWithPreview( + limit, + offset, + includeArchived + ); + } + ); + + ipcMain.handle("db-search-agent-conversations", async (event, query, limit) => { + return this.databaseManager.searchAgentConversations(query, limit); + }); + + ipcMain.handle("db-archive-agent-conversation", async (event, id) => { + return this.databaseManager.archiveAgentConversation(id); + }); + + ipcMain.handle("db-unarchive-agent-conversation", async (event, id) => { + return this.databaseManager.unarchiveAgentConversation(id); + }); + + ipcMain.handle("db-update-agent-conversation-cloud-id", async (event, id, cloudId) => { + return this.databaseManager.updateAgentConversationCloudId(id, cloudId); + }); + + ipcMain.handle("db-semantic-search-conversations", async (event, query, limit) => { + return this.databaseManager.searchAgentConversations(query, limit); + }); + ipcMain.handle("export-note", async (event, noteId, format) => { try { const note = this.databaseManager.getNote(noteId); diff --git a/src/helpers/vectorIndex.js b/src/helpers/vectorIndex.js index da53038cb..5cc85eb0b 100644 --- a/src/helpers/vectorIndex.js +++ b/src/helpers/vectorIndex.js @@ -2,11 +2,13 @@ const { QdrantClient } = require("@qdrant/js-client-rest"); const localEmbeddings = require("./localEmbeddings"); const { LocalEmbeddings } = localEmbeddings; const debugLogger = require("./debugLogger"); +const { chunkConversation } = require("./conversationChunker"); class VectorIndex { constructor() { this.client = null; this.collectionName = "notes"; + this.conversationChunksCollection = "conversation_chunks"; } init(port) { @@ -87,6 +89,118 @@ class VectorIndex { } } + async ensureConversationChunksCollection() { + if (!this.client) return; + try { + await this.client.getCollection(this.conversationChunksCollection); + } catch { + try { + await this.client.createCollection(this.conversationChunksCollection, { + vectors: { size: 384, distance: "Cosine" }, + }); + } catch (err) { + debugLogger.error("Failed to create conversation_chunks collection", { + error: err.message, + }); + } + } + } + + async upsertConversationChunks(conversationId, title, messages) { + if (!this.client) return; + try { + await this.deleteConversationChunks(conversationId); + const chunks = chunkConversation(title, messages); + if (chunks.length === 0) return; + + const texts = chunks.map((c) => c.text); + const vectors = await localEmbeddings.embedTexts(texts); + const points = chunks.map((c, i) => ({ + id: conversationId * 1000 + c.chunkIndex, + vector: Array.from(vectors[i]), + payload: { conversation_id: conversationId, chunk_index: c.chunkIndex }, + })); + await this.client.upsert(this.conversationChunksCollection, { points }); + } catch (err) { + debugLogger.debug("Conversation chunks upsert failed", { + conversationId, + error: err.message, + }); + } + } + + async deleteConversationChunks(conversationId) { + if (!this.client) return; + try { + await this.client.delete(this.conversationChunksCollection, { + filter: { must: [{ key: "conversation_id", match: { value: conversationId } }] }, + }); + } catch (err) { + debugLogger.debug("Conversation chunks delete failed", { + conversationId, + error: err.message, + }); + } + } + + async searchConversations(queryText, limit = 10) { + if (!this.client) return []; + try { + const vector = await localEmbeddings.embedText(queryText); + const results = await this.client.search(this.conversationChunksCollection, { + vector: Array.from(vector), + limit: limit * 3, + }); + + const bestByConversation = new Map(); + for (const r of results) { + if (r.score < 0.3) continue; + const convId = r.payload.conversation_id; + if (!bestByConversation.has(convId) || r.score > bestByConversation.get(convId)) { + bestByConversation.set(convId, r.score); + } + } + + return [...bestByConversation.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([conversationId, score]) => ({ conversationId, score })); + } catch (err) { + debugLogger.debug("Conversation search failed", { error: err.message }); + return []; + } + } + + async reindexAllConversations(conversations, onProgress) { + if (!this.client) return; + const BATCH_SIZE = 50; + for (let i = 0; i < conversations.length; i += BATCH_SIZE) { + const batch = conversations.slice(i, i + BATCH_SIZE); + for (const conv of batch) { + try { + const chunks = chunkConversation(conv.title, conv.messages); + if (chunks.length === 0) continue; + + const texts = chunks.map((c) => c.text); + const vectors = await localEmbeddings.embedTexts(texts); + const points = chunks.map((c, j) => ({ + id: conv.id * 1000 + c.chunkIndex, + vector: Array.from(vectors[j]), + payload: { conversation_id: conv.id, chunk_index: c.chunkIndex }, + })); + await this.client.upsert(this.conversationChunksCollection, { points }); + } catch (err) { + debugLogger.debug("Conversation reindex failed", { + conversationId: conv.id, + error: err.message, + }); + } + } + if (onProgress) + onProgress(Math.min(i + BATCH_SIZE, conversations.length), conversations.length); + } + } + isReady() { return this.client !== null; } diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 46514273d..0cccff0f4 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1735,7 +1735,25 @@ "learnMore": "Mehr erfahren", "integrations": "Integrationen", "defaultUser": "Benutzer", - "notSignedIn": "Nicht angemeldet" + "notSignedIn": "Nicht angemeldet", + "chat": "Chat" + }, + "chat": { + "newChat": "Neuer Chat", + "search": "Unterhaltungen suchen...", + "archived": "Archiviert", + "noConversations": "Starten Sie Ihre erste Unterhaltung", + "selectChat": "Wählen Sie eine Unterhaltung oder starten Sie eine neue", + "noResults": "Keine passenden Unterhaltungen", + "today": "Heute", + "yesterday": "Gestern", + "previousWeek": "Letzte 7 Tage", + "older": "Älter", + "archive": "Archivieren", + "unarchive": "Dearchivieren", + "delete": "Löschen", + "deleteConfirm": "Diese Unterhaltung wird dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", + "editTitle": "Doppelklick zum Bearbeiten des Titels" }, "usage": { "approachingLimit": "Wochenlimit fast erreicht", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c2a10f910..793ef3ec9 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1523,7 +1523,25 @@ "learnMore": "Learn more", "integrations": "Integrations", "defaultUser": "User", - "notSignedIn": "Not signed in" + "notSignedIn": "Not signed in", + "chat": "Chat" + }, + "chat": { + "newChat": "New chat", + "search": "Search conversations...", + "archived": "Archived", + "noConversations": "Start your first conversation", + "selectChat": "Select a conversation or start a new one", + "noResults": "No matching conversations", + "today": "Today", + "yesterday": "Yesterday", + "previousWeek": "Previous 7 Days", + "older": "Older", + "archive": "Archive", + "unarchive": "Unarchive", + "delete": "Delete", + "deleteConfirm": "This conversation will be permanently deleted. This cannot be undone.", + "editTitle": "Double-click to edit title" }, "referral": { "title": "Refer and earn rewards", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 106b7e71d..a79fd6874 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1735,7 +1735,25 @@ "learnMore": "Más información", "integrations": "Integraciones", "defaultUser": "Usuario", - "notSignedIn": "Sin sesión iniciada" + "notSignedIn": "Sin sesión iniciada", + "chat": "Chat" + }, + "chat": { + "newChat": "Nuevo chat", + "search": "Buscar conversaciones...", + "archived": "Archivadas", + "noConversations": "Inicia tu primera conversación", + "selectChat": "Selecciona una conversación o inicia una nueva", + "noResults": "Sin conversaciones coincidentes", + "today": "Hoy", + "yesterday": "Ayer", + "previousWeek": "Últimos 7 días", + "older": "Anteriores", + "archive": "Archivar", + "unarchive": "Desarchivar", + "delete": "Eliminar", + "deleteConfirm": "Esta conversación se eliminará permanentemente. No se puede deshacer.", + "editTitle": "Doble clic para editar el título" }, "usage": { "approachingLimit": "Cerca del límite semanal", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index d73e7dc33..9fdc11707 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1475,7 +1475,25 @@ "learnMore": "En savoir plus", "integrations": "Intégrations", "defaultUser": "Utilisateur", - "notSignedIn": "Non connecté" + "notSignedIn": "Non connecté", + "chat": "Chat" + }, + "chat": { + "newChat": "Nouveau chat", + "search": "Rechercher des conversations...", + "archived": "Archivées", + "noConversations": "Démarrez votre première conversation", + "selectChat": "Sélectionnez une conversation ou démarrez-en une nouvelle", + "noResults": "Aucune conversation correspondante", + "today": "Aujourd'hui", + "yesterday": "Hier", + "previousWeek": "7 derniers jours", + "older": "Plus anciennes", + "archive": "Archiver", + "unarchive": "Désarchiver", + "delete": "Supprimer", + "deleteConfirm": "Cette conversation sera définitivement supprimée. Cette action est irréversible.", + "editTitle": "Double-cliquez pour modifier le titre" }, "referral": { "title": "Parrainez et gagnez des récompenses", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 05536bcf9..b4f87c422 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1735,7 +1735,25 @@ "learnMore": "Scopri di più", "integrations": "Integrazioni", "defaultUser": "Utente", - "notSignedIn": "Non hai effettuato l'accesso" + "notSignedIn": "Non hai effettuato l'accesso", + "chat": "Chat" + }, + "chat": { + "newChat": "Nuova chat", + "search": "Cerca conversazioni...", + "archived": "Archiviate", + "noConversations": "Inizia la tua prima conversazione", + "selectChat": "Seleziona una conversazione o avviane una nuova", + "noResults": "Nessuna conversazione corrispondente", + "today": "Oggi", + "yesterday": "Ieri", + "previousWeek": "Ultimi 7 giorni", + "older": "Precedenti", + "archive": "Archivia", + "unarchive": "Ripristina", + "delete": "Elimina", + "deleteConfirm": "Questa conversazione verrà eliminata permanentemente. Non è possibile annullare.", + "editTitle": "Doppio clic per modificare il titolo" }, "usage": { "approachingLimit": "Limite settimanale quasi raggiunto", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 6b153204c..408d54e8c 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1735,7 +1735,25 @@ "learnMore": "詳しく見る", "integrations": "連携", "defaultUser": "ユーザー", - "notSignedIn": "未サインイン" + "notSignedIn": "未サインイン", + "chat": "チャット" + }, + "chat": { + "newChat": "新しいチャット", + "search": "会話を検索...", + "archived": "アーカイブ済み", + "noConversations": "最初の会話を始めましょう", + "selectChat": "会話を選択するか、新しく始めてください", + "noResults": "該当する会話がありません", + "today": "今日", + "yesterday": "昨日", + "previousWeek": "過去7日間", + "older": "それ以前", + "archive": "アーカイブ", + "unarchive": "アーカイブ解除", + "delete": "削除", + "deleteConfirm": "この会話は完全に削除されます。元に戻すことはできません。", + "editTitle": "ダブルクリックでタイトルを編集" }, "usage": { "approachingLimit": "週間制限に近づいています", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index e0e34e5b0..7683a1348 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1735,7 +1735,25 @@ "learnMore": "Saiba mais", "integrations": "Integrações", "defaultUser": "Usuário", - "notSignedIn": "Não conectado" + "notSignedIn": "Não conectado", + "chat": "Chat" + }, + "chat": { + "newChat": "Novo chat", + "search": "Pesquisar conversas...", + "archived": "Arquivadas", + "noConversations": "Inicie sua primeira conversa", + "selectChat": "Selecione uma conversa ou inicie uma nova", + "noResults": "Nenhuma conversa encontrada", + "today": "Hoje", + "yesterday": "Ontem", + "previousWeek": "Últimos 7 dias", + "older": "Mais antigas", + "archive": "Arquivar", + "unarchive": "Desarquivar", + "delete": "Excluir", + "deleteConfirm": "Esta conversa será excluída permanentemente. Não é possível desfazer.", + "editTitle": "Clique duplo para editar o título" }, "usage": { "approachingLimit": "Próximo do limite semanal", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index e367dc180..7f15d2e50 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1735,7 +1735,25 @@ "learnMore": "Узнать больше", "integrations": "Интеграции", "defaultUser": "Пользователь", - "notSignedIn": "Не авторизован" + "notSignedIn": "Не авторизован", + "chat": "Чат" + }, + "chat": { + "newChat": "Новый чат", + "search": "Поиск бесед...", + "archived": "Архив", + "noConversations": "Начните свою первую беседу", + "selectChat": "Выберите беседу или начните новую", + "noResults": "Совпадений не найдено", + "today": "Сегодня", + "yesterday": "Вчера", + "previousWeek": "Последние 7 дней", + "older": "Ранее", + "archive": "В архив", + "unarchive": "Из архива", + "delete": "Удалить", + "deleteConfirm": "Эта беседа будет удалена навсегда. Отменить действие невозможно.", + "editTitle": "Двойной клик для редактирования названия" }, "usage": { "approachingLimit": "Приближается недельный лимит", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 93c3ec54a..79afac049 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1730,7 +1730,25 @@ "learnMore": "了解更多", "integrations": "集成", "defaultUser": "用户", - "notSignedIn": "未登录" + "notSignedIn": "未登录", + "chat": "聊天" + }, + "chat": { + "newChat": "新对话", + "search": "搜索对话...", + "archived": "已归档", + "noConversations": "开始你的第一个对话", + "selectChat": "选择一个对话或开始新的对话", + "noResults": "没有匹配的对话", + "today": "今天", + "yesterday": "昨天", + "previousWeek": "最近 7 天", + "older": "更早", + "archive": "归档", + "unarchive": "取消归档", + "delete": "删除", + "deleteConfirm": "此对话将被永久删除,无法撤消。", + "editTitle": "双击编辑标题" }, "usage": { "approachingLimit": "即将达到周限额", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index e3811b4da..818b8208d 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1730,7 +1730,25 @@ "learnMore": "瞭解更多", "integrations": "整合", "defaultUser": "使用者", - "notSignedIn": "尚未登入" + "notSignedIn": "尚未登入", + "chat": "聊天" + }, + "chat": { + "newChat": "新對話", + "search": "搜尋對話...", + "archived": "已封存", + "noConversations": "開始你的第一個對話", + "selectChat": "選擇一個對話或開始新的對話", + "noResults": "沒有符合的對話", + "today": "今天", + "yesterday": "昨天", + "previousWeek": "最近 7 天", + "older": "更早", + "archive": "封存", + "unarchive": "取消封存", + "delete": "刪除", + "deleteConfirm": "此對話將被永久刪除,無法復原。", + "editTitle": "雙擊編輯標題" }, "usage": { "approachingLimit": "即將達到每週上限", diff --git a/src/services/ConversationsService.ts b/src/services/ConversationsService.ts new file mode 100644 index 000000000..c75f4d663 --- /dev/null +++ b/src/services/ConversationsService.ts @@ -0,0 +1,141 @@ +import { OPENWHISPR_API_URL } from "../config/constants.js"; + +interface ConversationInput { + client_conversation_id?: string; + title?: string; + created_at?: string; + updated_at?: string; + messages?: Array<{ + role: "user" | "assistant" | "system"; + content: string; + metadata?: Record | null; + }>; +} + +interface CloudConversation { + id: string; + client_conversation_id: string | null; + title: string; + archived_at: string | null; + deleted_at: string | null; + created_at: string; + updated_at: string; +} + +interface CloudMessage { + id: string; + conversation_id: string; + role: "user" | "assistant" | "system"; + content: string; + metadata: Record | null; + created_at: string; +} + +async function create(input: ConversationInput): Promise { + const res = await fetch(`${OPENWHISPR_API_URL}/api/conversations/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(input), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +async function update( + id: string, + updates: { title?: string; archived_at?: string } +): Promise { + const res = await fetch(`${OPENWHISPR_API_URL}/api/conversations/update`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ id, ...updates }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +async function deleteConversation(id: string): Promise { + const res = await fetch(`${OPENWHISPR_API_URL}/api/conversations/delete`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ id }), + }); + if (!res.ok) throw new Error(await res.text()); +} + +async function list( + limit?: number, + before?: string, + archived?: boolean +): Promise<{ conversations: CloudConversation[] }> { + const params = new URLSearchParams(); + if (limit !== undefined) params.set("limit", String(limit)); + if (before !== undefined) params.set("before", before); + if (archived) params.set("archived", "true"); + const query = params.toString(); + const res = await fetch( + `${OPENWHISPR_API_URL}/api/conversations/list${query ? `?${query}` : ""}`, + { credentials: "include" } + ); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise<{ conversations: CloudConversation[] }>; +} + +async function addMessage( + conversationId: string, + role: "user" | "assistant" | "system", + content: string, + metadata?: Record | null +): Promise { + const res = await fetch(`${OPENWHISPR_API_URL}/api/conversations/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + conversation_id: conversationId, + role, + content, + ...(metadata ? { metadata } : {}), + }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise; +} + +async function listMessages(conversationId: string): Promise<{ messages: CloudMessage[] }> { + const params = new URLSearchParams({ conversation_id: conversationId }); + const res = await fetch(`${OPENWHISPR_API_URL}/api/conversations/messages?${params}`, { + credentials: "include", + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise<{ messages: CloudMessage[] }>; +} + +async function search( + query: string, + limit?: number +): Promise<{ conversations: CloudConversation[] }> { + const res = await fetch(`${OPENWHISPR_API_URL}/api/conversations/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ query, ...(limit !== undefined ? { limit } : {}) }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json() as Promise<{ conversations: CloudConversation[] }>; +} + +export { create, update, deleteConversation, list, addMessage, listMessages, search }; + +export const ConversationsService = { + create, + update, + delete: deleteConversation, + list, + addMessage, + listMessages, + search, +}; diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts new file mode 100644 index 000000000..96b9df065 --- /dev/null +++ b/src/stores/chatStore.ts @@ -0,0 +1,173 @@ +import { create } from "zustand"; + +interface ConversationItem { + id: number; + title: string; + cloud_id?: string | null; + created_at: string; + updated_at: string; +} + +interface ChatState { + conversations: ConversationItem[]; + activeConversationId: number | null; + migration: { total: number; done: number } | null; +} + +const useChatStore = create()(() => ({ + conversations: [], + activeConversationId: null, + migration: null, +})); + +let hasBoundIpcListeners = false; +const DEFAULT_LIMIT = 50; + +function ensureIpcListeners() { + if (hasBoundIpcListeners || typeof window === "undefined") { + return; + } + hasBoundIpcListeners = true; +} + +export async function initializeConversations(limit = DEFAULT_LIMIT): Promise { + ensureIpcListeners(); + const items = (await window.electronAPI?.getAgentConversations?.(limit)) ?? []; + useChatStore.setState({ conversations: items }); + return items; +} + +export function addConversation(conversation: ConversationItem): void { + if (!conversation) return; + const { conversations } = useChatStore.getState(); + const withoutDuplicate = conversations.filter((c) => c.id !== conversation.id); + useChatStore.setState({ conversations: [conversation, ...withoutDuplicate] }); +} + +export function updateConversation(conversation: ConversationItem): void { + if (!conversation) return; + const { conversations } = useChatStore.getState(); + useChatStore.setState({ + conversations: conversations.map((c) => (c.id === conversation.id ? conversation : c)), + }); +} + +export function removeConversation(id: number): void { + if (id == null) return; + const { conversations, activeConversationId } = useChatStore.getState(); + const next = conversations.filter((c) => c.id !== id); + if (next.length === conversations.length) return; + const update: Partial = { conversations: next }; + if (activeConversationId === id) { + const idx = conversations.findIndex((c) => c.id === id); + const neighbor = next[Math.min(idx, next.length - 1)] ?? null; + update.activeConversationId = neighbor?.id ?? null; + } + useChatStore.setState(update); +} + +export function setActiveConversationId(id: number | null): void { + if (useChatStore.getState().activeConversationId === id) return; + useChatStore.setState({ activeConversationId: id }); +} + +export function getActiveConversationIdValue(): number | null { + return useChatStore.getState().activeConversationId; +} + +export function useConversations(): ConversationItem[] { + return useChatStore((state) => state.conversations); +} + +export function useActiveConversationId(): number | null { + return useChatStore((state) => state.activeConversationId); +} + +export function useActiveConversation(): ConversationItem | null { + return useChatStore((state) => { + if (state.activeConversationId == null) return null; + return state.conversations.find((c) => c.id === state.activeConversationId) ?? null; + }); +} + +export function useChatMigration(): { total: number; done: number } | null { + return useChatStore((state) => state.migration); +} + +export async function syncConversationToCloud(conversation: ConversationItem): Promise { + const { ConversationsService } = await import("../services/ConversationsService.js"); + + const messages = (await window.electronAPI?.getAgentMessages?.(conversation.id)) ?? []; + + const cloudConv = await ConversationsService.create({ + client_conversation_id: String(conversation.id), + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + messages: messages.map((m) => ({ + role: m.role, + content: m.content, + })), + }); + + updateConversation({ ...conversation, cloud_id: cloudConv.id }); +} + +export async function syncMessageToCloud( + conversation: ConversationItem, + role: "user" | "assistant" | "system", + content: string, + metadata?: Record | null +): Promise { + const { ConversationsService } = await import("../services/ConversationsService.js"); + if (conversation.cloud_id) { + await ConversationsService.addMessage(conversation.cloud_id, role, content, metadata); + } else { + await syncConversationToCloud(conversation); + } +} + +export async function syncConversationUpdateToCloud( + conversation: ConversationItem, + updates: { title?: string; archived_at?: string } +): Promise { + const { ConversationsService } = await import("../services/ConversationsService.js"); + if (conversation.cloud_id) { + await ConversationsService.update(conversation.cloud_id, updates); + } else { + await syncConversationToCloud(conversation); + } +} + +export async function syncConversationDeleteToCloud(cloudId: string): Promise { + const { ConversationsService } = await import("../services/ConversationsService.js"); + await ConversationsService.delete(cloudId); +} + +export async function startConversationMigration(): Promise { + const allConversations = (await window.electronAPI?.getAgentConversations?.(9999)) ?? []; + const unsynced = allConversations.filter( + (c: ConversationItem) => !(c as ConversationItem).cloud_id + ); + if (unsynced.length === 0) return; + + useChatStore.setState({ migration: { total: unsynced.length, done: 0 } }); + + for (const conv of unsynced) { + try { + await syncConversationToCloud(conv as ConversationItem); + useChatStore.setState((s) => ({ + migration: s.migration + ? { + total: s.migration.total, + done: Math.min(s.migration.done + 1, s.migration.total), + } + : null, + })); + } catch (err) { + console.error("Conversation migration failed:", err); + } + } + + useChatStore.setState({ migration: null }); +} diff --git a/src/types/electron.ts b/src/types/electron.ts index fd7c551b3..e27974439 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -269,6 +269,18 @@ export interface LlamaVulkanDownloadProgress { percentage: number; } +export interface ConversationPreview { + id: number; + title: string; + created_at: string; + updated_at: string; + archived_at?: string | null; + cloud_id?: string | null; + message_count: number; + last_message?: string | null; + last_message_role?: "user" | "assistant" | "system" | null; +} + export interface ReferralItem { id: string; email: string; @@ -1014,6 +1026,7 @@ declare global { conversation_id: number; role: "user" | "assistant" | "system"; content: string; + metadata?: string; created_at: string; }>; } | null>; @@ -1038,9 +1051,26 @@ declare global { conversation_id: number; role: "user" | "assistant" | "system"; content: string; + metadata?: string; created_at: string; }> >; + getAgentConversationsWithPreview?: ( + limit?: number, + offset?: number, + includeArchived?: boolean + ) => Promise; + searchAgentConversations?: (query: string, limit?: number) => Promise; + archiveAgentConversation?: (id: number) => Promise<{ success: boolean }>; + unarchiveAgentConversation?: (id: number) => Promise<{ success: boolean }>; + updateAgentConversationCloudId?: ( + id: number, + cloudId: string + ) => Promise<{ success: boolean }>; + semanticSearchConversations?: ( + query: string, + limit?: number + ) => Promise; // Deepgram Streaming deepgramStreamingWarmup?: (options?: { sampleRate?: number; language?: string }) => Promise<{ From 16ae259851609cfc4b9bf2e97e8842e3ea7a5605 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 21:08:51 -0700 Subject: [PATCH 29/91] =?UTF-8?q?fix(chat):=20address=20code=20review=20fi?= =?UTF-8?q?ndings=20=E2=80=94=20extract=20shared=20toolIcons,=20wire=20arc?= =?UTF-8?q?hive,=20fix=20i18n=20and=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract duplicated toolIcons map to shared chat/toolIcons.ts - Wire archive handler to actual archiveAgentConversation IPC method - Use getAgentConversationsWithPreview instead of getAgentConversations - Fix hardcoded "Untitled" strings to use t("chat.untitled") i18n key - Add chat.untitled key to all 10 locale files - Fix timestamp format: use "<1m" instead of hardcoded "now" - Add await to async calls in ChatView (prevent silent error swallowing) - Remove trivial handleTextSubmit wrapper in AgentOverlay - Remove dead onStateChange handler branches in AgentOverlay - Fix TypeScript errors: add missing Search/FileText imports, fix map types - Fix ESLint warnings: add missing deps to useCallback arrays --- src/components/AgentOverlay.tsx | 9 +-------- src/components/chat/ChatInput.tsx | 23 ++--------------------- src/components/chat/ChatMessage.tsx | 16 +--------------- src/components/chat/ChatView.tsx | 20 ++++++++++++-------- src/components/chat/ConversationItem.tsx | 3 ++- src/components/chat/ConversationList.tsx | 13 ++++++++----- src/components/chat/toolIcons.ts | 19 +++++++++++++++++++ src/components/chat/types.ts | 2 ++ src/locales/de/translation.json | 3 ++- src/locales/en/translation.json | 3 ++- src/locales/es/translation.json | 3 ++- src/locales/fr/translation.json | 3 ++- src/locales/it/translation.json | 3 ++- src/locales/ja/translation.json | 3 ++- src/locales/pt/translation.json | 3 ++- src/locales/ru/translation.json | 3 ++- src/locales/zh-CN/translation.json | 10 ++++++++-- src/locales/zh-TW/translation.json | 10 ++++++++-- 18 files changed, 79 insertions(+), 70 deletions(-) create mode 100644 src/components/chat/toolIcons.ts diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index a0ed30b21..124bd84fc 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -176,13 +176,6 @@ export default function AgentOverlay() { }; }, []); - const handleTextSubmit = useCallback( - (text: string) => { - handleTranscriptionComplete(text); - }, - [handleTranscriptionComplete] - ); - const handleNewChat = useCallback(() => { persistenceNewChat(); setPartialTranscript(""); @@ -211,7 +204,7 @@ export default function AgentOverlay() { partialTranscript={partialTranscript} toolStatus={toolStatus} activeToolName={activeToolName} - onTextSubmit={handleTextSubmit} + onTextSubmit={handleTranscriptionComplete} />
diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 53ba4d2aa..4885466a2 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -1,20 +1,11 @@ import { useState, useRef, useCallback } from "react"; -import { - Mic, - SendHorizontal, - Search, - Globe, - ClipboardCheck, - Calendar, - FileText, - FilePlus, - FilePen, -} from "lucide-react"; +import { Mic, SendHorizontal } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; import { useSettingsStore } from "../../stores/settingsStore"; import { formatHotkeyLabel, isGlobeLikeHotkey } from "../../utils/hotkeys"; import type { AgentState } from "./types"; +import { toolIcons } from "./toolIcons"; interface ChatInputProps { agentState: AgentState; @@ -25,16 +16,6 @@ interface ChatInputProps { showHotkey?: boolean; } -const toolIcons: Record = { - search_notes: Search, - web_search: Globe, - copy_to_clipboard: ClipboardCheck, - get_calendar_events: Calendar, - get_note: FileText, - create_note: FilePlus, - update_note: FilePen, -}; - function Kbd({ children }: { children: React.ReactNode }) { return ( = { - search_notes: Search, - web_search: Globe, - copy_to_clipboard: ClipboardCheck, - get_calendar_events: Calendar, - get_note: FileText, - create_note: FilePlus, - update_note: FilePen, -}; - const NOTE_TOOLS = new Set(["create_note", "update_note", "get_note"]); function ToolCallStep({ toolCall }: { toolCall: ToolCallInfo }) { diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 9216ebdfd..df9aa61b7 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -16,7 +16,7 @@ const platform = getCachedPlatform(); export default function ChatView() { const { t } = useTranslation(); const [activeConversationId, setActiveConversationId] = useState(null); - const [activeTitle, setActiveTitle] = useState("Untitled"); + const [activeTitle, setActiveTitle] = useState(t("chat.untitled")); const [refreshKey, setRefreshKey] = useState(0); const { confirmDialog, showConfirmDialog, hideConfirmDialog } = useDialogs(); @@ -49,9 +49,9 @@ export default function ChatView() { const handleNewChat = useCallback(() => { setActiveConversationId(null); - setActiveTitle("Untitled"); + setActiveTitle(t("chat.untitled")); persistence.handleNewChat(); - }, [persistence]); + }, [persistence, t]); const handleTextSubmit = useCallback( async (text: string) => { @@ -69,10 +69,10 @@ export default function ChatView() { isStreaming: false, }; persistence.setMessages((prev) => [...prev, userMsg]); - persistence.saveUserMessage(text); + await persistence.saveUserMessage(text); const allMessages = [...persistence.messages, userMsg]; - streaming.sendToAI(text, allMessages); + await streaming.sendToAI(text, allMessages); }, [activeConversationId, persistence, streaming] ); @@ -87,9 +87,13 @@ export default function ChatView() { [activeConversationId] ); - const handleArchive = useCallback(async (_id: number) => { - // Archive not yet supported in the DB API - }, []); + const handleArchive = useCallback(async (id: number) => { + await window.electronAPI?.archiveAgentConversation?.(id); + if (activeConversationId === id) { + handleNewChat(); + } + setRefreshKey((k) => k + 1); + }, [activeConversationId, handleNewChat]); const handleDelete = useCallback( (id: number) => { diff --git a/src/components/chat/ConversationItem.tsx b/src/components/chat/ConversationItem.tsx index f0c2a7036..6df2963e9 100644 --- a/src/components/chat/ConversationItem.tsx +++ b/src/components/chat/ConversationItem.tsx @@ -33,11 +33,12 @@ function formatTimestamp(dateStr: string): string { if (Number.isNaN(date.getTime())) return ""; const now = new Date(); const diff = now.getTime() - date.getTime(); + const seconds = Math.floor(diff / 1000); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); - if (minutes < 1) return "now"; + if (seconds < 60) return "<1m"; if (minutes < 60) return `${minutes}m`; if (hours < 24) return `${hours}h`; if (days < 7) return `${days}d`; diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx index f7d18c984..35fd2794e 100644 --- a/src/components/chat/ConversationList.tsx +++ b/src/components/chat/ConversationList.tsx @@ -108,13 +108,16 @@ export default function ConversationList({ const loadConversations = useCallback(async () => { try { - const result = await window.electronAPI.getAgentConversations?.(200); + const result = await window.electronAPI?.getAgentConversationsWithPreview?.(200, 0, showArchived); if (result) { setConversations( result.map((c) => ({ - ...c, - preview: undefined, - is_archived: false, + id: c.id, + title: c.title || "Untitled", + preview: c.last_message, + created_at: c.created_at, + updated_at: c.updated_at, + is_archived: !!(c.archived_at), })) ); } @@ -128,7 +131,7 @@ export default function ConversationList({ showSkeletonTimer.current = null; } } - }, []); + }, [showArchived]); useEffect(() => { setIsLoading(true); diff --git a/src/components/chat/toolIcons.ts b/src/components/chat/toolIcons.ts new file mode 100644 index 000000000..c01881107 --- /dev/null +++ b/src/components/chat/toolIcons.ts @@ -0,0 +1,19 @@ +import { + Search, + Globe, + ClipboardCheck, + Calendar, + FileText, + FilePlus, + FilePen, +} from "lucide-react"; + +export const toolIcons: Record = { + search_notes: Search, + web_search: Globe, + copy_to_clipboard: ClipboardCheck, + get_calendar_events: Calendar, + get_note: FileText, + create_note: FilePlus, + update_note: FilePen, +}; diff --git a/src/components/chat/types.ts b/src/components/chat/types.ts index b2ca6a8c4..987630295 100644 --- a/src/components/chat/types.ts +++ b/src/components/chat/types.ts @@ -22,3 +22,5 @@ export type AgentState = | "thinking" | "streaming" | "tool-executing"; + +export { toolIcons } from "./toolIcons"; diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 0cccff0f4..191607247 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1753,7 +1753,8 @@ "unarchive": "Dearchivieren", "delete": "Löschen", "deleteConfirm": "Diese Unterhaltung wird dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", - "editTitle": "Doppelklick zum Bearbeiten des Titels" + "editTitle": "Doppelklick zum Bearbeiten des Titels", + "untitled": "Ohne Titel" }, "usage": { "approachingLimit": "Wochenlimit fast erreicht", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 793ef3ec9..fb8a85663 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1541,7 +1541,8 @@ "unarchive": "Unarchive", "delete": "Delete", "deleteConfirm": "This conversation will be permanently deleted. This cannot be undone.", - "editTitle": "Double-click to edit title" + "editTitle": "Double-click to edit title", + "untitled": "Untitled" }, "referral": { "title": "Refer and earn rewards", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index a79fd6874..d979b1bda 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1753,7 +1753,8 @@ "unarchive": "Desarchivar", "delete": "Eliminar", "deleteConfirm": "Esta conversación se eliminará permanentemente. No se puede deshacer.", - "editTitle": "Doble clic para editar el título" + "editTitle": "Doble clic para editar el título", + "untitled": "Sin título" }, "usage": { "approachingLimit": "Cerca del límite semanal", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 9fdc11707..853bd4914 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1493,7 +1493,8 @@ "unarchive": "Désarchiver", "delete": "Supprimer", "deleteConfirm": "Cette conversation sera définitivement supprimée. Cette action est irréversible.", - "editTitle": "Double-cliquez pour modifier le titre" + "editTitle": "Double-cliquez pour modifier le titre", + "untitled": "Sans titre" }, "referral": { "title": "Parrainez et gagnez des récompenses", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index b4f87c422..4644afbf2 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1753,7 +1753,8 @@ "unarchive": "Ripristina", "delete": "Elimina", "deleteConfirm": "Questa conversazione verrà eliminata permanentemente. Non è possibile annullare.", - "editTitle": "Doppio clic per modificare il titolo" + "editTitle": "Doppio clic per modificare il titolo", + "untitled": "Senza titolo" }, "usage": { "approachingLimit": "Limite settimanale quasi raggiunto", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 408d54e8c..d3daa92f7 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1753,7 +1753,8 @@ "unarchive": "アーカイブ解除", "delete": "削除", "deleteConfirm": "この会話は完全に削除されます。元に戻すことはできません。", - "editTitle": "ダブルクリックでタイトルを編集" + "editTitle": "ダブルクリックでタイトルを編集", + "untitled": "無題" }, "usage": { "approachingLimit": "週間制限に近づいています", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 7683a1348..1bdd2cdc2 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1753,7 +1753,8 @@ "unarchive": "Desarquivar", "delete": "Excluir", "deleteConfirm": "Esta conversa será excluída permanentemente. Não é possível desfazer.", - "editTitle": "Clique duplo para editar o título" + "editTitle": "Clique duplo para editar o título", + "untitled": "Sem título" }, "usage": { "approachingLimit": "Próximo do limite semanal", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 7f15d2e50..76fb471aa 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1753,7 +1753,8 @@ "unarchive": "Из архива", "delete": "Удалить", "deleteConfirm": "Эта беседа будет удалена навсегда. Отменить действие невозможно.", - "editTitle": "Двойной клик для редактирования названия" + "editTitle": "Двойной клик для редактирования названия", + "untitled": "Без названия" }, "usage": { "approachingLimit": "Приближается недельный лимит", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 79afac049..5916023ae 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -992,7 +992,12 @@ "annualEquivalent": "$16.67", "includesPrefix": "所有 Pro 功能 +", "badge": "最受欢迎", - "features": ["无限会议录制", "代理模式", "与您的数据对话(即将推出)", "优先支持"], + "features": [ + "无限会议录制", + "代理模式", + "与您的数据对话(即将推出)", + "优先支持" + ], "cta": "开始使用", "switchToAnnual": "切换为年付" }, @@ -1748,7 +1753,8 @@ "unarchive": "取消归档", "delete": "删除", "deleteConfirm": "此对话将被永久删除,无法撤消。", - "editTitle": "双击编辑标题" + "editTitle": "双击编辑标题", + "untitled": "无标题" }, "usage": { "approachingLimit": "即将达到周限额", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 818b8208d..104298420 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -992,7 +992,12 @@ "annualEquivalent": "$16.67", "includesPrefix": "所有 Pro 功能 +", "badge": "最熱門", - "features": ["無限會議錄製", "代理模式", "與您的資料對話(即將推出)", "優先支援"], + "features": [ + "無限會議錄製", + "代理模式", + "與您的資料對話(即將推出)", + "優先支援" + ], "cta": "開始使用", "switchToAnnual": "切換為年付" }, @@ -1748,7 +1753,8 @@ "unarchive": "取消封存", "delete": "刪除", "deleteConfirm": "此對話將被永久刪除,無法復原。", - "editTitle": "雙擊編輯標題" + "editTitle": "雙擊編輯標題", + "untitled": "無標題" }, "usage": { "approachingLimit": "即將達到每週上限", From 04505e9b4f35fee23e9d49fb10f5bea2d24c6e8a Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 21:11:31 -0700 Subject: [PATCH 30/91] =?UTF-8?q?fix(chat):=20address=20backend=20review?= =?UTF-8?q?=20=E2=80=94=20wire=20vector=20search,=20fix=20types,=20remove?= =?UTF-8?q?=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire vector index into conversation mutations (upsert chunks on every 3rd message, delete chunks on conversation delete) - Wire db-semantic-search-conversations to actual vector search with keyword fallback instead of duplicate LIKE handler - Add archived_at and cloud_id to getAgentConversation(s) return types - Add archived_at to chatStore ConversationItem interface - Remove dead ensureIpcListeners no-op and hasBoundIpcListeners flag - Remove redundant type assertions in startConversationMigration - Remove unused CHUNK_SIZE export from conversationChunker --- src/helpers/conversationChunker.js | 2 +- src/helpers/ipcHandlers.js | 36 ++++++++++++++++++++++++++++-- src/stores/chatStore.ts | 20 ++++------------- src/types/electron.ts | 4 ++++ 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/helpers/conversationChunker.js b/src/helpers/conversationChunker.js index 76548acf7..0ec4cb014 100644 --- a/src/helpers/conversationChunker.js +++ b/src/helpers/conversationChunker.js @@ -23,4 +23,4 @@ function formatChunkText(title, messages) { return `${title}\n${body}`.slice(0, 1500); } -module.exports = { chunkConversation, CHUNK_SIZE }; +module.exports = { chunkConversation }; diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index 3fc7aa46f..c2e0f301b 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -733,7 +733,11 @@ class IPCHandlers { }); ipcMain.handle("db-delete-agent-conversation", async (event, id) => { - return this.databaseManager.deleteAgentConversation(id); + const result = this.databaseManager.deleteAgentConversation(id); + if (this.vectorIndex?.isReady?.()) { + this.vectorIndex.deleteConversationChunks(id).catch(() => {}); + } + return result; }); ipcMain.handle("db-update-agent-conversation-title", async (event, id, title) => { @@ -743,7 +747,16 @@ class IPCHandlers { ipcMain.handle( "db-add-agent-message", async (event, conversationId, role, content, metadata) => { - return this.databaseManager.addAgentMessage(conversationId, role, content, metadata); + const result = this.databaseManager.addAgentMessage(conversationId, role, content, metadata); + if (this.vectorIndex?.isReady?.()) { + const conv = this.databaseManager.getAgentConversation(conversationId); + if (conv && conv.messages?.length % 3 === 0) { + this.vectorIndex + .upsertConversationChunks(conversationId, conv.title, conv.messages) + .catch(() => {}); + } + } + return result; } ); @@ -779,6 +792,25 @@ class IPCHandlers { }); ipcMain.handle("db-semantic-search-conversations", async (event, query, limit) => { + if (this.vectorIndex?.isReady?.()) { + try { + const vectorResults = await this.vectorIndex.searchConversations(query, limit); + if (vectorResults?.length > 0) { + const ids = vectorResults.map((r) => r.conversationId); + const previews = ids + .map((id) => this.databaseManager.getAgentConversation(id)) + .filter(Boolean) + .map((c) => ({ + ...c, + message_count: c.messages?.length ?? 0, + last_message: c.messages?.[c.messages.length - 1]?.content, + })); + if (previews.length > 0) return previews; + } + } catch { + // fall through to keyword search + } + } return this.databaseManager.searchAgentConversations(query, limit); }); diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index 96b9df065..0b118e518 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -4,6 +4,7 @@ interface ConversationItem { id: number; title: string; cloud_id?: string | null; + archived_at?: string | null; created_at: string; updated_at: string; } @@ -20,18 +21,7 @@ const useChatStore = create()(() => ({ migration: null, })); -let hasBoundIpcListeners = false; -const DEFAULT_LIMIT = 50; - -function ensureIpcListeners() { - if (hasBoundIpcListeners || typeof window === "undefined") { - return; - } - hasBoundIpcListeners = true; -} - -export async function initializeConversations(limit = DEFAULT_LIMIT): Promise { - ensureIpcListeners(); +export async function initializeConversations(limit = 50): Promise { const items = (await window.electronAPI?.getAgentConversations?.(limit)) ?? []; useChatStore.setState({ conversations: items }); return items; @@ -146,16 +136,14 @@ export async function syncConversationDeleteToCloud(cloudId: string): Promise { const allConversations = (await window.electronAPI?.getAgentConversations?.(9999)) ?? []; - const unsynced = allConversations.filter( - (c: ConversationItem) => !(c as ConversationItem).cloud_id - ); + const unsynced = allConversations.filter((c) => !c.cloud_id); if (unsynced.length === 0) return; useChatStore.setState({ migration: { total: unsynced.length, done: 0 } }); for (const conv of unsynced) { try { - await syncConversationToCloud(conv as ConversationItem); + await syncConversationToCloud(conv); useChatStore.setState((s) => ({ migration: s.migration ? { diff --git a/src/types/electron.ts b/src/types/electron.ts index e27974439..8227680f5 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -1012,6 +1012,8 @@ declare global { Array<{ id: number; title: string; + archived_at?: string; + cloud_id?: string; created_at: string; updated_at: string; }> @@ -1019,6 +1021,8 @@ declare global { getAgentConversation?: (id: number) => Promise<{ id: number; title: string; + archived_at?: string; + cloud_id?: string; created_at: string; updated_at: string; messages: Array<{ From bcd4c0b6e8f500bd34bbf633122cfbc9326f6136 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 21:23:25 -0700 Subject: [PATCH 31/91] ci(win): add Azure Artifact Signing for Windows code signing Configure electron-builder azureSignOptions and pass Azure credentials in CI workflows. PRs build unsigned, pushes to main/develop sign via Azure Trusted Signing service principal. --- .github/workflows/build-and-notarize.yml | 16 ++++++++++++++++ .github/workflows/release.yml | 8 +++++++- electron-builder.json | 5 +++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-notarize.yml b/.github/workflows/build-and-notarize.yml index dc758b683..494602c77 100644 --- a/.github/workflows/build-and-notarize.yml +++ b/.github/workflows/build-and-notarize.yml @@ -163,6 +163,7 @@ jobs: echo "GOOGLE_CALENDAR_CLIENT_SECRET=${{ secrets.GOOGLE_CALENDAR_CLIENT_SECRET }}" >> .env - name: Build Application + if: github.event_name == 'pull_request' run: npm run build:win -- --publish never env: GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} @@ -170,6 +171,21 @@ jobs: VITE_OPENWHISPR_API_URL: ${{ vars.VITE_OPENWHISPR_API_URL }} VITE_OPENWHISPR_OAUTH_CALLBACK_URL: ${{ vars.VITE_OPENWHISPR_OAUTH_CALLBACK_URL }} + - name: Build and Sign Application + if: github.event_name != 'pull_request' + run: npm run build:win -- --publish never + env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + VITE_NEON_AUTH_URL: ${{ vars.VITE_NEON_AUTH_URL }} + VITE_OPENWHISPR_API_URL: ${{ vars.VITE_OPENWHISPR_API_URL }} + VITE_OPENWHISPR_OAUTH_CALLBACK_URL: ${{ vars.VITE_OPENWHISPR_OAUTH_CALLBACK_URL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_CODE_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT_NAME }} + AZURE_CERT_PROFILE_NAME: ${{ secrets.AZURE_CERT_PROFILE_NAME }} + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 524249387..08a20b242 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,13 +154,19 @@ jobs: echo "GOOGLE_CALENDAR_CLIENT_ID=${{ secrets.GOOGLE_CALENDAR_CLIENT_ID }}" >> .env echo "GOOGLE_CALENDAR_CLIENT_SECRET=${{ secrets.GOOGLE_CALENDAR_CLIENT_SECRET }}" >> .env - - name: Build Application + - name: Build and Sign Application run: npm run build:win -- --publish always env: GH_TOKEN: ${{ secrets.GH_TOKEN }} VITE_NEON_AUTH_URL: ${{ vars.VITE_NEON_AUTH_URL }} VITE_OPENWHISPR_API_URL: ${{ vars.VITE_OPENWHISPR_API_URL }} VITE_OPENWHISPR_OAUTH_CALLBACK_URL: ${{ vars.VITE_OPENWHISPR_OAUTH_CALLBACK_URL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_CODE_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT_NAME }} + AZURE_CERT_PROFILE_NAME: ${{ secrets.AZURE_CERT_PROFILE_NAME }} build-macos: runs-on: macos-latest diff --git a/electron-builder.json b/electron-builder.json index 2818e5bb1..0a51f5e3b 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -133,6 +133,11 @@ "win": { "target": ["nsis", "portable"], "icon": "src/assets/icon.ico", + "azureSignOptions": { + "endpoint": "${env.AZURE_SIGNING_ENDPOINT}", + "certificateProfileName": "${env.AZURE_CERT_PROFILE_NAME}", + "codeSigningAccountName": "${env.AZURE_CODE_SIGNING_ACCOUNT_NAME}" + }, "extraResources": [ { "from": "resources/bin/nircmd.exe", From 69b77b921a884b6c4444bd86c5fcb422bf78a22d Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 21:38:39 -0700 Subject: [PATCH 32/91] fix(chat): show input field on new chat instead of empty state --- src/components/chat/ChatView.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index df9aa61b7..3fd5d2860 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -42,19 +42,24 @@ export default function ChatView() { if (id === activeConversationId) return; setActiveConversationId(id); setActiveTitle(title); + setIsNewChat(false); await persistence.loadConversation(id); }, [activeConversationId, persistence] ); + const [isNewChat, setIsNewChat] = useState(false); + const handleNewChat = useCallback(() => { setActiveConversationId(null); setActiveTitle(t("chat.untitled")); + setIsNewChat(true); persistence.handleNewChat(); }, [persistence, t]); const handleTextSubmit = useCallback( async (text: string) => { + setIsNewChat(false); let convId = activeConversationId; if (!convId) { const title = text.length > 50 ? `${text.slice(0, 50)}...` : text; @@ -125,7 +130,7 @@ export default function ChatView() { return () => window.removeEventListener("keydown", handleKeyDown); }, [handleNewChat]); - const hasActiveChat = activeConversationId !== null || persistence.messages.length > 0; + const hasActiveChat = activeConversationId !== null || persistence.messages.length > 0 || isNewChat; return ( <> From 5d317cc9f018f7a0209444f077c6e0a54c2d7f49 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 21:52:31 -0700 Subject: [PATCH 33/91] =?UTF-8?q?feat(chat):=20redesign=20thinking/streami?= =?UTF-8?q?ng=20state=20=E2=80=94=20shimmer=20indicator=20+=20stop=20butto?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace blinking cursor with brand-blue shimmer animation when AI is thinking (no content yet). Keep cursor only during active content streaming. - During thinking/streaming/tool-executing: keep input field visible (disabled) with muted status text overlay instead of replacing the entire input area - Add stop/cancel button (filled square icon) on the right during busy states - Add onCancel prop to ChatInput, wire to cancelStream in both ChatView and AgentOverlay - Add thinking-shimmer CSS keyframe using brand primary color - Remove unused ThinkingIndicator component from ChatInput --- src/components/AgentOverlay.tsx | 1 + src/components/agent/AgentInput.tsx | 1 + src/components/chat/ChatInput.tsx | 120 ++++++++++++++-------------- src/components/chat/ChatMessage.tsx | 13 ++- src/components/chat/ChatView.tsx | 1 + src/index.css | 29 ++++--- 6 files changed, 92 insertions(+), 73 deletions(-) diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 124bd84fc..5eb308976 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -205,6 +205,7 @@ export default function AgentOverlay() { toolStatus={toolStatus} activeToolName={activeToolName} onTextSubmit={handleTranscriptionComplete} + onCancel={streaming.cancelStream} />
diff --git a/src/components/agent/AgentInput.tsx b/src/components/agent/AgentInput.tsx index 211e52a0b..714dbde40 100644 --- a/src/components/agent/AgentInput.tsx +++ b/src/components/agent/AgentInput.tsx @@ -7,6 +7,7 @@ interface AgentInputProps { toolStatus?: string; activeToolName?: string; onTextSubmit?: (text: string) => void; + onCancel?: () => void; } export function AgentInput(props: AgentInputProps) { diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 4885466a2..b31a5e331 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useCallback } from "react"; -import { Mic, SendHorizontal } from "lucide-react"; +import { Mic, SendHorizontal, Square } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; import { useSettingsStore } from "../../stores/settingsStore"; @@ -13,6 +13,7 @@ interface ChatInputProps { toolStatus?: string; activeToolName?: string; onTextSubmit?: (text: string) => void; + onCancel?: () => void; showHotkey?: boolean; } @@ -80,25 +81,13 @@ function ProcessingIndicator() { ); } -function ThinkingIndicator() { - return ( -
- ); -} - export function ChatInput({ agentState, partialTranscript, toolStatus, activeToolName, onTextSubmit, + onCancel, showHotkey = false, }: ChatInputProps) { const { t } = useTranslation(); @@ -124,31 +113,66 @@ export function ChatInput({ ); const isIdle = agentState === "idle"; + const isListening = agentState === "listening"; + const isTranscribing = agentState === "transcribing"; + const isBusy = agentState === "thinking" || agentState === "streaming" || agentState === "tool-executing"; const ToolIcon = activeToolName ? toolIcons[activeToolName] : null; return (
- {isIdle && ( -
+ {isListening && ( + <> + + + {partialTranscript || t("agentMode.input.listening")} + + + )} + + {isTranscribing && ( + <> + + + {t("agentMode.input.transcribing")} + + + )} + + {(isIdle || isBusy) && ( +
setInputText(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t("agentMode.input.typeMessage")} + disabled={isBusy} + placeholder={isBusy ? "" : t("agentMode.input.typeMessage")} className={cn( - "bg-transparent border-none outline-none flex-1", + "input-inline flex-1 outline-none bg-transparent", "text-[13px] text-foreground placeholder:text-muted-foreground/40", - "min-w-0" + "min-w-0 p-0", + isBusy && "opacity-0 pointer-events-none" )} /> - {inputText.trim() ? ( + {isBusy && ( +
+ {agentState === "tool-executing" && ToolIcon ? ( + + ) : null} + + {agentState === "tool-executing" && toolStatus + ? toolStatus + : t("agentMode.input.thinking")} + +
+ )} + {isIdle && inputText.trim() ? ( - ) : showHotkey ? ( + ) : isBusy && onCancel ? ( + + ) : isIdle && showHotkey ? (
)} - - {agentState === "listening" && ( - <> - - - {partialTranscript || t("agentMode.input.listening")} - - - )} - - {agentState === "transcribing" && ( - <> - - - {t("agentMode.input.transcribing")} - - - )} - - {(agentState === "thinking" || agentState === "streaming") && ( - <> - - - {t("agentMode.input.thinking")} - - - )} - - {agentState === "tool-executing" && ( - <> - {ToolIcon ? ( - - ) : ( - - )} - - {toolStatus || t("agentMode.input.thinking")} - - - )}
); } diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx index 2a83265c5..6c00232f6 100644 --- a/src/components/chat/ChatMessage.tsx +++ b/src/components/chat/ChatMessage.tsx @@ -241,13 +241,24 @@ export function ChatMessage({ role, content, isStreaming, toolCalls }: ChatMessa /> )} - {isStreaming && ( + {isStreaming && hasContent && ( )} + {isStreaming && !hasContent && ( +
+ )} + {noteCards.length > 0 && !isStreaming && (
{noteCards.map((card) => ( diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 3fd5d2860..55bb70a2f 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -168,6 +168,7 @@ export default function ChatView() { toolStatus={streaming.toolStatus} activeToolName={streaming.activeToolName} onTextSubmit={handleTextSubmit} + onCancel={streaming.cancelStream} /> ) : ( diff --git a/src/index.css b/src/index.css index 645d16336..d40ac0c9a 100644 --- a/src/index.css +++ b/src/index.css @@ -241,8 +241,8 @@ a svg:hover, } /* Premium input styling with subtle depth */ -input, -textarea { +input:not(.input-inline), +textarea:not(.input-inline) { border-radius: var(--radius); transition: background-color 0.2s ease, @@ -254,15 +254,15 @@ textarea { } /* Light mode inputs - embossed parchment */ -:root input, -:root textarea { +:root input:not(.input-inline), +:root textarea:not(.input-inline) { background-color: var(--color-parchment-bg); border: 1px solid var(--color-parchment-border); color: var(--color-parchment-text); } -:root input:focus, -:root textarea:focus { +:root input:not(.input-inline):focus, +:root textarea:not(.input-inline):focus { border-color: var(--color-parchment-accent); box-shadow: 0 0 0 2px oklch(from var(--color-parchment-accent) l c h / 0.2), @@ -271,16 +271,16 @@ textarea { } /* Dark mode inputs — flat, no embossed shadow */ -.dark input, -.dark textarea { +.dark input:not(.input-inline), +.dark textarea:not(.input-inline) { background-color: var(--color-input); border: 1px solid var(--color-border); color: var(--color-foreground); box-shadow: none; } -.dark input:focus, -.dark textarea:focus { +.dark input:not(.input-inline):focus, +.dark textarea:not(.input-inline):focus { border-color: var(--color-border-active); box-shadow: 0 0 0 2px oklch(from var(--color-ring) l c h / 0.15); } @@ -879,6 +879,15 @@ body:has(.agent-overlay-window) #root { } } +@keyframes thinking-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + @keyframes agent-loading-dot { 0%, 80%, From 23bfd71cec6ada823fac96b2a34f577aac78f620 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 21:57:05 -0700 Subject: [PATCH 34/91] refactor(chat): move thinking indicator to message bubble as shimmer gradient text - Show "Thinking" as brand-blue shimmer gradient text in the assistant message bubble (not in the input area) - Input stays visible but disabled during busy states with stop button - Remove status text overlay, toolStatus/activeToolName props from ChatInput - Remove unused ThinkingIndicator, toolIcons import from ChatInput - Add .thinking-shimmer-text CSS class using brand primary gradient --- src/components/AgentOverlay.tsx | 4 +--- src/components/agent/AgentInput.tsx | 2 -- src/components/chat/ChatInput.tsx | 25 +++---------------------- src/components/chat/ChatMessage.tsx | 14 ++++++-------- src/components/chat/ChatView.tsx | 2 -- src/index.css | 15 +++++++++++++++ 6 files changed, 25 insertions(+), 37 deletions(-) diff --git a/src/components/AgentOverlay.tsx b/src/components/AgentOverlay.tsx index 5eb308976..8bdb82075 100644 --- a/src/components/AgentOverlay.tsx +++ b/src/components/AgentOverlay.tsx @@ -29,7 +29,7 @@ export default function AgentOverlay() { }, }); - const { agentState, toolStatus, activeToolName } = streaming; + const { agentState } = streaming; useEffect(() => { agentStateRef.current = agentState; @@ -202,8 +202,6 @@ export default function AgentOverlay() { diff --git a/src/components/agent/AgentInput.tsx b/src/components/agent/AgentInput.tsx index 714dbde40..298f446c0 100644 --- a/src/components/agent/AgentInput.tsx +++ b/src/components/agent/AgentInput.tsx @@ -4,8 +4,6 @@ import type { AgentState } from "../chat/types"; interface AgentInputProps { agentState: AgentState; partialTranscript: string; - toolStatus?: string; - activeToolName?: string; onTextSubmit?: (text: string) => void; onCancel?: () => void; } diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index b31a5e331..4eb1c9364 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -5,13 +5,9 @@ import { cn } from "../lib/utils"; import { useSettingsStore } from "../../stores/settingsStore"; import { formatHotkeyLabel, isGlobeLikeHotkey } from "../../utils/hotkeys"; import type { AgentState } from "./types"; -import { toolIcons } from "./toolIcons"; - interface ChatInputProps { agentState: AgentState; partialTranscript: string; - toolStatus?: string; - activeToolName?: string; onTextSubmit?: (text: string) => void; onCancel?: () => void; showHotkey?: boolean; @@ -84,8 +80,6 @@ function ProcessingIndicator() { export function ChatInput({ agentState, partialTranscript, - toolStatus, - activeToolName, onTextSubmit, onCancel, showHotkey = false, @@ -116,7 +110,6 @@ export function ChatInput({ const isListening = agentState === "listening"; const isTranscribing = agentState === "transcribing"; const isBusy = agentState === "thinking" || agentState === "streaming" || agentState === "tool-executing"; - const ToolIcon = activeToolName ? toolIcons[activeToolName] : null; return (
+
setInputText(e.target.value)} onKeyDown={handleKeyDown} disabled={isBusy} - placeholder={isBusy ? "" : t("agentMode.input.typeMessage")} + placeholder={t("agentMode.input.typeMessage")} className={cn( "input-inline flex-1 outline-none bg-transparent", "text-[13px] text-foreground placeholder:text-muted-foreground/40", "min-w-0 p-0", - isBusy && "opacity-0 pointer-events-none" + isBusy && "text-muted-foreground/30 cursor-not-allowed" )} /> - {isBusy && ( -
- {agentState === "tool-executing" && ToolIcon ? ( - - ) : null} - - {agentState === "tool-executing" && toolStatus - ? toolStatus - : t("agentMode.input.thinking")} - -
- )} {isIdle && inputText.trim() ? (
); } diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index f41bffe30..487bad737 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -159,7 +159,6 @@ export default function ChatView() { Date: Mon, 23 Mar 2026 22:11:01 -0700 Subject: [PATCH 41/91] =?UTF-8?q?refactor(chat):=20remove=20ChatHeader=20?= =?UTF-8?q?=E2=80=94=20title=20visible=20in=20conversation=20list=20sideba?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/ChatView.tsx | 29 ++----------- src/components/chat/ConversationList.tsx | 54 ++++++++++++++++++------ 2 files changed, 44 insertions(+), 39 deletions(-) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 487bad737..b6273912b 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -4,7 +4,6 @@ import { useChatPersistence } from "./useChatPersistence"; import { useChatStreaming } from "./useChatStreaming"; import { ChatMessages } from "./ChatMessages"; import { ChatInput } from "./ChatInput"; -import ChatHeader from "./ChatHeader"; import ConversationList from "./ConversationList"; import EmptyChatState from "./EmptyChatState"; import { ConfirmDialog } from "../ui/dialog"; @@ -16,15 +15,14 @@ const platform = getCachedPlatform(); export default function ChatView() { const { t } = useTranslation(); const [activeConversationId, setActiveConversationId] = useState(null); - const [activeTitle, setActiveTitle] = useState(t("chat.untitled")); + const [isNewChat, setIsNewChat] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const { confirmDialog, showConfirmDialog, hideConfirmDialog } = useDialogs(); const persistence = useChatPersistence({ conversationId: activeConversationId, - onConversationCreated: (id, title) => { + onConversationCreated: (id) => { setActiveConversationId(id); - setActiveTitle(title); setRefreshKey((k) => k + 1); }, }); @@ -38,24 +36,20 @@ export default function ChatView() { }); const handleSelectConversation = useCallback( - async (id: number, title: string) => { + async (id: number) => { if (id === activeConversationId) return; setActiveConversationId(id); - setActiveTitle(title); setIsNewChat(false); await persistence.loadConversation(id); }, [activeConversationId, persistence] ); - const [isNewChat, setIsNewChat] = useState(false); - const handleNewChat = useCallback(() => { setActiveConversationId(null); - setActiveTitle(t("chat.untitled")); setIsNewChat(true); persistence.handleNewChat(); - }, [persistence, t]); + }, [persistence]); const handleTextSubmit = useCallback( async (text: string) => { @@ -64,7 +58,6 @@ export default function ChatView() { if (!convId) { const title = text.length > 50 ? `${text.slice(0, 50)}...` : text; convId = await persistence.createConversation(title); - setActiveTitle(title); } const userMsg = { @@ -82,16 +75,6 @@ export default function ChatView() { [activeConversationId, persistence, streaming] ); - const handleTitleChange = useCallback( - async (title: string) => { - if (!activeConversationId) return; - setActiveTitle(title); - await window.electronAPI?.updateAgentConversationTitle?.(activeConversationId, title); - setRefreshKey((k) => k + 1); - }, - [activeConversationId] - ); - const handleArchive = useCallback(async (id: number) => { await window.electronAPI?.archiveAgentConversation?.(id); if (activeConversationId === id) { @@ -156,10 +139,6 @@ export default function ChatView() {
{hasActiveChat ? ( <> - void; + onSelectConversation: (id: number) => void; onNewChat: () => void; onArchive: (id: number) => void; onDelete: (id: number) => void; @@ -101,6 +101,8 @@ export default function ConversationList({ const [conversations, setConversations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); + const [semanticResults, setSemanticResults] = useState(null); + const searchVersionRef = useRef(0); const [showArchived, setShowArchived] = useState(false); const scrollRef = useRef(null); const showSkeletonTimer = useRef | null>(null); @@ -143,23 +145,47 @@ export default function ConversationList({ }, [loadConversations, refreshKey]); const filtered = useMemo(() => { - let list = conversations; - if (!showArchived) { - list = list.filter((c) => !c.is_archived); - } else { - list = list.filter((c) => c.is_archived); - } - if (searchQuery.trim()) { + const source = semanticResults && searchQuery.trim().length >= 3 + ? semanticResults + : conversations; + + let list = showArchived + ? source.filter((c) => c.is_archived) + : source.filter((c) => !c.is_archived); + + if (!semanticResults && searchQuery.trim()) { const q = searchQuery.toLowerCase(); list = list.filter((c) => c.title.toLowerCase().includes(q)); } return list; - }, [conversations, searchQuery, showArchived]); + }, [conversations, searchQuery, showArchived, semanticResults]); const flatItems = useMemo(() => groupByDate(filtered, t), [filtered, t]); - const debouncedSearch = useDebouncedCallback((value: string) => { + const debouncedSearch = useDebouncedCallback(async (value: string) => { + const version = ++searchVersionRef.current; setSearchQuery(value); + setSemanticResults(null); + + if (value.trim().length >= 3) { + try { + const results = await window.electronAPI?.semanticSearchConversations?.(value, 20); + if (searchVersionRef.current === version && results) { + setSemanticResults( + results.map((c: any) => ({ + id: c.id, + title: c.title || "Untitled", + preview: c.last_message, + created_at: c.created_at, + updated_at: c.updated_at, + is_archived: !!(c.archived_at), + })) + ); + } + } catch { + // title filter remains active + } + } }, 200); const virtualizer = useVirtualizer({ @@ -186,13 +212,13 @@ export default function ConversationList({ e.preventDefault(); const next = currentIdx < convItems.length - 1 ? currentIdx + 1 : 0; const item = convItems[next].item; - if (item.type === "conversation") onSelectConversation(item.data.id, item.data.title); + if (item.type === "conversation") onSelectConversation(item.data.id); virtualizer.scrollToIndex(convItems[next].index); } else if (e.key === "ArrowUp") { e.preventDefault(); const prev = currentIdx > 0 ? currentIdx - 1 : convItems.length - 1; const item = convItems[prev].item; - if (item.type === "conversation") onSelectConversation(item.data.id, item.data.title); + if (item.type === "conversation") onSelectConversation(item.data.id); virtualizer.scrollToIndex(convItems[prev].index); } else if (e.key === "Enter" && activeConversationId) { e.preventDefault(); @@ -263,7 +289,7 @@ export default function ConversationList({ onChange={(e) => debouncedSearch(e.target.value)} placeholder={t("chat.search")} className={cn( - "w-full h-6 pl-6 pr-2 rounded-md text-[11px]", + "input-inline w-full h-6 pl-6 pr-2 rounded-md text-[11px]", "bg-foreground/3 dark:bg-white/3 border border-border/25 dark:border-white/8", "text-foreground placeholder:text-muted-foreground/40", "outline-none focus-visible:ring-1 focus-visible:ring-primary/30", @@ -310,7 +336,7 @@ export default function ConversationList({ onSelectConversation(item.data.id, item.data.title)} + onClick={() => onSelectConversation(item.data.id)} onArchive={onArchive} onDelete={onDelete} /> From 4c140185db6908b995e3bd8db5071c4ec770953d Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 22:19:21 -0700 Subject: [PATCH 42/91] chore: delete unused ChatHeader.tsx --- src/components/chat/ChatHeader.tsx | 72 ------------------------------ 1 file changed, 72 deletions(-) delete mode 100644 src/components/chat/ChatHeader.tsx diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx deleted file mode 100644 index 7ae880927..000000000 --- a/src/components/chat/ChatHeader.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useState, useRef, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { cn } from "../lib/utils"; - -interface ChatHeaderProps { - title: string; - onTitleChange: (title: string) => void; -} - -export default function ChatHeader({ title, onTitleChange }: ChatHeaderProps) { - const { t } = useTranslation(); - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(title); - const inputRef = useRef(null); - - const startEditing = useCallback(() => { - setEditValue(title); - setIsEditing(true); - requestAnimationFrame(() => inputRef.current?.select()); - }, [title]); - - const commitEdit = useCallback(() => { - setIsEditing(false); - const trimmed = editValue.trim(); - if (trimmed && trimmed !== title) { - onTitleChange(trimmed); - } - }, [editValue, title, onTitleChange]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - commitEdit(); - } - if (e.key === "Escape") { - setIsEditing(false); - setEditValue(title); - } - }, - [commitEdit, title] - ); - - return ( -
-
- {isEditing ? ( - setEditValue(e.target.value)} - onBlur={commitEdit} - onKeyDown={handleKeyDown} - className={cn( - "input-inline w-full text-xs font-medium text-foreground", - "outline-none appearance-none", - "rounded-sm focus-visible:ring-1 focus-visible:ring-primary/30 px-1 -mx-1" - )} - /> - ) : ( -

- {title} -

- )} -
-
- ); -} From b48aa534947cb61793f6923ef6be3be4c57eb117 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 23 Mar 2026 22:20:55 -0700 Subject: [PATCH 43/91] fix(chat): only show archived toggle when archived chats exist --- src/components/chat/ConversationList.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx index 9f5a551d2..971ee5b49 100644 --- a/src/components/chat/ConversationList.tsx +++ b/src/components/chat/ConversationList.tsx @@ -104,6 +104,7 @@ export default function ConversationList({ const [semanticResults, setSemanticResults] = useState(null); const searchVersionRef = useRef(0); const [showArchived, setShowArchived] = useState(false); + const [hasArchivedChats, setHasArchivedChats] = useState(false); const scrollRef = useRef(null); const showSkeletonTimer = useRef | null>(null); const [showSkeleton, setShowSkeleton] = useState(false); @@ -123,6 +124,10 @@ export default function ConversationList({ })) ); } + if (!showArchived) { + const archived = await window.electronAPI?.getAgentConversationsWithPreview?.(1, 0, true); + setHasArchivedChats(!!archived?.length); + } } catch { // silently fail } finally { @@ -253,6 +258,7 @@ export default function ConversationList({

{t("sidebar.chat")}

+ {(hasArchivedChats || showArchived) && ( + )} + )} +
+ +
+ {results.length === 0 ? ( +
+

+ {query.trim() ? t("chat.noResults") : t("chat.noConversations")} +

+
+ ) : ( + results.map((item, idx) => ( + + )) + )} +
+ +
+ + + +
+ + + + ); +} + +function mapResult(c: { id: number; title: string; last_message?: string; updated_at: string }): SearchResult { + return { + id: c.id, + title: c.title || "Untitled", + last_message: c.last_message, + updated_at: c.updated_at, + }; +} + +function FooterHint({ keys, label }: { keys: string[]; label: string }) { + return ( +
+ {keys.map((k) => ( + + {k} + + ))} + {label} +
+ ); +} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index b6273912b..701e04f33 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, lazy, Suspense } from "react"; import { useTranslation } from "react-i18next"; import { useChatPersistence } from "./useChatPersistence"; import { useChatStreaming } from "./useChatStreaming"; @@ -10,6 +10,8 @@ import { ConfirmDialog } from "../ui/dialog"; import { useDialogs } from "../../hooks/useDialogs"; import { getCachedPlatform } from "../../utils/platform"; +const ChatSearchDialog = lazy(() => import("./ChatSearchDialog")); + const platform = getCachedPlatform(); export default function ChatView() { @@ -17,6 +19,7 @@ export default function ChatView() { const [activeConversationId, setActiveConversationId] = useState(null); const [isNewChat, setIsNewChat] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const [showSearch, setShowSearch] = useState(false); const { confirmDialog, showConfirmDialog, hideConfirmDialog } = useDialogs(); const persistence = useChatPersistence({ @@ -125,12 +128,24 @@ export default function ChatView() { onConfirm={confirmDialog.onConfirm} variant={confirmDialog.variant} /> + {showSearch && ( + + { + handleSelectConversation(id); + }} + /> + + )}
setShowSearch(true)} onArchive={handleArchive} onDelete={handleDelete} refreshKey={refreshKey} diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx index bf99aa0bb..4a346f6f7 100644 --- a/src/components/chat/ConversationList.tsx +++ b/src/components/chat/ConversationList.tsx @@ -1,9 +1,8 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; -import { Plus, Search, Archive as ArchiveIcon } from "lucide-react"; +import { SquarePen, Search, Archive as ArchiveIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useVirtualizer } from "@tanstack/react-virtual"; import { cn } from "../lib/utils"; -import { useDebouncedCallback } from "../../hooks/useDebouncedCallback"; import { normalizeDbDate } from "../../utils/dateFormatting"; import ConversationItem, { type ConversationPreview } from "./ConversationItem"; import ConversationDateGroup from "./ConversationDateGroup"; @@ -17,6 +16,7 @@ interface ConversationListProps { activeConversationId: number | null; onSelectConversation: (id: number) => void; onNewChat: () => void; + onOpenSearch: () => void; onArchive: (id: number) => void; onDelete: (id: number) => void; refreshKey: number; @@ -93,6 +93,7 @@ export default function ConversationList({ activeConversationId, onSelectConversation, onNewChat, + onOpenSearch, onArchive, onDelete, refreshKey, @@ -100,9 +101,6 @@ export default function ConversationList({ const { t } = useTranslation(); const [conversations, setConversations] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(""); - const [semanticResults, setSemanticResults] = useState(null); - const searchVersionRef = useRef(0); const [showArchived, setShowArchived] = useState(false); const scrollRef = useRef(null); const showSkeletonTimer = useRef | null>(null); @@ -148,49 +146,13 @@ export default function ConversationList({ }, [loadConversations, refreshKey]); const filtered = useMemo(() => { - const source = semanticResults && searchQuery.trim().length >= 3 - ? semanticResults - : conversations; - - let list = showArchived - ? source.filter((c) => c.is_archived) - : source.filter((c) => !c.is_archived); - - if (!semanticResults && searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - list = list.filter((c) => c.title.toLowerCase().includes(q)); - } - return list; - }, [conversations, searchQuery, showArchived, semanticResults]); + return showArchived + ? conversations.filter((c) => c.is_archived) + : conversations.filter((c) => !c.is_archived); + }, [conversations, showArchived]); const flatItems = useMemo(() => groupByDate(filtered, t), [filtered, t]); - const debouncedSearch = useDebouncedCallback(async (value: string) => { - const version = ++searchVersionRef.current; - setSearchQuery(value); - setSemanticResults(null); - - if (value.trim().length >= 3) { - try { - const results = await window.electronAPI?.semanticSearchConversations?.(value, 20); - if (searchVersionRef.current === version && results) { - setSemanticResults( - results.map((c: any) => ({ - id: c.id, - title: c.title || "Untitled", - preview: c.last_message, - created_at: c.created_at, - updated_at: c.updated_at, - is_archived: !!(c.archived_at), - })) - ); - } - } catch { - // title filter remains active - } - } - }, 200); - const virtualizer = useVirtualizer({ count: flatItems.length, getScrollElement: () => scrollRef.current, @@ -253,65 +215,50 @@ export default function ConversationList({ return (
-
-
-

{t("sidebar.chat")}

- {conversations.some((c) => c.is_archived) && ( +
+ + + {conversations.some((c) => c.is_archived) && ( - )} - -
-
- - debouncedSearch(e.target.value)} - placeholder={t("chat.search")} - className={cn( - "input-inline w-full h-6 pl-6 pr-2 rounded-md text-[11px]", - "bg-foreground/3 dark:bg-white/3 border border-border/25 dark:border-white/8", - "text-foreground placeholder:text-muted-foreground/40", - "outline-none focus-visible:ring-1 focus-visible:ring-primary/30", - "transition-colors" - )} - /> -
+ )}
{flatItems.length === 0 ? ( - searchQuery.trim() ? ( -
-

{t("chat.noResults")}

-
- ) : ( - - ) + ) : (
Date: Tue, 24 Mar 2026 09:15:03 -0700 Subject: [PATCH 46/91] refactor(chat): reuse CommandSearch for chat search, delete ChatSearchDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add mode="conversations" prop to CommandSearch instead of maintaining a separate ChatSearchDialog component (was 239 lines of duplication) - CommandSearch now handles both notes/transcripts (mode="all", default) and conversations (mode="conversations") with semantic search - Delete ChatSearchDialog.tsx entirely — zero duplication - ChatView uses CommandSearch with mode="conversations" and onConversationSelect callback - Made transcriptions, onNoteSelect, onTranscriptSelect optional in CommandSearchProps since conversations mode doesn't need them --- src/components/CommandSearch.tsx | 142 +++++++++++--- src/components/chat/ChatSearchDialog.tsx | 238 ----------------------- src/components/chat/ChatView.tsx | 9 +- 3 files changed, 114 insertions(+), 275 deletions(-) delete mode 100644 src/components/chat/ChatSearchDialog.tsx diff --git a/src/components/CommandSearch.tsx b/src/components/CommandSearch.tsx index 15bd3b140..b7f8c13ce 100644 --- a/src/components/CommandSearch.tsx +++ b/src/components/CommandSearch.tsx @@ -1,22 +1,32 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { Search, FileText, Mic, Folder, Users, Upload } from "lucide-react"; +import { Search, FileText, Mic, Folder, Users, Upload, MessageSquare } from "lucide-react"; import { cn } from "./lib/utils"; import type { NoteItem, FolderItem, TranscriptionItem } from "../types/electron.js"; import { normalizeDbDate } from "../utils/dateFormatting"; +interface ConversationResult { + id: number; + title: string; + last_message?: string; + updated_at: string; +} + export interface CommandSearchProps { open: boolean; onOpenChange: (open: boolean) => void; - transcriptions: TranscriptionItem[]; - onNoteSelect: (noteId: number) => void; - onTranscriptSelect: (transcriptId: number) => void; + mode?: "all" | "conversations"; + transcriptions?: TranscriptionItem[]; + onNoteSelect?: (noteId: number) => void; + onTranscriptSelect?: (transcriptId: number) => void; + onConversationSelect?: (conversationId: number) => void; } type FlatItem = | { kind: "note"; note: NoteItem } - | { kind: "transcript"; transcript: TranscriptionItem }; + | { kind: "transcript"; transcript: TranscriptionItem } + | { kind: "conversation"; conversation: ConversationResult }; function relativeTime( dateStr: string, @@ -47,60 +57,89 @@ function stripMarkdownPreview(text: string): string { export default function CommandSearch({ open, onOpenChange, - transcriptions, + mode = "all", + transcriptions = [], onNoteSelect, onTranscriptSelect, + onConversationSelect, }: CommandSearchProps) { const { t } = useTranslation(); const [query, setQuery] = useState(""); const [notes, setNotes] = useState([]); const [folders, setFolders] = useState([]); + const [conversations, setConversations] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); const searchTimerRef = useRef | null>(null); + const searchVersionRef = useRef(0); + const isConversationsMode = mode === "conversations"; useEffect(() => { + if (isConversationsMode) return; window.electronAPI .getFolders() .then(setFolders) .catch(() => {}); - }, []); + }, [isConversationsMode]); useEffect(() => { if (!open) return; setQuery(""); setSelectedIndex(0); - window.electronAPI - .getNotes() - .then(setNotes) - .catch(() => {}); - }, [open]); - - useEffect(() => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current); - - if (!query.trim()) { + if (isConversationsMode) { + window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { + if (r) setConversations(r.map((c) => ({ id: c.id, title: c.title || "Untitled", last_message: c.last_message, updated_at: c.updated_at }))); + }); + } else { window.electronAPI .getNotes() .then(setNotes) .catch(() => {}); - return; } + }, [open, isConversationsMode]); - searchTimerRef.current = setTimeout(async () => { - try { - const results = await window.electronAPI.searchNotes(query); - setNotes(results); - } catch { - // keep current results + useEffect(() => { + if (searchTimerRef.current) clearTimeout(searchTimerRef.current); + const version = ++searchVersionRef.current; + + if (isConversationsMode) { + if (!query.trim()) { + window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { + if (searchVersionRef.current === version && r) { + setConversations(r.map((c) => ({ id: c.id, title: c.title || "Untitled", last_message: c.last_message, updated_at: c.updated_at }))); + } + }); + return; + } + searchTimerRef.current = setTimeout(async () => { + try { + const r = await window.electronAPI?.semanticSearchConversations?.(query, 20); + if (searchVersionRef.current === version && r) { + setConversations(r.map((c) => ({ id: c.id, title: c.title || "Untitled", last_message: c.last_message, updated_at: c.updated_at }))); + } + } catch { /* keep current */ } + }, 200); + } else { + if (!query.trim()) { + window.electronAPI + .getNotes() + .then(setNotes) + .catch(() => {}); + return; } - }, 200); + searchTimerRef.current = setTimeout(async () => { + try { + const results = await window.electronAPI.searchNotes(query); + setNotes(results); + } catch { /* keep current */ } + }, 200); + } return () => { if (searchTimerRef.current) clearTimeout(searchTimerRef.current); }; - }, [query]); + }, [query, isConversationsMode]); useEffect(() => { setSelectedIndex(0); @@ -139,21 +178,25 @@ export default function CommandSearch({ }, [transcriptions, query]); const flatItems = useMemo(() => { + if (isConversationsMode) { + return conversations.map((c) => ({ kind: "conversation" as const, conversation: c })); + } const items: FlatItem[] = []; for (const group of noteGroups) { for (const note of group.items) items.push({ kind: "note", note }); } for (const transcript of filteredTranscripts) items.push({ kind: "transcript", transcript }); return items; - }, [noteGroups, filteredTranscripts]); + }, [noteGroups, filteredTranscripts, conversations, isConversationsMode]); const selectItem = useCallback( (item: FlatItem) => { - if (item.kind === "note") onNoteSelect(item.note.id); - else onTranscriptSelect(item.transcript.id); + if (item.kind === "note") onNoteSelect?.(item.note.id); + else if (item.kind === "transcript") onTranscriptSelect?.(item.transcript.id); + else if (item.kind === "conversation") onConversationSelect?.(item.conversation.id); onOpenChange(false); }, - [onNoteSelect, onTranscriptSelect, onOpenChange] + [onNoteSelect, onTranscriptSelect, onConversationSelect, onOpenChange] ); const handleKeyDown = useCallback( @@ -211,7 +254,7 @@ export default function CommandSearch({ value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t("commandSearch.placeholder")} + placeholder={isConversationsMode ? t("chat.search") : t("commandSearch.placeholder")} autoFocus className="flex-1 text-sm text-foreground placeholder:text-muted-foreground/40" style={{ @@ -237,9 +280,44 @@ export default function CommandSearch({ {!hasResults ? (

- {query.trim() ? t("commandSearch.noResults") : t("commandSearch.emptyState")} + {query.trim() ? t("commandSearch.noResults") : isConversationsMode ? t("chat.noConversations") : t("commandSearch.emptyState")}

+ ) : isConversationsMode ? ( + conversations.map((conv, idx) => ( + + )) ) : ( <> {noteGroups.length > 0 && ( diff --git a/src/components/chat/ChatSearchDialog.tsx b/src/components/chat/ChatSearchDialog.tsx deleted file mode 100644 index fde603b39..000000000 --- a/src/components/chat/ChatSearchDialog.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { Search, MessageSquare } from "lucide-react"; -import { cn } from "../lib/utils"; -import { normalizeDbDate } from "../../utils/dateFormatting"; - -interface ChatSearchDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSelect: (id: number) => void; -} - -interface SearchResult { - id: number; - title: string; - last_message?: string; - updated_at: string; -} - -function relativeTime(dateStr: string): string { - const date = normalizeDbDate(dateStr); - if (Number.isNaN(date.getTime())) return ""; - const diff = Date.now() - date.getTime(); - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - if (minutes < 1) return "<1m"; - if (minutes < 60) return `${minutes}m`; - if (hours < 24) return `${hours}h`; - if (days < 7) return `${days}d`; - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); -} - -export default function ChatSearchDialog({ open, onOpenChange, onSelect }: ChatSearchDialogProps) { - const { t } = useTranslation(); - const [query, setQuery] = useState(""); - const [results, setResults] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const inputRef = useRef(null); - const listRef = useRef(null); - const searchTimerRef = useRef | null>(null); - const versionRef = useRef(0); - - useEffect(() => { - if (!open) return; - setQuery(""); - setSelectedIndex(0); - window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { - if (r) setResults(r.map(mapResult)); - }); - }, [open]); - - useEffect(() => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current); - - if (!query.trim()) { - window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { - if (r) setResults(r.map(mapResult)); - }); - return; - } - - const version = ++versionRef.current; - searchTimerRef.current = setTimeout(async () => { - try { - const r = await window.electronAPI?.semanticSearchConversations?.(query, 20); - if (versionRef.current === version && r) { - setResults(r.map(mapResult)); - } - } catch { - // keep current - } - }, 200); - - return () => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current); - }; - }, [query]); - - useEffect(() => { - setSelectedIndex(0); - }, [results]); - - const selectItem = useCallback( - (item: SearchResult) => { - onSelect(item.id); - onOpenChange(false); - }, - [onSelect, onOpenChange] - ); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, results.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setSelectedIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter") { - e.preventDefault(); - const item = results[selectedIndex]; - if (item) selectItem(item); - } - }, - [results, selectedIndex, selectItem] - ); - - useEffect(() => { - const el = listRef.current?.querySelector(`[data-idx="${selectedIndex}"]`); - el?.scrollIntoView({ block: "nearest" }); - }, [selectedIndex]); - - return ( - - - - - - {t("chat.searchTitle")} - - - {t("chat.searchDescription")} - - -
- - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={t("chat.search")} - autoFocus - className="flex-1 text-sm text-foreground placeholder:text-muted-foreground/40 bg-transparent border-none outline-none p-0" - /> - {query && ( - - )} -
- -
- {results.length === 0 ? ( -
-

- {query.trim() ? t("chat.noResults") : t("chat.noConversations")} -

-
- ) : ( - results.map((item, idx) => ( - - )) - )} -
- -
- - - -
-
-
-
- ); -} - -function mapResult(c: { id: number; title: string; last_message?: string; updated_at: string }): SearchResult { - return { - id: c.id, - title: c.title || "Untitled", - last_message: c.last_message, - updated_at: c.updated_at, - }; -} - -function FooterHint({ keys, label }: { keys: string[]; label: string }) { - return ( -
- {keys.map((k) => ( - - {k} - - ))} - {label} -
- ); -} diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 701e04f33..07fd82f65 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -10,7 +10,7 @@ import { ConfirmDialog } from "../ui/dialog"; import { useDialogs } from "../../hooks/useDialogs"; import { getCachedPlatform } from "../../utils/platform"; -const ChatSearchDialog = lazy(() => import("./ChatSearchDialog")); +const CommandSearch = lazy(() => import("../CommandSearch")); const platform = getCachedPlatform(); @@ -130,12 +130,11 @@ export default function ChatView() { /> {showSearch && ( - { - handleSelectConversation(id); - }} + mode="conversations" + onConversationSelect={handleSelectConversation} /> )} From 64053bc365ffed134415026dd09a242022ae83b9 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 09:33:24 -0700 Subject: [PATCH 47/91] fix(linux): force XWayland on GNOME Wayland for window positioning (#468) Wayland doesn't allow apps to set window positions, so setPosition/setBounds are no-ops on native GNOME Wayland. This broke floating panel placement and dragging. Force XWayland (same as KDE) to restore positioning support. --- main.js | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/main.js b/main.js index b28c064d1..edb060f34 100644 --- a/main.js +++ b/main.js @@ -75,13 +75,12 @@ if (process.platform === "win32") { app.commandLine.appendSwitch("disable-gpu-compositing"); } -// Enable native Wayland support: Ozone platform for native rendering. -// KDE uses XWayland for reliable clipboard access, with KGlobalAccel D-Bus -// for global shortcuts. GNOME and Hyprland run native Wayland with their -// own D-Bus shortcut managers. +// Wayland backend: KDE and GNOME use XWayland (KDE for clipboard, GNOME +// because Wayland forbids app-initiated window positioning). wlroots +// compositors (Hyprland, Sway) run native. D-Bus shortcuts work either way. if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland") { const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); - if (desktop.includes("kde")) { + if (desktop.includes("kde") || /gnome|ubuntu|unity/.test(desktop)) { app.commandLine.appendSwitch("ozone-platform-hint", "x11"); } else { app.commandLine.appendSwitch("ozone-platform-hint", "auto"); @@ -669,17 +668,20 @@ async function startApp() { const QdrantManager = require("./src/helpers/qdrantManager"); qdrantManager = new QdrantManager(); if (qdrantManager.isAvailable()) { - qdrantManager.start().then(() => { - if (qdrantManager.isReady()) { - const vectorIndex = require("./src/helpers/vectorIndex"); - vectorIndex.init(qdrantManager.getPort()); - vectorIndex.ensureCollection().catch((err) => { - debugLogger.debug("Qdrant collection setup error (non-fatal)", { error: err.message }); - }); - } - }).catch((err) => { - debugLogger.debug("Qdrant startup error (non-fatal)", { error: err.message }); - }); + qdrantManager + .start() + .then(() => { + if (qdrantManager.isReady()) { + const vectorIndex = require("./src/helpers/vectorIndex"); + vectorIndex.init(qdrantManager.getPort()); + vectorIndex.ensureCollection().catch((err) => { + debugLogger.debug("Qdrant collection setup error (non-fatal)", { error: err.message }); + }); + } + }) + .catch((err) => { + debugLogger.debug("Qdrant startup error (non-fatal)", { error: err.message }); + }); } const localEmbeddings = require("./src/helpers/localEmbeddings"); From a9a1e36dc61fde1bb5b3e3afcc9e3dafc7cd4cd8 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 09:42:06 -0700 Subject: [PATCH 48/91] =?UTF-8?q?fix(chat):=20fix=20duplicate=20conversati?= =?UTF-8?q?ons=20=E2=80=94=20includeArchived=20filter=20returned=20all=20i?= =?UTF-8?q?nstead=20of=20only=20archived?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/helpers/database.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/helpers/database.js b/src/helpers/database.js index c729c9435..004a32db5 100644 --- a/src/helpers/database.js +++ b/src/helpers/database.js @@ -1284,7 +1284,9 @@ class DatabaseManager { getAgentConversationsWithPreview(limit = 50, offset = 0, includeArchived = false) { try { if (!this.db) throw new Error("Database not initialized"); - const archiveFilter = includeArchived ? "" : "WHERE c.archived_at IS NULL"; + const archiveFilter = includeArchived + ? "WHERE c.archived_at IS NOT NULL" + : "WHERE c.archived_at IS NULL"; return this.db .prepare( `SELECT c.id, c.title, c.created_at, c.updated_at, c.archived_at, c.cloud_id, From f6510cafeb46bf8210eaef4d924134651f96ac85 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 11:27:33 -0700 Subject: [PATCH 49/91] fix(linux): use uinput before portal on GNOME Wayland (#494) Portal-first paste caused a 10s+ timeout on GNOME, losing window focus and clipboard state before the uinput fallback ran. Flip to uinput-first on GNOME while keeping portal-first on KDE where it prevents clipboard desync. Also fix wl-copy timeout from 1ms to 50ms so the fork completes. --- src/helpers/clipboard.js | 56 ++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/src/helpers/clipboard.js b/src/helpers/clipboard.js index c436d76f2..03b3f8cae 100644 --- a/src/helpers/clipboard.js +++ b/src/helpers/clipboard.js @@ -134,8 +134,7 @@ class ClipboardManager { if (this.commandExists("wl-copy")) { try { - const isHyprland = !!process.env.HYPRLAND_INSTANCE_SIGNATURE; - const result = spawnSync("wl-copy", ["--", text], { timeout: isHyprland ? 50 : 1 }); + const result = spawnSync("wl-copy", ["--", text], { timeout: 50 }); if (result.status === 0) { clipboard.writeText(text); return; @@ -1146,11 +1145,6 @@ class ClipboardManager { }); if (isWayland) { - // On KDE with XWayland (ozone-platform-hint: x11), portal paste works - // because clipboard and input are both on X11. uinput causes a clipboard - // desync (X11 clipboard vs Wayland input) so portal is preferred. - // On GNOME, Mutter doesn't reliably route uinput to native Wayland - // windows (issue #292), so portal is tried first there too. const tryUinputPaste = async () => { const args = ["--uinput"]; if (earlyIsTerminal) args.push("--terminal"); @@ -1164,7 +1158,7 @@ class ClipboardManager { restoreClipboard(); }; - if ((isGnome || isKde) && linuxFastPaste && !this.portalDenied) { + const tryPortalPaste = async () => { const MAX_PORTAL_RETRIES = 3; for (let attempt = 0; attempt < MAX_PORTAL_RETRIES; attempt++) { try { @@ -1176,7 +1170,7 @@ class ClipboardManager { "clipboard" ); restoreClipboard(); - return "portal"; + return true; } catch (portalError) { if (portalError?.message === "portal-dismissed") { debugLogger.warn( @@ -1195,21 +1189,49 @@ class ClipboardManager { ); } else { debugLogger.warn( - "linux-fast-paste --portal failed, falling back to uinput", + "linux-fast-paste --portal failed, falling back", { error: portalError?.message }, "clipboard" ); } - break; + return false; } } - } + return false; + }; - try { - await tryUinputPaste(); - return "uinput"; - } catch (uinputError) { - debugLogger.warn("uinput paste failed", { error: uinputError?.message }, "clipboard"); + // KDE with XWayland: portal first because clipboard and input are both + // on X11. uinput causes clipboard desync (X11 clipboard vs Wayland input). + // GNOME: uinput first because the portal often times out or shows a + // confusing permission dialog, causing a 10s+ delay (issue #494). + if (isKde && linuxFastPaste && !this.portalDenied) { + if (await tryPortalPaste()) return "portal"; + try { + await tryUinputPaste(); + return "uinput"; + } catch (uinputError) { + debugLogger.warn("uinput paste failed", { error: uinputError?.message }, "clipboard"); + } + } else if (isGnome && linuxFastPaste) { + try { + await tryUinputPaste(); + return "uinput"; + } catch (uinputError) { + debugLogger.warn( + "uinput paste failed on GNOME, trying portal", + { error: uinputError?.message }, + "clipboard" + ); + } + if (!this.portalDenied && (await tryPortalPaste())) return "portal"; + } else { + // Other compositors (wlroots, etc.): try uinput only + try { + await tryUinputPaste(); + return "uinput"; + } catch (uinputError) { + debugLogger.warn("uinput paste failed", { error: uinputError?.message }, "clipboard"); + } } // XTest/XWayland fallback: works for XWayland apps on any Wayland compositor From 3e63bf5e754a5ae61ffc77b501f0b61a61995c2c Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 11:36:27 -0700 Subject: [PATCH 50/91] fix(chat): auto-focus input on new chat, always show send button --- src/components/chat/ChatInput.tsx | 51 ++++++++++++++++++------------- src/components/chat/ChatView.tsx | 1 + 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 7a7bb639c..d1d8e3c0f 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -11,6 +11,7 @@ interface ChatInputProps { onTextSubmit?: (text: string) => void; onCancel?: () => void; showHotkey?: boolean; + autoFocus?: boolean; } function Kbd({ children }: { children: React.ReactNode }) { @@ -83,6 +84,7 @@ export function ChatInput({ onTextSubmit, onCancel, showHotkey = false, + autoFocus = false, }: ChatInputProps) { const { t } = useTranslation(); const agentKey = useSettingsStore((s) => s.agentKey); @@ -149,6 +151,7 @@ export function ChatInput({ onChange={(e) => setInputText(e.target.value)} onKeyDown={handleKeyDown} disabled={isBusy} + autoFocus={autoFocus} placeholder={t("agentMode.input.typeMessage")} className={cn( "input-inline flex-1 outline-none bg-transparent", @@ -157,19 +160,7 @@ export function ChatInput({ isBusy && "text-muted-foreground/30 cursor-not-allowed" )} /> - {isIdle && inputText.trim() ? ( - - ) : isBusy && onCancel ? ( + {isBusy && onCancel ? ( - ) : isIdle && showHotkey ? ( -
-
+ {showHotkey && ( +
+
+ +
+ +
+ )} +
- + +
) : null}
diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 07fd82f65..bf905e8fc 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -159,6 +159,7 @@ export default function ChatView() { partialTranscript="" onTextSubmit={handleTextSubmit} onCancel={streaming.cancelStream} + autoFocus={isNewChat} /> ) : ( From 95093e66adde0f1b459a13de653d3bbfa50204ca Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 11:41:19 -0700 Subject: [PATCH 51/91] =?UTF-8?q?feat(chat):=20add=20empty=20state=20for?= =?UTF-8?q?=20new=20chats=20=E2=80=94=20subtle=20SVG=20illustration=20+=20?= =?UTF-8?q?prompt=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/chat/ChatView.tsx | 48 +++++++++++++++++++++++++++++- src/locales/de/translation.json | 3 +- src/locales/en/translation.json | 3 +- src/locales/es/translation.json | 3 +- src/locales/fr/translation.json | 3 +- src/locales/it/translation.json | 3 +- src/locales/ja/translation.json | 3 +- src/locales/pt/translation.json | 3 +- src/locales/ru/translation.json | 3 +- src/locales/zh-CN/translation.json | 3 +- src/locales/zh-TW/translation.json | 3 +- 11 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index bf905e8fc..91f0f15fb 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -14,6 +14,52 @@ const CommandSearch = lazy(() => import("../CommandSearch")); const platform = getCachedPlatform(); +function NewChatEmptyState() { + const { t } = useTranslation(); + return ( +
+ + + + + + + + + +

+ {t("chat.newChatEmpty")} +

+
+ ); +} + export default function ChatView() { const { t } = useTranslation(); const [activeConversationId, setActiveConversationId] = useState(null); @@ -153,7 +199,7 @@ export default function ChatView() {
{hasActiveChat ? ( <> - + } /> Date: Tue, 24 Mar 2026 11:43:22 -0700 Subject: [PATCH 52/91] =?UTF-8?q?fix(chat):=20update=20new=20chat=20empty?= =?UTF-8?q?=20state=20text=20=E2=80=94=20mention=20notes,=20transcripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/de/translation.json | 2 +- src/locales/en/translation.json | 2 +- src/locales/es/translation.json | 2 +- src/locales/fr/translation.json | 2 +- src/locales/it/translation.json | 2 +- src/locales/ja/translation.json | 2 +- src/locales/pt/translation.json | 2 +- src/locales/ru/translation.json | 2 +- src/locales/zh-CN/translation.json | 2 +- src/locales/zh-TW/translation.json | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 0cb71d8a9..99d3e1de4 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "Chats suchen", "searchTitle": "Unterhaltungen suchen", "searchDescription": "In allen Unterhaltungen suchen", - "newChatEmpty": "Starten Sie ein Gespräch" + "newChatEmpty": "Fragen Sie zu Ihren Notizen, Transkripten oder anderem" }, "usage": { "approachingLimit": "Wochenlimit fast erreicht", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ce217c6ae..6a2242703 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1546,7 +1546,7 @@ "searchChats": "Search chats", "searchTitle": "Search conversations", "searchDescription": "Search across all your conversations", - "newChatEmpty": "Start a conversation" + "newChatEmpty": "Ask about your notes, transcripts, or anything else" }, "referral": { "title": "Refer and earn rewards", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index afa7dddc4..928f59f7b 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "Buscar chats", "searchTitle": "Buscar conversaciones", "searchDescription": "Buscar en todas tus conversaciones", - "newChatEmpty": "Inicia una conversación" + "newChatEmpty": "Pregunta sobre tus notas, transcripciones o lo que quieras" }, "usage": { "approachingLimit": "Cerca del límite semanal", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 804f837fc..a6d92b197 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1498,7 +1498,7 @@ "searchChats": "Rechercher", "searchTitle": "Rechercher des conversations", "searchDescription": "Rechercher dans toutes vos conversations", - "newChatEmpty": "Démarrez une conversation" + "newChatEmpty": "Posez des questions sur vos notes, transcriptions ou autre" }, "referral": { "title": "Parrainez et gagnez des récompenses", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index bc3c280ea..ba80327af 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "Cerca chat", "searchTitle": "Cerca conversazioni", "searchDescription": "Cerca in tutte le conversazioni", - "newChatEmpty": "Avvia una conversazione" + "newChatEmpty": "Chiedi delle tue note, trascrizioni o qualsiasi altra cosa" }, "usage": { "approachingLimit": "Limite settimanale quasi raggiunto", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index cc81e1e87..d58224ab8 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "チャットを検索", "searchTitle": "会話を検索", "searchDescription": "すべての会話を検索", - "newChatEmpty": "会話を始めましょう" + "newChatEmpty": "ノートや文字起こし、その他なんでも聞いてください" }, "usage": { "approachingLimit": "週間制限に近づいています", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 75f2a1c93..dcc234673 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "Pesquisar chats", "searchTitle": "Pesquisar conversas", "searchDescription": "Pesquisar em todas as conversas", - "newChatEmpty": "Inicie uma conversa" + "newChatEmpty": "Pergunte sobre suas notas, transcrições ou qualquer coisa" }, "usage": { "approachingLimit": "Próximo do limite semanal", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f099b593a..86c4581f8 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "Поиск чатов", "searchTitle": "Поиск бесед", "searchDescription": "Поиск по всем беседам", - "newChatEmpty": "Начните разговор" + "newChatEmpty": "Спросите о заметках, транскрипциях или о чём угодно" }, "usage": { "approachingLimit": "Приближается недельный лимит", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 01e7da396..31258abc9 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "搜索聊天", "searchTitle": "搜索对话", "searchDescription": "搜索所有对话", - "newChatEmpty": "开始对话" + "newChatEmpty": "询问关于笔记、转录或其他任何内容" }, "usage": { "approachingLimit": "即将达到周限额", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 7ae22a132..29a254802 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1758,7 +1758,7 @@ "searchChats": "搜尋聊天", "searchTitle": "搜尋對話", "searchDescription": "搜尋所有對話", - "newChatEmpty": "開始對話" + "newChatEmpty": "詢問關於筆記、轉錄或其他任何內容" }, "usage": { "approachingLimit": "即將達到每週上限", From fa6392e498a114253f4a5c66676d853e9d5e087f Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 11:45:29 -0700 Subject: [PATCH 53/91] fix(chat): default to new empty chat when selecting Chat tab --- src/components/chat/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 91f0f15fb..d90a46030 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -63,7 +63,7 @@ function NewChatEmptyState() { export default function ChatView() { const { t } = useTranslation(); const [activeConversationId, setActiveConversationId] = useState(null); - const [isNewChat, setIsNewChat] = useState(false); + const [isNewChat, setIsNewChat] = useState(true); const [refreshKey, setRefreshKey] = useState(0); const [showSearch, setShowSearch] = useState(false); const { confirmDialog, showConfirmDialog, hideConfirmDialog } = useDialogs(); From a9963472c8737e3207f024dcac6a227948ad8500 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 11:59:37 -0700 Subject: [PATCH 54/91] fix(chat): retain input focus after messages, move copy button to bottom-left Refocus chat input when agent transitions back to idle so the user can keep typing without clicking. Move the copy-content button into normal document flow at bottom-left of assistant bubbles to avoid overlaying text. --- src/components/chat/ChatInput.tsx | 149 +++++++++++++++------------- src/components/chat/ChatMessage.tsx | 26 ++--- 2 files changed, 92 insertions(+), 83 deletions(-) diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index d1d8e3c0f..de1e70bdf 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { Mic, SendHorizontal, Square } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "../lib/utils"; @@ -112,7 +112,14 @@ export function ChatInput({ const isIdle = agentState === "idle"; const isListening = agentState === "listening"; const isTranscribing = agentState === "transcribing"; - const isBusy = agentState === "thinking" || agentState === "streaming" || agentState === "tool-executing"; + const isBusy = + agentState === "thinking" || agentState === "streaming" || agentState === "tool-executing"; + + useEffect(() => { + if (isIdle) { + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [isIdle]); return (
@@ -124,85 +131,85 @@ export function ChatInput({ isIdle && "focus-within:border-primary/30" )} > - {isListening && ( - <> - - - {partialTranscript || t("agentMode.input.listening")} - - - )} + {isListening && ( + <> + + + {partialTranscript || t("agentMode.input.listening")} + + + )} - {isTranscribing && ( - <> - - - {t("agentMode.input.transcribing")} - - - )} + {isTranscribing && ( + <> + + + {t("agentMode.input.transcribing")} + + + )} - {(isIdle || isBusy) && ( -
- setInputText(e.target.value)} - onKeyDown={handleKeyDown} - disabled={isBusy} - autoFocus={autoFocus} - placeholder={t("agentMode.input.typeMessage")} - className={cn( - "input-inline flex-1 outline-none bg-transparent", - "text-[13px] text-foreground placeholder:text-muted-foreground/40", - "min-w-0 p-0", - isBusy && "text-muted-foreground/30 cursor-not-allowed" - )} - /> - {isBusy && onCancel ? ( - - ) : isIdle ? ( -
- {showHotkey && ( -
-
- -
- -
+ "input-inline flex-1 outline-none bg-transparent", + "text-[13px] text-foreground placeholder:text-muted-foreground/40", + "min-w-0 p-0", + isBusy && "text-muted-foreground/30 cursor-not-allowed" )} + /> + {isBusy && onCancel ? ( -
- ) : null} -
- )} + ) : isIdle ? ( +
+ {showHotkey && ( +
+
+ +
+ +
+ )} + +
+ ) : null} +
+ )}
); diff --git a/src/components/chat/ChatMessage.tsx b/src/components/chat/ChatMessage.tsx index 3fa626d59..d5725b0aa 100644 --- a/src/components/chat/ChatMessage.tsx +++ b/src/components/chat/ChatMessage.tsx @@ -218,7 +218,7 @@ export function ChatMessage({ role, content, isStreaming, toolCalls }: ChatMessa >
- {copied ? : } - +
+ +
)}
From 5e880d432f761c8f7371938c96c263b6a0b93235 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 24 Mar 2026 15:52:05 -0700 Subject: [PATCH 55/91] fix(ui): remove vertical accent bar selection indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background color change on selected items is sufficient for indicating active state — the extra vertical bars were redundant. --- src/components/ControlPanelSidebar.tsx | 3 --- src/components/chat/ConversationItem.tsx | 3 --- src/components/notes/NoteListItem.tsx | 3 --- src/components/notes/PersonalNotesView.tsx | 3 --- src/components/ui/SidebarModal.tsx | 4 ---- 5 files changed, 16 deletions(-) diff --git a/src/components/ControlPanelSidebar.tsx b/src/components/ControlPanelSidebar.tsx index 60ba72fff..8f8dabd1b 100644 --- a/src/components/ControlPanelSidebar.tsx +++ b/src/components/ControlPanelSidebar.tsx @@ -138,9 +138,6 @@ export default function ControlPanelSidebar({ : "hover:bg-foreground/4 dark:hover:bg-white/4 active:bg-foreground/6" )} > - {isActive && ( -
- )} - {isActive && ( -
- )}

diff --git a/src/components/notes/NoteListItem.tsx b/src/components/notes/NoteListItem.tsx index 988cb29ad..d979eecd8 100644 --- a/src/components/notes/NoteListItem.tsx +++ b/src/components/notes/NoteListItem.tsx @@ -108,9 +108,6 @@ export default function NoteListItem({ isDragging && "opacity-40 scale-[0.97]" )} > - {isActive && ( -

- )}

- {isActive && !isDragOver && !isDropSuccess && ( -

- )} ({ : "text-muted-foreground dark:text-foreground/75 hover:text-foreground hover:bg-muted/50 dark:hover:bg-surface-2" }`} > - {/* Active indicator bar */} - {isActive && ( -
- )}
Date: Wed, 25 Mar 2026 15:53:06 -0700 Subject: [PATCH 56/91] feat(agent): enable local model tool calling + RAG context injection Route local models through AI SDK chat completions with tool support for models >= 4B params. Inject RAG note context via Qdrant semantic search into system prompt for all models. Bundle MiniLM embedding model in production builds. Add --jinja flag for llama.cpp tool templates. Fix redirect handling for 303/307/308 in download utils. --- electron-builder.json | 1 + package.json | 8 ++--- scripts/download-minilm.js | 11 +++--- scripts/lib/download-utils.js | 18 ++++++---- src/components/chat/useChatStreaming.ts | 46 +++++++++++++++++++++++-- src/config/prompts.ts | 9 ++++- src/helpers/llamaServer.js | 2 +- src/helpers/localEmbeddings.js | 30 ++++++++++++++-- src/services/ReasoningService.ts | 26 ++++++++++---- src/services/ai/providers.ts | 5 +++ 10 files changed, 126 insertions(+), 30 deletions(-) diff --git a/electron-builder.json b/electron-builder.json index 0a51f5e3b..f4c011cdd 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -107,6 +107,7 @@ "windows-mic-listener*", "windows-text-monitor*", "windows-fast-paste*", + "all-MiniLM-L6-v2/**/*", "*.dylib", "*.dll", "*.so*" diff --git a/package.json b/package.json index 684fef926..76eca4135 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "compile:mic-listener": "node scripts/build-macos-mic-listener.js", "compile:audio-tap": "node scripts/build-macos-audio-tap.js", "compile:native": "npm run compile:globe && npm run compile:fast-paste && npm run compile:winkeys && npm run compile:winpaste && npm run compile:linux-paste && npm run compile:text-monitor && npm run compile:media-remote && npm run compile:mic-listener && npm run compile:audio-tap", - "prestart": "npm run compile:native && npm run download:qdrant", + "prestart": "npm run compile:native && npm run download:qdrant && npm run download:embedding-model", "start": "electron .", - "predev": "npm run compile:native && npm run download:qdrant", + "predev": "npm run compile:native && npm run download:qdrant && npm run download:embedding-model", "dev": "concurrently -k -r \"npm:dev:renderer\" \"npm:dev:main\"", - "predev:main": "npm run compile:native && npm run download:qdrant", + "predev:main": "npm run compile:native && npm run download:qdrant && npm run download:embedding-model", "dev:main": "cross-env NODE_ENV=development node scripts/run-electron.js --dev", "dev:renderer": "cd src && vite", - "prebuild": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant", + "prebuild": "npm run compile:native && npm run download:whisper-cpp && npm run download:llama-server && npm run download:sherpa-onnx && npm run download:qdrant && npm run download:embedding-model -- --for-build", "build": "cd src && vite build && cd .. && electron-builder", "postinstall": "electron-builder install-app-deps", "build:renderer": "cd src && vite build", diff --git a/scripts/download-minilm.js b/scripts/download-minilm.js index 270ba15cd..a590d10dd 100644 --- a/scripts/download-minilm.js +++ b/scripts/download-minilm.js @@ -4,13 +4,10 @@ const os = require("os"); const path = require("path"); const { downloadFile, parseArgs } = require("./lib/download-utils"); -const MODEL_DIR = path.join( - os.homedir(), - ".cache", - "openwhispr", - "embedding-models", - "all-MiniLM-L6-v2" -); +const forBuild = process.argv.includes("--for-build"); +const MODEL_DIR = forBuild + ? path.join(__dirname, "..", "resources", "bin", "all-MiniLM-L6-v2") + : path.join(os.homedir(), ".cache", "openwhispr", "embedding-models", "all-MiniLM-L6-v2"); const FILES = [ { diff --git a/scripts/lib/download-utils.js b/scripts/lib/download-utils.js index 1135858c9..18d0018b1 100644 --- a/scripts/lib/download-utils.js +++ b/scripts/lib/download-utils.js @@ -39,12 +39,15 @@ function fetchJson(url, redirectCount = 0) { https .get(url, options, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - const redirectUrl = res.headers.location; - if (!redirectUrl) { + if ([301, 302, 303, 307, 308].includes(res.statusCode)) { + const location = res.headers.location; + if (!location) { reject(new Error("Redirect without location header")); return; } + const redirectUrl = location.startsWith("/") + ? new URL(location, url).href + : location; fetchJson(redirectUrl, redirectCount + 1) .then(resolve) .catch(reject); @@ -156,13 +159,16 @@ function downloadFile(url, dest, retryCount = 0) { } activeRequest = https.get(currentUrl, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - const redirectUrl = response.headers.location; - if (!redirectUrl) { + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + const location = response.headers.location; + if (!location) { cleanup(); reject(new Error("Redirect without location header")); return; } + const redirectUrl = location.startsWith("/") + ? new URL(location, currentUrl).href + : location; request(redirectUrl, redirectCount + 1); return; } diff --git a/src/components/chat/useChatStreaming.ts b/src/components/chat/useChatStreaming.ts index 17702e7ef..de179c8fb 100644 --- a/src/components/chat/useChatStreaming.ts +++ b/src/components/chat/useChatStreaming.ts @@ -7,6 +7,37 @@ import { createToolRegistry } from "../../services/tools"; import type { ToolRegistry } from "../../services/tools/ToolRegistry"; import type { Message, AgentState, ToolCallInfo } from "./types"; +const RAG_NOTE_LIMIT = 5; +const RAG_NOTE_SNIPPET_LENGTH = 500; + +const LOCAL_TOOL_MIN_PARAMS_B = 4; + +function estimateModelSizeB(modelId: string): number { + const match = modelId.match(/-([\d.]+)[bB]/); + return match ? parseFloat(match[1]) : 0; +} + +async function buildRAGContext(userText: string): Promise { + if (!window.electronAPI?.semanticSearchNotes) return ""; + try { + const results = await window.electronAPI.semanticSearchNotes(userText, RAG_NOTE_LIMIT); + if (!results || results.length === 0) return ""; + + const snippets = await Promise.all( + results.map(async (r: { id: number; title: string; score?: number }) => { + const note = await window.electronAPI.getNote(r.id); + if (!note) return null; + const content = (note.content || "").slice(0, RAG_NOTE_SNIPPET_LENGTH); + return `\n${content}\n`; + }) + ); + + return snippets.filter(Boolean).join("\n\n"); + } catch { + return ""; + } +} + interface UseChatStreamingOptions { messages: Message[]; setMessages: React.Dispatch>; @@ -59,8 +90,12 @@ export function useChatStreaming({ const settings = getSettings(); const isCloudAgent = settings.isSignedIn && settings.cloudAgentMode === "openwhispr"; - const toolSupportedProviders = ["openai", "groq", "custom", "anthropic", "gemini"]; - const supportsTools = isCloudAgent || toolSupportedProviders.includes(settings.agentProvider); + const isLocalProvider = !["openai", "groq", "custom", "anthropic", "gemini"].includes( + settings.agentProvider + ); + const localModelCanUseTool = + isLocalProvider && estimateModelSizeB(settings.agentModel) >= LOCAL_TOOL_MIN_PARAMS_B; + const supportsTools = isCloudAgent || !isLocalProvider || localModelCanUseTool; let registry: ToolRegistry | null = null; if (supportsTools) { @@ -76,7 +111,12 @@ export function useChatStreaming({ toolRegistryRef.current = { key: cacheKey, registry }; } } - const systemPrompt = getAgentSystemPrompt(registry?.getAll().map((t) => t.name)); + + const noteContext = await buildRAGContext(userText); + const systemPrompt = getAgentSystemPrompt( + registry?.getAll().map((t) => t.name), + noteContext + ); const llmMessages = [ { role: "system", content: systemPrompt }, diff --git a/src/config/prompts.ts b/src/config/prompts.ts index 1a5cbc9d9..82d03f585 100644 --- a/src/config/prompts.ts +++ b/src/config/prompts.ts @@ -160,7 +160,7 @@ const TOOL_INSTRUCTIONS: Record = { "Use get_calendar_events to check the user's schedule, upcoming meetings, or calendar events.", }; -export function getAgentSystemPrompt(availableTools?: string[]): string { +export function getAgentSystemPrompt(availableTools?: string[], noteContext?: string): string { if (typeof window !== "undefined" && window.localStorage) { const custom = window.localStorage.getItem("agentSystemPrompt"); if (custom) return custom; @@ -175,5 +175,12 @@ export function getAgentSystemPrompt(availableTools?: string[]): string { } } + if (noteContext) { + prompt += + "\n\nBelow are notes from the user's library that may be relevant. " + + "Reference them naturally if they help answer the question.\n\n" + + noteContext; + } + return prompt; } diff --git a/src/helpers/llamaServer.js b/src/helpers/llamaServer.js index 381b36d62..4feeed810 100644 --- a/src/helpers/llamaServer.js +++ b/src/helpers/llamaServer.js @@ -136,7 +136,6 @@ class LlamaServerManager { this.port = await this.findAvailablePort(); this.modelPath = modelPath; - // Let llama.cpp's auto-fit system choose --ctx-size based on available GPU memory const baseArgs = [ "--model", modelPath, @@ -146,6 +145,7 @@ class LlamaServerManager { String(this.port), "--threads", String(options.threads || 4), + "--jinja", ]; if (process.platform === "darwin") { diff --git a/src/helpers/localEmbeddings.js b/src/helpers/localEmbeddings.js index 343a42191..5732fc556 100644 --- a/src/helpers/localEmbeddings.js +++ b/src/helpers/localEmbeddings.js @@ -6,17 +6,43 @@ const debugLogger = require("./debugLogger"); const MAX_TOKENS = 256; const EMBEDDING_DIM = 384; +const MODEL_SUBDIR = "all-MiniLM-L6-v2"; + class LocalEmbeddings { constructor() { this.session = null; this.tokenizer = null; - this.modelDir = path.join( + this.modelDir = this._resolveModelDir(); + } + + _resolveModelDir() { + const cacheDir = path.join( os.homedir(), ".cache", "openwhispr", "embedding-models", - "all-MiniLM-L6-v2" + MODEL_SUBDIR ); + + if (process.resourcesPath) { + const bundled = path.join(process.resourcesPath, "bin", MODEL_SUBDIR); + if ( + fs.existsSync(path.join(bundled, "model.onnx")) && + fs.existsSync(path.join(bundled, "tokenizer.json")) + ) { + return bundled; + } + } + + const projectBin = path.resolve(__dirname, "..", "..", "resources", "bin", MODEL_SUBDIR); + if ( + fs.existsSync(path.join(projectBin, "model.onnx")) && + fs.existsSync(path.join(projectBin, "tokenizer.json")) + ) { + return projectBin; + } + + return cacheDir; } isAvailable() { diff --git a/src/services/ReasoningService.ts b/src/services/ReasoningService.ts index 317846630..ba6f43a3c 100644 --- a/src/services/ReasoningService.ts +++ b/src/services/ReasoningService.ts @@ -1325,7 +1325,7 @@ class ReasoningService extends BaseReasoningService { const cloudProviders = ["openai", "groq", "gemini", "anthropic", "custom"]; const isLocalProvider = !cloudProviders.includes(provider); - if (isLocalProvider) { + if (isLocalProvider && !tools) { const contentGen = this.processTextStreaming(messages, model, provider, config); for await (const text of contentGen) { yield { type: "content", text }; @@ -1334,12 +1334,24 @@ class ReasoningService extends BaseReasoningService { return; } - const providerKey = provider as "openai" | "groq" | "gemini" | "anthropic" | "custom"; - const apiKey = await this.getApiKey(providerKey); - const baseURL = provider === "custom" ? this.getConfiguredOpenAIBase() : undefined; + let apiKey = ""; + let baseURL: string | undefined; + + if (isLocalProvider) { + const serverResult = await window.electronAPI.llamaServerStart(model); + if (!serverResult.success || !serverResult.port) { + throw new Error(serverResult.error || "Failed to start local model server"); + } + baseURL = `http://127.0.0.1:${serverResult.port}/v1`; + } else { + const providerKey = provider as "openai" | "groq" | "gemini" | "anthropic" | "custom"; + apiKey = await this.getApiKey(providerKey); + baseURL = provider === "custom" ? this.getConfiguredOpenAIBase() : undefined; + } const apiConfig = getOpenAiApiConfig(model); - const aiModel = getAIModel(provider, model, apiKey, baseURL); + const aiProvider = isLocalProvider ? "local" : provider; + const aiModel = getAIModel(aiProvider, model, apiKey, baseURL); const modelDef = getCloudModel(model); const needsDisableThinking = provider === "groq" && modelDef?.disableThinking; @@ -1352,6 +1364,8 @@ class ReasoningService extends BaseReasoningService { messageCount: messages.length, }); + const useTemperature = isLocalProvider || apiConfig.supportsTemperature; + const result = streamText({ model: aiModel, messages: messages.map((m) => ({ @@ -1360,7 +1374,7 @@ class ReasoningService extends BaseReasoningService { })), tools: tools || undefined, stopWhen: stepCountIs(tools ? ReasoningService.MAX_TOOL_STEPS : 1), - ...(apiConfig.supportsTemperature ? { temperature: config.temperature ?? 0.3 } : {}), + ...(useTemperature ? { temperature: config.temperature ?? 0.3 } : {}), maxOutputTokens: config.maxTokens || 4096, ...(needsDisableThinking ? { providerOptions: { groq: { reasoningEffort: "none" } } } : {}), }); diff --git a/src/services/ai/providers.ts b/src/services/ai/providers.ts index b760cf958..e0b3c1461 100644 --- a/src/services/ai/providers.ts +++ b/src/services/ai/providers.ts @@ -21,6 +21,11 @@ export function getAIModel( return createGoogleGenerativeAI({ apiKey })(model); case "custom": return createOpenAI({ apiKey, baseURL })(model); + case "local": + return createOpenAI({ + apiKey: "no-key", + baseURL, + }).chat(model); default: throw new Error(`Unsupported AI SDK provider: ${provider}`); } From 833b73da3f1f0679b4975e1f8fba2227c04b6dd1 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 15:57:01 -0700 Subject: [PATCH 57/91] style: format chat components, command search, and locale files --- src/components/CommandSearch.tsx | 42 ++++++++++++++++++++---- src/components/chat/ChatView.tsx | 20 ++++++----- src/components/chat/ConversationList.tsx | 14 +++++--- src/components/chat/toolIcons.ts | 10 +----- src/helpers/ipcHandlers.js | 7 +++- src/locales/zh-CN/translation.json | 7 +--- src/locales/zh-TW/translation.json | 7 +--- 7 files changed, 66 insertions(+), 41 deletions(-) diff --git a/src/components/CommandSearch.tsx b/src/components/CommandSearch.tsx index b7f8c13ce..3dfdfd98b 100644 --- a/src/components/CommandSearch.tsx +++ b/src/components/CommandSearch.tsx @@ -89,7 +89,15 @@ export default function CommandSearch({ setSelectedIndex(0); if (isConversationsMode) { window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { - if (r) setConversations(r.map((c) => ({ id: c.id, title: c.title || "Untitled", last_message: c.last_message, updated_at: c.updated_at }))); + if (r) + setConversations( + r.map((c) => ({ + id: c.id, + title: c.title || "Untitled", + last_message: c.last_message, + updated_at: c.updated_at, + })) + ); }); } else { window.electronAPI @@ -107,7 +115,14 @@ export default function CommandSearch({ if (!query.trim()) { window.electronAPI?.getAgentConversationsWithPreview?.(20, 0, false).then((r) => { if (searchVersionRef.current === version && r) { - setConversations(r.map((c) => ({ id: c.id, title: c.title || "Untitled", last_message: c.last_message, updated_at: c.updated_at }))); + setConversations( + r.map((c) => ({ + id: c.id, + title: c.title || "Untitled", + last_message: c.last_message, + updated_at: c.updated_at, + })) + ); } }); return; @@ -116,9 +131,18 @@ export default function CommandSearch({ try { const r = await window.electronAPI?.semanticSearchConversations?.(query, 20); if (searchVersionRef.current === version && r) { - setConversations(r.map((c) => ({ id: c.id, title: c.title || "Untitled", last_message: c.last_message, updated_at: c.updated_at }))); + setConversations( + r.map((c) => ({ + id: c.id, + title: c.title || "Untitled", + last_message: c.last_message, + updated_at: c.updated_at, + })) + ); } - } catch { /* keep current */ } + } catch { + /* keep current */ + } }, 200); } else { if (!query.trim()) { @@ -132,7 +156,9 @@ export default function CommandSearch({ try { const results = await window.electronAPI.searchNotes(query); setNotes(results); - } catch { /* keep current */ } + } catch { + /* keep current */ + } }, 200); } @@ -280,7 +306,11 @@ export default function CommandSearch({ {!hasResults ? (

- {query.trim() ? t("commandSearch.noResults") : isConversationsMode ? t("chat.noConversations") : t("commandSearch.emptyState")} + {query.trim() + ? t("commandSearch.noResults") + : isConversationsMode + ? t("chat.noConversations") + : t("commandSearch.emptyState")}

) : isConversationsMode ? ( diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index d90a46030..a65e656f1 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -124,13 +124,16 @@ export default function ChatView() { [activeConversationId, persistence, streaming] ); - const handleArchive = useCallback(async (id: number) => { - await window.electronAPI?.archiveAgentConversation?.(id); - if (activeConversationId === id) { - handleNewChat(); - } - setRefreshKey((k) => k + 1); - }, [activeConversationId, handleNewChat]); + const handleArchive = useCallback( + async (id: number) => { + await window.electronAPI?.archiveAgentConversation?.(id); + if (activeConversationId === id) { + handleNewChat(); + } + setRefreshKey((k) => k + 1); + }, + [activeConversationId, handleNewChat] + ); const handleDelete = useCallback( (id: number) => { @@ -162,7 +165,8 @@ export default function ChatView() { return () => window.removeEventListener("keydown", handleKeyDown); }, [handleNewChat]); - const hasActiveChat = activeConversationId !== null || persistence.messages.length > 0 || isNewChat; + const hasActiveChat = + activeConversationId !== null || persistence.messages.length > 0 || isNewChat; return ( <> diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx index 4a346f6f7..df95768ff 100644 --- a/src/components/chat/ConversationList.tsx +++ b/src/components/chat/ConversationList.tsx @@ -112,7 +112,14 @@ export default function ConversationList({ window.electronAPI?.getAgentConversationsWithPreview?.(200, 0, false), window.electronAPI?.getAgentConversationsWithPreview?.(200, 0, true), ]); - const toPreview = (c: { id: number; title: string; last_message?: string; created_at: string; updated_at: string; archived_at?: string }) => ({ + const toPreview = (c: { + id: number; + title: string; + last_message?: string; + created_at: string; + updated_at: string; + archived_at?: string; + }) => ({ id: c.id, title: c.title || "Untitled", preview: c.last_message, @@ -120,10 +127,7 @@ export default function ConversationList({ updated_at: c.updated_at, is_archived: !!c.archived_at, }); - setConversations([ - ...(active ?? []).map(toPreview), - ...(archived ?? []).map(toPreview), - ]); + setConversations([...(active ?? []).map(toPreview), ...(archived ?? []).map(toPreview)]); } catch { // silently fail } finally { diff --git a/src/components/chat/toolIcons.ts b/src/components/chat/toolIcons.ts index c01881107..3155c8da1 100644 --- a/src/components/chat/toolIcons.ts +++ b/src/components/chat/toolIcons.ts @@ -1,12 +1,4 @@ -import { - Search, - Globe, - ClipboardCheck, - Calendar, - FileText, - FilePlus, - FilePen, -} from "lucide-react"; +import { Search, Globe, ClipboardCheck, Calendar, FileText, FilePlus, FilePen } from "lucide-react"; export const toolIcons: Record = { search_notes: Search, diff --git a/src/helpers/ipcHandlers.js b/src/helpers/ipcHandlers.js index c2e0f301b..400f24abb 100644 --- a/src/helpers/ipcHandlers.js +++ b/src/helpers/ipcHandlers.js @@ -747,7 +747,12 @@ class IPCHandlers { ipcMain.handle( "db-add-agent-message", async (event, conversationId, role, content, metadata) => { - const result = this.databaseManager.addAgentMessage(conversationId, role, content, metadata); + const result = this.databaseManager.addAgentMessage( + conversationId, + role, + content, + metadata + ); if (this.vectorIndex?.isReady?.()) { const conv = this.databaseManager.getAgentConversation(conversationId); if (conv && conv.messages?.length % 3 === 0) { diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 31258abc9..86be4715f 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -992,12 +992,7 @@ "annualEquivalent": "$16.67", "includesPrefix": "所有 Pro 功能 +", "badge": "最受欢迎", - "features": [ - "无限会议录制", - "代理模式", - "与您的数据对话(即将推出)", - "优先支持" - ], + "features": ["无限会议录制", "代理模式", "与您的数据对话(即将推出)", "优先支持"], "cta": "开始使用", "switchToAnnual": "切换为年付" }, diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 29a254802..7a742e568 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -992,12 +992,7 @@ "annualEquivalent": "$16.67", "includesPrefix": "所有 Pro 功能 +", "badge": "最熱門", - "features": [ - "無限會議錄製", - "代理模式", - "與您的資料對話(即將推出)", - "優先支援" - ], + "features": ["無限會議錄製", "代理模式", "與您的資料對話(即將推出)", "優先支援"], "cta": "開始使用", "switchToAnnual": "切換為年付" }, From dc4ac9d69fe5df9b74994df00e04b92d25fe9195 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 16:34:28 -0700 Subject: [PATCH 58/91] fix(linux): force XWayland on KDE/GNOME Wayland, fix hotkey startup race Replace broken ozone-platform-hint (removed in Electron 39 / Chromium 140) with self-relaunch that puts --ozone-platform=x11 on the real command line. Load userData .env early so DICTATION_KEY is available before page load. Skip D-Bus shortcut managers on XWayland (globalShortcut works via X11). Fix KGlobalAccel stale shortcut overwrite and KDE clipboard restore timing. Co-Authored-By: Alcahest Co-Authored-By: DamianPala --- main.js | 38 ++++++++++++++++++++++++++---------- scripts/run-electron.js | 20 +++++++++++++++++-- src/helpers/clipboard.js | 4 +++- src/helpers/hotkeyManager.js | 36 +++++++++++++++++++++++++++------- src/helpers/kdeShortcut.js | 5 +++-- 5 files changed, 81 insertions(+), 22 deletions(-) diff --git a/main.js b/main.js index edb060f34..b4139870a 100644 --- a/main.js +++ b/main.js @@ -1,3 +1,21 @@ +// KDE/GNOME Wayland: self-relaunch with --ozone-platform=x11 to force XWayland. +// Chromium picks the display backend before JS runs, so appendSwitch is too late. +if ( + process.platform === "linux" && + process.env.XDG_SESSION_TYPE === "wayland" && + !process.argv.includes("--ozone-platform=x11") +) { + const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); + if (desktop.includes("kde") || /gnome|ubuntu|unity/.test(desktop)) { + const { spawn } = require("child_process"); + spawn(process.execPath, [...process.argv.slice(1), "--ozone-platform=x11"], { + stdio: "inherit", + detached: true, + }).unref(); + process.exit(0); + } +} + const { app, globalShortcut, @@ -62,6 +80,13 @@ function configureChannelUserDataPath() { configureChannelUserDataPath(); +// Load userData .env (contains DICTATION_KEY, API keys, etc.) early — before +// hotkey registration, which needs DICTATION_KEY before the renderer loads. +require("dotenv").config({ + path: path.join(app.getPath("userData"), ".env"), + override: false, +}); + // Fix transparent window flickering on Linux: --enable-transparent-visuals requires // the compositor to set up an ARGB visual before any windows are created. // --disable-gpu-compositing prevents GPU compositing conflicts with the compositor. @@ -75,17 +100,10 @@ if (process.platform === "win32") { app.commandLine.appendSwitch("disable-gpu-compositing"); } -// Wayland backend: KDE and GNOME use XWayland (KDE for clipboard, GNOME -// because Wayland forbids app-initiated window positioning). wlroots -// compositors (Hyprland, Sway) run native. D-Bus shortcuts work either way. +// Wayland window decorations for native Wayland sessions (wlroots compositors). +// KDE/GNOME use XWayland via the self-relaunch guard at the top of this file. if (process.platform === "linux" && process.env.XDG_SESSION_TYPE === "wayland") { - const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); - if (desktop.includes("kde") || /gnome|ubuntu|unity/.test(desktop)) { - app.commandLine.appendSwitch("ozone-platform-hint", "x11"); - } else { - app.commandLine.appendSwitch("ozone-platform-hint", "auto"); - } - app.commandLine.appendSwitch("enable-features", "UseOzonePlatform,WaylandWindowDecorations"); + app.commandLine.appendSwitch("enable-features", "WaylandWindowDecorations"); } // Set desktop filename so Wayland compositors can match windows to the .desktop entry. diff --git a/scripts/run-electron.js b/scripts/run-electron.js index 8bb7ae72e..68ec180cd 100644 --- a/scripts/run-electron.js +++ b/scripts/run-electron.js @@ -25,8 +25,24 @@ console.log("[run-electron] Electron path:", electronPath); console.log("[run-electron] App dir:", appDir); console.log("[run-electron] Args:", args); -// Spawn electron with the cleaned environment -const child = spawn(electronPath, [appDir, ...args], { +// On KDE/GNOME Wayland, force XWayland so globalShortcut and window positioning work. +// Adding it here avoids the self-relaunch in main.js which kills concurrently in dev mode. +if ( + process.platform === "linux" && + process.env.XDG_SESSION_TYPE === "wayland" && + !args.includes("--ozone-platform=x11") +) { + const desktop = (process.env.XDG_CURRENT_DESKTOP || "").toLowerCase(); + if (desktop.includes("kde") || /gnome|ubuntu|unity/.test(desktop)) { + args.push("--ozone-platform=x11"); + console.log("[run-electron] KDE/GNOME Wayland detected, forcing XWayland"); + } +} + +// Chromium flags must come before the app path, app args after. +const chromiumFlags = args.filter((a) => a.startsWith("--ozone-platform=")); +const appArgs = args.filter((a) => !a.startsWith("--ozone-platform=")); +const child = spawn(electronPath, [...chromiumFlags, appDir, ...appArgs], { stdio: "inherit", env: process.env, cwd: appDir, diff --git a/src/helpers/clipboard.js b/src/helpers/clipboard.js index 03b3f8cae..9ccf6028b 100644 --- a/src/helpers/clipboard.js +++ b/src/helpers/clipboard.js @@ -57,6 +57,7 @@ const RESTORE_DELAYS = { win32_nircmd: 80, win32_pwsh: 80, linux: 200, + linux_kde_wayland: 600, }; function writeClipboardInRenderer(webContents, text) { @@ -1028,13 +1029,14 @@ class ClipboardManager { const restoreClipboard = () => { if (originalClipboard == null) return; + const delay = isKde && isWayland ? RESTORE_DELAYS.linux_kde_wayland : RESTORE_DELAYS.linux; setTimeout(() => { if (isWayland) { this._writeClipboardWayland(originalClipboard, webContents); } else { clipboard.writeText(originalClipboard); } - }, RESTORE_DELAYS.linux); + }, delay); }; const terminalClasses = [ diff --git a/src/helpers/hotkeyManager.js b/src/helpers/hotkeyManager.js index 3409d600d..a2a8119ef 100644 --- a/src/helpers/hotkeyManager.js +++ b/src/helpers/hotkeyManager.js @@ -559,7 +559,9 @@ class HotkeyManager { this.mainWindow = mainWindow; this.hotkeyCallback = callback; - if (process.platform === "linux" && GnomeShortcutManager.isWayland()) { + // On XWayland, skip D-Bus shortcut managers — globalShortcut works via X11. + const isXWayland = process.argv.includes("--ozone-platform=x11"); + if (process.platform === "linux" && GnomeShortcutManager.isWayland() && !isXWayland) { const gnomeOk = await this.initializeGnomeShortcuts(callback); if (gnomeOk) { @@ -679,9 +681,24 @@ class HotkeyManager { globalShortcut.unregisterAll(); } - setTimeout(() => { - this.loadSavedHotkeyOrDefault(mainWindow, callback); - }, HOTKEY_REGISTRATION_DELAY_MS); + // Register from env var immediately if available, otherwise wait for page load. + const envHotkey = process.env.DICTATION_KEY || ""; + if (envHotkey) { + const result = this.setupShortcuts(envHotkey, callback); + if (result.success) { + debugLogger.log(`[HotkeyManager] Hotkey "${envHotkey}" registered from env`); + } else { + debugLogger.log(`[HotkeyManager] Env hotkey "${envHotkey}" failed, waiting for page`); + this.loadSavedHotkeyOrDefault(mainWindow, callback); + } + } else { + const loadHotkey = () => this.loadSavedHotkeyOrDefault(mainWindow, callback); + if (mainWindow.webContents.isLoading()) { + mainWindow.webContents.once("did-finish-load", loadHotkey); + } else { + loadHotkey(); + } + } this.isInitialized = true; } @@ -693,9 +710,14 @@ class HotkeyManager { // Fall back to localStorage if env var is empty if (!savedHotkey) { - savedHotkey = await mainWindow.webContents.executeJavaScript(` - localStorage.getItem("dictationKey") || "" - `); + try { + savedHotkey = await mainWindow.webContents.executeJavaScript(` + localStorage.getItem("dictationKey") || "" + `); + } catch (jsErr) { + debugLogger.log(`[HotkeyManager] executeJavaScript failed: ${jsErr.message}`); + savedHotkey = ""; + } // If we found a hotkey in localStorage but not in env, migrate it to .env file if (savedHotkey && savedHotkey.trim() !== "") { diff --git a/src/helpers/kdeShortcut.js b/src/helpers/kdeShortcut.js index e1d511030..d8f8fb5dc 100644 --- a/src/helpers/kdeShortcut.js +++ b/src/helpers/kdeShortcut.js @@ -220,9 +220,10 @@ class KDEShortcutManager { const actionId = [COMPONENT_NAME, "OpenWhispr", slotName, `OpenWhispr ${slotName}`]; try { - // Register action then set shortcut + // Flag 0: always overwrite. 0x02 keeps stale saved values that + // silently prevent the hotkey from firing on startup. await this.kglobalaccel.doRegister(actionId); - const result = await this.kglobalaccel.setShortcut(actionId, [qtKey], 0x02); + const result = await this.kglobalaccel.setShortcut(actionId, [qtKey], 0); if (callback) this.callbacks.set(slotName, callback); this.registeredSlots.add(slotName); From 33eae8c4c98842482d16b3e3a35de276f41fe00e Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 17:09:27 -0700 Subject: [PATCH 59/91] feat(notes): add embedded chat input to note bottom bar Replace standalone DictationWidget with NoteBottomBar that combines mic button, "Ask anything" text input, and action picker in a unified bottom bar. Recording/processing states delegate to DictationWidget. --- src/components/notes/NoteBottomBar.tsx | 125 +++++++++++++++++++++++++ src/components/notes/NoteEditor.tsx | 11 ++- src/locales/en/translation.json | 4 + 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 src/components/notes/NoteBottomBar.tsx diff --git a/src/components/notes/NoteBottomBar.tsx b/src/components/notes/NoteBottomBar.tsx new file mode 100644 index 000000000..448ab692e --- /dev/null +++ b/src/components/notes/NoteBottomBar.tsx @@ -0,0 +1,125 @@ +import { useState, useRef, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Mic, SendHorizontal } from "lucide-react"; +import { cn } from "../lib/utils"; +import DictationWidget from "./DictationWidget"; + +interface NoteBottomBarProps { + isRecording: boolean; + isProcessing: boolean; + onStartRecording: () => void; + onStopRecording: () => void; + onAskSubmit: (text: string) => void; + askDisabled?: boolean; + actionPicker?: React.ReactNode; +} + +export default function NoteBottomBar({ + isRecording, + isProcessing, + onStartRecording, + onStopRecording, + onAskSubmit, + askDisabled, + actionPicker, +}: NoteBottomBarProps) { + const { t } = useTranslation(); + const [inputText, setInputText] = useState(""); + const inputRef = useRef(null); + + const handleSubmit = useCallback(() => { + const text = inputText.trim(); + if (!text || askDisabled) return; + onAskSubmit(text); + setInputText(""); + requestAnimationFrame(() => inputRef.current?.focus()); + }, [inputText, askDisabled, onAskSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + if (isRecording || isProcessing) { + return ( + + ); + } + + return ( +
+
+ + + setInputText(e.target.value)} + onKeyDown={handleKeyDown} + disabled={askDisabled} + placeholder={t("embeddedChat.askPlaceholder")} + className={cn( + "input-inline flex-1 bg-transparent outline-none min-w-0 p-0", + "text-[13px] text-foreground placeholder:text-muted-foreground/30" + )} + /> + + {inputText.trim() && ( + + )} + + {actionPicker} +
+
+ ); +} diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 85ca4bc52..775cf046b 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -15,7 +15,7 @@ import { cn } from "../lib/utils"; import type { NoteItem } from "../../types/electron"; import type { ActionProcessingState } from "../../hooks/useActionProcessing"; import ActionProcessingOverlay from "./ActionProcessingOverlay"; -import DictationWidget from "./DictationWidget"; +import NoteBottomBar from "./NoteBottomBar"; import { normalizeDbDate } from "../../utils/dateFormatting"; import { parseTranscriptSegments } from "../../utils/parseTranscriptSegments"; @@ -60,6 +60,7 @@ interface NoteEditorProps { meetingSystemPartial?: string; onStopMeetingRecording?: () => void; liveTranscript?: string; + onAskSubmit?: (text: string) => void; } export default function NoteEditor({ @@ -83,6 +84,7 @@ export default function NoteEditor({ meetingSystemPartial, onStopMeetingRecording, liveTranscript, + onAskSubmit, }: NoteEditorProps) { const { t } = useTranslation(); const [viewMode, setViewMode] = useState("raw"); @@ -366,13 +368,14 @@ export default function NoteEditor({ className="absolute bottom-0 left-0 right-0 h-24 pointer-events-none" style={{ background: "linear-gradient(to bottom, transparent, var(--color-background))" }} /> - {})} actionPicker={isMeetingRecording ? undefined : actionPicker} />
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6a2242703..760c7c5c5 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -2048,5 +2048,9 @@ "title": "Permissions Required", "description": "OpenWhispr needs a few permissions to work on this computer.", "continue": "Continue" + }, + "embeddedChat": { + "askPlaceholder": "Ask anything...", + "send": "Send" } } From 2035b414f1075b7f8a2a79de95aea95a43adedb4 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 17:19:25 -0700 Subject: [PATCH 60/91] feat(notes): add embedded chat panel with floating and sidebar modes Add EmbeddedChat component that supports floating panel and dockable sidebar modes for chatting about note content. The chat uses existing ChatMessages and ChatInput components, composed via a new useEmbeddedChat hook that wires useChatPersistence + useChatStreaming with note context injection. Extend useChatStreaming to accept optional noteContext that gets prepended to the system prompt alongside RAG context. Integrate into NoteEditor with NoteBottomBar ask input triggering the chat panel. Add i18n keys for all 10 supported languages. --- src/components/chat/useChatStreaming.ts | 10 +- src/components/notes/EmbeddedChat.tsx | 160 ++++++++++++++++++++++++ src/components/notes/NoteEditor.tsx | 125 ++++++++++++------ src/hooks/useEmbeddedChat.ts | 102 +++++++++++++++ src/locales/de/translation.json | 9 ++ src/locales/en/translation.json | 7 +- src/locales/es/translation.json | 9 ++ src/locales/fr/translation.json | 9 ++ src/locales/it/translation.json | 9 ++ src/locales/ja/translation.json | 9 ++ src/locales/pt/translation.json | 9 ++ src/locales/ru/translation.json | 9 ++ src/locales/zh-CN/translation.json | 9 ++ src/locales/zh-TW/translation.json | 9 ++ 14 files changed, 443 insertions(+), 42 deletions(-) create mode 100644 src/components/notes/EmbeddedChat.tsx create mode 100644 src/hooks/useEmbeddedChat.ts diff --git a/src/components/chat/useChatStreaming.ts b/src/components/chat/useChatStreaming.ts index de179c8fb..ee99bff30 100644 --- a/src/components/chat/useChatStreaming.ts +++ b/src/components/chat/useChatStreaming.ts @@ -41,6 +41,8 @@ async function buildRAGContext(userText: string): Promise { interface UseChatStreamingOptions { messages: Message[]; setMessages: React.Dispatch>; + /** Optional note context to prepend to the system prompt (used by embedded note chat). */ + noteContext?: string; onStreamComplete?: (assistantId: string, content: string, toolCalls?: ToolCallInfo[]) => void; } @@ -55,6 +57,7 @@ export interface ChatStreaming { export function useChatStreaming({ messages, setMessages, + noteContext: externalNoteContext, onStreamComplete, }: UseChatStreamingOptions): ChatStreaming { const { t } = useTranslation(); @@ -63,6 +66,8 @@ export function useChatStreaming({ const [activeToolName, setActiveToolName] = useState(""); const mountedRef = useRef(true); const messagesRef = useRef([]); + const noteContextRef = useRef(externalNoteContext); + noteContextRef.current = externalNoteContext; const toolRegistryRef = useRef<{ key: string; registry: ToolRegistry } | null>(null); useEffect(() => { @@ -112,10 +117,11 @@ export function useChatStreaming({ } } - const noteContext = await buildRAGContext(userText); + const ragContext = await buildRAGContext(userText); + const combinedContext = [noteContextRef.current, ragContext].filter(Boolean).join("\n\n"); const systemPrompt = getAgentSystemPrompt( registry?.getAll().map((t) => t.name), - noteContext + combinedContext || undefined ); const llmMessages = [ diff --git a/src/components/notes/EmbeddedChat.tsx b/src/components/notes/EmbeddedChat.tsx new file mode 100644 index 000000000..d6ddd1361 --- /dev/null +++ b/src/components/notes/EmbeddedChat.tsx @@ -0,0 +1,160 @@ +import { useEffect, useCallback, forwardRef, useImperativeHandle } from "react"; +import { useTranslation } from "react-i18next"; +import { X, PanelRight, PanelRightClose } from "lucide-react"; +import { cn } from "../lib/utils"; +import { ChatMessages } from "../chat/ChatMessages"; +import { ChatInput } from "../chat/ChatInput"; +import { useEmbeddedChat } from "../../hooks/useEmbeddedChat"; + +type EmbeddedChatMode = "hidden" | "floating" | "sidebar"; + +interface EmbeddedChatProps { + mode: EmbeddedChatMode; + onModeChange: (mode: EmbeddedChatMode) => void; + noteId: number | null; + noteTitle: string; + noteContent: string; + noteTranscript?: string; +} + +export interface EmbeddedChatHandle { + sendMessage: (text: string) => Promise; +} + +function EmptyState() { + const { t } = useTranslation(); + return ( +
+

+ {t("embeddedChat.emptyState")} +

+
+ ); +} + +const EmbeddedChat = forwardRef( + function EmbeddedChat( + { mode, onModeChange, noteId, noteTitle, noteContent, noteTranscript }, + ref + ) { + const { t } = useTranslation(); + const { messages, agentState, sendMessage, cancelStream } = useEmbeddedChat({ + noteId, + noteTitle, + noteContent, + noteTranscript, + }); + + useImperativeHandle(ref, () => ({ sendMessage }), [sendMessage]); + + // Escape key closes floating panel + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && mode === "floating") { + onModeChange("hidden"); + } + }, + [mode, onModeChange] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + if (mode === "hidden") return null; + + const handleTextSubmit = (text: string) => { + sendMessage(text); + }; + + const headerContent = ( +
+ + {t("embeddedChat.title")} + +
+
+ {mode === "floating" ? ( + + ) : ( + + )} + +
+
+ ); + + if (mode === "floating") { + return ( +
+ {headerContent} + } /> + +
+ ); + } + + // sidebar mode + return ( +
+ {headerContent} + } /> + +
+ ); + } +); + +export default EmbeddedChat; diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 775cf046b..8c8c2a4c0 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -16,9 +16,12 @@ import type { NoteItem } from "../../types/electron"; import type { ActionProcessingState } from "../../hooks/useActionProcessing"; import ActionProcessingOverlay from "./ActionProcessingOverlay"; import NoteBottomBar from "./NoteBottomBar"; +import EmbeddedChat, { type EmbeddedChatHandle } from "./EmbeddedChat"; import { normalizeDbDate } from "../../utils/dateFormatting"; import { parseTranscriptSegments } from "../../utils/parseTranscriptSegments"; +type EmbeddedChatMode = "hidden" | "floating" | "sidebar"; + function formatNoteDate(dateStr: string): string { const date = normalizeDbDate(dateStr); if (Number.isNaN(date.getTime())) return ""; @@ -60,7 +63,6 @@ interface NoteEditorProps { meetingSystemPartial?: string; onStopMeetingRecording?: () => void; liveTranscript?: string; - onAskSubmit?: (text: string) => void; } export default function NoteEditor({ @@ -84,10 +86,12 @@ export default function NoteEditor({ meetingSystemPartial, onStopMeetingRecording, liveTranscript, - onAskSubmit, }: NoteEditorProps) { const { t } = useTranslation(); const [viewMode, setViewMode] = useState("raw"); + const [chatMode, setChatMode] = useState("hidden"); + const embeddedChatRef = useRef(null); + const pendingAskRef = useRef(null); const editorRef = useRef(null); const titleRef = useRef(null); const prevNoteIdRef = useRef(note.id); @@ -212,6 +216,25 @@ export default function NoteEditor({ [enhancement] ); + // Handle ask submit from NoteBottomBar: open chat + send message + const handleAskSubmit = useCallback((text: string) => { + if (chatMode === "hidden") { + pendingAskRef.current = text; + setChatMode("floating"); + } else { + embeddedChatRef.current?.sendMessage(text); + } + }, [chatMode]); + + // Send pending message once EmbeddedChat mounts + useEffect(() => { + if (chatMode !== "hidden" && pendingAskRef.current && embeddedChatRef.current) { + const text = pendingAskRef.current; + pendingAskRef.current = null; + embeddedChatRef.current.sendMessage(text); + } + }, [chatMode]); + const wordCount = useMemo(() => { const trimmed = note.content.trim(); return trimmed ? trimmed.split(/\s+/).length : 0; @@ -338,46 +361,70 @@ export default function NoteEditor({
-
-
- {viewMode === "transcript" && (hasChatSegments || isMeetingRecording) ? ( - - ) : viewMode === "transcript" && hasMeetingTranscript ? ( - - ) : viewMode === "enhanced" && enhancement ? ( - - ) : ( - +
+
+ {viewMode === "transcript" && (hasChatSegments || isMeetingRecording) ? ( + + ) : viewMode === "transcript" && hasMeetingTranscript ? ( + + ) : viewMode === "enhanced" && enhancement ? ( + + ) : ( + + )} +
+ +
+ + {chatMode === "floating" && ( + )}
- -
- {})} - actionPicker={isMeetingRecording ? undefined : actionPicker} - /> + {chatMode === "sidebar" && ( + + )}
); diff --git a/src/hooks/useEmbeddedChat.ts b/src/hooks/useEmbeddedChat.ts new file mode 100644 index 000000000..b2fb29486 --- /dev/null +++ b/src/hooks/useEmbeddedChat.ts @@ -0,0 +1,102 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { useChatPersistence } from "../components/chat/useChatPersistence"; +import { useChatStreaming } from "../components/chat/useChatStreaming"; +import type { Message, AgentState } from "../components/chat/types"; + +interface UseEmbeddedChatOptions { + noteId: number | null; + noteTitle: string; + noteContent: string; + noteTranscript?: string; +} + +interface UseEmbeddedChatReturn { + messages: Message[]; + agentState: AgentState; + sendMessage: (text: string) => Promise; + cancelStream: () => void; + clearChat: () => void; + hasMessages: boolean; +} + +export function useEmbeddedChat({ + noteId, + noteTitle, + noteContent, + noteTranscript, +}: UseEmbeddedChatOptions): UseEmbeddedChatReturn { + const [conversationId, setConversationId] = useState(null); + const noteIdRef = useRef(noteId); + + const persistence = useChatPersistence({ + conversationId, + onConversationCreated: (id) => { + setConversationId(id); + }, + }); + + const noteContextRef = useRef(""); + noteContextRef.current = [ + `Title: ${noteTitle}`, + `Content:\n${noteContent}`, + noteTranscript ? `\nTranscript:\n${noteTranscript}` : "", + ] + .filter(Boolean) + .join("\n"); + + const streaming = useChatStreaming({ + messages: persistence.messages, + setMessages: persistence.setMessages, + noteContext: noteContextRef.current, + onStreamComplete: (_id, content, toolCalls) => { + persistence.saveAssistantMessage(content, toolCalls); + }, + }); + + // Reset chat when noteId changes + useEffect(() => { + if (noteId !== noteIdRef.current) { + noteIdRef.current = noteId; + persistence.handleNewChat(); + setConversationId(null); + } + }, [noteId, persistence]); + + const sendMessage = useCallback( + async (text: string) => { + let convId = conversationId; + if (!convId) { + const title = `Note: ${noteTitle || "Untitled"}`; + convId = await persistence.createConversation(title); + } + + const userMsg: Message = { + id: crypto.randomUUID(), + role: "user", + content: text, + isStreaming: false, + }; + persistence.setMessages((prev) => [...prev, userMsg]); + await persistence.saveUserMessage(text); + + const allMessages = [...persistence.messages, userMsg]; + await streaming.sendToAI(text, allMessages); + }, + [conversationId, noteTitle, persistence, streaming] + ); + + const clearChat = useCallback(() => { + persistence.handleNewChat(); + setConversationId(null); + streaming.cancelStream(); + }, [persistence, streaming]); + + return { + messages: persistence.messages, + agentState: streaming.agentState, + sendMessage, + cancelStream: streaming.cancelStream, + clearChat, + hasMessages: persistence.messages.length > 0, + }; +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 99d3e1de4..7b7499994 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -2048,5 +2048,14 @@ "title": "Berechtigungen erforderlich", "description": "OpenWhispr benötigt einige Berechtigungen, um auf diesem Computer zu funktionieren.", "continue": "Weiter" + }, + "embeddedChat": { + "askPlaceholder": "Frag etwas...", + "send": "Senden", + "title": "Chat", + "emptyState": "Stelle eine Frage zu dieser Notiz", + "dock": "In Seitenleiste andocken", + "undock": "Von Seitenleiste abdocken", + "close": "Chat schließen" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 760c7c5c5..470c6850a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -2051,6 +2051,11 @@ }, "embeddedChat": { "askPlaceholder": "Ask anything...", - "send": "Send" + "send": "Send", + "title": "Chat", + "emptyState": "Ask a question about this note", + "dock": "Dock to sidebar", + "undock": "Undock from sidebar", + "close": "Close chat" } } diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 928f59f7b..6dd8581a8 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -2048,5 +2048,14 @@ "title": "Permisos necesarios", "description": "OpenWhispr necesita algunos permisos para funcionar en este ordenador.", "continue": "Continuar" + }, + "embeddedChat": { + "askPlaceholder": "Pregunta algo...", + "send": "Enviar", + "title": "Chat", + "emptyState": "Haz una pregunta sobre esta nota", + "dock": "Anclar a la barra lateral", + "undock": "Desanclar de la barra lateral", + "close": "Cerrar chat" } } diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index a6d92b197..7ef68ad5f 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -2048,5 +2048,14 @@ "title": "Autorisations requises", "description": "OpenWhispr a besoin de quelques autorisations pour fonctionner sur cet ordinateur.", "continue": "Continuer" + }, + "embeddedChat": { + "askPlaceholder": "Posez une question...", + "send": "Envoyer", + "title": "Chat", + "emptyState": "Posez une question sur cette note", + "dock": "Ancrer dans la barre latérale", + "undock": "Détacher de la barre latérale", + "close": "Fermer le chat" } } diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index ba80327af..198e8567a 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -2048,5 +2048,14 @@ "title": "Autorizzazioni necessarie", "description": "OpenWhispr ha bisogno di alcune autorizzazioni per funzionare su questo computer.", "continue": "Continua" + }, + "embeddedChat": { + "askPlaceholder": "Chiedi qualcosa...", + "send": "Invia", + "title": "Chat", + "emptyState": "Fai una domanda su questa nota", + "dock": "Ancora nella barra laterale", + "undock": "Sgancia dalla barra laterale", + "close": "Chiudi chat" } } diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index d58224ab8..e068099f4 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -2048,5 +2048,14 @@ "title": "権限が必要です", "description": "OpenWhispr がこのコンピュータで動作するために、いくつかの権限が必要です。", "continue": "続行" + }, + "embeddedChat": { + "askPlaceholder": "何でも聞いてください...", + "send": "送信", + "title": "チャット", + "emptyState": "このノートについて質問してください", + "dock": "サイドバーに固定", + "undock": "サイドバーから外す", + "close": "チャットを閉じる" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index dcc234673..1f565bd20 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -2048,5 +2048,14 @@ "title": "Permissões necessárias", "description": "O OpenWhispr precisa de algumas permissões para funcionar neste computador.", "continue": "Continuar" + }, + "embeddedChat": { + "askPlaceholder": "Pergunte algo...", + "send": "Enviar", + "title": "Chat", + "emptyState": "Faça uma pergunta sobre esta nota", + "dock": "Fixar na barra lateral", + "undock": "Desafixar da barra lateral", + "close": "Fechar chat" } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 86c4581f8..ec46e6d53 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -2048,5 +2048,14 @@ "title": "Требуются разрешения", "description": "OpenWhispr нужны некоторые разрешения для работы на этом компьютере.", "continue": "Продолжить" + }, + "embeddedChat": { + "askPlaceholder": "Спросите что-нибудь...", + "send": "Отправить", + "title": "Чат", + "emptyState": "Задайте вопрос об этой заметке", + "dock": "Закрепить на боковой панели", + "undock": "Открепить от боковой панели", + "close": "Закрыть чат" } } diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 86be4715f..c0ada9cbf 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -2043,5 +2043,14 @@ "title": "需要权限", "description": "OpenWhispr 需要一些权限才能在此电脑上正常运行。", "continue": "继续" + }, + "embeddedChat": { + "askPlaceholder": "随便问点什么...", + "send": "发送", + "title": "聊天", + "emptyState": "就这条笔记提个问题", + "dock": "固定到侧边栏", + "undock": "从侧边栏取消固定", + "close": "关闭聊天" } } diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 7a742e568..0714ca1ed 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -2043,5 +2043,14 @@ "title": "需要權限", "description": "OpenWhispr 需要一些權限才能在此電腦上正常運作。", "continue": "繼續" + }, + "embeddedChat": { + "askPlaceholder": "隨便問點什麼...", + "send": "傳送", + "title": "聊天", + "emptyState": "就這條筆記提個問題", + "dock": "固定到側邊欄", + "undock": "從側邊欄取消固定", + "close": "關閉聊天" } } From 942a7fd6a664b2f2bb39511b8f6f3b7f8ad5051c Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 17:23:39 -0700 Subject: [PATCH 61/91] refactor(notes): lift embedded chat hook to NoteEditor, simplify EmbeddedChat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move useEmbeddedChat call from EmbeddedChat to NoteEditor so chat state persists when switching between floating and sidebar modes - Remove forwardRef/useImperativeHandle — pass messages and callbacks as props - Export EmbeddedChatMode type from EmbeddedChat, remove duplicate in NoteEditor - Use canonical Tailwind classes (w-95, max-h-120, min-h-50, w-85) - Remove unused onAskSubmit prop from NoteEditorProps --- src/components/notes/EmbeddedChat.tsx | 211 ++++++++++++-------------- src/components/notes/NoteEditor.tsx | 48 +++--- 2 files changed, 114 insertions(+), 145 deletions(-) diff --git a/src/components/notes/EmbeddedChat.tsx b/src/components/notes/EmbeddedChat.tsx index d6ddd1361..0ce8f9043 100644 --- a/src/components/notes/EmbeddedChat.tsx +++ b/src/components/notes/EmbeddedChat.tsx @@ -1,24 +1,20 @@ -import { useEffect, useCallback, forwardRef, useImperativeHandle } from "react"; +import { useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { X, PanelRight, PanelRightClose } from "lucide-react"; import { cn } from "../lib/utils"; import { ChatMessages } from "../chat/ChatMessages"; import { ChatInput } from "../chat/ChatInput"; -import { useEmbeddedChat } from "../../hooks/useEmbeddedChat"; +import type { Message, AgentState } from "../chat/types"; -type EmbeddedChatMode = "hidden" | "floating" | "sidebar"; +export type EmbeddedChatMode = "hidden" | "floating" | "sidebar"; interface EmbeddedChatProps { mode: EmbeddedChatMode; onModeChange: (mode: EmbeddedChatMode) => void; - noteId: number | null; - noteTitle: string; - noteContent: string; - noteTranscript?: string; -} - -export interface EmbeddedChatHandle { - sendMessage: (text: string) => Promise; + messages: Message[]; + agentState: AgentState; + onTextSubmit: (text: string) => void; + onCancel: () => void; } function EmptyState() { @@ -32,129 +28,112 @@ function EmptyState() { ); } -const EmbeddedChat = forwardRef( - function EmbeddedChat( - { mode, onModeChange, noteId, noteTitle, noteContent, noteTranscript }, - ref - ) { - const { t } = useTranslation(); - const { messages, agentState, sendMessage, cancelStream } = useEmbeddedChat({ - noteId, - noteTitle, - noteContent, - noteTranscript, - }); - - useImperativeHandle(ref, () => ({ sendMessage }), [sendMessage]); - - // Escape key closes floating panel - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Escape" && mode === "floating") { - onModeChange("hidden"); - } - }, - [mode, onModeChange] - ); +export default function EmbeddedChat({ + mode, + onModeChange, + messages, + agentState, + onTextSubmit, + onCancel, +}: EmbeddedChatProps) { + const { t } = useTranslation(); - useEffect(() => { - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [handleKeyDown]); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && mode === "floating") { + onModeChange("hidden"); + } + }, + [mode, onModeChange] + ); - if (mode === "hidden") return null; + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); - const handleTextSubmit = (text: string) => { - sendMessage(text); - }; + if (mode === "hidden") return null; - const headerContent = ( -
- - {t("embeddedChat.title")} - -
-
- {mode === "floating" ? ( - - ) : ( - - )} + const header = ( +
+ + {t("embeddedChat.title")} + +
+
+ {mode === "floating" ? ( + + ) : ( -
+ )} +
- ); +
+ ); - if (mode === "floating") { - return ( -
- {headerContent} - } /> - -
- ); - } + const chatContent = ( + <> + {header} + } /> + + + ); - // sidebar mode + if (mode === "floating") { return (
- {headerContent} - } /> - + {chatContent}
); } -); -export default EmbeddedChat; + return ( +
+ {chatContent} +
+ ); +} diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 8c8c2a4c0..62a68f098 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -16,12 +16,11 @@ import type { NoteItem } from "../../types/electron"; import type { ActionProcessingState } from "../../hooks/useActionProcessing"; import ActionProcessingOverlay from "./ActionProcessingOverlay"; import NoteBottomBar from "./NoteBottomBar"; -import EmbeddedChat, { type EmbeddedChatHandle } from "./EmbeddedChat"; +import EmbeddedChat, { type EmbeddedChatMode } from "./EmbeddedChat"; +import { useEmbeddedChat } from "../../hooks/useEmbeddedChat"; import { normalizeDbDate } from "../../utils/dateFormatting"; import { parseTranscriptSegments } from "../../utils/parseTranscriptSegments"; -type EmbeddedChatMode = "hidden" | "floating" | "sidebar"; - function formatNoteDate(dateStr: string): string { const date = normalizeDbDate(dateStr); if (Number.isNaN(date.getTime())) return ""; @@ -90,9 +89,14 @@ export default function NoteEditor({ const { t } = useTranslation(); const [viewMode, setViewMode] = useState("raw"); const [chatMode, setChatMode] = useState("hidden"); - const embeddedChatRef = useRef(null); - const pendingAskRef = useRef(null); const editorRef = useRef(null); + + const embeddedChat = useEmbeddedChat({ + noteId: note.id, + noteTitle: note.title, + noteContent: note.content, + noteTranscript: note.transcript, + }); const titleRef = useRef(null); const prevNoteIdRef = useRef(note.id); @@ -216,24 +220,12 @@ export default function NoteEditor({ [enhancement] ); - // Handle ask submit from NoteBottomBar: open chat + send message const handleAskSubmit = useCallback((text: string) => { if (chatMode === "hidden") { - pendingAskRef.current = text; setChatMode("floating"); - } else { - embeddedChatRef.current?.sendMessage(text); - } - }, [chatMode]); - - // Send pending message once EmbeddedChat mounts - useEffect(() => { - if (chatMode !== "hidden" && pendingAskRef.current && embeddedChatRef.current) { - const text = pendingAskRef.current; - pendingAskRef.current = null; - embeddedChatRef.current.sendMessage(text); } - }, [chatMode]); + embeddedChat.sendMessage(text); + }, [chatMode, embeddedChat]); const wordCount = useMemo(() => { const trimmed = note.content.trim(); @@ -404,25 +396,23 @@ export default function NoteEditor({ /> {chatMode === "floating" && ( )}
{chatMode === "sidebar" && ( )}
From cd34a0adb856039a37a568c2f4dc46a472eecd03 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 17:27:53 -0700 Subject: [PATCH 62/91] refactor(notes): remove dead code from embedded chat feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused clearChat/hasMessages from useEmbeddedChat return - Remove redundant backdrop-blur-xl from mic button (parent has it) - Fix null→undefined coercion for note.transcript type safety --- src/components/notes/NoteBottomBar.tsx | 1 - src/components/notes/NoteEditor.tsx | 2 +- src/hooks/useEmbeddedChat.ts | 10 ---------- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/components/notes/NoteBottomBar.tsx b/src/components/notes/NoteBottomBar.tsx index 448ab692e..0fb321c45 100644 --- a/src/components/notes/NoteBottomBar.tsx +++ b/src/components/notes/NoteBottomBar.tsx @@ -74,7 +74,6 @@ export default function NoteBottomBar({ className={cn( "flex items-center justify-center w-11 h-11 rounded-full", "bg-primary/8 dark:bg-primary/12", - "backdrop-blur-xl", "border border-primary/15 dark:border-primary/20", "shadow-sm hover:shadow-md", "text-primary/60 hover:text-primary", diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 62a68f098..3feaa3dab 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -95,7 +95,7 @@ export default function NoteEditor({ noteId: note.id, noteTitle: note.title, noteContent: note.content, - noteTranscript: note.transcript, + noteTranscript: note.transcript ?? undefined, }); const titleRef = useRef(null); const prevNoteIdRef = useRef(note.id); diff --git a/src/hooks/useEmbeddedChat.ts b/src/hooks/useEmbeddedChat.ts index b2fb29486..065070e2b 100644 --- a/src/hooks/useEmbeddedChat.ts +++ b/src/hooks/useEmbeddedChat.ts @@ -15,8 +15,6 @@ interface UseEmbeddedChatReturn { agentState: AgentState; sendMessage: (text: string) => Promise; cancelStream: () => void; - clearChat: () => void; - hasMessages: boolean; } export function useEmbeddedChat({ @@ -85,18 +83,10 @@ export function useEmbeddedChat({ [conversationId, noteTitle, persistence, streaming] ); - const clearChat = useCallback(() => { - persistence.handleNewChat(); - setConversationId(null); - streaming.cancelStream(); - }, [persistence, streaming]); - return { messages: persistence.messages, agentState: streaming.agentState, sendMessage, cancelStream: streaming.cancelStream, - clearChat, - hasMessages: persistence.messages.length > 0, }; } From 7db0d91a86f10b109a0c6611976a30e464585cd1 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 17:52:45 -0700 Subject: [PATCH 63/91] refactor(notes): remove word count from note editor Remove the word count display and its useMemo calculation from the note editor header. Clean up the dead wordsCount translation key from all 10 locale files. --- src/components/notes/NoteEditor.tsx | 42 ++++++++++++++--------------- src/locales/de/translation.json | 1 - src/locales/en/translation.json | 1 - src/locales/es/translation.json | 1 - src/locales/fr/translation.json | 1 - src/locales/it/translation.json | 1 - src/locales/ja/translation.json | 1 - src/locales/pt/translation.json | 1 - src/locales/ru/translation.json | 1 - src/locales/zh-CN/translation.json | 1 - src/locales/zh-TW/translation.json | 1 - 11 files changed, 20 insertions(+), 32 deletions(-) diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index 3feaa3dab..654b9f0cd 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -220,17 +220,15 @@ export default function NoteEditor({ [enhancement] ); - const handleAskSubmit = useCallback((text: string) => { - if (chatMode === "hidden") { - setChatMode("floating"); - } - embeddedChat.sendMessage(text); - }, [chatMode, embeddedChat]); - - const wordCount = useMemo(() => { - const trimmed = note.content.trim(); - return trimmed ? trimmed.split(/\s+/).length : 0; - }, [note.content]); + const handleAskSubmit = useCallback( + (text: string) => { + if (chatMode === "hidden") { + setChatMode("floating"); + } + embeddedChat.sendMessage(text); + }, + [chatMode, embeddedChat] + ); const noteDate = formatNoteDate(note.created_at); @@ -252,15 +250,13 @@ export default function NoteEditor({
{noteDate && {noteDate}} - {noteDate && (isSaving || wordCount > 0) && ·} - - {isSaving && } - {isSaving - ? t("notes.editor.saving") - : wordCount > 0 - ? t("notes.editor.wordsCount", { count: wordCount }) - : ""} - + {noteDate && isSaving && ·} + {isSaving && ( + + + {t("notes.editor.saving")} + + )}
@@ -381,8 +377,10 @@ export default function NoteEditor({ actionName={actionName ?? null} />
Date: Wed, 25 Mar 2026 18:43:12 -0700 Subject: [PATCH 64/91] refactor(notes): redesign bottom bar and compact action picker Redesign NoteBottomBar with expandable input, inline recording timer, and compact ActionPicker with smaller sizing to match the new layout. --- src/components/notes/ActionPicker.tsx | 40 +++-- src/components/notes/EmbeddedChat.tsx | 4 +- src/components/notes/NoteBottomBar.tsx | 216 +++++++++++++++++-------- 3 files changed, 170 insertions(+), 90 deletions(-) diff --git a/src/components/notes/ActionPicker.tsx b/src/components/notes/ActionPicker.tsx index 6971fa5be..0c5d4365c 100644 --- a/src/components/notes/ActionPicker.tsx +++ b/src/components/notes/ActionPicker.tsx @@ -49,26 +49,24 @@ export default function ActionPicker({ if (!activeAction) return null; return ( -
+
@@ -79,18 +77,16 @@ export default function ActionPicker({ disabled={disabled} aria-label={t("notes.actions.selectAction")} className={cn( - "flex items-center justify-center h-11 w-8 rounded-r-xl", - "bg-accent/8 dark:bg-accent/12", - "backdrop-blur-xl", - "border border-l-0 border-accent/15 dark:border-accent/20", - "shadow-sm hover:shadow-md", - "text-accent/50 hover:text-accent", - "transition-[background-color,color,transform] duration-200", - "hover:bg-accent/15 dark:hover:bg-accent/22", - "disabled:opacity-40 disabled:pointer-events-none" + "flex items-center justify-center h-7 w-5 rounded-r-lg", + "bg-accent/6 dark:bg-accent/10", + "text-accent/35 dark:text-accent/25", + "transition-colors duration-150", + "hover:bg-accent/10 dark:hover:bg-accent/15", + "hover:text-accent/60", + "disabled:opacity-30 disabled:pointer-events-none" )} > - + diff --git a/src/components/notes/EmbeddedChat.tsx b/src/components/notes/EmbeddedChat.tsx index 0ce8f9043..161d712fe 100644 --- a/src/components/notes/EmbeddedChat.tsx +++ b/src/components/notes/EmbeddedChat.tsx @@ -56,9 +56,7 @@ export default function EmbeddedChat({ const header = (
- - {t("embeddedChat.title")} - + {t("embeddedChat.title")}
{mode === "floating" ? ( diff --git a/src/components/notes/NoteBottomBar.tsx b/src/components/notes/NoteBottomBar.tsx index 0fb321c45..23de72a7d 100644 --- a/src/components/notes/NoteBottomBar.tsx +++ b/src/components/notes/NoteBottomBar.tsx @@ -1,8 +1,9 @@ -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Mic, SendHorizontal } from "lucide-react"; +import { Mic, ArrowUp, Square, Loader2 } from "lucide-react"; import { cn } from "../lib/utils"; -import DictationWidget from "./DictationWidget"; + +const BAR_COUNT = 5; interface NoteBottomBarProps { isRecording: boolean; @@ -25,14 +26,31 @@ export default function NoteBottomBar({ }: NoteBottomBarProps) { const { t } = useTranslation(); const [inputText, setInputText] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); const inputRef = useRef(null); + const containerRef = useRef(null); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!isRecording) { + setElapsed(0); + return; + } + const id = setInterval(() => setElapsed((s) => s + 1), 1000); + return () => clearInterval(id); + }, [isRecording]); + + const minutes = String(Math.floor(elapsed / 60)).padStart(2, "0"); + const seconds = String(elapsed % 60).padStart(2, "0"); + + const hasText = inputText.trim().length > 0; const handleSubmit = useCallback(() => { const text = inputText.trim(); if (!text || askDisabled) return; onAskSubmit(text); setInputText(""); - requestAnimationFrame(() => inputRef.current?.focus()); + setIsExpanded(false); }, [inputText, askDisabled, onAskSubmit]); const handleKeyDown = useCallback( @@ -41,83 +59,151 @@ export default function NoteBottomBar({ e.preventDefault(); handleSubmit(); } + if (e.key === "Escape") { + setIsExpanded(false); + inputRef.current?.blur(); + } }, [handleSubmit] ); - if (isRecording || isProcessing) { - return ( - - ); - } + const handleInputFocus = useCallback(() => { + setIsExpanded(true); + }, []); + + useEffect(() => { + if (!isExpanded) return; + const handleClickOutside = (e: MouseEvent) => { + if (!hasText && containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsExpanded(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isExpanded, hasText]); return ( -
-
- + {isRecording ? ( + + ) : isProcessing ? ( +
+ +
+ ) : ( + + )} +
- setInputText(e.target.value)} - onKeyDown={handleKeyDown} - disabled={askDisabled} - placeholder={t("embeddedChat.askPlaceholder")} +
- - {inputText.trim() && ( - - )} + /> - {actionPicker} + {hasText ? ( + + ) : !isExpanded ? ( +
{actionPicker}
+ ) : null} +
); From e3d38491d64ed2b83a450d26945238c31d87e05d Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 25 Mar 2026 18:43:18 -0700 Subject: [PATCH 65/91] feat(notes): add new note and search notes buttons to sidebar Add "New note" and "Search notes" buttons at the top of the notes sidebar, matching the chat sidebar pattern. New note opens a dialog with folder picker (defaulting to Personal) and inline folder creation. --- src/components/ControlPanel.tsx | 1 + src/components/notes/PersonalNotesView.tsx | 195 ++++++++++++++++++++- src/locales/de/translation.json | 4 + src/locales/en/translation.json | 4 + src/locales/es/translation.json | 4 + src/locales/fr/translation.json | 4 + src/locales/it/translation.json | 4 + src/locales/ja/translation.json | 4 + src/locales/pt/translation.json | 4 + src/locales/ru/translation.json | 4 + src/locales/zh-CN/translation.json | 4 + src/locales/zh-TW/translation.json | 4 + 12 files changed, 234 insertions(+), 2 deletions(-) diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index a39ba3b32..29cdc4533 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -745,6 +745,7 @@ export default function ControlPanel() { setSettingsSection(section); setShowSettings(true); }} + onOpenSearch={() => setShowSearch(true)} meetingRecordingRequest={meetingRecordingRequest} onMeetingRecordingRequestHandled={handleMeetingRecordingRequestHandled} isMeetingMode={isMeetingMode} diff --git a/src/components/notes/PersonalNotesView.tsx b/src/components/notes/PersonalNotesView.tsx index 4f5caf810..433aad2e8 100644 --- a/src/components/notes/PersonalNotesView.tsx +++ b/src/components/notes/PersonalNotesView.tsx @@ -1,6 +1,16 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Plus, Loader2, FolderOpen, MoreHorizontal, Pencil, Trash2, Check } from "lucide-react"; +import { + Plus, + Loader2, + FolderOpen, + MoreHorizontal, + Pencil, + Trash2, + Check, + SquarePen, + Search, +} from "lucide-react"; import { Button } from "../ui/button"; import { DropdownMenu, @@ -9,6 +19,16 @@ import { DropdownMenuItem, DropdownMenuSeparator, } from "../ui/dropdown-menu"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog"; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, + SelectSeparator, +} from "../ui/select"; +import { Input } from "../ui/input"; import { useToast } from "../ui/Toast"; import NoteListItem from "./NoteListItem"; import NoteEditor from "./NoteEditor"; @@ -20,7 +40,7 @@ import { useSettingsStore, selectIsCloudReasoningMode } from "../../stores/setti import { useFolderManagement } from "../../hooks/useFolderManagement"; import { useNoteDragAndDrop } from "../../hooks/useNoteDragAndDrop"; import { cn } from "../lib/utils"; -import { MEETINGS_FOLDER_NAME } from "./shared"; +import { MEETINGS_FOLDER_NAME, findDefaultFolder } from "./shared"; import logger from "../../utils/logger"; import { parseTranscriptSegments } from "../../utils/parseTranscriptSegments"; import { @@ -44,6 +64,7 @@ function makeContentHash(content: string): string { interface PersonalNotesViewProps { onOpenSettings?: (section: string) => void; + onOpenSearch?: () => void; meetingRecordingRequest?: { noteId: number; folderId: number; event: any } | null; onMeetingRecordingRequestHandled?: () => void; isMeetingMode?: boolean; @@ -51,6 +72,7 @@ interface PersonalNotesViewProps { export default function PersonalNotesView({ onOpenSettings, + onOpenSearch, meetingRecordingRequest, onMeetingRecordingRequestHandled, isMeetingMode, @@ -64,6 +86,10 @@ export default function PersonalNotesView({ const [localContent, setLocalContent] = useState(""); const [localEnhancedContent, setLocalEnhancedContent] = useState(null); const [showActionManager, setShowActionManager] = useState(false); + const [showNewNoteDialog, setShowNewNoteDialog] = useState(false); + const [newNoteFolderId, setNewNoteFolderId] = useState(""); + const [showNewNoteFolderDialog, setShowNewNoteFolderDialog] = useState(false); + const [newNoteFolderName, setNewNoteFolderName] = useState(""); const saveTimeoutRef = useRef(null); const enhancedSaveTimeoutRef = useRef(null); const activeNoteRef = useRef(null); @@ -234,6 +260,51 @@ export default function PersonalNotesView({ } }, [activeFolderId, loadFolders]); + const handleOpenNewNoteDialog = useCallback(() => { + const personal = findDefaultFolder(folders); + setNewNoteFolderId(personal ? String(personal.id) : folders[0] ? String(folders[0].id) : ""); + setShowNewNoteDialog(true); + }, [folders]); + + const handleNewNoteFolderChange = useCallback((val: string) => { + if (val === "__create_new__") { + setShowNewNoteFolderDialog(true); + return; + } + setNewNoteFolderId(val); + }, []); + + const handleCreateNewNoteFolder = useCallback(async () => { + const trimmed = newNoteFolderName.trim(); + if (!trimmed) return; + const res = await window.electronAPI.createFolder(trimmed); + if (res.success && res.folder) { + await loadFolders(); + setNewNoteFolderId(String(res.folder.id)); + } + setNewNoteFolderName(""); + setShowNewNoteFolderDialog(false); + }, [newNoteFolderName, loadFolders]); + + const handleConfirmNewNote = useCallback(async () => { + const folderId = Number(newNoteFolderId); + if (!folderId) return; + const result = await window.electronAPI.saveNote( + t("notes.list.untitledNote"), + "", + "personal", + null, + null, + folderId + ); + if (result.success && result.note) { + setActiveFolderId(folderId); + setActiveNoteId(result.note.id); + loadFolders(); + } + setShowNewNoteDialog(false); + }, [newNoteFolderId, loadFolders]); + const handleNotesAdded = useCallback(async () => { if (activeFolderId) { await initializeNotes(null, 50, activeFolderId); @@ -410,6 +481,35 @@ export default function PersonalNotesView({ style={{ width: isMeetingMode ? 0 : "13rem" }} >
+
+ + {onOpenSearch && ( + + )} +
+ {/* Folders */}
@@ -920,6 +1020,97 @@ export default function PersonalNotesView({ onNotesAdded={handleNotesAdded} /> )} + + + + + {t("notes.sidebar.newNote")} + + {folders.length > 0 && ( +
+ + +
+ )} + + + + +
+
+ + + + + {t("notes.upload.newFolder")} + + setNewNoteFolderName(e.target.value)} + placeholder={t("notes.upload.folderName")} + className="h-8 text-xs" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleCreateNewNoteFolder(); + }} + /> + + + + + +
); } diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 0fc00dbd4..989365adc 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1532,6 +1532,10 @@ } }, "notes": { + "sidebar": { + "newNote": "Neue Notiz", + "searchNotes": "Notizen suchen" + }, "folders": { "title": "Ordner", "soon": "Demnächst", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 6a9046e23..1c202113c 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1641,6 +1641,10 @@ "upgradeUnlimited": "Upgrade to Pro — unlimited transcriptions" }, "notes": { + "sidebar": { + "newNote": "New note", + "searchNotes": "Search notes" + }, "folders": { "title": "Folders", "soon": "Soon", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 0a49a2685..6d48e01bc 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -1532,6 +1532,10 @@ } }, "notes": { + "sidebar": { + "newNote": "Nueva nota", + "searchNotes": "Buscar notas" + }, "folders": { "title": "Carpetas", "soon": "Próximamente", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 071d7a788..09768f151 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -1593,6 +1593,10 @@ "upgradeUnlimited": "Passer à Pro — transcriptions illimitées" }, "notes": { + "sidebar": { + "newNote": "Nouvelle note", + "searchNotes": "Rechercher des notes" + }, "folders": { "title": "Dossiers", "soon": "Bientôt", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 16e221b94..5ecbb05fa 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -1532,6 +1532,10 @@ } }, "notes": { + "sidebar": { + "newNote": "Nuova nota", + "searchNotes": "Cerca note" + }, "folders": { "title": "Cartelle", "soon": "Presto", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 463211264..ac71a3615 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -1460,6 +1460,10 @@ } }, "notes": { + "sidebar": { + "newNote": "新しいノート", + "searchNotes": "ノートを検索" + }, "editor": { "transcribe": "文字起こし", "staleIndicator": "強化後にコンテンツが変更されました", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 7b0adfd3c..150c04f46 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -1532,6 +1532,10 @@ } }, "notes": { + "sidebar": { + "newNote": "Nova nota", + "searchNotes": "Pesquisar notas" + }, "folders": { "title": "Pastas", "soon": "Em breve", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 2a090450f..2d8d41697 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1532,6 +1532,10 @@ } }, "notes": { + "sidebar": { + "newNote": "Новая заметка", + "searchNotes": "Поиск заметок" + }, "folders": { "title": "Папки", "soon": "Скоро", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index a00a6c9c7..d8313ac35 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -1527,6 +1527,10 @@ } }, "notes": { + "sidebar": { + "newNote": "新建笔记", + "searchNotes": "搜索笔记" + }, "folders": { "title": "文件夹", "soon": "即将推出", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index dc1427e6e..2f9b354ac 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -1527,6 +1527,10 @@ } }, "notes": { + "sidebar": { + "newNote": "新增筆記", + "searchNotes": "搜尋筆記" + }, "folders": { "title": "資料夾", "soon": "即將推出", From b345064fa8c38126804140d4b245b6c4835d3fa8 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 31 Mar 2026 10:47:12 -0700 Subject: [PATCH 66/91] refactor(ui): align all dialogs with design system guidelines Standardize dialog sizing, inputs, and buttons across all dialog instances to use design system defaults instead of ad-hoc overrides. - UploadAudioView: use design system Input/Button defaults, add label - ActionManagerDialog: switch raw inputs to design system Input, align textarea styling with Input tokens, remove button size overrides - SettingsPage: use canonical Tailwind width class - PersonalNotesView: merge two nested dialogs into single dialog with inline view toggle to fix overlay stacking bug --- src/components/SettingsPage.tsx | 2 +- src/components/notes/ActionManagerDialog.tsx | 38 ++--- src/components/notes/PersonalNotesView.tsx | 170 +++++++++---------- src/components/notes/UploadAudioView.tsx | 40 ++--- 4 files changed, 121 insertions(+), 129 deletions(-) diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index 4a06bb1f9..a11751336 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -1709,7 +1709,7 @@ export default function SettingsPage({ activeSection = "general" }: SettingsPage open={!!switchPreview} onOpenChange={(open) => !open && setSwitchPreview(null)} > - + {t("settingsPage.account.pricing.confirmSwitch.title")} diff --git a/src/components/notes/ActionManagerDialog.tsx b/src/components/notes/ActionManagerDialog.tsx index 1ec3166a6..65bbe7feb 100644 --- a/src/components/notes/ActionManagerDialog.tsx +++ b/src/components/notes/ActionManagerDialog.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Sparkles, Pencil, Trash2, Loader2 } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { cn } from "../lib/utils"; import { useActions, @@ -10,7 +11,6 @@ import { getActionName, getActionDescription, } from "../../stores/actionStore"; -import { notesInputClass, notesTextareaClass } from "./shared"; import type { ActionItem } from "../../types/electron"; interface ActionManagerDialogProps { @@ -75,33 +75,29 @@ export default function ActionManagerDialog({ open, onOpenChange }: ActionManage return ( - + - - + + {t("notes.actions.manageTitle")} -
+

{editingId !== null ? t("notes.actions.editAction") : t("notes.actions.addAction")}

- setName(e.target.value)} placeholder={t("notes.actions.namePlaceholder")} disabled={isSaving} - className={cn(notesInputClass, "disabled:opacity-40")} /> - setDescription(e.target.value)} placeholder={t("notes.actions.descriptionPlaceholder")} disabled={isSaving} - className={cn(notesInputClass, "disabled:opacity-40")} />