feat(llm): add basic web search support

This commit is contained in:
Elian Doran
2026-03-28 21:00:53 +02:00
parent aa1fe549c7
commit d61ade9fe9
7 changed files with 314 additions and 37 deletions

View File

@@ -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<string, unknown>) => 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;

View File

@@ -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",

View File

@@ -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 && <span className="llm-chat-cursor" />}
</div>
{message.citations && message.citations.length > 0 && (
<div className="llm-chat-citations">
<div className="llm-chat-citations-label">
<span className="bx bx-link" />
{t("llm_chat.sources")}
</div>
<ul className="llm-chat-citations-list">
{message.citations.map((citation, idx) => (
<li key={idx}>
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
title={citation.url}
>
{citation.title || new URL(citation.url).hostname}
</a>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -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;
}

View File

@@ -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<StoredMessage[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [toolActivity, setToolActivity] = useState<string | null>(null);
const [pendingCitations, setPendingCitations] = useState<Citation[]>([]);
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div className="llm-chat-container">
<div className="llm-chat-messages">
@@ -135,13 +164,20 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
{messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{toolActivity && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{toolActivity}
</div>
)}
{isStreaming && streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: streamingContent,
createdAt: new Date().toISOString()
createdAt: new Date().toISOString(),
citations: pendingCitations.length > 0 ? pendingCitations : undefined
}}
isStreaming
/>
@@ -154,23 +190,37 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
<div ref={messagesEndRef} />
</div>
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
<textarea
ref={textareaRef}
className="llm-chat-input"
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={isStreaming}
onKeyDown={handleKeyDown}
rows={3}
/>
<button
type="submit"
className="llm-chat-send-btn"
disabled={isStreaming || !input.trim()}
>
{isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
</button>
<div className="llm-chat-input-row">
<textarea
ref={textareaRef}
className="llm-chat-input"
value={input}
onInput={(e) => setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={isStreaming}
onKeyDown={handleKeyDown}
rows={3}
/>
<button
type="submit"
className="llm-chat-send-btn"
disabled={isStreaming || !input.trim()}
>
{isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
</button>
</div>
<div className="llm-chat-options">
<label className="llm-chat-toggle">
<input
type="checkbox"
checked={enableWebSearch}
onChange={toggleWebSearch}
disabled={isStreaming}
/>
<span className="bx bx-globe" />
{t("llm_chat.web_search")}
</label>
</div>
</form>
</div>
);

View File

@@ -2,7 +2,7 @@ import Anthropic from "@anthropic-ai/sdk";
import type { LlmProvider, LlmMessage, LlmStreamChunk, LlmProviderConfig } from "../types.js";
const DEFAULT_MODEL = "claude-sonnet-4-20250514";
const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_MAX_TOKENS = 8096;
export class AnthropicProvider implements LlmProvider {
name = "anthropic";
@@ -23,8 +23,20 @@ export class AnthropicProvider implements LlmProvider {
const systemPrompt = config.systemPrompt || messages.find(m => m.role === "system")?.content;
const chatMessages = messages.filter(m => m.role !== "system");
// Build tools array - using 'unknown' assertion for server-side tools
// that may not be in the SDK types yet
const tools: unknown[] = [];
if (config.enableWebSearch) {
tools.push({
type: "web_search_20250305",
name: "web_search",
max_uses: 5 // Limit searches per request
});
}
try {
const stream = this.client.messages.stream({
// Cast tools to any since server-side tools may not be in SDK types yet
const streamParams: Anthropic.Messages.MessageStreamParams = {
model: config.model || DEFAULT_MODEL,
max_tokens: config.maxTokens || DEFAULT_MAX_TOKENS,
system: systemPrompt,
@@ -32,11 +44,60 @@ export class AnthropicProvider implements LlmProvider {
role: m.role as "user" | "assistant",
content: m.content
}))
});
};
if (tools.length > 0) {
(streamParams as any).tools = tools;
}
const stream = this.client.messages.stream(streamParams);
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
yield { type: "text", content: event.delta.text };
// Handle different event types
if (event.type === "content_block_start") {
const block = event.content_block;
if (block.type === "tool_use") {
yield {
type: "tool_use",
toolName: block.name,
toolInput: {} // Input comes in deltas
};
}
} else if (event.type === "content_block_delta") {
const delta = event.delta;
if (delta.type === "text_delta") {
yield { type: "text", content: delta.text };
} else if (delta.type === "input_json_delta") {
// Tool input is being streamed - we could accumulate it
// For now, we already emitted tool_use at start
}
} else if (event.type === "content_block_stop") {
// Content block finished
// For server-side tools, results come in subsequent blocks
}
// Handle server-side tool results (for web_search)
// These appear as special content blocks in the response
if (event.type === "message_delta") {
// Check for citations in stop_reason or other metadata
}
}
// Get the final message to extract any citations
const finalMessage = await stream.finalMessage();
for (const block of finalMessage.content) {
if (block.type === "text") {
// Check for citations in the text block
// Anthropic returns citations as part of the content
if ("citations" in block && Array.isArray((block as any).citations)) {
for (const citation of (block as any).citations) {
yield {
type: "citation",
url: citation.url || citation.source,
title: citation.title
};
}
}
}
}

View File

@@ -8,11 +8,16 @@ export interface LlmMessage {
content: string;
}
export interface LlmStreamChunk {
type: "text" | "error" | "done";
content?: string;
error?: string;
}
/**
* Stream chunk types for real-time updates.
*/
export type LlmStreamChunk =
| { type: "text"; content: string }
| { type: "tool_use"; toolName: string; toolInput: Record<string, unknown> }
| { type: "tool_result"; toolName: string; result: string }
| { type: "citation"; url: string; title?: string }
| { type: "error"; error: string }
| { type: "done" };
export interface LlmProviderConfig {
provider?: string;
@@ -20,6 +25,8 @@ export interface LlmProviderConfig {
maxTokens?: number;
temperature?: number;
systemPrompt?: string;
/** Enable web search tool */
enableWebSearch?: boolean;
}
export interface LlmProvider {