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
This commit is contained in:
Claude
2026-01-31 10:19:24 +00:00
parent 75fdce994a
commit fa70933388
6 changed files with 875 additions and 307 deletions

View File

@@ -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 (
<div
className={cn("flex w-full", isUser ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] rounded-lg px-3 py-2 text-sm",
isUser
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground",
)}
>
{isUser ? (
<div className="whitespace-pre-wrap break-words">
{message.content}
// Tool message (response from tool execution)
if (isTool) {
return (
<div className="flex w-full justify-start">
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-accent/50 border border-border">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
<Settings2 className="h-3 w-3" />
<span>Tool Result</span>
</div>
) : (
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-all font-mono">
{content}
</pre>
</div>
</div>
);
}
// User message
if (isUser) {
return (
<div className="flex w-full justify-end">
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
<div className="whitespace-pre-wrap break-words">{content}</div>
</div>
</div>
);
}
// Assistant message
const assistantMsg = message as AssistantMessage;
return (
<div className="flex w-full justify-start">
<div className="max-w-[85%] rounded-lg px-3 py-2 text-sm bg-muted text-foreground space-y-2">
{/* Reasoning content (collapsible) */}
{assistantMsg.reasoning_content && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground flex items-center gap-1.5">
<Brain className="h-3 w-3" />
<span>Reasoning</span>
</summary>
<div className="mt-2 pl-4 border-l-2 border-muted-foreground/30 text-muted-foreground whitespace-pre-wrap">
{assistantMsg.reasoning_content}
</div>
</details>
)}
{/* Tool calls */}
{assistantMsg.tool_calls && assistantMsg.tool_calls.length > 0 && (
<div className="space-y-1">
{assistantMsg.tool_calls.map((tc) => (
<div
key={tc.id}
className="text-xs bg-background/50 rounded px-2 py-1.5 border border-border"
>
<div className="flex items-center gap-1.5 text-muted-foreground mb-1">
<Settings2 className="h-3 w-3" />
<span className="font-medium">{tc.function.name}</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[10px]">
{formatToolArgs(tc.function.arguments)}
</pre>
</div>
))}
</div>
)}
{/* Regular content */}
{content && (
<div className="[&>article]:p-0 [&>article]:m-0">
<MarkdownContent content={message.content} />
<MarkdownContent content={content} />
</div>
)}
</div>
@@ -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
// ─────────────────────────────────────────────────────────────

View File

@@ -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<string> => {
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<LLMMessage, "id" | "timestamp">) => {
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<AbortController | null>(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
// ─────────────────────────────────────────────────────────────

View File

@@ -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<OpenAI.Chat.Completions.ChatCompletionChunk>;
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.
*/

View File

@@ -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<void> {
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<number, StreamingToolCall>();
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<void> {
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(),

212
src/services/llm/tools.ts Normal file
View File

@@ -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<string, unknown>;
/**
* Execute the tool with the given arguments.
* @param args Parsed arguments from the model
* @param context Execution context
* @returns Tool result
*/
execute(
args: Record<string, unknown>,
context: ToolContext,
): Promise<ToolResult>;
}
// ─────────────────────────────────────────────────────────────
// Tool Registry
// ─────────────────────────────────────────────────────────────
class ToolRegistryImpl {
private tools = new Map<string, Tool>();
/**
* 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<ToolMessage> {
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<string, unknown> = {};
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<ToolMessage[]> {
return Promise.all(toolCalls.map((tc) => executeToolCall(tc, context)));
}

View File

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