From d61ade9fe98cee4e1919619306b2f0607cc810e8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 28 Mar 2026 21:00:53 +0200 Subject: [PATCH] feat(llm): add basic web search support --- apps/client/src/services/llm_chat.ts | 18 +++ .../src/translations/en/translation.json | 5 +- .../type_widgets/llm_chat/ChatMessage.tsx | 25 ++++ .../widgets/type_widgets/llm_chat/LlmChat.css | 113 ++++++++++++++++++ .../widgets/type_widgets/llm_chat/LlmChat.tsx | 102 ++++++++++++---- .../src/services/llm/providers/anthropic.ts | 71 ++++++++++- apps/server/src/services/llm/types.ts | 17 ++- 7 files changed, 314 insertions(+), 37 deletions(-) diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index 759ed74773..1ba76067da 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -9,10 +9,19 @@ export interface ChatConfig { provider?: string; model?: string; systemPrompt?: string; + enableWebSearch?: boolean; +} + +export interface Citation { + url: string; + title?: string; } export interface StreamCallbacks { onChunk: (text: string) => void; + onToolUse?: (toolName: string, input: Record) => void; + onToolResult?: (toolName: string, result: string) => void; + onCitation?: (citation: Citation) => void; onError: (error: string) => void; onDone: () => void; } @@ -68,6 +77,15 @@ export async function streamChatCompletion( case "text": callbacks.onChunk(data.content); break; + case "tool_use": + callbacks.onToolUse?.(data.toolName, data.toolInput); + break; + case "tool_result": + callbacks.onToolResult?.(data.toolName, data.result); + break; + case "citation": + callbacks.onCitation?.({ url: data.url, title: data.title }); + break; case "error": callbacks.onError(data.error); break; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index caca3397fe..06e92dd0fa 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1615,7 +1615,10 @@ "placeholder": "Type a message...", "send": "Send", "sending": "Sending...", - "empty_state": "Start a conversation by typing a message below." + "empty_state": "Start a conversation by typing a message below.", + "searching_web": "Searching the web...", + "web_search": "Web search", + "sources": "Sources" }, "shared_switch": { "shared": "Shared", diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index 337bc4188b..3e5c6affdf 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -1,3 +1,5 @@ +import { t } from "../../../services/i18n.js"; +import type { Citation } from "../../../services/llm_chat.js"; import "./LlmChat.css"; interface StoredMessage { @@ -5,6 +7,7 @@ interface StoredMessage { role: "user" | "assistant" | "system"; content: string; createdAt: string; + citations?: Citation[]; } interface Props { @@ -24,6 +27,28 @@ export default function ChatMessage({ message, isStreaming }: Props) { {message.content} {isStreaming && } + {message.citations && message.citations.length > 0 && ( +
+
+ + {t("llm_chat.sources")} +
+ +
+ )} ); } diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css index ce9436f74f..8052103c1e 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.css @@ -67,6 +67,77 @@ 51%, 100% { opacity: 0; } } +/* Tool activity indicator */ +.llm-chat-tool-activity { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + margin-bottom: 1rem; + border-radius: 8px; + background: var(--accented-background-color); + color: var(--muted-text-color); + font-size: 0.9rem; + max-width: 85%; +} + +.llm-chat-tool-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--muted-text-color); + border-top-color: transparent; + border-radius: 50%; + animation: llm-chat-spin 0.8s linear infinite; +} + +@keyframes llm-chat-spin { + to { transform: rotate(360deg); } +} + +/* Citations */ +.llm-chat-citations { + margin-top: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--main-border-color); +} + +.llm-chat-citations-label { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--muted-text-color); + margin-bottom: 0.25rem; +} + +.llm-chat-citations-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.llm-chat-citations-list li { + font-size: 0.8rem; +} + +.llm-chat-citations-list a { + color: var(--link-color, #007bff); + text-decoration: none; + padding: 0.125rem 0.5rem; + background: var(--accented-background-color); + border-radius: 4px; + display: inline-block; +} + +.llm-chat-citations-list a:hover { + text-decoration: underline; +} + +/* Error */ .llm-chat-error { padding: 0.75rem 1rem; margin-bottom: 1rem; @@ -76,11 +147,18 @@ color: var(--danger-text-color, #c00); } +/* Input form */ .llm-chat-input-form { display: flex; + flex-direction: column; gap: 0.5rem; padding-top: 1rem; border-top: 1px solid var(--main-border-color); +} + +.llm-chat-input-row { + display: flex; + gap: 0.5rem; align-items: flex-end; } @@ -129,3 +207,38 @@ opacity: 0.5; cursor: not-allowed; } + +/* Options row */ +.llm-chat-options { + display: flex; + gap: 1rem; + padding-left: 0.25rem; +} + +.llm-chat-toggle { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.85rem; + color: var(--muted-text-color); + cursor: pointer; + user-select: none; +} + +.llm-chat-toggle input[type="checkbox"] { + margin: 0; + cursor: pointer; +} + +.llm-chat-toggle .bx { + font-size: 1rem; +} + +.llm-chat-toggle:has(input:checked) { + color: var(--main-text-color); +} + +.llm-chat-toggle:has(input:disabled) { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx index 3e20e32a55..b045652e4c 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/LlmChat.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks"; import { t } from "../../../services/i18n.js"; -import { streamChatCompletion, type ChatMessage as ChatMessageData } from "../../../services/llm_chat.js"; +import { streamChatCompletion, type ChatMessage as ChatMessageData, type Citation } from "../../../services/llm_chat.js"; import { useEditorSpacedUpdate } from "../../react/hooks.js"; import { TypeWidgetProps } from "../type_widget.js"; import ChatMessage from "./ChatMessage.js"; @@ -11,20 +11,23 @@ interface StoredMessage { role: "user" | "assistant" | "system"; content: string; createdAt: string; + citations?: Citation[]; } interface LlmChatContent { version: 1; messages: StoredMessage[]; + enableWebSearch?: boolean; } -const EMPTY_CONTENT: LlmChatContent = { version: 1, messages: [] }; - export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(""); + const [toolActivity, setToolActivity] = useState(null); + const [pendingCitations, setPendingCitations] = useState([]); + const [enableWebSearch, setEnableWebSearch] = useState(true); const [error, setError] = useState(null); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -35,14 +38,14 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { useEffect(() => { scrollToBottom(); - }, [messages, streamingContent, scrollToBottom]); + }, [messages, streamingContent, toolActivity, scrollToBottom]); const spacedUpdate = useEditorSpacedUpdate({ note, noteType: "llmChat", noteContext, getData: () => { - const content: LlmChatContent = { version: 1, messages }; + const content: LlmChatContent = { version: 1, messages, enableWebSearch }; return { content: JSON.stringify(content) }; }, onContentChange: (content) => { @@ -53,6 +56,9 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { try { const parsed: LlmChatContent = JSON.parse(content); setMessages(parsed.messages || []); + if (typeof parsed.enableWebSearch === "boolean") { + setEnableWebSearch(parsed.enableWebSearch); + } } catch (e) { console.error("Failed to parse LLM chat content:", e); setMessages([]); @@ -65,6 +71,8 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { if (!input.trim() || isStreaming) return; setError(null); + setToolActivity(null); + setPendingCitations([]); const userMessage: StoredMessage = { id: crypto.randomUUID(), @@ -80,6 +88,7 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { setStreamingContent(""); let assistantContent = ""; + const citations: Citation[] = []; const apiMessages: ChatMessageData[] = newMessages.map(m => ({ role: m.role, @@ -88,16 +97,28 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { await streamChatCompletion( apiMessages, - {}, + { enableWebSearch }, { onChunk: (text) => { assistantContent += text; setStreamingContent(assistantContent); + setToolActivity(null); // Clear tool activity when text starts + }, + onToolUse: (toolName, _input) => { + const toolLabel = toolName === "web_search" + ? t("llm_chat.searching_web") + : `Using ${toolName}...`; + setToolActivity(toolLabel); + }, + onCitation: (citation) => { + citations.push(citation); + setPendingCitations([...citations]); }, onError: (errorMsg) => { console.error("Chat error:", errorMsg); setError(errorMsg); setIsStreaming(false); + setToolActivity(null); }, onDone: () => { if (assistantContent) { @@ -105,17 +126,20 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { id: crypto.randomUUID(), role: "assistant", content: assistantContent, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + citations: citations.length > 0 ? citations : undefined }; setMessages(prev => [...prev, assistantMessage]); } setStreamingContent(""); + setPendingCitations([]); setIsStreaming(false); + setToolActivity(null); spacedUpdate.scheduleUpdate(); } } ); - }, [input, isStreaming, messages, spacedUpdate]); + }, [input, isStreaming, messages, enableWebSearch, spacedUpdate]); const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -124,6 +148,11 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { } }, [handleSubmit]); + const toggleWebSearch = useCallback(() => { + setEnableWebSearch(prev => !prev); + spacedUpdate.scheduleUpdate(); + }, [spacedUpdate]); + return (
@@ -135,13 +164,20 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) { {messages.map(msg => ( ))} + {toolActivity && ( +
+ + {toolActivity} +
+ )} {isStreaming && streamingContent && ( 0 ? pendingCitations : undefined }} isStreaming /> @@ -154,23 +190,37 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
-