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
@@ -7,6 +7,10 @@
|
||||
* - Session state management
|
||||
* - 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:**
|
||||
* - **ChatService**: Stateless API layer (sendMessage, streaming)
|
||||
* - **mcpStore**: MCP connection management and tool execution
|
||||
@@ -16,7 +20,6 @@
|
||||
* @see mcpStore in stores/mcp.svelte.ts for MCP operations
|
||||
*/
|
||||
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ChatService } from '$lib/services';
|
||||
import { config } from '$lib/stores/settings.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 {
|
||||
DEFAULT_AGENTIC_CONFIG,
|
||||
AGENTIC_TAGS,
|
||||
NEWLINE_SEPARATOR,
|
||||
TURN_LIMIT_MESSAGE,
|
||||
LLM_ERROR_BLOCK_START,
|
||||
@@ -193,17 +195,6 @@ class AgenticStore {
|
||||
|
||||
async runAgenticFlow(params: AgenticFlowParams): Promise<AgenticFlowResult> {
|
||||
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);
|
||||
if (!agenticConfig.enabled) return { handled: false };
|
||||
@@ -253,24 +244,14 @@ class AgenticStore {
|
||||
options,
|
||||
tools,
|
||||
agenticConfig,
|
||||
callbacks: {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onError,
|
||||
onTimings,
|
||||
onTurnComplete
|
||||
},
|
||||
callbacks,
|
||||
signal
|
||||
});
|
||||
return { handled: true };
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
this.updateSession(conversationId, { lastError: normalizedError });
|
||||
onError?.(normalizedError);
|
||||
callbacks.onError?.(normalizedError);
|
||||
return { handled: true, error: normalizedError };
|
||||
} finally {
|
||||
this.updateSession(conversationId, { isRunning: false });
|
||||
@@ -295,17 +276,20 @@ class AgenticStore {
|
||||
const {
|
||||
onChunk,
|
||||
onReasoningChunk,
|
||||
onToolCallChunk,
|
||||
onToolCallsStreaming,
|
||||
onAttachments,
|
||||
onModel,
|
||||
onComplete,
|
||||
onAssistantTurnComplete,
|
||||
createToolResultMessage,
|
||||
createAssistantMessage,
|
||||
onFlowComplete,
|
||||
onTimings,
|
||||
onTurnComplete
|
||||
} = callbacks;
|
||||
|
||||
const sessionMessages: AgenticMessage[] = toAgenticMessages(messages);
|
||||
const allToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let capturedTimings: ChatMessageTimings | undefined;
|
||||
let totalToolCallCount = 0;
|
||||
|
||||
const agenticTimings: ChatMessageAgenticTimings = {
|
||||
turns: 0,
|
||||
@@ -316,12 +300,7 @@ class AgenticStore {
|
||||
llm: { predicted_n: 0, predicted_ms: 0, prompt_n: 0, prompt_ms: 0 }
|
||||
};
|
||||
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 || '';
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
@@ -329,23 +308,20 @@ class AgenticStore {
|
||||
agenticTimings.turns = turn + 1;
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
return;
|
||||
}
|
||||
|
||||
// For turns > 0, create a new assistant message via callback
|
||||
if (turn > 0 && createAssistantMessage) {
|
||||
await createAssistantMessage();
|
||||
}
|
||||
|
||||
let turnContent = '';
|
||||
let turnReasoningContent = '';
|
||||
let turnToolCalls: ApiChatCompletionToolCall[] = [];
|
||||
let lastStreamingToolCallName = '';
|
||||
let lastStreamingToolCallArgsLength = 0;
|
||||
const emittedToolCallStates = new SvelteMap<
|
||||
number,
|
||||
{ emittedOnce: boolean; lastArgs: string }
|
||||
>();
|
||||
let turnTimings: ChatMessageTimings | undefined;
|
||||
|
||||
const turnStats: ChatMessageAgenticTurnStats = {
|
||||
@@ -366,30 +342,15 @@ class AgenticStore {
|
||||
turnContent += chunk;
|
||||
onChunk?.(chunk);
|
||||
},
|
||||
onReasoningChunk,
|
||||
onReasoningChunk: (chunk: string) => {
|
||||
turnReasoningContent += chunk;
|
||||
onReasoningChunk?.(chunk);
|
||||
},
|
||||
onToolCallChunk: (serialized: string) => {
|
||||
try {
|
||||
turnToolCalls = JSON.parse(serialized) as ApiChatCompletionToolCall[];
|
||||
for (let i = 0; i < turnToolCalls.length; i++) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
onToolCallsStreaming?.(turnToolCalls);
|
||||
|
||||
if (turnToolCalls.length > 0 && turnToolCalls[0]?.function) {
|
||||
const name = turnToolCalls[0].function.name || '';
|
||||
const args = turnToolCalls[0].function.arguments || '';
|
||||
@@ -442,77 +403,84 @@ class AgenticStore {
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
// Save whatever we have for this turn before exiting
|
||||
await onAssistantTurnComplete?.(
|
||||
turnContent,
|
||||
turnReasoningContent || undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
return;
|
||||
}
|
||||
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}`);
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
await onAssistantTurnComplete?.(
|
||||
turnContent + `${LLM_ERROR_BLOCK_START}${normalizedError.message}${LLM_ERROR_BLOCK_END}`,
|
||||
turnReasoningContent || undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
throw normalizedError;
|
||||
}
|
||||
|
||||
// No tool calls = final turn, save and complete
|
||||
if (turnToolCalls.length === 0) {
|
||||
agenticTimings.perTurn!.push(turnStats);
|
||||
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
const finalTimings = this.buildFinalTimings(capturedTimings, agenticTimings);
|
||||
|
||||
await onAssistantTurnComplete?.(
|
||||
turnContent,
|
||||
turnReasoningContent || undefined,
|
||||
finalTimings,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (finalTimings) onTurnComplete?.(finalTimings);
|
||||
|
||||
onFlowComplete?.(finalTimings);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize and save assistant turn with tool calls
|
||||
const normalizedCalls = this.normalizeToolCalls(turnToolCalls);
|
||||
if (normalizedCalls.length === 0) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
await onAssistantTurnComplete?.(
|
||||
turnContent,
|
||||
turnReasoningContent || undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const call of normalizedCalls) {
|
||||
allToolCalls.push({
|
||||
id: call.id,
|
||||
type: call.type,
|
||||
function: call.function ? { ...call.function } : undefined
|
||||
});
|
||||
}
|
||||
totalToolCallCount += normalizedCalls.length;
|
||||
this.updateSession(conversationId, { totalToolCalls: totalToolCallCount });
|
||||
|
||||
this.updateSession(conversationId, { totalToolCalls: allToolCalls.length });
|
||||
onToolCallChunk?.(JSON.stringify(allToolCalls));
|
||||
// Save the assistant message with its tool calls
|
||||
await onAssistantTurnComplete?.(
|
||||
turnContent,
|
||||
turnReasoningContent || undefined,
|
||||
turnTimings,
|
||||
normalizedCalls
|
||||
);
|
||||
|
||||
// Add assistant message to session history
|
||||
sessionMessages.push({
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: turnContent || undefined,
|
||||
tool_calls: normalizedCalls
|
||||
});
|
||||
|
||||
// Execute each tool call and create result messages
|
||||
for (const toolCall of normalizedCalls) {
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,13 +498,7 @@ class AgenticStore {
|
||||
result = executionResult.content;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
return;
|
||||
}
|
||||
result = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
@@ -557,21 +519,27 @@ class AgenticStore {
|
||||
turnStats.toolsMs += Math.round(toolDurationMs);
|
||||
|
||||
if (signal?.aborted) {
|
||||
onComplete?.(
|
||||
'',
|
||||
undefined,
|
||||
this.buildFinalTimings(capturedTimings, agenticTimings),
|
||||
undefined
|
||||
);
|
||||
|
||||
onFlowComplete?.(this.buildFinalTimings(capturedTimings, agenticTimings));
|
||||
return;
|
||||
}
|
||||
|
||||
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[] = [
|
||||
{ type: ContentPartType.TEXT, text: cleanedResult }
|
||||
];
|
||||
@@ -605,8 +573,15 @@ class AgenticStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Turn limit reached
|
||||
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(
|
||||
@@ -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): {
|
||||
cleanedResult: string;
|
||||
attachments: DatabaseMessageExtra[];
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
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 { config } from '$lib/stores/settings.svelte';
|
||||
import { agenticStore } from '$lib/stores/agentic.svelte';
|
||||
@@ -34,7 +35,6 @@ import {
|
||||
import {
|
||||
MAX_INACTIVE_CONVERSATION_STATES,
|
||||
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
|
||||
REASONING_TAGS,
|
||||
SYSTEM_MESSAGE_PLACEHOLDER
|
||||
} from '$lib/constants';
|
||||
import type {
|
||||
@@ -50,15 +50,6 @@ interface ConversationStateEntry {
|
||||
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 {
|
||||
activeProcessingState = $state<ApiProcessingState | null>(null);
|
||||
currentResponse = $state('');
|
||||
@@ -557,83 +548,76 @@ class ChatStore {
|
||||
await modelsStore.fetchModelProps(effectiveModel);
|
||||
}
|
||||
|
||||
let streamedContent = '',
|
||||
streamedToolCallContent = '',
|
||||
isReasoningOpen = false,
|
||||
hasStreamedChunks = false,
|
||||
resolvedModel: string | null = null,
|
||||
modelPersisted = false;
|
||||
let streamedExtras: DatabaseMessageExtra[] = assistantMessage.extra
|
||||
? JSON.parse(JSON.stringify(assistantMessage.extra))
|
||||
: [];
|
||||
// Mutable state for the current message being streamed
|
||||
let currentMessageId = assistantMessage.id;
|
||||
let streamedContent = '';
|
||||
let streamedReasoningContent = '';
|
||||
let resolvedModel: string | null = null;
|
||||
let modelPersisted = false;
|
||||
const convId = assistantMessage.convId;
|
||||
|
||||
const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
|
||||
if (!modelName) return;
|
||||
const n = normalizeModelName(modelName);
|
||||
if (!n || n === resolvedModel) return;
|
||||
resolvedModel = n;
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
const idx = conversationsStore.findMessageIndex(currentMessageId);
|
||||
conversationsStore.updateMessageAtIndex(idx, { model: n });
|
||||
if (persistImmediately && !modelPersisted) {
|
||||
modelPersisted = true;
|
||||
DatabaseService.updateMessage(assistantMessage.id, { model: n }).catch(() => {
|
||||
DatabaseService.updateMessage(currentMessageId, { model: n }).catch(() => {
|
||||
modelPersisted = false;
|
||||
resolvedModel = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
const updateStreamingContent = () => {
|
||||
this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
|
||||
const updateStreamingUI = () => {
|
||||
this.setChatStreaming(convId, streamedContent, currentMessageId);
|
||||
const idx = conversationsStore.findMessageIndex(currentMessageId);
|
||||
conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
|
||||
};
|
||||
const appendContentChunk = (chunk: string) => {
|
||||
if (isReasoningOpen) {
|
||||
streamedContent += REASONING_TAGS.END;
|
||||
isReasoningOpen = false;
|
||||
}
|
||||
streamedContent += chunk;
|
||||
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;
|
||||
}
|
||||
|
||||
const cleanupStreamingState = () => {
|
||||
this.setStreamingActive(false);
|
||||
this.setChatLoading(convId, false);
|
||||
this.clearChatStreaming(convId);
|
||||
this.setProcessingState(convId, null);
|
||||
};
|
||||
|
||||
this.setStreamingActive(true);
|
||||
this.setActiveProcessingConversation(assistantMessage.convId);
|
||||
const abortController = this.getOrCreateAbortController(assistantMessage.convId);
|
||||
this.setActiveProcessingConversation(convId);
|
||||
const abortController = this.getOrCreateAbortController(convId);
|
||||
|
||||
const streamCallbacks: ChatStreamCallbacks = {
|
||||
onChunk: (chunk: string) => appendContentChunk(chunk),
|
||||
onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
|
||||
onToolCallChunk: (chunk: string) => {
|
||||
const c = chunk.trim();
|
||||
if (!c) return;
|
||||
streamedToolCallContent = c;
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
|
||||
onChunk: (chunk: string) => {
|
||||
streamedContent += chunk;
|
||||
updateStreamingUI();
|
||||
},
|
||||
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;
|
||||
streamedExtras = [...streamedExtras, ...extras];
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
conversationsStore.updateMessageAtIndex(idx, { extra: streamedExtras });
|
||||
DatabaseService.updateMessage(assistantMessage.id, { extra: streamedExtras }).catch(
|
||||
console.error
|
||||
);
|
||||
const idx = conversationsStore.findMessageIndex(messageId);
|
||||
if (idx === -1) return;
|
||||
const msg = conversationsStore.activeMessages[idx];
|
||||
const updatedExtras = [...(msg.extra || []), ...extras];
|
||||
conversationsStore.updateMessageAtIndex(idx, { extra: updatedExtras });
|
||||
DatabaseService.updateMessage(messageId, { extra: updatedExtras }).catch(console.error);
|
||||
},
|
||||
onModel: (modelName: string) => recordModel(modelName),
|
||||
onTurnComplete: (intermediateTimings: ChatMessageTimings) => {
|
||||
// Update the first assistant message with cumulative agentic timings
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
conversationsStore.updateMessageAtIndex(idx, { timings: intermediateTimings });
|
||||
},
|
||||
@@ -651,56 +635,104 @@ class ChatStore {
|
||||
cache_n: timings?.cache_n || 0,
|
||||
prompt_progress: promptProgress
|
||||
},
|
||||
assistantMessage.convId
|
||||
convId
|
||||
);
|
||||
},
|
||||
onComplete: async (
|
||||
finalContent?: string,
|
||||
reasoningContent?: string,
|
||||
timings?: ChatMessageTimings,
|
||||
toolCallContent?: string
|
||||
onAssistantTurnComplete: async (
|
||||
content: string,
|
||||
reasoningContent: string | undefined,
|
||||
timings: ChatMessageTimings | undefined,
|
||||
toolCalls: import('$lib/types/api').ApiChatCompletionToolCall[] | undefined
|
||||
) => {
|
||||
this.setStreamingActive(false);
|
||||
finalizeReasoning();
|
||||
const combinedContent = hasStreamedChunks
|
||||
? streamedContent
|
||||
: wrapReasoningContent(finalContent || '', reasoningContent);
|
||||
const updateData: Record<string, unknown> = {
|
||||
content: combinedContent,
|
||||
toolCalls: toolCallContent || streamedToolCallContent,
|
||||
content,
|
||||
reasoningContent: reasoningContent || undefined,
|
||||
toolCalls: toolCalls ? JSON.stringify(toolCalls) : '',
|
||||
timings
|
||||
};
|
||||
if (streamedExtras.length > 0) updateData.extra = streamedExtras;
|
||||
if (resolvedModel && !modelPersisted) updateData.model = resolvedModel;
|
||||
await DatabaseService.updateMessage(assistantMessage.id, updateData);
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
await DatabaseService.updateMessage(currentMessageId, updateData);
|
||||
const idx = conversationsStore.findMessageIndex(currentMessageId);
|
||||
const uiUpdate: Partial<DatabaseMessage> = {
|
||||
content: combinedContent,
|
||||
toolCalls: updateData.toolCalls as string
|
||||
content,
|
||||
reasoningContent: reasoningContent || undefined,
|
||||
toolCalls: toolCalls ? JSON.stringify(toolCalls) : ''
|
||||
};
|
||||
if (streamedExtras.length > 0) uiUpdate.extra = streamedExtras;
|
||||
if (timings) uiUpdate.timings = timings;
|
||||
if (resolvedModel) uiUpdate.model = resolvedModel;
|
||||
conversationsStore.updateMessageAtIndex(idx, uiUpdate);
|
||||
await conversationsStore.updateCurrentNode(assistantMessage.id);
|
||||
if (onComplete) await onComplete(combinedContent);
|
||||
this.setChatLoading(assistantMessage.convId, false);
|
||||
this.clearChatStreaming(assistantMessage.convId);
|
||||
this.setProcessingState(assistantMessage.convId, null);
|
||||
await conversationsStore.updateCurrentNode(currentMessageId);
|
||||
},
|
||||
createToolResultMessage: async (
|
||||
toolCallId: string,
|
||||
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);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
this.setStreamingActive(false);
|
||||
if (isAbortError(error)) {
|
||||
this.setChatLoading(assistantMessage.convId, false);
|
||||
this.clearChatStreaming(assistantMessage.convId);
|
||||
this.setProcessingState(assistantMessage.convId, null);
|
||||
cleanupStreamingState();
|
||||
return;
|
||||
}
|
||||
console.error('Streaming error:', error);
|
||||
this.setChatLoading(assistantMessage.convId, false);
|
||||
this.clearChatStreaming(assistantMessage.convId);
|
||||
this.setProcessingState(assistantMessage.convId, null);
|
||||
cleanupStreamingState();
|
||||
const idx = conversationsStore.findMessageIndex(assistantMessage.id);
|
||||
if (idx !== -1) {
|
||||
const failedMessage = conversationsStore.removeMessageAtIndex(idx);
|
||||
@@ -717,12 +749,13 @@ class ChatStore {
|
||||
if (onError) onError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const perChatOverrides = conversationsStore.activeConversation?.mcpServerOverrides;
|
||||
|
||||
const agenticConfig = agenticStore.getConfig(config(), perChatOverrides);
|
||||
if (agenticConfig.enabled) {
|
||||
const agenticResult = await agenticStore.runAgenticFlow({
|
||||
conversationId: assistantMessage.convId,
|
||||
conversationId: convId,
|
||||
messages: allMessages,
|
||||
options: { ...this.getApiOptions(), ...(effectiveModel ? { model: effectiveModel } : {}) },
|
||||
callbacks: streamCallbacks,
|
||||
@@ -732,16 +765,50 @@ class ChatStore {
|
||||
if (agenticResult.handled) return;
|
||||
}
|
||||
|
||||
const completionOptions = {
|
||||
...this.getApiOptions(),
|
||||
...(effectiveModel ? { model: effectiveModel } : {}),
|
||||
...streamCallbacks
|
||||
};
|
||||
|
||||
// Non-agentic path: direct streaming into the single assistant message
|
||||
await ChatService.sendMessage(
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -1033,56 +1100,40 @@ class ChatStore {
|
||||
}
|
||||
|
||||
const originalContent = dbMessage.content;
|
||||
const originalReasoning = dbMessage.reasoningContent || '';
|
||||
const conversationContext = conversationsStore.activeMessages.slice(0, idx);
|
||||
const contextWithContinue = [
|
||||
...conversationContext,
|
||||
{ role: MessageRole.ASSISTANT as const, content: originalContent }
|
||||
];
|
||||
|
||||
let appendedContent = '',
|
||||
hasReceivedContent = false,
|
||||
isReasoningOpen = hasUnclosedReasoningTag(originalContent);
|
||||
let appendedContent = '';
|
||||
let appendedReasoning = '';
|
||||
let hasReceivedContent = false;
|
||||
|
||||
const updateStreamingContent = (fullContent: string) => {
|
||||
this.setChatStreaming(msg.convId, fullContent, msg.id);
|
||||
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);
|
||||
|
||||
await ChatService.sendMessage(
|
||||
contextWithContinue,
|
||||
{
|
||||
...this.getApiOptions(),
|
||||
onChunk: (chunk: string) => appendContentChunk(chunk),
|
||||
onReasoningChunk: (chunk: string) => appendReasoningChunk(chunk),
|
||||
onChunk: (chunk: string) => {
|
||||
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) => {
|
||||
const tokensPerSecond =
|
||||
timings?.predicted_ms && timings?.predicted_n
|
||||
@@ -1105,21 +1156,23 @@ class ChatStore {
|
||||
reasoningContent?: string,
|
||||
timings?: ChatMessageTimings
|
||||
) => {
|
||||
finalizeReasoning();
|
||||
|
||||
const appendedFromCompletion = hasReceivedContent
|
||||
? appendedContent
|
||||
: wrapReasoningContent(finalContent || '', reasoningContent);
|
||||
const fullContent = originalContent + appendedFromCompletion;
|
||||
const finalAppendedContent = hasReceivedContent ? appendedContent : finalContent || '';
|
||||
const finalAppendedReasoning = hasReceivedContent
|
||||
? appendedReasoning
|
||||
: reasoningContent || '';
|
||||
const fullContent = originalContent + finalAppendedContent;
|
||||
const fullReasoning = originalReasoning + finalAppendedReasoning || undefined;
|
||||
|
||||
await DatabaseService.updateMessage(msg.id, {
|
||||
content: fullContent,
|
||||
reasoningContent: fullReasoning,
|
||||
timestamp: Date.now(),
|
||||
timings
|
||||
});
|
||||
|
||||
conversationsStore.updateMessageAtIndex(idx, {
|
||||
content: fullContent,
|
||||
reasoningContent: fullReasoning,
|
||||
timestamp: Date.now(),
|
||||
timings
|
||||
});
|
||||
@@ -1135,11 +1188,13 @@ class ChatStore {
|
||||
if (hasReceivedContent && appendedContent) {
|
||||
await DatabaseService.updateMessage(msg.id, {
|
||||
content: originalContent + appendedContent,
|
||||
reasoningContent: originalReasoning + appendedReasoning || undefined,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
conversationsStore.updateMessageAtIndex(idx, {
|
||||
content: originalContent + appendedContent,
|
||||
reasoningContent: originalReasoning + appendedReasoning || undefined,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
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 { MessageRole } from '$lib/enums';
|
||||
import {
|
||||
@@ -128,6 +128,10 @@ class ConversationsStore {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
// @deprecated Legacy migration for old marker-based messages.
|
||||
// Remove once all users have migrated to the structured format.
|
||||
await runLegacyMigration();
|
||||
|
||||
await this.loadConversations();
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user