From fa70933388332f049f0f527a9209e23357c02668 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 10:19:24 +0000 Subject: [PATCH] feat: add tool calling support to AI chat - Extend LLMMessage types to support tool calls, tool results, multimodal content (images), and reasoning content (extended thinking) - Add ChatStreamChunk types for tool_call and reasoning streaming - Implement agentic loop in session manager that continues generation after tool execution until finish_reason is "stop" - Create tool interface and registry for registering executable tools - Update provider manager to stream tool calls and pass tools to API - Update AIViewer UI to render tool calls, tool results, and reasoning - Clean up legacy hooks in useLLM.ts (replaced by session manager) https://claude.ai/code/session_01HqtD9R33oqfB14Gu1V5wHC --- src/components/AIViewer.tsx | 112 +++++++-- src/hooks/useLLM.ts | 190 +------------- src/services/llm/provider-manager.ts | 123 +++++++-- src/services/llm/session-manager.ts | 360 +++++++++++++++++++++------ src/services/llm/tools.ts | 212 ++++++++++++++++ src/types/llm.ts | 185 +++++++++++++- 6 files changed, 875 insertions(+), 307 deletions(-) create mode 100644 src/services/llm/tools.ts diff --git a/src/components/AIViewer.tsx b/src/components/AIViewer.tsx index 8c961aa..6639d70 100644 --- a/src/components/AIViewer.tsx +++ b/src/components/AIViewer.tsx @@ -42,7 +42,13 @@ import { formatTimestamp } from "@/hooks/useLocale"; import { useGrimoire } from "@/core/state"; import { AIProvidersViewer } from "./AIProvidersViewer"; import { MarkdownContent } from "./nostr/MarkdownContent"; -import type { LLMMessage } from "@/types/llm"; +import { + getMessageTextContent, + hasToolCalls, + isToolMessage, + type LLMMessage, + type AssistantMessage, +} from "@/types/llm"; const MOBILE_BREAKPOINT = 768; @@ -70,30 +76,86 @@ const MessageBubble = memo(function MessageBubble({ message: LLMMessage; }) { const isUser = message.role === "user"; + const isTool = isToolMessage(message); + const hasTools = hasToolCalls(message); + const content = getMessageTextContent(message); - if (!isUser && !message.content) { + // Skip empty non-user messages that don't have tool calls + if (!isUser && !content && !hasTools) { return null; } - return ( -
-
- {isUser ? ( -
- {message.content} + // Tool message (response from tool execution) + if (isTool) { + return ( +
+
+
+ + Tool Result
- ) : ( +
+            {content}
+          
+
+
+ ); + } + + // User message + if (isUser) { + return ( +
+
+
{content}
+
+
+ ); + } + + // Assistant message + const assistantMsg = message as AssistantMessage; + + return ( +
+
+ {/* Reasoning content (collapsible) */} + {assistantMsg.reasoning_content && ( +
+ + + Reasoning + +
+ {assistantMsg.reasoning_content} +
+
+ )} + + {/* Tool calls */} + {assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0 && ( +
+ {assistantMsg.tool_calls.map((tc) => ( +
+
+ + {tc.function.name} +
+
+                  {formatToolArgs(tc.function.arguments)}
+                
+
+ ))} +
+ )} + + {/* Regular content */} + {content && (
- +
)}
@@ -101,6 +163,18 @@ const MessageBubble = memo(function MessageBubble({ ); }); +/** + * Format tool call arguments for display. + */ +function formatToolArgs(args: string): string { + try { + const parsed = JSON.parse(args); + return JSON.stringify(parsed, null, 2); + } catch { + return args; + } +} + // ───────────────────────────────────────────────────────────── // Thinking Indicator // ───────────────────────────────────────────────────────────── diff --git a/src/hooks/useLLM.ts b/src/hooks/useLLM.ts index 1d2222e..022df7e 100644 --- a/src/hooks/useLLM.ts +++ b/src/hooks/useLLM.ts @@ -4,11 +4,9 @@ import { useState, useCallback, useEffect } from "react"; import { use$ } from "applesauce-react/hooks"; -import { useLiveQuery } from "dexie-react-hooks"; -import db from "@/services/db"; import { providerManager } from "@/services/llm/provider-manager"; import { AI_PROVIDER_PRESETS } from "@/lib/ai-provider-presets"; -import type { LLMProviderInstance, LLMModel, LLMMessage } from "@/types/llm"; +import type { LLMProviderInstance, LLMModel } from "@/types/llm"; // ───────────────────────────────────────────────────────────── // Provider Management @@ -108,192 +106,6 @@ export function useRecentModels() { return recentModels ?? []; } -// ───────────────────────────────────────────────────────────── -// Conversations -// ───────────────────────────────────────────────────────────── - -export function useLLMConversations() { - const conversations = useLiveQuery( - () => db.llmConversations.orderBy("updatedAt").reverse().toArray(), - [], - ); - - const createConversation = useCallback( - async ( - providerInstanceId: string, - modelId: string, - systemPrompt?: string, - ): Promise => { - const id = crypto.randomUUID(); - const now = Date.now(); - - await db.llmConversations.add({ - id, - title: "New conversation", - providerInstanceId, - modelId, - systemPrompt, - messages: [], - createdAt: now, - updatedAt: now, - }); - - return id; - }, - [], - ); - - const deleteConversation = useCallback(async (conversationId: string) => { - await db.llmConversations.delete(conversationId); - }, []); - - const updateTitle = useCallback( - async (conversationId: string, title: string) => { - await db.llmConversations.update(conversationId, { - title, - updatedAt: Date.now(), - }); - }, - [], - ); - - return { - conversations: conversations ?? [], - createConversation, - deleteConversation, - updateTitle, - }; -} - -export function useLLMConversation(conversationId: string | null) { - const conversation = useLiveQuery( - () => - conversationId ? db.llmConversations.get(conversationId) : undefined, - [conversationId], - ); - - const addMessage = useCallback( - async (message: Omit) => { - if (!conversationId) return null; - - const conv = await db.llmConversations.get(conversationId); - if (!conv) return null; - - const newMessage: LLMMessage = { - ...message, - id: crypto.randomUUID(), - timestamp: Date.now(), - }; - - const isFirstUserMessage = - conv.messages.length === 0 && message.role === "user"; - - await db.llmConversations.update(conversationId, { - messages: [...conv.messages, newMessage], - updatedAt: Date.now(), - // Auto-title from first user message - title: isFirstUserMessage - ? message.content.slice(0, 50) + - (message.content.length > 50 ? "..." : "") - : conv.title, - }); - - return newMessage; - }, - [conversationId], - ); - - const updateLastMessage = useCallback( - async (content: string) => { - if (!conversationId) return; - - const conv = await db.llmConversations.get(conversationId); - if (!conv || conv.messages.length === 0) return; - - const messages = [...conv.messages]; - messages[messages.length - 1] = { - ...messages[messages.length - 1], - content, - }; - - await db.llmConversations.update(conversationId, { - messages, - updatedAt: Date.now(), - }); - }, - [conversationId], - ); - - return { - conversation, - addMessage, - updateLastMessage, - }; -} - -// ───────────────────────────────────────────────────────────── -// Chat -// ───────────────────────────────────────────────────────────── - -export function useLLMChat() { - const [isGenerating, setIsGenerating] = useState(false); - const [abortController, setAbortController] = - useState(null); - - const sendMessage = useCallback( - async ( - instanceId: string, - modelId: string, - messages: LLMMessage[], - onToken: (token: string) => void, - onDone: () => void, - onError: (error: string) => void, - ) => { - const controller = new AbortController(); - setAbortController(controller); - setIsGenerating(true); - - try { - const stream = providerManager.chat(instanceId, modelId, messages, { - signal: controller.signal, - }); - - for await (const chunk of stream) { - if (chunk.type === "token" && chunk.content) { - onToken(chunk.content); - } else if (chunk.type === "error" && chunk.error) { - onError(chunk.error); - break; - } else if (chunk.type === "done") { - onDone(); - break; - } - } - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") { - onDone(); - } else { - onError(err instanceof Error ? err.message : "Chat failed"); - } - } finally { - setIsGenerating(false); - setAbortController(null); - } - }, - [], - ); - - const cancel = useCallback(() => { - abortController?.abort(); - }, [abortController]); - - return { - isGenerating, - sendMessage, - cancel, - }; -} - // ───────────────────────────────────────────────────────────── // Connection Test // ───────────────────────────────────────────────────────────── diff --git a/src/services/llm/provider-manager.ts b/src/services/llm/provider-manager.ts index 7f947dd..64affef 100644 --- a/src/services/llm/provider-manager.ts +++ b/src/services/llm/provider-manager.ts @@ -258,24 +258,77 @@ class AIProviderManager { try { const client = this.getClient(instance); - const stream = await client.chat.completions.create( - { - model: modelId, - messages: messages.map((m) => ({ role: m.role, content: m.content })), - stream: true, - stream_options: { include_usage: true }, - temperature: options.temperature ?? 0.7, - max_tokens: options.maxTokens, - }, - { signal: options.signal }, - ); + // Format messages for OpenAI API + const formattedMessages = this.formatMessages(messages); + + // Build request params + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params: any = { + model: modelId, + messages: formattedMessages, + stream: true, + stream_options: { include_usage: true }, + temperature: options.temperature ?? 0.7, + max_tokens: options.maxTokens, + }; + + // Add tools if provided + if (options.tools && options.tools.length > 0) { + params.tools = options.tools; + if (options.tool_choice) { + params.tool_choice = options.tool_choice; + } + } + + const stream = (await client.chat.completions.create(params, { + signal: options.signal, + })) as unknown as AsyncIterable; let usage: ChatStreamChunk["usage"] | undefined; + let finishReason: ChatStreamChunk["finish_reason"] = null; for await (const chunk of stream) { - const content = chunk.choices[0]?.delta?.content; - if (content) { - yield { type: "token", content }; + const choice = chunk.choices[0]; + if (!choice) continue; + + const delta = choice.delta; + + // Regular content + if (delta?.content) { + yield { type: "token", content: delta.content }; + } + + // Extended thinking / reasoning (Claude, DeepSeek, etc.) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reasoning = (delta as any)?.reasoning_content; + if (reasoning) { + yield { type: "reasoning", content: reasoning }; + } + + // Tool calls (streamed incrementally) + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + yield { + type: "tool_call", + tool_call: { + index: tc.index, + id: tc.id, + type: tc.type, + function: tc.function + ? { + name: tc.function.name, + arguments: tc.function.arguments, + } + : undefined, + }, + }; + } + } + + // Capture finish reason + if (choice.finish_reason) { + finishReason = + choice.finish_reason as ChatStreamChunk["finish_reason"]; } // Capture usage from final chunk @@ -287,7 +340,7 @@ class AIProviderManager { } } - yield { type: "done", usage }; + yield { type: "done", usage, finish_reason: finishReason }; // Update lastUsed and add to recent models await db.llmProviders.update(instanceId, { @@ -306,6 +359,46 @@ class AIProviderManager { } } + /** + * Format LLMMessage array for OpenAI API. + * Handles tool messages and multimodal content. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private formatMessages(messages: LLMMessage[]): any[] { + return messages.map((m) => { + if (m.role === "tool") { + // Tool response message + return { + role: "tool", + content: m.content, + tool_call_id: m.tool_call_id, + }; + } + + if (m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0) { + // Assistant message with tool calls + return { + role: "assistant", + content: m.content || null, + tool_calls: m.tool_calls.map((tc) => ({ + id: tc.id, + type: tc.type, + function: { + name: tc.function.name, + arguments: tc.function.arguments, + }, + })), + }; + } + + // Standard message (handles string content and array content) + return { + role: m.role, + content: m.content, + }; + }); + } + /** * Test connection to a provider. */ diff --git a/src/services/llm/session-manager.ts b/src/services/llm/session-manager.ts index a4f0c3f..f2f6ef2 100644 --- a/src/services/llm/session-manager.ts +++ b/src/services/llm/session-manager.ts @@ -13,6 +13,7 @@ import { BehaviorSubject, Subject } from "rxjs"; import db from "@/services/db"; import { providerManager } from "./provider-manager"; +import { toolRegistry, executeToolCalls, type ToolContext } from "./tools"; import type { ChatSessionState, StreamingUpdateEvent, @@ -21,6 +22,10 @@ import type { SessionErrorEvent, LLMMessage, LLMConversation, + AssistantMessage, + StreamingMessage, + StreamingToolCall, + ToolCall, } from "@/types/llm"; // Session cleanup delay (ms) - wait before cleaning up after last subscriber leaves @@ -293,6 +298,7 @@ class ChatSessionManager { /** * Start or resume AI generation for a conversation. + * Implements the agentic loop: generates, executes tools, continues until done. */ async startGeneration(conversationId: string): Promise { const session = this.getSession(conversationId); @@ -318,6 +324,7 @@ class ChatSessionManager { ...session, isLoading: true, streamingContent: "", + streamingMessage: undefined, abortController, lastError: undefined, finishReason: null, @@ -326,89 +333,87 @@ class ChatSessionManager { this.loadingChanged$.next({ conversationId, isLoading: true }); + // Get tool definitions if any tools are registered + const tools = toolRegistry.getDefinitions(); + try { - // Stream response from provider - let fullContent = ""; - let usage: ChatSessionState["usage"]; + // Agentic loop - continue until we get a final response + let continueLoop = true; + let totalCost = 0; - const chatGenerator = providerManager.chat( - session.providerInstanceId, - session.modelId, - conversation.messages, - { signal: abortController.signal }, - ); - - for await (const chunk of chatGenerator) { - // Check if session still exists and is loading - const currentSession = this.getSession(conversationId); - if (!currentSession?.isLoading) { - break; + while (continueLoop) { + // Check if aborted + if (abortController.signal.aborted) { + throw new DOMException("Aborted", "AbortError"); } - if (chunk.type === "token" && chunk.content) { - fullContent += chunk.content; - - // Update streaming content - this.updateSession(conversationId, { - ...currentSession, - streamingContent: fullContent, - lastActivity: Date.now(), - }); - - this.streamingUpdate$.next({ - conversationId, - content: fullContent, - }); - } else if (chunk.type === "done") { - usage = chunk.usage; - } else if (chunk.type === "error") { - throw new Error(chunk.error || "Unknown error"); - } - } - - // Create assistant message - const assistantMessage: LLMMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: fullContent, - timestamp: Date.now(), - }; - - // Add to Dexie - const updatedConv = await db.llmConversations.get(conversationId); - if (updatedConv) { - await db.llmConversations.update(conversationId, { - messages: [...updatedConv.messages, assistantMessage], - updatedAt: Date.now(), - }); - } - - this.messageAdded$.next({ conversationId, message: assistantMessage }); - - // Calculate cost if we have usage and pricing - let cost = 0; - if (usage) { - cost = await this.calculateCost( + // Run one turn of generation + const result = await this.runGenerationTurn( + conversationId, session.providerInstanceId, session.modelId, - usage.promptTokens, - usage.completionTokens, + tools.length > 0 ? tools : undefined, + abortController.signal, ); - } - // Update session to completed state - const finalSession = this.getSession(conversationId); - if (finalSession) { - this.updateSession(conversationId, { - ...finalSession, - isLoading: false, - streamingContent: "", - abortController: undefined, - usage, - sessionCost: finalSession.sessionCost + cost, - finishReason: "stop", - lastActivity: Date.now(), - }); + // Accumulate cost + totalCost += result.cost; + + // Check if we need to execute tools + if ( + result.finishReason === "tool_calls" && + result.toolCalls && + result.toolCalls.length > 0 + ) { + // Execute tool calls + const toolContext: ToolContext = { + conversationId, + providerInstanceId: session.providerInstanceId, + modelId: session.modelId, + signal: abortController.signal, + }; + + const toolMessages = await executeToolCalls( + result.toolCalls, + toolContext, + ); + + // Add tool messages to conversation + const conv = await db.llmConversations.get(conversationId); + if (conv) { + await db.llmConversations.update(conversationId, { + messages: [...conv.messages, ...toolMessages], + updatedAt: Date.now(), + }); + } + + // Emit events for each tool message + for (const msg of toolMessages) { + this.messageAdded$.next({ conversationId, message: msg }); + } + + // Continue the loop to process tool results + continueLoop = true; + } else { + // No more tools to execute, we're done + continueLoop = false; + + // Update session to completed state + const finalSession = this.getSession(conversationId); + if (finalSession) { + this.updateSession(conversationId, { + ...finalSession, + isLoading: false, + streamingContent: "", + streamingMessage: undefined, + abortController: undefined, + usage: result.usage, + sessionCost: finalSession.sessionCost + totalCost, + finishReason: result.finishReason, + lastActivity: Date.now(), + }); + } + } } this.loadingChanged$.next({ conversationId, isLoading: false }); @@ -420,6 +425,7 @@ class ChatSessionManager { this.updateSession(conversationId, { ...currentSession, isLoading: false, + streamingMessage: undefined, abortController: undefined, finishReason: null, // Can resume lastActivity: Date.now(), @@ -439,6 +445,7 @@ class ChatSessionManager { ...currentSession, isLoading: false, streamingContent: "", + streamingMessage: undefined, abortController: undefined, lastError: errorMessage, finishReason: "error", @@ -451,6 +458,188 @@ class ChatSessionManager { } } + /** + * Run a single turn of generation (stream response from model). + * Returns the assistant message, finish reason, and cost. + */ + private async runGenerationTurn( + conversationId: string, + providerInstanceId: string, + modelId: string, + tools: import("@/types/llm").ToolDefinition[] | undefined, + signal: AbortSignal, + ): Promise<{ + assistantMessage: AssistantMessage; + finishReason: ChatSessionState["finishReason"]; + toolCalls: ToolCall[] | undefined; + usage: ChatSessionState["usage"]; + cost: number; + }> { + // Get current messages from Dexie + const conversation = await db.llmConversations.get(conversationId); + if (!conversation) { + throw new Error("Conversation not found"); + } + + // Initialize streaming message state + const streaming: StreamingMessage = { + content: "", + reasoning_content: undefined, + tool_calls: undefined, + }; + + // Update session with empty streaming message + const currentSession = this.getSession(conversationId); + if (currentSession) { + this.updateSession(conversationId, { + ...currentSession, + streamingMessage: { ...streaming }, + streamingContent: "", + lastActivity: Date.now(), + }); + } + + // Track streaming tool calls by index + const toolCallsMap = new Map(); + let usage: ChatSessionState["usage"]; + let finishReason: ChatSessionState["finishReason"] = "stop"; + + const chatGenerator = providerManager.chat( + providerInstanceId, + modelId, + conversation.messages, + { signal, tools }, + ); + + for await (const chunk of chatGenerator) { + // Check if session still exists and is loading + const session = this.getSession(conversationId); + if (!session?.isLoading) { + break; + } + + if (chunk.type === "token" && chunk.content) { + // Regular content token + streaming.content += chunk.content; + + // Update streaming state + this.updateSession(conversationId, { + ...session, + streamingContent: streaming.content, + streamingMessage: { ...streaming }, + lastActivity: Date.now(), + }); + + this.streamingUpdate$.next({ + conversationId, + content: streaming.content, + }); + } else if (chunk.type === "reasoning" && chunk.content) { + // Reasoning/thinking content (Claude, DeepSeek, etc.) + streaming.reasoning_content = + (streaming.reasoning_content || "") + chunk.content; + + this.updateSession(conversationId, { + ...session, + streamingMessage: { ...streaming }, + lastActivity: Date.now(), + }); + } else if (chunk.type === "tool_call" && chunk.tool_call) { + // Accumulate streaming tool call + const tc = chunk.tool_call; + const existing = toolCallsMap.get(tc.index); + + if (existing) { + // Append to existing tool call + if (tc.id) existing.id = tc.id; + if (tc.type) existing.type = tc.type; + if (tc.function) { + existing.function = existing.function || {}; + if (tc.function.name) existing.function.name = tc.function.name; + if (tc.function.arguments) { + existing.function.arguments = + (existing.function.arguments || "") + tc.function.arguments; + } + } + } else { + // New tool call + toolCallsMap.set(tc.index, { ...tc }); + } + + // Convert map to array for state + streaming.tool_calls = Array.from(toolCallsMap.values()) + .filter((t) => t.id && t.function?.name) + .map((t) => ({ + id: t.id!, + type: "function" as const, + function: { + name: t.function!.name!, + arguments: t.function!.arguments || "", + }, + })); + + this.updateSession(conversationId, { + ...session, + streamingMessage: { ...streaming }, + lastActivity: Date.now(), + }); + } else if (chunk.type === "done") { + usage = chunk.usage; + if (chunk.finish_reason) { + finishReason = chunk.finish_reason; + } + } else if (chunk.type === "error") { + throw new Error(chunk.error || "Unknown error"); + } + } + + // Create assistant message with all accumulated content + const assistantMessage: AssistantMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: streaming.content, + timestamp: Date.now(), + }; + + // Add optional fields if present + if (streaming.reasoning_content) { + assistantMessage.reasoning_content = streaming.reasoning_content; + } + if (streaming.tool_calls && streaming.tool_calls.length > 0) { + assistantMessage.tool_calls = streaming.tool_calls; + } + + // Add to Dexie + const updatedConv = await db.llmConversations.get(conversationId); + if (updatedConv) { + await db.llmConversations.update(conversationId, { + messages: [...updatedConv.messages, assistantMessage], + updatedAt: Date.now(), + }); + } + + this.messageAdded$.next({ conversationId, message: assistantMessage }); + + // Calculate cost + let cost = 0; + if (usage) { + cost = await this.calculateCost( + providerInstanceId, + modelId, + usage.promptTokens, + usage.completionTokens, + ); + } + + return { + assistantMessage, + finishReason, + toolCalls: streaming.tool_calls, + usage, + cost, + }; + } + /** * Stop generation for a conversation. */ @@ -461,14 +650,15 @@ class ChatSessionManager { session.abortController?.abort("User stopped generation"); // If there's streaming content, save it as a partial message - if (session.streamingContent) { - this.savePartialMessage(conversationId, session.streamingContent); + if (session.streamingMessage?.content || session.streamingContent) { + this.savePartialMessage(conversationId, session.streamingMessage); } this.updateSession(conversationId, { ...session, isLoading: false, streamingContent: "", + streamingMessage: undefined, abortController: undefined, finishReason: null, // Can resume lastActivity: Date.now(), @@ -482,20 +672,32 @@ class ChatSessionManager { */ private async savePartialMessage( conversationId: string, - content: string, + streamingMessage?: StreamingMessage, ): Promise { + const content = streamingMessage?.content || ""; if (!content.trim()) return; const conversation = await db.llmConversations.get(conversationId); if (!conversation) return; - const assistantMessage: LLMMessage = { + const assistantMessage: AssistantMessage = { id: crypto.randomUUID(), role: "assistant", content: content + "\n\n_(generation stopped)_", timestamp: Date.now(), }; + // Preserve reasoning and tool calls if present + if (streamingMessage?.reasoning_content) { + assistantMessage.reasoning_content = streamingMessage.reasoning_content; + } + if ( + streamingMessage?.tool_calls && + streamingMessage.tool_calls.length > 0 + ) { + assistantMessage.tool_calls = streamingMessage.tool_calls; + } + await db.llmConversations.update(conversationId, { messages: [...conversation.messages, assistantMessage], updatedAt: Date.now(), diff --git a/src/services/llm/tools.ts b/src/services/llm/tools.ts new file mode 100644 index 0000000..62e1597 --- /dev/null +++ b/src/services/llm/tools.ts @@ -0,0 +1,212 @@ +/** + * Tool System for AI Chat + * + * Defines the interface for tools that can be called by AI models. + * Tools are registered with the ToolRegistry and executed during the agentic loop. + */ + +import type { ToolDefinition, ToolCall, ToolMessage } from "@/types/llm"; + +// ───────────────────────────────────────────────────────────── +// Tool Interface +// ───────────────────────────────────────────────────────────── + +/** + * Context provided to tool execution. + */ +export interface ToolContext { + /** The conversation ID this tool is being executed for */ + conversationId: string; + /** The provider instance ID */ + providerInstanceId: string; + /** The model ID */ + modelId: string; + /** Abort signal for cancellation */ + signal?: AbortSignal; +} + +/** + * Result from tool execution. + */ +export interface ToolResult { + /** Whether the execution was successful */ + success: boolean; + /** The result content (will be sent back to the model) */ + content: string; + /** Optional error message */ + error?: string; +} + +/** + * A tool that can be called by the AI model. + */ +export interface Tool { + /** Unique tool name (must match what's sent to the model) */ + name: string; + + /** Human-readable description */ + description: string; + + /** JSON Schema for the parameters */ + parameters: Record; + + /** + * Execute the tool with the given arguments. + * @param args Parsed arguments from the model + * @param context Execution context + * @returns Tool result + */ + execute( + args: Record, + context: ToolContext, + ): Promise; +} + +// ───────────────────────────────────────────────────────────── +// Tool Registry +// ───────────────────────────────────────────────────────────── + +class ToolRegistryImpl { + private tools = new Map(); + + /** + * Register a tool. + */ + register(tool: Tool): void { + if (this.tools.has(tool.name)) { + console.warn(`Tool "${tool.name}" is already registered, overwriting.`); + } + this.tools.set(tool.name, tool); + } + + /** + * Unregister a tool. + */ + unregister(name: string): void { + this.tools.delete(name); + } + + /** + * Get a tool by name. + */ + get(name: string): Tool | undefined { + return this.tools.get(name); + } + + /** + * Check if a tool exists. + */ + has(name: string): boolean { + return this.tools.has(name); + } + + /** + * Get all registered tools. + */ + getAll(): Tool[] { + return Array.from(this.tools.values()); + } + + /** + * Get tool definitions for the API. + */ + getDefinitions(): ToolDefinition[] { + return this.getAll().map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + })); + } + + /** + * Clear all registered tools. + */ + clear(): void { + this.tools.clear(); + } +} + +// Singleton instance +export const toolRegistry = new ToolRegistryImpl(); + +// ───────────────────────────────────────────────────────────── +// Tool Executor +// ───────────────────────────────────────────────────────────── + +/** + * Execute a tool call and return a ToolMessage. + */ +export async function executeToolCall( + toolCall: ToolCall, + context: ToolContext, +): Promise { + const tool = toolRegistry.get(toolCall.function.name); + + if (!tool) { + return { + id: crypto.randomUUID(), + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify({ + error: `Unknown tool: ${toolCall.function.name}`, + }), + timestamp: Date.now(), + }; + } + + try { + // Parse arguments + let args: Record = {}; + if (toolCall.function.arguments) { + try { + args = JSON.parse(toolCall.function.arguments); + } catch { + return { + id: crypto.randomUUID(), + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify({ + error: "Failed to parse tool arguments as JSON", + }), + timestamp: Date.now(), + }; + } + } + + // Execute the tool + const result = await tool.execute(args, context); + + return { + id: crypto.randomUUID(), + role: "tool", + tool_call_id: toolCall.id, + content: result.success + ? result.content + : JSON.stringify({ error: result.error || "Tool execution failed" }), + timestamp: Date.now(), + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return { + id: crypto.randomUUID(), + role: "tool", + tool_call_id: toolCall.id, + content: JSON.stringify({ error: errorMessage }), + timestamp: Date.now(), + }; + } +} + +/** + * Execute multiple tool calls in parallel. + */ +export async function executeToolCalls( + toolCalls: ToolCall[], + context: ToolContext, +): Promise { + return Promise.all(toolCalls.map((tc) => executeToolCall(tc, context))); +} diff --git a/src/types/llm.ts b/src/types/llm.ts index ac313f4..eb226a5 100644 --- a/src/types/llm.ts +++ b/src/types/llm.ts @@ -74,8 +74,12 @@ export interface AISettings { // ───────────────────────────────────────────────────────────── export interface ChatStreamChunk { - type: "token" | "done" | "error"; + type: "token" | "reasoning" | "tool_call" | "done" | "error"; content?: string; + /** Streaming tool call delta */ + tool_call?: StreamingToolCall; + /** Finish reason from the API */ + finish_reason?: "stop" | "length" | "tool_calls" | null; error?: string; usage?: { promptTokens: number; @@ -83,10 +87,28 @@ export interface ChatStreamChunk { }; } +/** + * Tool definition for function calling. + */ +export interface ToolDefinition { + type: "function"; + function: { + name: string; + description?: string; + parameters?: Record; // JSON Schema + }; +} + export interface ChatOptions { model: string; temperature?: number; maxTokens?: number; + tools?: ToolDefinition[]; + tool_choice?: + | "none" + | "auto" + | "required" + | { type: "function"; function: { name: string } }; signal?: AbortSignal; } @@ -94,6 +116,15 @@ export interface ChatOptions { // Session State (for ChatSessionManager) // ───────────────────────────────────────────────────────────── +/** + * Streaming message state during generation. + */ +export interface StreamingMessage { + content: string; + reasoning_content?: string; + tool_calls?: ToolCall[]; +} + /** * Transient state for an active chat session. * Multiple windows can view the same conversation and share this state. @@ -107,6 +138,7 @@ export interface ChatSessionState { // Streaming state (shared across all windows viewing this conversation) isLoading: boolean; streamingContent: string; + streamingMessage?: StreamingMessage; abortController?: AbortController; // Usage from last completion @@ -119,7 +151,7 @@ export interface ChatSessionState { sessionCost: number; // For resume functionality - finishReason?: "stop" | "length" | "error" | null; + finishReason?: "stop" | "length" | "tool_calls" | "error" | null; lastError?: string; // Reference counting - how many windows have this session open @@ -199,13 +231,156 @@ export interface LLMModel { }; } -export interface LLMMessage { +// ───────────────────────────────────────────────────────────── +// Message Content Types (OpenAI-compatible) +// ───────────────────────────────────────────────────────────── + +/** + * Text content part for multimodal messages. + */ +export interface TextContentPart { + type: "text"; + text: string; +} + +/** + * Image content part for multimodal messages. + * Supports both URLs and base64 data URIs. + */ +export interface ImageContentPart { + type: "image_url"; + image_url: { + url: string; // URL or data:image/...;base64,... + detail?: "auto" | "low" | "high"; + }; +} + +/** + * Content can be a simple string or an array of content parts (for multimodal). + */ +export type MessageContent = string | (TextContentPart | ImageContentPart)[]; + +// ───────────────────────────────────────────────────────────── +// Tool Call Types (OpenAI-compatible) +// ───────────────────────────────────────────────────────────── + +/** + * A tool call made by the assistant. + */ +export interface ToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; // JSON string + }; +} + +/** + * Streaming tool call (may have partial data). + */ +export interface StreamingToolCall { + index: number; + id?: string; + type?: "function"; + function?: { + name?: string; + arguments?: string; + }; +} + +// ───────────────────────────────────────────────────────────── +// Message Types (OpenAI-compatible with extensions) +// ───────────────────────────────────────────────────────────── + +/** + * Base message fields shared by all message types. + */ +interface BaseMessage { id: string; - role: "system" | "user" | "assistant"; - content: string; timestamp: number; } +/** + * System message - sets context for the conversation. + */ +export interface SystemMessage extends BaseMessage { + role: "system"; + content: string; +} + +/** + * User message - can include text and/or images. + */ +export interface UserMessage extends BaseMessage { + role: "user"; + content: MessageContent; +} + +/** + * Assistant message - can include text, reasoning, and tool calls. + */ +export interface AssistantMessage extends BaseMessage { + role: "assistant"; + content: string; + /** Extended thinking / reasoning (Claude, DeepSeek, etc.) */ + reasoning_content?: string; + /** Tool calls requested by the assistant */ + tool_calls?: ToolCall[]; +} + +/** + * Tool result message - response to a tool call. + */ +export interface ToolMessage extends BaseMessage { + role: "tool"; + content: string; + /** ID of the tool call this responds to */ + tool_call_id: string; +} + +/** + * Union type for all message types. + */ +export type LLMMessage = + | SystemMessage + | UserMessage + | AssistantMessage + | ToolMessage; + +/** + * Helper to get text content from a message (handles multimodal). + */ +export function getMessageTextContent(message: LLMMessage): string { + if (typeof message.content === "string") { + return message.content; + } + // For array content, concatenate all text parts + return message.content + .filter((part): part is TextContentPart => part.type === "text") + .map((part) => part.text) + .join("\n"); +} + +/** + * Helper to check if a message has tool calls. + */ +export function hasToolCalls(message: LLMMessage): message is AssistantMessage { + return ( + message.role === "assistant" && + "tool_calls" in message && + Array.isArray(message.tool_calls) && + message.tool_calls.length > 0 + ); +} + +/** + * Helper to check if a message is a tool result. + */ +export function isToolMessage(message: LLMMessage): message is ToolMessage { + return message.role === "tool"; +} + export interface LLMConversation { id: string; title: string;