mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
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:
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
212
src/services/llm/tools.ts
Normal 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)));
|
||||
}
|
||||
185
src/types/llm.ts
185
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<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;
|
||||
|
||||
Reference in New Issue
Block a user