server/webui: cleanup dual representation approach, simplify to openai-compat (#21090)

* server/webui: cleanup dual representation approach, simplify to openai-compat

* feat: Fix regression for Agentic Loop UI

* chore: update webui build output

* refactor: Post-review code improvements

* chore: update webui build output

* refactor: Cleanup

* chore: update webui build output

---------

Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
Piotr Wilkin (ilintar)
2026-03-31 10:42:06 +02:00
committed by GitHub
co-authored by Aleksander Grygier
parent 26dac845cc
commit 4453e77561
20 changed files with 1308 additions and 909 deletions
Binary file not shown.
@@ -4,7 +4,7 @@
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts'; import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte'; import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services'; import { DatabaseService } from '$lib/services/database.service';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants'; import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { MessageRole, AttachmentType } from '$lib/enums'; import { MessageRole, AttachmentType } from '$lib/enums';
import { import {
@@ -19,6 +19,7 @@
interface Props { interface Props {
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
isLastAssistantMessage?: boolean; isLastAssistantMessage?: boolean;
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
} }
@@ -26,6 +27,7 @@
let { let {
class: className = '', class: className = '',
message, message,
toolMessages = [],
isLastAssistantMessage = false, isLastAssistantMessage = false,
siblingInfo = null siblingInfo = null
}: Props = $props(); }: Props = $props();
@@ -302,6 +304,7 @@
{deletionInfo} {deletionInfo}
{isLastAssistantMessage} {isLastAssistantMessage}
{message} {message}
{toolMessages}
messageContent={message.content} messageContent={message.content}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue} onContinue={handleContinue}
@@ -6,42 +6,42 @@
SyntaxHighlightedCode SyntaxHighlightedCode
} from '$lib/components/app'; } from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte'; import { Wrench, Loader2, Brain } from '@lucide/svelte';
import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums'; import { AgenticSectionType, FileTypeText } from '$lib/enums';
import { formatJsonPretty } from '$lib/utils'; import { formatJsonPretty } from '$lib/utils';
import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants'; import {
import { parseAgenticContent, type AgenticSection } from '$lib/utils'; deriveAgenticSections,
import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database'; parseToolResultWithImages,
type AgenticSection,
type ToolResultLine
} from '$lib/utils';
import type { DatabaseMessage } from '$lib/types/database';
import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat'; import type { ChatMessageAgenticTimings, ChatMessageAgenticTurnStats } from '$lib/types/chat';
import { ChatMessageStatsView } from '$lib/enums'; import { ChatMessageStatsView } from '$lib/enums';
interface Props { interface Props {
message?: DatabaseMessage; message: DatabaseMessage;
content: string; toolMessages?: DatabaseMessage[];
isStreaming?: boolean; isStreaming?: boolean;
highlightTurns?: boolean; highlightTurns?: boolean;
} }
type ToolResultLine = { let { message, toolMessages = [], isStreaming = false, highlightTurns = false }: Props = $props();
text: string;
image?: DatabaseMessageExtraImageFile;
};
let { content, message, isStreaming = false, highlightTurns = false }: Props = $props();
let expandedStates: Record<number, boolean> = $state({}); let expandedStates: Record<number, boolean> = $state({});
const sections = $derived(parseAgenticContent(content));
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean); const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean); const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
// Parse toolResults with images only when sections or message.extra change const sections = $derived(deriveAgenticSections(message, toolMessages, []));
// Parse tool results with images
const sectionsParsed = $derived( const sectionsParsed = $derived(
sections.map((section) => ({ sections.map((section) => ({
...section, ...section,
parsedLines: section.toolResult parsedLines: section.toolResult
? parseToolResultWithImages(section.toolResult, message?.extra) ? parseToolResultWithImages(section.toolResult, section.toolResultExtras || message?.extra)
: [] : ([] as ToolResultLine[])
})) }))
); );
@@ -107,26 +107,6 @@
expandedStates[index] = !currentState; expandedStates[index] = !currentState;
} }
function parseToolResultWithImages(
toolResult: string,
extras?: DatabaseMessage['extra']
): ToolResultLine[] {
const lines = toolResult.split(NEWLINE_SEPARATOR);
return lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };
const attachmentName = match[1];
const image = extras.find(
(e): e is DatabaseMessageExtraImageFile =>
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
return { text: line, image };
});
}
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings { function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
return { return {
turns: 1, turns: 1,
@@ -144,9 +124,8 @@
<MarkdownContent content={section.content} attachments={message?.extra} /> <MarkdownContent content={section.content} attachments={message?.extra} />
</div> </div>
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING} {:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
{@const streamingIcon = isStreaming ? Loader2 : AlertTriangle} {@const streamingIcon = isStreaming ? Loader2 : Loader2}
{@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4 text-yellow-500'} {@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
{@const streamingSubtitle = isStreaming ? '' : 'incomplete'}
<CollapsibleContentBlock <CollapsibleContentBlock
open={isExpanded(index, section)} open={isExpanded(index, section)}
@@ -154,7 +133,7 @@
icon={streamingIcon} icon={streamingIcon}
iconClass={streamingIconClass} iconClass={streamingIconClass}
title={section.toolName || 'Tool call'} title={section.toolName || 'Tool call'}
subtitle={streamingSubtitle} subtitle={isStreaming ? '' : 'incomplete'}
{isStreaming} {isStreaming}
onToggle={() => toggleExpanded(index, section)} onToggle={() => toggleExpanded(index, section)}
> >
@@ -15,7 +15,7 @@
import { Check, X } from '@lucide/svelte'; import { Check, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { AGENTIC_TAGS, INPUT_CLASSES, REASONING_TAGS } from '$lib/constants'; import { INPUT_CLASSES } from '$lib/constants';
import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums'; import { MessageRole, KeyboardKey, ChatMessageStatsView } from '$lib/enums';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
@@ -23,6 +23,8 @@
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums'; import { ServerModelStatus } from '$lib/enums';
import { hasAgenticContent } from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
deletionInfo: { deletionInfo: {
@@ -33,6 +35,7 @@
} | null; } | null;
isLastAssistantMessage?: boolean; isLastAssistantMessage?: boolean;
message: DatabaseMessage; message: DatabaseMessage;
toolMessages?: DatabaseMessage[];
messageContent: string | undefined; messageContent: string | undefined;
onCopy: () => void; onCopy: () => void;
onConfirmDelete: () => void; onConfirmDelete: () => void;
@@ -53,6 +56,7 @@
deletionInfo, deletionInfo,
isLastAssistantMessage = false, isLastAssistantMessage = false,
message, message,
toolMessages = [],
messageContent, messageContent,
onConfirmDelete, onConfirmDelete,
onContinue, onContinue,
@@ -84,10 +88,8 @@
} }
} }
const hasAgenticMarkers = $derived( const isAgentic = $derived(hasAgenticContent(message, toolMessages));
messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false const hasReasoning = $derived(!!message.reasoningContent);
);
const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false);
const processingState = useProcessingState(); const processingState = useProcessingState();
let currentConfig = $derived(config()); let currentConfig = $derived(config());
@@ -145,7 +147,7 @@
} }
let highlightAgenticTurns = $derived( let highlightAgenticTurns = $derived(
hasAgenticMarkers && isAgentic &&
(currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY) (currentConfig.alwaysShowAgenticTurns || activeStatsView === ChatMessageStatsView.SUMMARY)
); );
@@ -160,13 +162,14 @@
message?.role === MessageRole.ASSISTANT && message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing && isActivelyProcessing &&
hasNoContent && hasNoContent &&
!isAgentic &&
isLastAssistantMessage isLastAssistantMessage
); );
let showProcessingInfoBottom = $derived( let showProcessingInfoBottom = $derived(
message?.role === MessageRole.ASSISTANT && message?.role === MessageRole.ASSISTANT &&
isActivelyProcessing && isActivelyProcessing &&
!hasNoContent && (!hasNoContent || isAgentic) &&
isLastAssistantMessage isLastAssistantMessage
); );
@@ -252,10 +255,10 @@
<pre class="raw-output">{messageContent || ''}</pre> <pre class="raw-output">{messageContent || ''}</pre>
{:else} {:else}
<ChatMessageAgenticContent <ChatMessageAgenticContent
content={messageContent || ''} {message}
{toolMessages}
isStreaming={isChatStreaming()} isStreaming={isChatStreaming()}
highlightTurns={highlightAgenticTurns} highlightTurns={highlightAgenticTurns}
{message}
/> />
{/if} {/if}
{:else} {:else}
@@ -344,9 +347,7 @@
{onCopy} {onCopy}
{onEdit} {onEdit}
{onRegenerate} {onRegenerate}
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers onContinue={currentConfig.enableContinueGeneration && !hasReasoning ? onContinue : undefined}
? onContinue
: undefined}
{onForkConversation} {onForkConversation}
{onDelete} {onDelete}
{onConfirmDelete} {onConfirmDelete}
@@ -6,7 +6,12 @@
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils'; import {
copyToClipboard,
formatMessageForClipboard,
getMessageSiblings,
hasAgenticContent
} from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
@@ -119,32 +124,75 @@
? messages ? messages
: messages.filter((msg) => msg.type !== MessageRole.SYSTEM); : messages.filter((msg) => msg.type !== MessageRole.SYSTEM);
let lastAssistantIndex = -1; // Build display entries, grouping agentic sessions into single entries.
// An agentic session = assistant(with tool_calls) → tool → assistant → tool → ... → assistant(final)
const result: Array<{
message: DatabaseMessage;
toolMessages: DatabaseMessage[];
isLastAssistantMessage: boolean;
siblingInfo: ChatMessageSiblingInfo;
}> = [];
for (let i = filteredMessages.length - 1; i >= 0; i--) { for (let i = 0; i < filteredMessages.length; i++) {
if (filteredMessages[i].role === MessageRole.ASSISTANT) { const msg = filteredMessages[i];
lastAssistantIndex = i;
// Skip tool messages - they're grouped with preceding assistant
if (msg.role === MessageRole.TOOL) continue;
const toolMessages: DatabaseMessage[] = [];
if (msg.role === MessageRole.ASSISTANT && hasAgenticContent(msg)) {
let j = i + 1;
while (j < filteredMessages.length) {
const next = filteredMessages[j];
if (next.role === MessageRole.TOOL) {
toolMessages.push(next);
j++;
} else if (next.role === MessageRole.ASSISTANT) {
toolMessages.push(next);
j++;
} else {
break; break;
} }
} }
return filteredMessages.map((message, index) => { i = j - 1;
const siblingInfo = getMessageSiblings(allConversationMessages, message.id); } else if (msg.role === MessageRole.ASSISTANT) {
const isLastAssistantMessage = let j = i + 1;
message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
return { while (j < filteredMessages.length && filteredMessages[j].role === MessageRole.TOOL) {
message, toolMessages.push(filteredMessages[j]);
isLastAssistantMessage, j++;
}
}
const siblingInfo = getMessageSiblings(allConversationMessages, msg.id);
result.push({
message: msg,
toolMessages,
isLastAssistantMessage: false,
siblingInfo: siblingInfo || { siblingInfo: siblingInfo || {
message, message: msg,
siblingIds: [message.id], siblingIds: [msg.id],
currentIndex: 0, currentIndex: 0,
totalSiblings: 1 totalSiblings: 1
} }
};
}); });
}
// Mark the last assistant message
for (let i = result.length - 1; i >= 0; i--) {
if (result[i].message.role === MessageRole.ASSISTANT) {
result[i].isLastAssistantMessage = true;
break;
}
}
return result;
}); });
</script> </script>
@@ -152,11 +200,12 @@
class="flex h-full flex-col space-y-10 pt-24 {className}" class="flex h-full flex-col space-y-10 pt-24 {className}"
style="height: auto; min-height: calc(100dvh - 14rem);" style="height: auto; min-height: calc(100dvh - 14rem);"
> >
{#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)} {#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
<div use:fadeInView> <div use:fadeInView>
<ChatMessage <ChatMessage
class="mx-auto w-full max-w-[48rem]" class="mx-auto w-full max-w-[48rem]"
{message} {message}
{toolMessages}
{isLastAssistantMessage} {isLastAssistantMessage}
{siblingInfo} {siblingInfo}
/> />
@@ -425,21 +425,16 @@ export { default as ChatMessage } from './ChatMessages/ChatMessage.svelte';
/** /**
* **ChatMessageAgenticContent** - Agentic workflow output display * **ChatMessageAgenticContent** - Agentic workflow output display
* *
* Specialized renderer for assistant messages containing agentic workflow markers. * Specialized renderer for assistant messages with tool calls and reasoning.
* Parses structured content and displays tool calls and reasoning blocks as * Derives display sections from structured message data (toolCalls, reasoningContent,
* interactive collapsible sections with real-time streaming support. * and child tool result messages) and renders them as interactive collapsible sections.
* *
* **Architecture:** * **Architecture:**
* - Uses `parseAgenticContent()` from `$lib/utils` to parse markers * - Uses `deriveAgenticSections()` from `$lib/utils` to build sections from structured data
* - Renders sections as CollapsibleContentBlock components * - Renders sections as CollapsibleContentBlock components
* - Handles streaming state for progressive content display * - Handles streaming state for progressive content display
* - Falls back to MarkdownContent for plain text sections * - Falls back to MarkdownContent for plain text sections
* *
* **Marker Format:**
* - Tool calls: in constants/agentic.ts (AGENTIC_TAGS)
* - Reasoning: in constants/agentic.ts (REASONING_TAGS)
* - Partial markers handled gracefully during streaming
*
* **Execution States:** * **Execution States:**
* - **Streaming**: Animated spinner, block expanded, auto-scroll enabled * - **Streaming**: Animated spinner, block expanded, auto-scroll enabled
* - **Pending**: Waiting indicator for queued tool calls * - **Pending**: Waiting indicator for queued tool calls
+15 -26
View File
@@ -15,8 +15,11 @@ export const DEFAULT_AGENTIC_CONFIG: AgenticConfig = {
maxToolPreviewLines: 25 maxToolPreviewLines: 25
} as const; } as const;
// Agentic tool call tag markers /**
export const AGENTIC_TAGS = { * @deprecated Legacy marker tags - only used for migration of old stored messages.
* New messages use structured fields (reasoningContent, toolCalls, toolCallId).
*/
export const LEGACY_AGENTIC_TAGS = {
TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>', TOOL_CALL_START: '<<<AGENTIC_TOOL_CALL_START>>>',
TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>', TOOL_CALL_END: '<<<AGENTIC_TOOL_CALL_END>>>',
TOOL_NAME_PREFIX: '<<<TOOL_NAME:', TOOL_NAME_PREFIX: '<<<TOOL_NAME:',
@@ -25,39 +28,25 @@ export const AGENTIC_TAGS = {
TAG_SUFFIX: '>>>' TAG_SUFFIX: '>>>'
} as const; } as const;
export const REASONING_TAGS = { /**
* @deprecated Legacy reasoning tags - only used for migration of old stored messages.
* New messages use the dedicated reasoningContent field.
*/
export const LEGACY_REASONING_TAGS = {
START: '<<<reasoning_content_start>>>', START: '<<<reasoning_content_start>>>',
END: '<<<reasoning_content_end>>>' END: '<<<reasoning_content_end>>>'
} as const; } as const;
// Regex for trimming leading/trailing newlines /**
export const TRIM_NEWLINES_REGEX = /^\n+|\n+$/g; * @deprecated Legacy regex patterns - only used for migration of old stored messages.
*/
// Regex patterns for parsing agentic content export const LEGACY_AGENTIC_REGEX = {
export const AGENTIC_REGEX = {
// Matches completed tool calls (with END marker)
COMPLETED_TOOL_CALL: COMPLETED_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g, /<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*?)<<<AGENTIC_TOOL_CALL_END>>>/g,
// Matches pending tool call (has NAME and ARGS but no END)
PENDING_TOOL_CALL:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*?)<<<TOOL_ARGS_END>>>([\s\S]*)$/,
// Matches partial tool call (has START and NAME, ARGS still streaming)
PARTIAL_WITH_NAME:
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_START>>>([\s\S]*)$/,
// Matches early tool call (just START marker)
EARLY_MATCH: /<<<AGENTIC_TOOL_CALL_START>>>([\s\S]*)$/,
// Matches partial marker at end of content
PARTIAL_MARKER: /<<<[A-Za-z_]*$/,
// Matches reasoning content blocks (including tags)
REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g, REASONING_BLOCK: /<<<reasoning_content_start>>>[\s\S]*?<<<reasoning_content_end>>>/g,
// Captures the reasoning text between start/end tags
REASONING_EXTRACT: /<<<reasoning_content_start>>>([\s\S]*?)<<<reasoning_content_end>>>/, REASONING_EXTRACT: /<<<reasoning_content_start>>>([\s\S]*?)<<<reasoning_content_end>>>/,
// Matches an opening reasoning tag and any remaining content (unterminated)
REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/, REASONING_OPEN: /<<<reasoning_content_start>>>[\s\S]*$/,
// Matches a complete agentic tool call display block (start to end marker)
AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g, AGENTIC_TOOL_CALL_BLOCK: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*?<<<AGENTIC_TOOL_CALL_END>>>/g,
// Matches a pending/partial agentic tool call (start marker with no matching end)
AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/, AGENTIC_TOOL_CALL_OPEN: /\n*<<<AGENTIC_TOOL_CALL_START>>>[\s\S]*$/,
// Matches tool name inside content HAS_LEGACY_MARKERS: /<<<(?:AGENTIC_TOOL_CALL_START|reasoning_content_start)>>>/
TOOL_NAME_EXTRACT: /<<<TOOL_NAME:([^>]+)>>>/
} as const; } as const;
@@ -1,6 +1,7 @@
import { getJsonHeaders, formatAttachmentText, isAbortError } from '$lib/utils'; import { getJsonHeaders } from '$lib/utils/api-headers';
import { formatAttachmentText } from '$lib/utils/formatters';
import { isAbortError } from '$lib/utils/abort';
import { import {
AGENTIC_REGEX,
ATTACHMENT_LABEL_PDF_FILE, ATTACHMENT_LABEL_PDF_FILE,
ATTACHMENT_LABEL_MCP_PROMPT, ATTACHMENT_LABEL_MCP_PROMPT,
ATTACHMENT_LABEL_MCP_RESOURCE ATTACHMENT_LABEL_MCP_RESOURCE
@@ -17,38 +18,6 @@ import type { DatabaseMessageExtraMcpPrompt, DatabaseMessageExtraMcpResource } f
import { modelsStore } from '$lib/stores/models.svelte'; import { modelsStore } from '$lib/stores/models.svelte';
export class ChatService { export class ChatService {
private static stripReasoningContent(
content: ApiChatMessageData['content'] | null | undefined
): ApiChatMessageData['content'] | null | undefined {
if (!content) {
return content;
}
if (typeof content === 'string') {
return content
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
}
if (!Array.isArray(content)) {
return content;
}
return content.map((part: ApiChatMessageContentPart) => {
if (part.type !== ContentPartType.TEXT || !part.text) return part;
return {
...part,
text: part.text
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
};
});
}
/** /**
* *
* *
@@ -57,46 +26,6 @@ export class ChatService {
* *
*/ */
/**
* Extracts reasoning text from content that contains internal reasoning tags.
* Returns the concatenated reasoning content or undefined if none found.
*/
private static extractReasoningFromContent(
content: ApiChatMessageData['content'] | null | undefined
): string | undefined {
if (!content) return undefined;
const extractFromString = (text: string): string => {
const parts: string[] = [];
// Use a fresh regex instance to avoid shared lastIndex state
const re = new RegExp(AGENTIC_REGEX.REASONING_EXTRACT.source);
let match = re.exec(text);
while (match) {
parts.push(match[1]);
// advance past the matched portion and retry
text = text.slice(match.index + match[0].length);
match = re.exec(text);
}
return parts.join('');
};
if (typeof content === 'string') {
const result = extractFromString(content);
return result || undefined;
}
if (!Array.isArray(content)) return undefined;
const parts: string[] = [];
for (const part of content) {
if (part.type === ContentPartType.TEXT && part.text) {
const result = extractFromString(part.text);
if (result) parts.push(result);
}
}
return parts.length > 0 ? parts.join('') : undefined;
}
/** /**
* Sends a chat completion request to the llama.cpp server. * Sends a chat completion request to the llama.cpp server.
* Supports both streaming and non-streaming responses with comprehensive parameter configuration. * Supports both streaming and non-streaming responses with comprehensive parameter configuration.
@@ -201,20 +130,15 @@ export class ChatService {
const requestBody: ApiChatCompletionRequest = { const requestBody: ApiChatCompletionRequest = {
messages: normalizedMessages.map((msg: ApiChatMessageData) => { messages: normalizedMessages.map((msg: ApiChatMessageData) => {
// Always strip internal reasoning/agentic tags from content
const cleanedContent = ChatService.stripReasoningContent(msg.content);
const mapped: ApiChatCompletionRequest['messages'][0] = { const mapped: ApiChatCompletionRequest['messages'][0] = {
role: msg.role, role: msg.role,
content: cleanedContent, content: msg.content,
tool_calls: msg.tool_calls, tool_calls: msg.tool_calls,
tool_call_id: msg.tool_call_id tool_call_id: msg.tool_call_id
}; };
// When preserving reasoning, extract it from raw content and send as separate field // Include reasoning_content from the dedicated field
if (!excludeReasoningFromContext) { if (!excludeReasoningFromContext && msg.reasoning_content) {
const reasoning = ChatService.extractReasoningFromContent(msg.content); mapped.reasoning_content = msg.reasoning_content;
if (reasoning) {
mapped.reasoning_content = reasoning;
}
} }
return mapped; return mapped;
}), }),
@@ -726,6 +650,10 @@ export class ChatService {
content: message.content content: message.content
}; };
if (message.reasoningContent) {
result.reasoning_content = message.reasoningContent;
}
if (toolCalls && toolCalls.length > 0) { if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls; result.tool_calls = toolCalls;
} }
@@ -854,6 +782,9 @@ export class ChatService {
role: message.role as MessageRole, role: message.role as MessageRole,
content: contentParts content: contentParts
}; };
if (message.reasoningContent) {
result.reasoning_content = message.reasoningContent;
}
if (toolCalls && toolCalls.length > 0) { if (toolCalls && toolCalls.length > 0) {
result.tool_calls = toolCalls; result.tool_calls = toolCalls;
} }
@@ -7,6 +7,10 @@
* - Session state management * - Session state management
* - Turn limit enforcement * - Turn limit enforcement
* *
* Each agentic turn produces separate DB messages:
* - One assistant message per LLM turn (with tool_calls if any)
* - One tool result message per tool call execution
*
* **Architecture & Relationships:** * **Architecture & Relationships:**
* - **ChatService**: Stateless API layer (sendMessage, streaming) * - **ChatService**: Stateless API layer (sendMessage, streaming)
* - **mcpStore**: MCP connection management and tool execution * - **mcpStore**: MCP connection management and tool execution
@@ -16,7 +20,6 @@
* @see mcpStore in stores/mcp.svelte.ts for MCP operations * @see mcpStore in stores/mcp.svelte.ts for MCP operations
*/ */
import { SvelteMap } from 'svelte/reactivity';
import { ChatService } from '$lib/services'; import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte';
@@ -24,7 +27,6 @@ import { modelsStore } from '$lib/stores/models.svelte';
import { isAbortError } from '$lib/utils'; import { isAbortError } from '$lib/utils';
import { import {
DEFAULT_AGENTIC_CONFIG, DEFAULT_AGENTIC_CONFIG,
AGENTIC_TAGS,
NEWLINE_SEPARATOR, NEWLINE_SEPARATOR,
TURN_LIMIT_MESSAGE, TURN_LIMIT_MESSAGE,
LLM_ERROR_BLOCK_START, LLM_ERROR_BLOCK_START,
@@ -193,17 +195,6 @@ class AgenticStore {
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> { async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params; const { conversationId, messages, options = {}, callbacks, signal, perChatOverrides } = params;
const {
onChunk,
onReasoningChunk,
onToolCallChunk,
onAttachments,
onModel,
onComplete,
onError,
onTimings,
onTurnComplete
} = callbacks;
const agenticConfig = this.getConfig(config(), perChatOverrides); const agenticConfig = this.getConfig(config(), perChatOverrides);
if (!agenticConfig.enabled) return { handled: false }; if (!agenticConfig.enabled) return { handled: false };
@@ -253,24 +244,14 @@ class AgenticStore {
options, options,
tools, tools,
agenticConfig, agenticConfig,
callbacks: { callbacks,
onChunk,
onReasoningChunk,
onToolCallChunk,
onAttachments,
onModel,
onComplete,
onError,
onTimings,
onTurnComplete
},
signal signal
}); });
return { handled: true }; return { handled: true };
} catch (error) { } catch (error) {
const normalizedError = error instanceof Error ? error : new Error(String(error)); const normalizedError = error instanceof Error ? error : new Error(String(error));
this.updateSession(conversationId, { lastError: normalizedError }); this.updateSession(conversationId, { lastError: normalizedError });
onError?.(normalizedError); callbacks.onError?.(normalizedError);
return { handled: true, error: normalizedError }; return { handled: true, error: normalizedError };
} finally { } finally {
this.updateSession(conversationId, { isRunning: false }); this.updateSession(conversationId, { isRunning: false });
@@ -295,17 +276,20 @@ class AgenticStore {
const { const {
onChunk, onChunk,
onReasoningChunk, onReasoningChunk,
onToolCallChunk, onToolCallsStreaming,
onAttachments, onAttachments,
onModel, onModel,
onComplete, onAssistantTurnComplete,
createToolResultMessage,
createAssistantMessage,
onFlowComplete,
onTimings, onTimings,
onTurnComplete onTurnComplete
} = callbacks; } = callbacks;
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages); const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
const allToolCalls: ApiChatCompletionToolCall[] = [];
let capturedTimings: ChatMessageTimings | undefined; let capturedTimings: ChatMessageTimings | undefined;
let totalToolCallCount = 0;
const agenticTimings: ChatMessageAgenticTimings = { const agenticTimings: ChatMessageAgenticTimings = {
turns: 0, turns: 0,
@@ -316,12 +300,7 @@ class AgenticStore {
llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 } llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
}; };
const maxTurns = agenticConfig.maxTurns; const maxTurns = agenticConfig.maxTurns;
const maxToolPreviewLines = agenticConfig.maxToolPreviewLines;
// Resolve effective model for vision capability checks.
// In ROUTER mode, options.model is always set by the caller.
// In MODEL mode, options.model is undefined; use the single loaded model
// which carries modalities bridged from /props.
const effectiveModel = options.model || modelsStore.models[0]?.model || ''; const effectiveModel = options.model || modelsStore.models[0]?.model || '';
for (let turn = 0; turn < maxTurns; turn++) { for (let turn = 0; turn < maxTurns; turn++) {
@@ -329,23 +308,20 @@ class AgenticStore {
agenticTimings.turns = turn + 1; agenticTimings.turns = turn + 1;
if (signal?.aborted) { if (signal?.aborted) {
onComplete?.( onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return; return;
} }
// For turns > 0, create a new assistant message via callback
if (turn > 0 && createAssistantMessage) {
await createAssistantMessage();
}
let turnContent = ''; let turnContent = '';
let turnReasoningContent = '';
let turnToolCalls: ApiChatCompletionToolCall[] = []; let turnToolCalls: ApiChatCompletionToolCall[] = [];
let lastStreamingToolCallName = ''; let lastStreamingToolCallName = '';
let lastStreamingToolCallArgsLength = 0; let lastStreamingToolCallArgsLength = 0;
const emittedToolCallStates = new SvelteMap<
number,
{ emittedOnce: boolean; lastArgs: string }
>();
let turnTimings: ChatMessageTimings | undefined; let turnTimings: ChatMessageTimings | undefined;
const turnStats: ChatMessageAgenticTurnStats = { const turnStats: ChatMessageAgenticTurnStats = {
@@ -366,30 +342,15 @@ class AgenticStore {
turnContent += chunk; turnContent += chunk;
onChunk?.(chunk); onChunk?.(chunk);
}, },
onReasoningChunk, onReasoningChunk: (chunk: string) => {
turnReasoningContent += chunk;
onReasoningChunk?.(chunk);
},
onToolCallChunk: (serialized: string) => { onToolCallChunk: (serialized: string) => {
try { try {
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[]; turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
for (let i = 0; i < turnToolCalls.length; i++) { onToolCallsStreaming?.(turnToolCalls);
const toolCall = turnToolCalls[i];
const toolName = toolCall.function?.name ?? '';
const toolArgs = toolCall.function?.arguments ?? '';
const state = emittedToolCallStates.get(i) || {
emittedOnce: false,
lastArgs: ''
};
if (!state.emittedOnce) {
const output = `\n\n${AGENTIC_TAGS.TOOL_CALL_START}\n${AGENTIC_TAGS.TOOL_NAME_PREFIX}${toolName}${AGENTIC_TAGS.TAG_SUFFIX}\n${AGENTIC_TAGS.TOOL_ARGS_START}\n${toolArgs}`;
onChunk?.(output);
state.emittedOnce = true;
state.lastArgs = toolArgs;
emittedToolCallStates.set(i, state);
} else if (toolArgs.length > state.lastArgs.length) {
onChunk?.(toolArgs.slice(state.lastArgs.length));
state.lastArgs = toolArgs;
emittedToolCallStates.set(i, state);
}
}
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) { if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
const name = turnToolCalls[0].function.name || ''; const name = turnToolCalls[0].function.name || '';
const args = turnToolCalls[0].function.arguments || ''; const args = turnToolCalls[0].function.arguments || '';
@@ -442,77 +403,84 @@ class AgenticStore {
} }
} catch (error) { } catch (error) {
if (signal?.aborted) { if (signal?.aborted) {
onComplete?.( // Save whatever we have for this turn before exiting
'', await onAssistantTurnComplete?.(
undefined, turnContent,
turnReasoningContent || undefined,
this.buildFinalTimings(capturedTimings, agenticTimings), this.buildFinalTimings(capturedTimings, agenticTimings),
undefined undefined
); );
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return; return;
} }
const normalizedError = error instanceof Error ? error : new Error('LLM stream error'); const normalizedError = error instanceof Error ? error : new Error('LLM stream error');
// Save error as content in the current turn
onChunk?.(`${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`); onChunk?.(`${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`);
onComplete?.( await onAssistantTurnComplete?.(
'', turnContent + `${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`,
undefined, turnReasoningContent || undefined,
this.buildFinalTimings(capturedTimings, agenticTimings), this.buildFinalTimings(capturedTimings, agenticTimings),
undefined undefined
); );
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
throw normalizedError; throw normalizedError;
} }
// No tool calls = final turn, save and complete
if (turnToolCalls.length === 0) { if (turnToolCalls.length === 0) {
agenticTimings.perTurn!.push(turnStats); agenticTimings.perTurn!.push(turnStats);
onComplete?.( const finalTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
'',
undefined, await onAssistantTurnComplete?.(
this.buildFinalTimings(capturedTimings, agenticTimings), turnContent,
turnReasoningContent || undefined,
finalTimings,
undefined undefined
); );
if (finalTimings) onTurnComplete?.(finalTimings);
onFlowComplete?.(finalTimings);
return; return;
} }
// Normalize and save assistant turn with tool calls
const normalizedCalls = this.normalizeToolCalls(turnToolCalls); const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
if (normalizedCalls.length === 0) { if (normalizedCalls.length === 0) {
onComplete?.( await onAssistantTurnComplete?.(
'', turnContent,
undefined, turnReasoningContent || undefined,
this.buildFinalTimings(capturedTimings, agenticTimings), this.buildFinalTimings(capturedTimings, agenticTimings),
undefined undefined
); );
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
return; return;
} }
for (const call of normalizedCalls) { totalToolCallCount += normalizedCalls.length;
allToolCalls.push({ this.updateSession(conversationId, { totalToolCalls: totalToolCallCount });
id: call.id,
type: call.type,
function: call.function ? { ...call.function } : undefined
});
}
this.updateSession(conversationId, { totalToolCalls: allToolCalls.length }); // Save the assistant message with its tool calls
onToolCallChunk?.(JSON.stringify(allToolCalls)); await onAssistantTurnComplete?.(
turnContent,
turnReasoningContent || undefined,
turnTimings,
normalizedCalls
);
// Add assistant message to session history
sessionMessages.push({ sessionMessages.push({
role: MessageRole.ASSISTANT, role: MessageRole.ASSISTANT,
content: turnContent || undefined, content: turnContent || undefined,
tool_calls: normalizedCalls tool_calls: normalizedCalls
}); });
// Execute each tool call and create result messages
for (const toolCall of normalizedCalls) { for (const toolCall of normalizedCalls) {
if (signal?.aborted) { if (signal?.aborted) {
onComplete?.( onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return; return;
} }
@@ -530,13 +498,7 @@ class AgenticStore {
result = executionResult.content; result = executionResult.content;
} catch (error) { } catch (error) {
if (isAbortError(error)) { if (isAbortError(error)) {
onComplete?.( onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return; return;
} }
result = `Error: ${error instanceof Error ? error.message : String(error)}`; result = `Error: ${error instanceof Error ? error.message : String(error)}`;
@@ -557,21 +519,27 @@ class AgenticStore {
turnStats.toolsMs += Math.round(toolDurationMs); turnStats.toolsMs += Math.round(toolDurationMs);
if (signal?.aborted) { if (signal?.aborted) {
onComplete?.( onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
'',
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
return; return;
} }
const { cleanedResult, attachments } = this.extractBase64Attachments(result); const { cleanedResult, attachments } = this.extractBase64Attachments(result);
if (attachments.length > 0) onAttachments?.(attachments);
this.emitToolCallResult(cleanedResult, maxToolPreviewLines, onChunk); // Create the tool result message in the DB
let toolResultMessage: DatabaseMessage | undefined;
if (createToolResultMessage) {
toolResultMessage = await createToolResultMessage(
toolCall.id,
cleanedResult,
attachments.length > 0 ? attachments : undefined
);
}
if (attachments.length > 0 && toolResultMessage) {
onAttachments?.(toolResultMessage.id, attachments);
}
// Build content parts for session history (including images for vision models)
const contentParts: ApiChatMessageContentPart[] = [ const contentParts: ApiChatMessageContentPart[] = [
{ type: ContentPartType.TEXT, text: cleanedResult } { type: ContentPartType.TEXT, text: cleanedResult }
]; ];
@@ -605,8 +573,15 @@ class AgenticStore {
} }
} }
// Turn limit reached
onChunk?.(TURN_LIMIT_MESSAGE); onChunk?.(TURN_LIMIT_MESSAGE);
onComplete?.('', undefined, this.buildFinalTimings(capturedTimings, agenticTimings), undefined); await onAssistantTurnComplete?.(
TURN_LIMIT_MESSAGE,
undefined,
this.buildFinalTimings(capturedTimings, agenticTimings),
undefined
);
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
} }
private buildFinalTimings( private buildFinalTimings(
@@ -633,23 +608,6 @@ class AgenticStore {
})); }));
} }
private emitToolCallResult(
result: string,
maxLines: number,
emit?: (chunk: string) => void
): void {
if (!emit) {
return;
}
let output = `${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_ARGS_END}`;
const lines = result.split(NEWLINE_SEPARATOR);
const trimmedLines = lines.length > maxLines ? lines.slice(-maxLines) : lines;
output += `${NEWLINE_SEPARATOR}${trimmedLines.join(NEWLINE_SEPARATOR)}${NEWLINE_SEPARATOR}${AGENTIC_TAGS.TOOL_CALL_END}${NEWLINE_SEPARATOR}`;
emit(output);
}
private extractBase64Attachments(result: string): { private extractBase64Attachments(result: string): {
cleanedResult: string; cleanedResult: string;
attachments: DatabaseMessageExtra[]; attachments: DatabaseMessageExtra[];
+197 -142
View File
@@ -12,7 +12,8 @@
*/ */
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { DatabaseService, ChatService } from '$lib/services'; import { DatabaseService } from '$lib/services/database.service';
import { ChatService } from '$lib/services/chat.service';
import { conversationsStore } from '$lib/stores/conversations.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { agenticStore } from '$lib/stores/agentic.svelte'; import { agenticStore } from '$lib/stores/agentic.svelte';
@@ -34,7 +35,6 @@ import {
import { import {
MAX_INACTIVE_CONVERSATION_STATES, MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS, INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
REASONING_TAGS,
SYSTEM_MESSAGE_PLACEHOLDER SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants'; } from '$lib/constants';
import type { import type {
@@ -50,15 +50,6 @@ interface ConversationStateEntry {
lastAccessed: number; lastAccessed: number;
} }
const countOccurrences = (source: string, token: string): number =>
source ? source.split(token).length - 1 : 0;
const hasUnclosedReasoningTag = (content: string): boolean =>
countOccurrences(content, REASONING_TAGS.START) > countOccurrences(content, REASONING_TAGS.END);
const wrapReasoningContent = (content: string, reasoningContent?: string): string => {
if (!reasoningContent) return content;
return `${REASONING_TAGS.START}${reasoningContent}${REASONING_TAGS.END}${content}`;
};
class ChatStore { class ChatStore {
activeProcessingState = $state<ApiProcessingState | null>(null); activeProcessingState = $state<ApiProcessingState | null>(null);
currentResponse = $state(''); currentResponse = $state('');
@@ -557,83 +548,76 @@ class ChatStore {
await modelsStore.fetchModelProps(effectiveModel); await modelsStore.fetchModelProps(effectiveModel);
} }
let streamedContent = '', // Mutable state for the current message being streamed
streamedToolCallContent = '', let currentMessageId = assistantMessage.id;
isReasoningOpen = false, let streamedContent = '';
hasStreamedChunks = false, let streamedReasoningContent = '';
resolvedModel: string | null = null, let resolvedModel: string | null = null;
modelPersisted = false; let modelPersisted = false;
let streamedExtras: DatabaseMessageExtra[] = assistantMessage.extra const convId = assistantMessage.convId;
? JSON.parse(JSON.stringify(assistantMessage.extra))
: [];
const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
if (!modelName) return; if (!modelName) return;
const n = normalizeModelName(modelName); const n = normalizeModelName(modelName);
if (!n || n === resolvedModel) return; if (!n || n === resolvedModel) return;
resolvedModel = n; resolvedModel = n;
const idx = conversationsStore.findMessageIndex(assistantMessage.id); const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { model: n }); conversationsStore.updateMessageAtIndex(idx, { model: n });
if (persistImmediately && !modelPersisted) { if (persistImmediately && !modelPersisted) {
modelPersisted = true; modelPersisted = true;
DatabaseService.updateMessage(assistantMessage.id, { model: n }).catch(() => { DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => {
modelPersisted = false; modelPersisted = false;
resolvedModel = null; resolvedModel = null;
}); });
} }
}; };
const updateStreamingContent = () => {
this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id); const updateStreamingUI = () => {
const idx = conversationsStore.findMessageIndex(assistantMessage.id); this.setChatStreaming(convId, streamedContent, currentMessageId);
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { content: streamedContent }); conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
}; };
const appendContentChunk = (chunk: string) => {
if (isReasoningOpen) { const cleanupStreamingState = () => {
streamedContent += REASONING_TAGS.END; this.setStreamingActive(false);
isReasoningOpen = false; this.setChatLoading(convId, false);
} this.clearChatStreaming(convId);
streamedContent += chunk; this.setProcessingState(convId, null);
hasStreamedChunks = true;
updateStreamingContent();
};
const appendReasoningChunk = (chunk: string) => {
if (!isReasoningOpen) {
streamedContent += REASONING_TAGS.START;
isReasoningOpen = true;
}
streamedContent += chunk;
hasStreamedChunks = true;
updateStreamingContent();
};
const finalizeReasoning = () => {
if (isReasoningOpen) {
streamedContent += REASONING_TAGS.END;
isReasoningOpen = false;
}
}; };
this.setStreamingActive(true); this.setStreamingActive(true);
this.setActiveProcessingConversation(assistantMessage.convId); this.setActiveProcessingConversation(convId);
const abortController = this.getOrCreateAbortController(assistantMessage.convId); const abortController = this.getOrCreateAbortController(convId);
const streamCallbacks: ChatStreamCallbacks = { const streamCallbacks: ChatStreamCallbacks = {
onChunk: (chunk: string) => appendContentChunk(chunk), onChunk: (chunk: string) => {
onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk), streamedContent += chunk;
onToolCallChunk: (chunk: string) => { updateStreamingUI();
const c = chunk.trim();
if (!c) return;
streamedToolCallContent = c;
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
}, },
onAttachments: (extras: DatabaseMessageExtra[]) => { onReasoningChunk: (chunk: string) => {
streamedReasoningContent += chunk;
// Update UI to show reasoning is being received
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, {
reasoningContent: streamedReasoningContent
});
},
onToolCallsStreaming: (toolCalls) => {
const idx = conversationsStore.findMessageIndex(currentMessageId);
conversationsStore.updateMessageAtIndex(idx, { toolCalls: JSON.stringify(toolCalls) });
},
onAttachments: (messageId: string, extras: DatabaseMessageExtra[]) => {
if (!extras.length) return; if (!extras.length) return;
streamedExtras = [...streamedExtras, ...extras]; const idx = conversationsStore.findMessageIndex(messageId);
const idx = conversationsStore.findMessageIndex(assistantMessage.id); if (idx === -1) return;
conversationsStore.updateMessageAtIndex(idx, { extra: streamedExtras }); const msg = conversationsStore.activeMessages[idx];
DatabaseService.updateMessage(assistantMessage.id, { extra: streamedExtras }).catch( const updatedExtras = [...(msg.extra || []), ...extras];
console.error conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras });
); DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
}, },
onModel: (modelName: string) => recordModel(modelName), onModel: (modelName: string) => recordModel(modelName),
onTurnComplete: (intermediateTimings: ChatMessageTimings) => { onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
// Update the first assistant message with cumulative agentic timings
const idx = conversationsStore.findMessageIndex(assistantMessage.id); const idx = conversationsStore.findMessageIndex(assistantMessage.id);
conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings }); conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
}, },
@@ -651,56 +635,104 @@ class ChatStore {
cache_n: timings?.cache_n || 0, cache_n: timings?.cache_n || 0,
prompt_progress: promptProgress prompt_progress: promptProgress
}, },
assistantMessage.convId convId
); );
}, },
onComplete: async ( onAssistantTurnComplete: async (
finalContent?: string, content: string,
reasoningContent?: string, reasoningContent: string | undefined,
timings?: ChatMessageTimings, timings: ChatMessageTimings | undefined,
toolCallContent?: string toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined
) => { ) => {
this.setStreamingActive(false);
finalizeReasoning();
const combinedContent = hasStreamedChunks
? streamedContent
: wrapReasoningContent(finalContent || '', reasoningContent);
const updateData: Record<string, unknown> = { const updateData: Record<string, unknown> = {
content: combinedContent, content,
toolCalls: toolCallContent || streamedToolCallContent, reasoningContent: reasoningContent || undefined,
toolCalls: toolCalls ? JSON.stringify(toolCalls) : '',
timings timings
}; };
if (streamedExtras.length > 0) updateData.extra = streamedExtras;
if (resolvedModel && !modelPersisted) updateData.model = resolvedModel; if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
await DatabaseService.updateMessage(assistantMessage.id, updateData); await DatabaseService.updateMessage(currentMessageId, updateData);
const idx = conversationsStore.findMessageIndex(assistantMessage.id); const idx = conversationsStore.findMessageIndex(currentMessageId);
const uiUpdate: Partial<DatabaseMessage> = { const uiUpdate: Partial<DatabaseMessage> = {
content: combinedContent, content,
toolCalls: updateData.toolCalls as string reasoningContent: reasoningContent || undefined,
toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
}; };
if (streamedExtras.length > 0) uiUpdate.extra = streamedExtras;
if (timings) uiUpdate.timings = timings; if (timings) uiUpdate.timings = timings;
if (resolvedModel) uiUpdate.model = resolvedModel; if (resolvedModel) uiUpdate.model = resolvedModel;
conversationsStore.updateMessageAtIndex(idx, uiUpdate); conversationsStore.updateMessageAtIndex(idx, uiUpdate);
await conversationsStore.updateCurrentNode(assistantMessage.id); await conversationsStore.updateCurrentNode(currentMessageId);
if (onComplete) await onComplete(combinedContent); },
this.setChatLoading(assistantMessage.convId, false); createToolResultMessage: async (
this.clearChatStreaming(assistantMessage.convId); toolCallId: string,
this.setProcessingState(assistantMessage.convId, null); content: string,
extras?: DatabaseMessageExtra[]
) => {
const msg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.TOOL,
content,
toolCallId,
timestamp: Date.now(),
toolCalls: '',
children: [],
extra: extras
},
currentMessageId
);
conversationsStore.addMessageToActive(msg);
await conversationsStore.updateCurrentNode(msg.id);
return msg;
},
createAssistantMessage: async () => {
// Reset streaming state for new message
streamedContent = '';
streamedReasoningContent = '';
const lastMsg =
conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
const msg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.ASSISTANT,
content: '',
timestamp: Date.now(),
toolCalls: '',
children: [],
model: resolvedModel
},
lastMsg.id
);
conversationsStore.addMessageToActive(msg);
currentMessageId = msg.id;
return msg;
},
onFlowComplete: (finalTimings?: ChatMessageTimings) => {
if (finalTimings) {
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
conversationsStore.updateMessageAtIndex(idx, { timings: finalTimings });
DatabaseService.updateMessage(assistantMessage.id, { timings: finalTimings }).catch(
console.error
);
}
cleanupStreamingState();
if (onComplete) onComplete(streamedContent);
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error); if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
}, },
onError: (error: Error) => { onError: (error: Error) => {
this.setStreamingActive(false); this.setStreamingActive(false);
if (isAbortError(error)) { if (isAbortError(error)) {
this.setChatLoading(assistantMessage.convId, false); cleanupStreamingState();
this.clearChatStreaming(assistantMessage.convId);
this.setProcessingState(assistantMessage.convId, null);
return; return;
} }
console.error('Streaming error:', error); console.error('Streaming error:', error);
this.setChatLoading(assistantMessage.convId, false); cleanupStreamingState();
this.clearChatStreaming(assistantMessage.convId);
this.setProcessingState(assistantMessage.convId, null);
const idx = conversationsStore.findMessageIndex(assistantMessage.id); const idx = conversationsStore.findMessageIndex(assistantMessage.id);
if (idx !== -1) { if (idx !== -1) {
const failedMessage = conversationsStore.removeMessageAtIndex(idx); const failedMessage = conversationsStore.removeMessageAtIndex(idx);
@@ -717,12 +749,13 @@ class ChatStore {
if (onError) onError(error); if (onError) onError(error);
} }
}; };
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides; const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
const agenticConfig = agenticStore.getConfig(config(), perChatOverrides); const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
if (agenticConfig.enabled) { if (agenticConfig.enabled) {
const agenticResult = await agenticStore.runAgenticFlow({ const agenticResult = await agenticStore.runAgenticFlow({
conversationId: assistantMessage.convId, conversationId: convId,
messages: allMessages, messages: allMessages,
options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) }, options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) },
callbacks: streamCallbacks, callbacks: streamCallbacks,
@@ -732,16 +765,50 @@ class ChatStore {
if (agenticResult.handled) return; if (agenticResult.handled) return;
} }
const completionOptions = { // Non-agentic path: direct streaming into the single assistant message
...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {}),
...streamCallbacks
};
await ChatService.sendMessage( await ChatService.sendMessage(
allMessages, allMessages,
completionOptions, {
assistantMessage.convId, ...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {}),
stream: true,
onChunk: streamCallbacks.onChunk,
onReasoningChunk: streamCallbacks.onReasoningChunk,
onModel: streamCallbacks.onModel,
onTimings: streamCallbacks.onTimings,
onComplete: async (
finalContent?: string,
reasoningContent?: string,
timings?: ChatMessageTimings,
toolCalls?: string
) => {
const content = streamedContent || finalContent || '';
const reasoning = streamedReasoningContent || reasoningContent;
const updateData: Record<string, unknown> = {
content,
reasoningContent: reasoning || undefined,
toolCalls: toolCalls || '',
timings
};
if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
await DatabaseService.updateMessage(currentMessageId, updateData);
const idx = conversationsStore.findMessageIndex(currentMessageId);
const uiUpdate: Partial<DatabaseMessage> = {
content,
reasoningContent: reasoning || undefined,
toolCalls: toolCalls || ''
};
if (timings) uiUpdate.timings = timings;
if (resolvedModel) uiUpdate.model = resolvedModel;
conversationsStore.updateMessageAtIndex(idx, uiUpdate);
await conversationsStore.updateCurrentNode(currentMessageId);
cleanupStreamingState();
if (onComplete) await onComplete(content);
if (isRouterMode()) modelsStore.fetchRouterModels().catch(console.error);
},
onError: streamCallbacks.onError
},
convId,
abortController.signal abortController.signal
); );
} }
@@ -1033,56 +1100,40 @@ class ChatStore {
} }
const originalContent = dbMessage.content; const originalContent = dbMessage.content;
const originalReasoning = dbMessage.reasoningContent || '';
const conversationContext = conversationsStore.activeMessages.slice(0, idx); const conversationContext = conversationsStore.activeMessages.slice(0, idx);
const contextWithContinue = [ const contextWithContinue = [
...conversationContext, ...conversationContext,
{ role: MessageRole.ASSISTANT as const, content: originalContent } { role: MessageRole.ASSISTANT as const, content: originalContent }
]; ];
let appendedContent = '', let appendedContent = '';
hasReceivedContent = false, let appendedReasoning = '';
isReasoningOpen = hasUnclosedReasoningTag(originalContent); let hasReceivedContent = false;
const updateStreamingContent = (fullContent: string) => { const updateStreamingContent = (fullContent: string) => {
this.setChatStreaming(msg.convId, fullContent, msg.id); this.setChatStreaming(msg.convId, fullContent, msg.id);
conversationsStore.updateMessageAtIndex(idx, { content: fullContent }); conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
}; };
const appendContentChunk = (chunk: string) => {
if (isReasoningOpen) {
appendedContent += REASONING_TAGS.END;
isReasoningOpen = false;
}
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
};
const appendReasoningChunk = (chunk: string) => {
if (!isReasoningOpen) {
appendedContent += REASONING_TAGS.START;
isReasoningOpen = true;
}
appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
};
const finalizeReasoning = () => {
if (isReasoningOpen) {
appendedContent += REASONING_TAGS.END;
isReasoningOpen = false;
}
};
const abortController = this.getOrCreateAbortController(msg.convId); const abortController = this.getOrCreateAbortController(msg.convId);
await ChatService.sendMessage( await ChatService.sendMessage(
contextWithContinue, contextWithContinue,
{ {
...this.getApiOptions(), ...this.getApiOptions(),
onChunk: (chunk: string) => appendContentChunk(chunk), onChunk: (chunk: string) => {
onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk), appendedContent += chunk;
hasReceivedContent = true;
updateStreamingContent(originalContent + appendedContent);
},
onReasoningChunk: (chunk: string) => {
appendedReasoning += chunk;
hasReceivedContent = true;
conversationsStore.updateMessageAtIndex(idx, {
reasoningContent: originalReasoning + appendedReasoning
});
},
onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => { onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
const tokensPerSecond = const tokensPerSecond =
timings?.predicted_ms && timings?.predicted_n timings?.predicted_ms && timings?.predicted_n
@@ -1105,21 +1156,23 @@ class ChatStore {
reasoningContent?: string, reasoningContent?: string,
timings?: ChatMessageTimings timings?: ChatMessageTimings
) => { ) => {
finalizeReasoning(); const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || '';
const finalAppendedReasoning = hasReceivedContent
const appendedFromCompletion = hasReceivedContent ? appendedReasoning
? appendedContent : reasoningContent || '';
: wrapReasoningContent(finalContent || '', reasoningContent); const fullContent = originalContent + finalAppendedContent;
const fullContent = originalContent + appendedFromCompletion; const fullReasoning = originalReasoning + finalAppendedReasoning || undefined;
await DatabaseService.updateMessage(msg.id, { await DatabaseService.updateMessage(msg.id, {
content: fullContent, content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(), timestamp: Date.now(),
timings timings
}); });
conversationsStore.updateMessageAtIndex(idx, { conversationsStore.updateMessageAtIndex(idx, {
content: fullContent, content: fullContent,
reasoningContent: fullReasoning,
timestamp: Date.now(), timestamp: Date.now(),
timings timings
}); });
@@ -1135,11 +1188,13 @@ class ChatStore {
if (hasReceivedContent && appendedContent) { if (hasReceivedContent && appendedContent) {
await DatabaseService.updateMessage(msg.id, { await DatabaseService.updateMessage(msg.id, {
content: originalContent + appendedContent, content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now() timestamp: Date.now()
}); });
conversationsStore.updateMessageAtIndex(idx, { conversationsStore.updateMessageAtIndex(idx, {
content: originalContent + appendedContent, content: originalContent + appendedContent,
reasoningContent: originalReasoning + appendedReasoning || undefined,
timestamp: Date.now() timestamp: Date.now()
}); });
} }
@@ -23,7 +23,7 @@ import { browser } from '$app/environment';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { DatabaseService } from '$lib/services/database.service'; import { DatabaseService } from '$lib/services/database.service';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { filterByLeafNodeId, findLeafNode } from '$lib/utils'; import { filterByLeafNodeId, findLeafNode, runLegacyMigration } from '$lib/utils';
import type { McpServerOverride } from '$lib/types/database'; import type { McpServerOverride } from '$lib/types/database';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { import {
@@ -128,6 +128,10 @@ class ConversationsStore {
if (this.isInitialized) return; if (this.isInitialized) return;
try { try {
// @deprecated Legacy migration for old marker-based messages.
// Remove once all users have migrated to the structured format.
await runLegacyMigration();
await this.loadConversations(); await this.loadConversations();
this.isInitialized = true; this.isInitialized = true;
} catch (error) { } catch (error) {
+35 -8
View File
@@ -2,6 +2,7 @@ import type { MessageRole } from '$lib/enums';
import { ToolCallType } from '$lib/enums'; import { ToolCallType } from '$lib/enums';
import type { import type {
ApiChatCompletionRequest, ApiChatCompletionRequest,
ApiChatCompletionToolCall,
ApiChatMessageContentPart, ApiChatMessageContentPart,
ApiChatMessageData ApiChatMessageData
} from './api'; } from './api';
@@ -70,22 +71,48 @@ export interface AgenticSession {
} }
/** /**
* Callbacks for agentic flow execution * Callbacks for agentic flow execution.
*
* The agentic loop creates separate DB messages for each turn:
* - assistant messages (one per LLM turn, with tool_calls if any)
* - tool result messages (one per tool call execution)
*
* The first assistant message is created by the caller before starting the flow.
* Subsequent messages are created via createToolResultMessage / createAssistantMessage.
*/ */
export interface AgenticFlowCallbacks { export interface AgenticFlowCallbacks {
/** Content chunk for the current assistant message */
onChunk?: (chunk: string) => void; onChunk?: (chunk: string) => void;
/** Reasoning content chunk for the current assistant message */
onReasoningChunk?: (chunk: string) => void; onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (serializedToolCalls: string) => void; /** Tool calls being streamed (partial, accumulating) for the current turn */
onAttachments?: (extras: DatabaseMessageExtra[]) => void; onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void;
/** Attachments extracted from tool results */
onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
/** Model name detected from response */
onModel?: (model: string) => void; onModel?: (model: string) => void;
onComplete?: ( /** Current assistant turn's streaming is complete - save to DB */
onAssistantTurnComplete?: (
content: string, content: string,
reasoningContent?: string, reasoningContent: string | undefined,
timings?: ChatMessageTimings, timings: ChatMessageTimings | undefined,
toolCalls?: string toolCalls: ApiChatCompletionToolCall[] | undefined
) => void; ) => Promise<void>;
/** Create a tool result message in the DB tree */
createToolResultMessage?: (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
) => Promise<DatabaseMessage>;
/** Create a new assistant message for the next agentic turn */
createAssistantMessage?: () => Promise<DatabaseMessage>;
/** Entire agentic flow is complete */
onFlowComplete?: (timings?: ChatMessageTimings) => void;
/** Error during flow */
onError?: (error: Error) => void; onError?: (error: Error) => void;
/** Timing updates during streaming */
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
/** An agentic turn (LLM + tool execution) completed - intermediate timing update */
onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void; onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
} }
+18 -10
View File
@@ -1,5 +1,6 @@
import type { ErrorDialogType } from '$lib/enums'; import type { ErrorDialogType } from '$lib/enums';
import type { DatabaseMessageExtra } from './database'; import type { ApiChatCompletionToolCall } from './api';
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
export interface ChatUploadedFile { export interface ChatUploadedFile {
id: string; id: string;
@@ -99,21 +100,28 @@ export interface ChatMessageToolCallTiming {
} }
/** /**
* Callbacks for streaming chat responses * Callbacks for streaming chat responses (used by both agentic and non-agentic paths)
*/ */
export interface ChatStreamCallbacks { export interface ChatStreamCallbacks {
onChunk?: (chunk: string) => void; onChunk?: (chunk: string) => void;
onReasoningChunk?: (chunk: string) => void; onReasoningChunk?: (chunk: string) => void;
onToolCallChunk?: (chunk: string) => void; onToolCallsStreaming?: (toolCalls: ApiChatCompletionToolCall[]) => void;
onAttachments?: (extras: DatabaseMessageExtra[]) => void; onAttachments?: (messageId: string, extras: DatabaseMessageExtra[]) => void;
onModel?: (model: string) => void; onModel?: (model: string) => void;
onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void; onTimings?: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => void;
onComplete?: ( onAssistantTurnComplete?: (
content?: string, content: string,
reasoningContent?: string, reasoningContent: string | undefined,
timings?: ChatMessageTimings, timings: ChatMessageTimings | undefined,
toolCallContent?: string toolCalls: ApiChatCompletionToolCall[] | undefined
) => void; ) => Promise<void>;
createToolResultMessage?: (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
) => Promise<DatabaseMessage>;
createAssistantMessage?: () => Promise<DatabaseMessage>;
onFlowComplete?: (timings?: ChatMessageTimings) => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void; onTurnComplete?: (intermediateTimings: ChatMessageTimings) => void;
} }
+2
View File
@@ -92,6 +92,8 @@ export interface DatabaseMessage {
* @deprecated - left for backward compatibility * @deprecated - left for backward compatibility
*/ */
thinking?: string; thinking?: string;
/** Reasoning content produced by the model (separate from visible content) */
reasoningContent?: string;
/** Serialized JSON array of tool calls made by assistant messages */ /** Serialized JSON array of tool calls made by assistant messages */
toolCalls?: string; toolCalls?: string;
/** Tool call ID for tool result messages (role: 'tool') */ /** Tool call ID for tool result messages (role: 'tool') */
+155 -229
View File
@@ -1,8 +1,15 @@
import { AgenticSectionType } from '$lib/enums'; import { AgenticSectionType, MessageRole } from '$lib/enums';
import { AGENTIC_TAGS, AGENTIC_REGEX, REASONING_TAGS, TRIM_NEWLINES_REGEX } from '$lib/constants'; import { ATTACHMENT_SAVED_REGEX, NEWLINE_SEPARATOR } from '$lib/constants';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
DatabaseMessageExtra,
DatabaseMessageExtraImageFile
} from '$lib/types/database';
import { AttachmentType } from '$lib/enums';
/** /**
* Represents a parsed section of agentic content * Represents a parsed section of agentic content for display
*/ */
export interface AgenticSection { export interface AgenticSection {
type: AgenticSectionType; type: AgenticSectionType;
@@ -10,63 +17,70 @@ export interface AgenticSection {
toolName?: string; toolName?: string;
toolArgs?: string; toolArgs?: string;
toolResult?: string; toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
} }
/** /**
* Represents a segment of content that may contain reasoning blocks * Represents a tool result line that may reference an image attachment
*/ */
type ReasoningSegment = { export type ToolResultLine = {
type: text: string;
| AgenticSectionType.TEXT image?: DatabaseMessageExtraImageFile;
| AgenticSectionType.REASONING
| AgenticSectionType.REASONING_PENDING;
content: string;
}; };
/** /**
* Parses agentic content into structured sections * Derives display sections from a single assistant message and its direct tool results.
* *
* Main parsing function that processes content containing: * @param message - The assistant message
* - Tool calls (completed, pending, or streaming) * @param toolMessages - Tool result messages for this assistant's tool_calls
* - Reasoning blocks (completed or streaming) * @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
* - Regular text content
*
* The parser handles chronological display of agentic flow output, maintaining
* the order of operations and properly identifying different states of tool calls
* and reasoning blocks during streaming.
*
* @param rawContent - The raw content string to parse
* @returns Array of structured agentic sections ready for display
*
* @example
* ```typescript
* const content = "Some text <<<AGENTIC_TOOL_CALL>>>tool_name...";
* const sections = parseAgenticContent(content);
* // Returns: [{ type: 'text', content: 'Some text' }, { type: 'tool_call_streaming', ... }]
* ```
*/ */
export function parseAgenticContent(rawContent: string): AgenticSection[] { function deriveSingleTurnSections(
if (!rawContent) return []; message: DatabaseMessage,
toolMessages: DatabaseMessage[] = [],
const segments = splitReasoningSegments(rawContent); streamingToolCalls: ApiChatCompletionToolCall[] = []
): AgenticSection[] {
const sections: AgenticSection[] = []; const sections: AgenticSection[] = [];
for (const segment of segments) { // 1. Reasoning content (from dedicated field)
if (segment.type === AgenticSectionType.TEXT) { if (message.reasoningContent) {
sections.push(...parseToolCallContent(segment.content));
continue;
}
if (segment.type === AgenticSectionType.REASONING) {
if (segment.content.trim()) {
sections.push({ type: AgenticSectionType.REASONING, content: segment.content });
}
continue;
}
sections.push({ sections.push({
type: AgenticSectionType.REASONING_PENDING, type: AgenticSectionType.REASONING,
content: segment.content content: message.reasoningContent
});
}
// 2. Text content
if (message.content?.trim()) {
sections.push({
type: AgenticSectionType.TEXT,
content: message.content
});
}
// 3. Persisted tool calls (from message.toolCalls field)
const toolCalls = parseToolCalls(message.toolCalls);
for (const tc of toolCalls) {
const resultMsg = toolMessages.find((m) => m.toolCallId === tc.id);
sections.push({
type: resultMsg ? AgenticSectionType.TOOL_CALL : AgenticSectionType.TOOL_CALL_PENDING,
content: resultMsg?.content || '',
toolName: tc.function?.name,
toolArgs: tc.function?.arguments,
toolResult: resultMsg?.content,
toolResultExtras: resultMsg?.extra
});
}
// 4. Streaming tool calls (not yet persisted - currently being received)
for (const tc of streamingToolCalls) {
// Skip if already in persisted tool calls
if (tc.id && toolCalls.find((t) => t.id === tc.id)) continue;
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: tc.function?.name,
toolArgs: tc.function?.arguments
}); });
} }
@@ -74,211 +88,123 @@ export function parseAgenticContent(rawContent: string): AgenticSection[] {
} }
/** /**
* Parses content containing tool call markers * Derives display sections from structured message data.
* *
* Identifies and extracts tool calls from content, handling: * Handles both single-turn (one assistant + its tool results) and multi-turn
* - Completed tool calls with name, arguments, and results * agentic sessions (multiple assistant + tool messages grouped together).
* - Pending tool calls (execution in progress)
* - Streaming tool calls (arguments being received)
* - Early-stage tool calls (just started)
* *
* @param rawContent - The raw content string to parse * When `toolMessages` contains continuation assistant messages (from multi-turn
* @returns Array of agentic sections representing tool calls and text * agentic flows), they are processed in order to produce sections across all turns.
*
* @param message - The first/anchor assistant message
* @param toolMessages - Tool result messages and continuation assistant messages
* @param streamingToolCalls - Partial tool calls during streaming (not yet persisted)
* @param isStreaming - Whether the message is currently being streamed
*/ */
function parseToolCallContent(rawContent: string): AgenticSection[] { export function deriveAgenticSections(
if (!rawContent) return []; message: DatabaseMessage,
toolMessages: DatabaseMessage[] = [],
streamingToolCalls: ApiChatCompletionToolCall[] = []
): AgenticSection[] {
const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
if (!hasAssistantContinuations) {
return deriveSingleTurnSections(message, toolMessages, streamingToolCalls);
}
const sections: AgenticSection[] = []; const sections: AgenticSection[] = [];
const completedToolCallRegex = new RegExp(AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g'); const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
let lastIndex = 0; let i = firstTurnToolMsgs.length;
let match;
while ((match = completedToolCallRegex.exec(rawContent)) !== null) { while (i < toolMessages.length) {
if (match.index > lastIndex) { const msg = toolMessages[i];
const textBefore = rawContent.slice(lastIndex, match.index).trim();
if (textBefore) { if (msg.role === MessageRole.ASSISTANT) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore }); const turnToolMsgs = collectToolMessages(toolMessages, i + 1);
const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
sections.push(
...deriveSingleTurnSections(msg, turnToolMsgs, isLastTurn ? streamingToolCalls : [])
);
i += 1 + turnToolMsgs.length;
} else {
i++;
} }
} }
const toolName = match[1];
const toolArgs = match[2];
const toolResult = match[3].replace(TRIM_NEWLINES_REGEX, '');
sections.push({
type: AgenticSectionType.TOOL_CALL,
content: toolResult,
toolName,
toolArgs,
toolResult
});
lastIndex = match.index + match[0].length;
}
const remainingContent = rawContent.slice(lastIndex);
const pendingMatch = remainingContent.match(AGENTIC_REGEX.PENDING_TOOL_CALL);
const partialWithNameMatch = remainingContent.match(AGENTIC_REGEX.PARTIAL_WITH_NAME);
const earlyMatch = remainingContent.match(AGENTIC_REGEX.EARLY_MATCH);
if (pendingMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const toolName = pendingMatch[1];
const toolArgs = pendingMatch[2];
const streamingResult = (pendingMatch[3] || '').replace(TRIM_NEWLINES_REGEX, '');
sections.push({
type: AgenticSectionType.TOOL_CALL_PENDING,
content: streamingResult,
toolName,
toolArgs,
toolResult: streamingResult || undefined
});
} else if (partialWithNameMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const partialArgs = partialWithNameMatch[2] || '';
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: partialWithNameMatch[1],
toolArgs: partialArgs || undefined,
toolResult: undefined
});
} else if (earlyMatch) {
const pendingIndex = remainingContent.indexOf(AGENTIC_TAGS.TOOL_CALL_START);
if (pendingIndex > 0) {
const textBefore = remainingContent.slice(0, pendingIndex).trim();
if (textBefore) {
sections.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const nameMatch = earlyMatch[1]?.match(AGENTIC_REGEX.TOOL_NAME_EXTRACT);
sections.push({
type: AgenticSectionType.TOOL_CALL_STREAMING,
content: '',
toolName: nameMatch?.[1],
toolArgs: undefined,
toolResult: undefined
});
} else if (lastIndex < rawContent.length) {
let remainingText = rawContent.slice(lastIndex).trim();
const partialMarkerMatch = remainingText.match(AGENTIC_REGEX.PARTIAL_MARKER);
if (partialMarkerMatch) {
remainingText = remainingText.slice(0, partialMarkerMatch.index).trim();
}
if (remainingText) {
sections.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
}
if (sections.length === 0 && rawContent.trim()) {
sections.push({ type: AgenticSectionType.TEXT, content: rawContent });
}
return sections; return sections;
} }
/** /**
* Strips partial marker from text content * Collect consecutive tool messages starting at `startIndex`.
*
* Removes incomplete agentic markers (e.g., "<<<", "<<<AGENTIC") that may appear
* at the end of streaming content.
*
* @param text - The text content to process
* @returns Text with partial markers removed
*/ */
function stripPartialMarker(text: string): string { function collectToolMessages(messages: DatabaseMessage[], startIndex: number): DatabaseMessage[] {
const partialMarkerMatch = text.match(AGENTIC_REGEX.PARTIAL_MARKER); const result: DatabaseMessage[] = [];
if (partialMarkerMatch) { for (let i = startIndex; i < messages.length; i++) {
return text.slice(0, partialMarkerMatch.index).trim(); if (messages[i].role === MessageRole.TOOL) {
result.push(messages[i]);
} else {
break;
}
} }
return text; return result;
} }
/** /**
* Splits raw content into segments based on reasoning blocks * Parse tool result text into lines, matching image attachments by name.
*
* Identifies and extracts reasoning content wrapped in REASONING_TAGS.START/END markers,
* separating it from regular text content. Handles both complete and incomplete
* (streaming) reasoning blocks.
*
* @param rawContent - The raw content string to parse
* @returns Array of reasoning segments with their types and content
*/ */
function splitReasoningSegments(rawContent: string): ReasoningSegment[] { export function parseToolResultWithImages(
if (!rawContent) return []; toolResult: string,
extras?: DatabaseMessageExtra[]
): ToolResultLine[] {
const lines = toolResult.split(NEWLINE_SEPARATOR);
return lines.map((line) => {
const match = line.match(ATTACHMENT_SAVED_REGEX);
if (!match || !extras) return { text: line };
const segments: ReasoningSegment[] = []; const attachmentName = match[1];
let cursor = 0; const image = extras.find(
(e): e is DatabaseMessageExtraImageFile =>
e.type === AttachmentType.IMAGE && e.name === attachmentName
);
while (cursor < rawContent.length) { return { text: line, image };
const startIndex = rawContent.indexOf(REASONING_TAGS.START, cursor);
if (startIndex === -1) {
const remainingText = rawContent.slice(cursor);
if (remainingText) {
segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
}
break;
}
if (startIndex > cursor) {
const textBefore = rawContent.slice(cursor, startIndex);
if (textBefore) {
segments.push({ type: AgenticSectionType.TEXT, content: textBefore });
}
}
const contentStart = startIndex + REASONING_TAGS.START.length;
const endIndex = rawContent.indexOf(REASONING_TAGS.END, contentStart);
if (endIndex === -1) {
const pendingContent = rawContent.slice(contentStart);
segments.push({
type: AgenticSectionType.REASONING_PENDING,
content: stripPartialMarker(pendingContent)
}); });
}
break;
} /**
* Safely parse the toolCalls JSON string from a DatabaseMessage.
const reasoningContent = rawContent.slice(contentStart, endIndex); */
segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent }); function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
cursor = endIndex + REASONING_TAGS.END.length; if (!toolCallsJson) return [];
}
try {
return segments; const parsed = JSON.parse(toolCallsJson);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* Check if a message has agentic content (tool calls or is part of an agentic flow).
*/
export function hasAgenticContent(
message: DatabaseMessage,
toolMessages: DatabaseMessage[] = []
): boolean {
if (message.toolCalls) {
const tc = parseToolCalls(message.toolCalls);
if (tc.length > 0) return true;
}
return toolMessages.length > 0;
} }
+11 -2
View File
@@ -149,8 +149,17 @@ export { parseHeadersToArray, serializeHeaders } from './headers';
// Favicon utilities // Favicon utilities
export { getFaviconUrl } from './favicon'; export { getFaviconUrl } from './favicon';
// Agentic content parsing utilities // Agentic content utilities (structured section derivation)
export { parseAgenticContent, type AgenticSection } from './agentic'; export {
deriveAgenticSections,
parseToolResultWithImages,
hasAgenticContent,
type AgenticSection,
type ToolResultLine
} from './agentic';
// Legacy migration utilities
export { runLegacyMigration, isMigrationNeeded } from './legacy-migration';
// Cache utilities // Cache utilities
export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl'; export { TTLCache, ReactiveTTLMap, type TTLCacheOptions } from './cache-ttl';
@@ -0,0 +1,345 @@
/**
* @deprecated Legacy migration utility — remove at some point in the future once all users have migrated to the new structured agentic message format.
*
* Converts old marker-based agentic messages to the new structured format
* with separate messages per turn.
*
* Old format: Single assistant message with markers in content:
* <<<reasoning_content_start>>>...<<<reasoning_content_end>>>
* <<<AGENTIC_TOOL_CALL_START>>>...<<<AGENTIC_TOOL_CALL_END>>>
*
* New format: Separate messages per turn:
* - assistant (content + reasoningContent + toolCalls)
* - tool (toolCallId + content)
* - assistant (next turn)
* - ...
*/
import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants';
import { DatabaseService } from '$lib/services/database.service';
import { MessageRole, MessageType } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types/database';
const MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done';
/**
* @deprecated Part of legacy migration — remove with the migration module.
* Check if migration has been performed.
*/
export function isMigrationNeeded(): boolean {
try {
return !localStorage.getItem(MIGRATION_DONE_KEY);
} catch {
return false;
}
}
/**
* Mark migration as done.
*/
function markMigrationDone(): void {
try {
localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
} catch {
// Ignore localStorage errors
}
}
/**
* Check if a message has legacy markers in its content.
*/
function hasLegacyMarkers(message: DatabaseMessage): boolean {
if (!message.content) return false;
return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content);
}
/**
* Extract reasoning content from legacy marker format.
*/
function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
let reasoning = '';
let cleanContent = content;
// Extract all reasoning blocks
const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
let match;
while ((match = re.exec(content)) !== null) {
reasoning += match[1];
}
// Remove reasoning tags from content
cleanContent = cleanContent
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
return { reasoning, cleanContent };
}
/**
* Parse legacy content with tool call markers into structured turns.
*/
interface ParsedTurn {
textBefore: string;
toolCalls: Array<{
name: string;
args: string;
result: string;
}>;
}
function parseLegacyToolCalls(content: string): ParsedTurn[] {
const turns: ParsedTurn[] = [];
const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
let lastIndex = 0;
let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
let match;
while ((match = regex.exec(content)) !== null) {
const textBefore = content.slice(lastIndex, match.index).trim();
// If there's text between tool calls and we already have tool calls,
// that means a new turn started (text after tool results = new LLM turn)
if (textBefore && currentTurn.toolCalls.length > 0) {
turns.push(currentTurn);
currentTurn = { textBefore, toolCalls: [] };
} else if (textBefore && currentTurn.toolCalls.length === 0) {
currentTurn.textBefore = textBefore;
}
currentTurn.toolCalls.push({
name: match[1],
args: match[2],
result: match[3].replace(/^\n+|\n+$/g, '')
});
lastIndex = match.index + match[0].length;
}
// Any remaining text after the last tool call
const remainingText = content.slice(lastIndex).trim();
if (currentTurn.toolCalls.length > 0) {
turns.push(currentTurn);
}
// If there's text after all tool calls, it's the final assistant response
if (remainingText) {
// Remove any partial/open markers
const cleanRemaining = remainingText
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
.trim();
if (cleanRemaining) {
turns.push({ textBefore: cleanRemaining, toolCalls: [] });
}
}
// If no tool calls found at all, return the original content as a single turn
if (turns.length === 0) {
turns.push({ textBefore: content.trim(), toolCalls: [] });
}
return turns;
}
/**
* Migrate a single conversation's messages from legacy format to new format.
*/
async function migrateConversation(convId: string): Promise<number> {
const allMessages = await DatabaseService.getConversationMessages(convId);
let migratedCount = 0;
for (const message of allMessages) {
if (message.role !== MessageRole.ASSISTANT) continue;
if (!hasLegacyMarkers(message)) {
// Still check for reasoning-only markers (no tool calls)
if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
await DatabaseService.updateMessage(message.id, {
content: cleanContent.trim(),
reasoningContent: reasoning || undefined
});
migratedCount++;
}
continue;
}
// Has agentic markers - full migration needed
const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
const turns = parseLegacyToolCalls(cleanContent);
// Parse existing toolCalls JSON to try to match IDs
let existingToolCalls: Array<{ id: string; function?: { name: string; arguments: string } }> =
[];
if (message.toolCalls) {
try {
existingToolCalls = JSON.parse(message.toolCalls);
} catch {
// Ignore
}
}
// First turn uses the existing message
const firstTurn = turns[0];
if (!firstTurn) continue;
// Match tool calls from the first turn to existing IDs
const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
const existing =
existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
return {
id: existing?.id || `legacy_tool_${i}`,
type: 'function' as const,
function: { name: tc.name, arguments: tc.args }
};
});
// Update the existing message for the first turn
await DatabaseService.updateMessage(message.id, {
content: firstTurn.textBefore,
reasoningContent: reasoning || undefined,
toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
});
let currentParentId = message.id;
let toolCallIdCounter = existingToolCalls.length;
// Create tool result messages for the first turn
for (let i = 0; i < firstTurn.toolCalls.length; i++) {
const tc = firstTurn.toolCalls[i];
const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
const toolMsg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.TOOL,
content: tc.result,
toolCallId,
timestamp: message.timestamp + i + 1,
toolCalls: '',
children: []
},
currentParentId
);
currentParentId = toolMsg.id;
}
// Create messages for subsequent turns
for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
const turn = turns[turnIdx];
const turnToolCalls = turn.toolCalls.map((tc, i) => {
const idx = toolCallIdCounter + i;
const existing = existingToolCalls[idx];
return {
id: existing?.id || `legacy_tool_${idx}`,
type: 'function' as const,
function: { name: tc.name, arguments: tc.args }
};
});
toolCallIdCounter += turn.toolCalls.length;
// Create assistant message for this turn
const assistantMsg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.ASSISTANT,
content: turn.textBefore,
timestamp: message.timestamp + turnIdx * 100,
toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
children: [],
model: message.model
},
currentParentId
);
currentParentId = assistantMsg.id;
// Create tool result messages for this turn
for (let i = 0; i < turn.toolCalls.length; i++) {
const tc = turn.toolCalls[i];
const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
const toolMsg = await DatabaseService.createMessageBranch(
{
convId,
type: MessageType.TEXT,
role: MessageRole.TOOL,
content: tc.result,
toolCallId,
timestamp: message.timestamp + turnIdx * 100 + i + 1,
toolCalls: '',
children: []
},
currentParentId
);
currentParentId = toolMsg.id;
}
}
// Re-parent any children of the original message to the last created message
// (the original message's children list was the next user message or similar)
if (message.children.length > 0 && currentParentId !== message.id) {
for (const childId of message.children) {
// Skip children we just created (they were already properly parented)
const child = allMessages.find((m) => m.id === childId);
if (!child) continue;
// Only re-parent non-tool messages that were original children
if (child.role !== MessageRole.TOOL) {
await DatabaseService.updateMessage(childId, { parent: currentParentId });
// Add to new parent's children
const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) =>
msgs.find((m) => m.id === currentParentId)
);
if (newParent && !newParent.children.includes(childId)) {
await DatabaseService.updateMessage(currentParentId, {
children: [...newParent.children, childId]
});
}
}
}
// Clear re-parented children from the original message
await DatabaseService.updateMessage(message.id, { children: [] });
}
migratedCount++;
}
return migratedCount;
}
/**
* @deprecated Part of legacy migration — remove with the migration module.
* Run the full migration across all conversations.
* This should be called once at app startup if migration is needed.
*/
export async function runLegacyMigration(): Promise<void> {
if (!isMigrationNeeded()) return;
console.log('[Migration] Starting legacy message format migration...');
try {
const conversations = await DatabaseService.getAllConversations();
let totalMigrated = 0;
for (const conv of conversations) {
const count = await migrateConversation(conv.id);
totalMigrated += count;
}
if (totalMigrated > 0) {
console.log(
`[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
);
} else {
console.log('[Migration] No legacy messages found, marking as done');
}
markMigrationDone();
} catch (error) {
console.error('[Migration] Failed to migrate legacy messages:', error);
// Still mark as done to avoid infinite retry loops
markMigrationDone();
}
}
@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest';
import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
import { AgenticSectionType, MessageRole } from '$lib/enums';
import type { DatabaseMessage } from '$lib/types/database';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
id: overrides.id ?? 'ast-1',
convId: 'conv-1',
type: 'text',
timestamp: Date.now(),
role: MessageRole.ASSISTANT,
content: overrides.content ?? '',
parent: null,
children: [],
...overrides
} as DatabaseMessage;
}
function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
return {
id: overrides.id ?? 'tool-1',
convId: 'conv-1',
type: 'text',
timestamp: Date.now(),
role: MessageRole.TOOL,
content: overrides.content ?? 'tool result',
parent: null,
children: [],
toolCallId: overrides.toolCallId ?? 'call_1',
...overrides
} as DatabaseMessage;
}
describe('deriveAgenticSections', () => {
it('returns empty array for assistant with no content', () => {
const msg = makeAssistant({ content: '' });
const sections = deriveAgenticSections(msg);
expect(sections).toEqual([]);
});
it('returns text section for simple assistant message', () => {
const msg = makeAssistant({ content: 'Hello world' });
const sections = deriveAgenticSections(msg);
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[0].content).toBe('Hello world');
});
it('returns reasoning + text for message with reasoning', () => {
const msg = makeAssistant({
content: 'Answer is 4.',
reasoningContent: 'Let me think...'
});
const sections = deriveAgenticSections(msg);
expect(sections).toHaveLength(2);
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
expect(sections[0].content).toBe('Let me think...');
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
});
it('single turn: assistant with tool calls and results', () => {
const msg = makeAssistant({
content: 'Let me check.',
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"test"}' } }
])
});
const toolResult = makeToolMsg({
toolCallId: 'call_1',
content: 'Found 3 results'
});
const sections = deriveAgenticSections(msg, [toolResult]);
expect(sections).toHaveLength(2);
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[1].toolName).toBe('search');
expect(sections[1].toolResult).toBe('Found 3 results');
});
it('single turn: pending tool call without result', () => {
const msg = makeAssistant({
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } }
])
});
const sections = deriveAgenticSections(msg, []);
expect(sections).toHaveLength(1);
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
expect(sections[0].toolName).toBe('bash');
});
it('multi-turn: two assistant turns grouped as one session', () => {
const assistant1 = makeAssistant({
id: 'ast-1',
content: 'Turn 1 text',
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"foo"}' } }
])
});
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' });
const assistant2 = makeAssistant({
id: 'ast-2',
content: 'Final answer based on results.'
});
// toolMessages contains both tool result and continuation assistant
const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
expect(sections).toHaveLength(3);
// Turn 1
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
expect(sections[0].content).toBe('Turn 1 text');
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[1].toolName).toBe('search');
expect(sections[1].toolResult).toBe('result 1');
// Turn 2 (final)
expect(sections[2].type).toBe(AgenticSectionType.TEXT);
expect(sections[2].content).toBe('Final answer based on results.');
});
it('multi-turn: three turns with tool calls', () => {
const assistant1 = makeAssistant({
id: 'ast-1',
content: '',
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'list_files', arguments: '{}' } }
])
});
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' });
const assistant2 = makeAssistant({
id: 'ast-2',
content: 'Reading file1...',
toolCalls: JSON.stringify([
{
id: 'call_2',
type: 'function',
function: { name: 'read_file', arguments: '{"path":"file1"}' }
}
])
});
const tool2 = makeToolMsg({ id: 'tool-2', toolCallId: 'call_2', content: 'contents of file1' });
const assistant3 = makeAssistant({
id: 'ast-3',
content: 'Here is the analysis.',
reasoningContent: 'The file contains...'
});
const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
// Turn 1: tool_call (no text since content is empty)
// Turn 2: text + tool_call
// Turn 3: reasoning + text
expect(sections).toHaveLength(5);
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[0].toolName).toBe('list_files');
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
expect(sections[1].content).toBe('Reading file1...');
expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL);
expect(sections[2].toolName).toBe('read_file');
expect(sections[3].type).toBe(AgenticSectionType.REASONING);
expect(sections[4].type).toBe(AgenticSectionType.TEXT);
expect(sections[4].content).toBe('Here is the analysis.');
});
it('multi-turn: streaming tool calls on last turn', () => {
const assistant1 = makeAssistant({
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }
])
});
const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' });
const assistant2 = makeAssistant({ id: 'ast-2', content: '' });
const streamingToolCalls: ApiChatCompletionToolCall[] = [
{ id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } }
];
const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
// Turn 1: tool_call
// Turn 2 (streaming): streaming tool call
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true);
});
});
describe('hasAgenticContent', () => {
it('returns false for plain assistant', () => {
const msg = makeAssistant({ content: 'Just text' });
expect(hasAgenticContent(msg)).toBe(false);
});
it('returns true when message has toolCalls', () => {
const msg = makeAssistant({
toolCalls: JSON.stringify([
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
])
});
expect(hasAgenticContent(msg)).toBe(true);
});
it('returns true when toolMessages are provided', () => {
const msg = makeAssistant();
const tool = makeToolMsg();
expect(hasAgenticContent(msg, [tool])).toBe(true);
});
it('returns false for empty toolCalls JSON', () => {
const msg = makeAssistant({ toolCalls: '[]' });
expect(hasAgenticContent(msg)).toBe(false);
});
});
@@ -1,17 +1,22 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { AGENTIC_REGEX } from '$lib/constants/agentic'; import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic';
// Mirror the logic in ChatService.stripReasoningContent so we can test it in isolation. /**
// The real function is private static, so we replicate the strip pipeline here. * Tests for legacy marker stripping (used in migration).
function stripContextMarkers(content: string): string { * The new system does not embed markers in content - these tests verify
* the legacy regex patterns still work for the migration code.
*/
// Mirror the legacy stripping logic used during migration
function stripLegacyContextMarkers(content: string): string {
return content return content
.replace(AGENTIC_REGEX.REASONING_BLOCK, '') .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '') .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '') .replace(new RegExp(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK.source, 'g'), '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, ''); .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
} }
// A realistic complete tool call block as stored in message.content after a turn. // A realistic complete tool call block as stored in old message.content
const COMPLETE_BLOCK = const COMPLETE_BLOCK =
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' + '\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
'<<<TOOL_NAME:bash_tool>>>\n' + '<<<TOOL_NAME:bash_tool>>>\n' +
@@ -30,11 +35,10 @@ const OPEN_BLOCK =
'<<<TOOL_ARGS_END>>>\n' + '<<<TOOL_ARGS_END>>>\n' +
'partial output...'; 'partial output...';
describe('agentic marker stripping for context', () => { describe('legacy agentic marker stripping (for migration)', () => {
it('strips a complete tool call block, leaving surrounding text', () => { it('strips a complete tool call block, leaving surrounding text', () => {
const input = 'Before.' + COMPLETE_BLOCK + 'After.'; const input = 'Before.' + COMPLETE_BLOCK + 'After.';
const result = stripContextMarkers(input); const result = stripLegacyContextMarkers(input);
// markers gone; residual newlines between fragments are fine
expect(result).not.toContain('<<<'); expect(result).not.toContain('<<<');
expect(result).toContain('Before.'); expect(result).toContain('Before.');
expect(result).toContain('After.'); expect(result).toContain('After.');
@@ -42,7 +46,7 @@ describe('agentic marker stripping for context', () => {
it('strips multiple complete tool call blocks', () => { it('strips multiple complete tool call blocks', () => {
const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C'; const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C';
const result = stripContextMarkers(input); const result = stripLegacyContextMarkers(input);
expect(result).not.toContain('<<<'); expect(result).not.toContain('<<<');
expect(result).toContain('A'); expect(result).toContain('A');
expect(result).toContain('B'); expect(result).toContain('B');
@@ -51,19 +55,19 @@ describe('agentic marker stripping for context', () => {
it('strips an open/partial tool call block (no END marker)', () => { it('strips an open/partial tool call block (no END marker)', () => {
const input = 'Lead text.' + OPEN_BLOCK; const input = 'Lead text.' + OPEN_BLOCK;
const result = stripContextMarkers(input); const result = stripLegacyContextMarkers(input);
expect(result).toBe('Lead text.'); expect(result).toBe('Lead text.');
expect(result).not.toContain('<<<'); expect(result).not.toContain('<<<');
}); });
it('does not alter content with no markers', () => { it('does not alter content with no markers', () => {
const input = 'Just a normal assistant response.'; const input = 'Just a normal assistant response.';
expect(stripContextMarkers(input)).toBe(input); expect(stripLegacyContextMarkers(input)).toBe(input);
}); });
it('strips reasoning block independently', () => { it('strips reasoning block independently', () => {
const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.'; const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.';
expect(stripContextMarkers(input)).toBe('Answer.'); expect(stripLegacyContextMarkers(input)).toBe('Answer.');
}); });
it('strips both reasoning and agentic blocks together', () => { it('strips both reasoning and agentic blocks together', () => {
@@ -71,11 +75,21 @@ describe('agentic marker stripping for context', () => {
'<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' + '<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
'Some text.' + 'Some text.' +
COMPLETE_BLOCK; COMPLETE_BLOCK;
expect(stripContextMarkers(input)).not.toContain('<<<'); expect(stripLegacyContextMarkers(input)).not.toContain('<<<');
expect(stripContextMarkers(input)).toContain('Some text.'); expect(stripLegacyContextMarkers(input)).toContain('Some text.');
}); });
it('empty string survives', () => { it('empty string survives', () => {
expect(stripContextMarkers('')).toBe(''); expect(stripLegacyContextMarkers('')).toBe('');
});
it('detects legacy markers', () => {
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('normal text')).toBe(false);
expect(
LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('text<<<AGENTIC_TOOL_CALL_START>>>more')
).toBe(true);
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<<reasoning_content_start>>>think')).toBe(
true
);
}); });
}); });
@@ -1,196 +1,89 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { AGENTIC_REGEX, REASONING_TAGS } from '$lib/constants/agentic'; import { MessageRole } from '$lib/enums';
import { ContentPartType } from '$lib/enums';
// Replicate ChatService.extractReasoningFromContent (private static) /**
function extractReasoningFromContent( * Tests for the new reasoning content handling.
content: string | Array<{ type: string; text?: string }> | null | undefined * In the new architecture, reasoning content is stored in a dedicated
): string | undefined { * `reasoningContent` field on DatabaseMessage, not embedded in content with tags.
if (!content) return undefined; * The API sends it as `reasoning_content` on ApiChatMessageData.
*/
const extractFromString = (text: string): string => { describe('reasoning content in new structured format', () => {
const parts: string[] = []; it('reasoning is stored as separate field, not in content', () => {
const re = new RegExp(AGENTIC_REGEX.REASONING_EXTRACT.source); // Simulate what the new chat store does
let match = re.exec(text); const message = {
while (match) { content: 'The answer is 4.',
parts.push(match[1]); reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
text = text.slice(match.index + match[0].length);
match = re.exec(text);
}
return parts.join('');
}; };
if (typeof content === 'string') { // Content should be clean
const result = extractFromString(content); expect(message.content).not.toContain('<<<');
return result || undefined; expect(message.content).toBe('The answer is 4.');
}
if (!Array.isArray(content)) return undefined; // Reasoning in dedicated field
expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.');
});
const parts: string[] = []; it('convertDbMessageToApiChatMessageData includes reasoning_content', () => {
for (const part of content) { // Simulate the conversion logic
if (part.type === ContentPartType.TEXT && part.text) { const dbMessage = {
const result = extractFromString(part.text); role: MessageRole.ASSISTANT,
if (result) parts.push(result); content: 'The answer is 4.',
} reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
}
return parts.length > 0 ? parts.join('') : undefined;
}
// Replicate ChatService.stripReasoningContent (private static)
function stripReasoningContent(
content: string | Array<{ type: string; text?: string }> | null | undefined
): typeof content {
if (!content) return content;
if (typeof content === 'string') {
return content
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
}
if (!Array.isArray(content)) return content;
return content.map((part) => {
if (part.type !== ContentPartType.TEXT || !part.text) return part;
return {
...part,
text: part.text
.replace(AGENTIC_REGEX.REASONING_BLOCK, '')
.replace(AGENTIC_REGEX.REASONING_OPEN, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK, '')
.replace(AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
}; };
});
}
// Simulate the message mapping logic from ChatService.sendMessage const apiMessage: Record<string, unknown> = {
function buildApiMessage( role: dbMessage.role,
content: string, content: dbMessage.content
excludeReasoningFromContext: boolean
): { role: string; content: string; reasoning_content?: string } {
const cleaned = stripReasoningContent(content) as string;
const mapped: { role: string; content: string; reasoning_content?: string } = {
role: 'assistant',
content: cleaned
}; };
if (!excludeReasoningFromContext) { if (dbMessage.reasoningContent) {
const reasoning = extractReasoningFromContent(content); apiMessage.reasoning_content = dbMessage.reasoningContent;
if (reasoning) {
mapped.reasoning_content = reasoning;
} }
expect(apiMessage.content).toBe('The answer is 4.');
expect(apiMessage.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.');
// No internal tags leak into either field
expect(apiMessage.content).not.toContain('<<<');
expect(apiMessage.reasoning_content).not.toContain('<<<');
});
it('API message excludes reasoning when excludeReasoningFromContext is true', () => {
const dbMessage = {
role: MessageRole.ASSISTANT,
content: 'The answer is 4.',
reasoningContent: 'internal thinking'
};
const excludeReasoningFromContext = true;
const apiMessage: Record<string, unknown> = {
role: dbMessage.role,
content: dbMessage.content
};
if (!excludeReasoningFromContext && dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
} }
return mapped;
}
// Helper: wrap reasoning the same way the chat store does during streaming expect(apiMessage.content).toBe('The answer is 4.');
function wrapReasoning(reasoning: string, content: string): string { expect(apiMessage.reasoning_content).toBeUndefined();
return `${REASONING_TAGS.START}${reasoning}${REASONING_TAGS.END}${content}`;
}
describe('reasoning content extraction', () => {
it('extracts reasoning from tagged string content', () => {
const input = wrapReasoning('step 1, step 2', 'The answer is 42.');
const result = extractReasoningFromContent(input);
expect(result).toBe('step 1, step 2');
}); });
it('returns undefined when no reasoning tags present', () => { it('handles messages with no reasoning', () => {
expect(extractReasoningFromContent('Just a normal response.')).toBeUndefined(); const dbMessage = {
}); role: MessageRole.ASSISTANT,
content: 'No reasoning here.',
reasoningContent: undefined
};
it('returns undefined for null/empty input', () => { const apiMessage: Record<string, unknown> = {
expect(extractReasoningFromContent(null)).toBeUndefined(); role: dbMessage.role,
expect(extractReasoningFromContent(undefined)).toBeUndefined(); content: dbMessage.content
expect(extractReasoningFromContent('')).toBeUndefined(); };
}); if (dbMessage.reasoningContent) {
apiMessage.reasoning_content = dbMessage.reasoningContent;
it('extracts reasoning from content part arrays', () => {
const input = [
{
type: ContentPartType.TEXT,
text: wrapReasoning('thinking hard', 'result')
} }
];
expect(extractReasoningFromContent(input)).toBe('thinking hard');
});
it('handles multiple reasoning blocks', () => { expect(apiMessage.content).toBe('No reasoning here.');
const input = expect(apiMessage.reasoning_content).toBeUndefined();
REASONING_TAGS.START +
'block1' +
REASONING_TAGS.END +
'middle' +
REASONING_TAGS.START +
'block2' +
REASONING_TAGS.END +
'end';
expect(extractReasoningFromContent(input)).toBe('block1block2');
});
it('ignores non-text content parts', () => {
const input = [{ type: 'image_url', text: wrapReasoning('hidden', 'img') }];
expect(extractReasoningFromContent(input)).toBeUndefined();
});
});
describe('strip reasoning content', () => {
it('removes reasoning tags from string content', () => {
const input = wrapReasoning('internal thoughts', 'visible answer');
expect(stripReasoningContent(input)).toBe('visible answer');
});
it('removes reasoning from content part arrays', () => {
const input = [
{
type: ContentPartType.TEXT,
text: wrapReasoning('thoughts', 'answer')
}
];
const result = stripReasoningContent(input) as Array<{ type: string; text?: string }>;
expect(result[0].text).toBe('answer');
});
});
describe('API message building with reasoning preservation', () => {
const storedContent = wrapReasoning('Let me think: 2+2=4, basic arithmetic.', 'The answer is 4.');
it('preserves reasoning_content when excludeReasoningFromContext is false', () => {
const msg = buildApiMessage(storedContent, false);
expect(msg.content).toBe('The answer is 4.');
expect(msg.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.');
// no internal tags leak into either field
expect(msg.content).not.toContain('<<<');
expect(msg.reasoning_content).not.toContain('<<<');
});
it('strips reasoning_content when excludeReasoningFromContext is true', () => {
const msg = buildApiMessage(storedContent, true);
expect(msg.content).toBe('The answer is 4.');
expect(msg.reasoning_content).toBeUndefined();
});
it('handles content with no reasoning in both modes', () => {
const plain = 'No reasoning here.';
const msgPreserve = buildApiMessage(plain, false);
const msgExclude = buildApiMessage(plain, true);
expect(msgPreserve.content).toBe(plain);
expect(msgPreserve.reasoning_content).toBeUndefined();
expect(msgExclude.content).toBe(plain);
expect(msgExclude.reasoning_content).toBeUndefined();
});
it('cleans agentic tool call blocks from content even when preserving reasoning', () => {
const input =
wrapReasoning('plan', 'text') +
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
'<<<TOOL_NAME:bash>>>\n' +
'<<<TOOL_ARGS_START>>>\n{}\n<<<TOOL_ARGS_END>>>\nout\n' +
'<<<AGENTIC_TOOL_CALL_END>>>\n';
const msg = buildApiMessage(input, false);
expect(msg.content).not.toContain('<<<');
expect(msg.reasoning_content).toBe('plan');
}); });
}); });