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:
co-authored by
Aleksander Grygier
parent
26dac845cc
commit
4453e77561
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}
|
||||||
|
|||||||
+20
-41
@@ -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)}
|
||||||
>
|
>
|
||||||
|
|||||||
+13
-12
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i = j - 1;
|
||||||
|
} else if (msg.role === MessageRole.ASSISTANT) {
|
||||||
|
let j = i + 1;
|
||||||
|
|
||||||
|
while (j < filteredMessages.length && filteredMessages[j].role === MessageRole.TOOL) {
|
||||||
|
toolMessages.push(filteredMessages[j]);
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblingInfo = getMessageSiblings(allConversationMessages, msg.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
message: msg,
|
||||||
|
toolMessages,
|
||||||
|
isLastAssistantMessage: false,
|
||||||
|
siblingInfo: siblingInfo || {
|
||||||
|
message: msg,
|
||||||
|
siblingIds: [msg.id],
|
||||||
|
currentIndex: 0,
|
||||||
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredMessages.map((message, index) => {
|
return result;
|
||||||
const siblingInfo = getMessageSiblings(allConversationMessages, message.id);
|
|
||||||
const isLastAssistantMessage =
|
|
||||||
message.role === MessageRole.ASSISTANT && index === lastAssistantIndex;
|
|
||||||
|
|
||||||
return {
|
|
||||||
message,
|
|
||||||
isLastAssistantMessage,
|
|
||||||
siblingInfo: siblingInfo || {
|
|
||||||
message,
|
|
||||||
siblingIds: [message.id],
|
|
||||||
currentIndex: 0,
|
|
||||||
totalSiblings: 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</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,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[];
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') */
|
||||||
|
|||||||
@@ -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);
|
* Safely parse the toolCalls JSON string from a DatabaseMessage.
|
||||||
|
*/
|
||||||
|
function parseToolCalls(toolCallsJson?: string): ApiChatCompletionToolCall[] {
|
||||||
|
if (!toolCallsJson) return [];
|
||||||
|
|
||||||
if (remainingText) {
|
try {
|
||||||
segments.push({ type: AgenticSectionType.TEXT, content: remainingText });
|
const parsed = JSON.parse(toolCallsJson);
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
}
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (startIndex > cursor) {
|
/**
|
||||||
const textBefore = rawContent.slice(cursor, startIndex);
|
* 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 (textBefore) {
|
if (tc.length > 0) return true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reasoningContent = rawContent.slice(contentStart, endIndex);
|
|
||||||
segments.push({ type: AgenticSectionType.REASONING, content: reasoningContent });
|
|
||||||
cursor = endIndex + REASONING_TAGS.END.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return toolMessages.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Content should be clean
|
||||||
function buildApiMessage(
|
expect(message.content).not.toContain('<<<');
|
||||||
content: string,
|
expect(message.content).toBe('The answer is 4.');
|
||||||
excludeReasoningFromContext: boolean
|
|
||||||
): { role: string; content: string; reasoning_content?: string } {
|
// Reasoning in dedicated field
|
||||||
const cleaned = stripReasoningContent(content) as string;
|
expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.');
|
||||||
const mapped: { role: string; content: string; reasoning_content?: string } = {
|
});
|
||||||
role: 'assistant',
|
|
||||||
content: cleaned
|
it('convertDbMessageToApiChatMessageData includes reasoning_content', () => {
|
||||||
};
|
// Simulate the conversion logic
|
||||||
if (!excludeReasoningFromContext) {
|
const dbMessage = {
|
||||||
const reasoning = extractReasoningFromContent(content);
|
role: MessageRole.ASSISTANT,
|
||||||
if (reasoning) {
|
content: 'The answer is 4.',
|
||||||
mapped.reasoning_content = reasoning;
|
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiMessage: Record<string, unknown> = {
|
||||||
|
role: dbMessage.role,
|
||||||
|
content: dbMessage.content
|
||||||
|
};
|
||||||
|
if (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).toBe('Let me think: 2+2=4, basic arithmetic.');
|
||||||
return `${REASONING_TAGS.START}${reasoning}${REASONING_TAGS.END}${content}`;
|
// No internal tags leak into either field
|
||||||
}
|
expect(apiMessage.content).not.toContain('<<<');
|
||||||
|
expect(apiMessage.reasoning_content).not.toContain('<<<');
|
||||||
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('API message excludes reasoning when excludeReasoningFromContext is true', () => {
|
||||||
expect(extractReasoningFromContent('Just a normal response.')).toBeUndefined();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(apiMessage.content).toBe('The answer is 4.');
|
||||||
|
expect(apiMessage.reasoning_content).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for null/empty input', () => {
|
it('handles messages with no reasoning', () => {
|
||||||
expect(extractReasoningFromContent(null)).toBeUndefined();
|
const dbMessage = {
|
||||||
expect(extractReasoningFromContent(undefined)).toBeUndefined();
|
role: MessageRole.ASSISTANT,
|
||||||
expect(extractReasoningFromContent('')).toBeUndefined();
|
content: 'No reasoning here.',
|
||||||
});
|
reasoningContent: undefined
|
||||||
|
};
|
||||||
|
|
||||||
it('extracts reasoning from content part arrays', () => {
|
const apiMessage: Record<string, unknown> = {
|
||||||
const input = [
|
role: dbMessage.role,
|
||||||
{
|
content: dbMessage.content
|
||||||
type: ContentPartType.TEXT,
|
};
|
||||||
text: wrapReasoning('thinking hard', 'result')
|
if (dbMessage.reasoningContent) {
|
||||||
}
|
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||||
];
|
}
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user