mirror of
https://github.com/zadam/trilium.git
synced 2026-06-26 19:21:42 +02:00
feat(llm): add basic web search support
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user