- {message.content}
+ // Tool message (response from tool execution)
+ if (isTool) {
+ return (
+
+ );
+ }
+
+ // 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;