ui: Improve Chat Messages rendering performance (#28460)
* ui : update active conversation fields in place updateCurrentNode, applyConversationUpdate, updateConversationTimestamp and the pin toggle replaced the whole activeConversation object, so its identity changed on every send, tool result and rename. ChatMessages tracks that identity to refresh sibling info, so each replacement triggered a full refetch of every message in the conversation. Write the changed fields instead, mirroring updateMessageAtIndex. Assisted-by: pi:zai-org/GLM-5.3 * ui : reuse the conversation load read for sibling info Opening a conversation read every message from the database twice: once in loadConversation for the active path, once in ChatMessages for the sibling map. Hand the freshly read array over once so the chat screen builds sibling info from it, and set the conversation and its messages in one sync block so effects never see the new conversation paired with the previous one's messages. Assisted-by: pi:zai-org/GLM-5.3 * ui : memoize leaf walks in sibling map build buildSiblingInfoMap resolves each sibling's leaf by walking the last-child chain, once per sibling per message, so the walk repeats along the same chains for every message in the conversation ( O(messages^2) on long chats ). Memoize leaf resolution per build with path compression so each edge is walked once. Assisted-by: pi:zai-org/GLM-5.3 * ui : skip sibling refetch for in-place message edits refreshAllMessages refetches every message of the conversation just to rebuild sibling info, but preserve-responses and non-branching assistant edits never create branches, so the sibling map stays valid. Refresh only after actions that branch (editWithBranching kept) or delete. Assisted-by: pi:zai-org/GLM-5.3 * ui : drop unused currentResponse reactive writes Nothing reads chatStore.currentResponse, but setChatStreaming reassigned it on every streamed chunk, so each token paid a reactive write and string assignment for nothing. Remove the field and the clearUIState wrapper that only reset it. Assisted-by: pi:zai-org/GLM-5.3 * ui : reuse completed agentic turn sections during streaming deriveAgenticSections runs in a $derived invalidated per streamed chunk, but re-derived every turn of the session each time, so per-chunk cost grew with session length. Cache completed turns keyed by their assistant message plus reference checks on every field that feeds derivation; only the streaming turn recomputes. Cache hits return the same section objects, so tool block props stay stable and skip their per-chunk re-derive. Assisted-by: pi:zai-org/GLM-5.3 * ui : share markdown block infrastructure Every markdown block duplicated shared work: a full copy of the hljs theme CSS per instance, and the remark/rehype plugin chain rebuilt on every processMarkdown call ( once per block at mount, again per coalesced chunk while streaming ). Use the single theme style element already maintained by SyntaxHighlightedCode, and build pipelines once - shared process-wide for attachment-less blocks, cached by attachments identity otherwise. Assisted-by: pi:zai-org/GLM-5.3 * ui : measure assistant layout only for the last message Every assistant message ran getComputedStyle, getBoundingClientRect and a ResizeObserver over the previous user bubble at mount, even off-screen ones, forcing a layout pass per message while a long conversation renders. The measured vars only feed the :last-child min-height rule, so gate the effect on isLastAssistantMessage; one measurement and one observer remain, and the effect re-runs when the last message changes. Assisted-by: pi:zai-org/GLM-5.3 * ui : trim whole-blob scans in tool block headers Tool block headers parsed their entire blobs at mount, even collapsed, and most tool results and args are large plain text or embedded file content: skip JSON.parse unless the blob starts with a JSON container, prefilter search-result extraction with a Title:/URL: substring check, and match the end-anchored exit-code marker against only the tail of exec outputs. Assisted-by: pi:zai-org/GLM-5.3 * ui : parse write_file and edit_file titles without the content blob Both block headers parsed the full args JSON at mount, even collapsed, and write_file and edit_file args embed the whole file content or edit strings, so every block paid a full-blob JSON parse just to read the path. Split the meta into a title tier that extracts the path with a targeted key match (full parse only as fallback) and a body tier that keeps the full parse; Svelte deriveds are lazy, and the body snippet renders only while the block is expanded, so collapsed blocks no longer parse args. Assisted-by: pi:zai-org/GLM-5.3 * ui : mount chat messages lazily near the viewport Every message row mounted its full component tree on load, so the cycle collector, GC and layout invalidation kept walking every live object and DOM node even for rows the user never scrolls to - which dominated the profile of long conversations. Wrap each row in a placeholder with an IntersectionObserver ( two viewport heights of runway ) that swaps in the real ChatMessage when the row approaches the viewport; the row shell keeps the content-visibility sizing, and rows stay mounted once realized. Rows targeted by the pending-edit flow mount eagerly. Assisted-by: pi:zai-org/GLM-5.3 * ui : smooth the chat navigation animations Slide the centered new-chat form to the bottom edge with a transform instead of a bottom offset - layout-property transitions need the main thread every frame and stutter while a long conversation loads, while transform transitions run on the compositor. Fade the message list in with a CSS animation keyed to the conversation id, disabled under prefers-reduced-motion. Assisted-by: pi:zai-org/GLM-5.3 * ui : follow the svelte runes guidance in chat message code Two effects detected changes with manual previous-value refs and reset flags. The permission request carries object identity, so its dismissal is now a derived comparing the dismissed request; the continue request is a bare boolean, so its dismissal only shrinks to a reset while no request is pending. Also drop a dead if (browser) guard in the markdown theme loader - effects never run on the server. Assisted-by: pi:zai-org/GLM-5.3 * test : pin the chat perf invariants in the unit suite Cover the fixes whose silent regression would be stale or wrong UI rather than a crash: the turn-section cache must reuse unchanged turns yet recompute on every field it compares; the sibling map must resolve the same leaves after the leaf-walk memoization; the active conversation must keep its identity through field updates; and the blob gates ( exec tail window, plain-text result gate, search prefilter ) must keep accepting what they gate. Only the risky invariants are pinned - no coverage for coverage's sake. Assisted-by: pi:zai-org/GLM-5.3 * refactor : address review remarks Name the tool-arg string-field pattern, move the file tools' path field aliases and the JSON container gates into lib/constants, and export the write_file / edit_file meta types from $lib/types instead of the parser modules. Assisted-by: pi:zai-org/GLM-5.3
This commit is contained in:
Vendored
-1
@@ -137,7 +137,6 @@ declare global {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
idxThemeStyle?: number;
|
||||
idxCodeBlock?: number;
|
||||
|
||||
// File System Access API - not in the DOM lib and unavailable in some browsers
|
||||
|
||||
@@ -404,7 +404,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class:chat-message--synthetic={isSynthetic} class="chat-message">
|
||||
<div>
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem bind:textareaElement class={className} {message} />
|
||||
{:else if mcpPromptExtra}
|
||||
@@ -425,25 +425,3 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The browser skips layout and paint for messages outside the
|
||||
* viewport. contain-intrinsic-size reuses the last rendered size
|
||||
* once known; 500px sizes messages that have never been rendered.
|
||||
*/
|
||||
.chat-message {
|
||||
--chat-message-intrinsic-size: 500px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
|
||||
}
|
||||
|
||||
/*
|
||||
* Synthetic rows (e.g. the working-directory change) are small, so an
|
||||
* accurate placeholder keeps the injected row from inflating the
|
||||
* auto-scroll offset; the 500px default is for ordinary bubbles.
|
||||
*/
|
||||
.chat-message--synthetic {
|
||||
--chat-message-intrinsic-size: 40px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-1
@@ -82,8 +82,11 @@
|
||||
let lastUserMessageHeight = $state(0);
|
||||
let assistantMarginTop = $state(0);
|
||||
|
||||
// The measured CSS vars feed the :last-child min-height rule only, so only
|
||||
// the last assistant message needs them. Reading isLastAssistantMessage
|
||||
// here also re-runs the effect when this message stops being the last.
|
||||
$effect(() => {
|
||||
if (!assistantEl) return;
|
||||
if (!assistantEl || !isLastAssistantMessage) return;
|
||||
|
||||
assistantMarginTop = Math.round(parseFloat(getComputedStyle(assistantEl).marginTop));
|
||||
|
||||
|
||||
+16
-6
@@ -13,7 +13,12 @@
|
||||
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection, DatabaseMessageExtra } from '$lib/types';
|
||||
import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils';
|
||||
import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
@@ -26,11 +31,16 @@
|
||||
|
||||
let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props();
|
||||
|
||||
const searchResults = $derived(extractSearchResults(section.toolResult));
|
||||
const searchQuery = $derived(extractSearchQuery(section.toolArgs));
|
||||
const isSearchCall = $derived(
|
||||
searchResults.length > 0 || (searchQuery.length > 0 && isWebSearchToolName(section.toolName))
|
||||
);
|
||||
// Runs for every tool block on mount, before the body renders: the cheap
|
||||
// content prefilter and the tool-name allow-list come first so blobs from
|
||||
// exec/file tools are never line-split or JSON-parsed here
|
||||
const isSearchCall = $derived.by(() => {
|
||||
if (looksLikeSearchResult(section.toolResult)) {
|
||||
return extractSearchResults(section.toolResult).length > 0;
|
||||
}
|
||||
|
||||
return isWebSearchToolName(section.toolName) && extractSearchQuery(section.toolArgs).length > 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isSearchCall}
|
||||
|
||||
+9
-5
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { parseEditFileMeta } from './parsers/edit-file';
|
||||
import { parseEditFileMeta, parseEditFileTitleMeta } from './parsers/edit-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||
@@ -16,10 +16,14 @@
|
||||
|
||||
let { isStreaming, onToggle, open, section }: Props = $props();
|
||||
|
||||
const editFileMeta = $derived(parseEditFileMeta(section));
|
||||
const editFileMeta = $derived(parseEditFileTitleMeta(section));
|
||||
// body-only: the full meta parses the embedded edit strings, and these
|
||||
// deriveds are read solely from the children snippet, which renders only
|
||||
// while the block is expanded
|
||||
const editFileBody = $derived(parseEditFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const editDiffs = $derived(
|
||||
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||
(editFileBody?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -45,11 +49,11 @@
|
||||
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.edits.length > 0}
|
||||
{:else if meta && editFileBody && editFileBody.edits.length > 0}
|
||||
{#each editDiffs as diffLines, ei (ei)}
|
||||
<div class={ei === 0 ? '' : 'mt-3'}>
|
||||
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Edit {ei + 1} of {meta.edits.length}
|
||||
Edit {ei + 1} of {editFileBody.edits.length}
|
||||
</div>
|
||||
|
||||
<div style:max-height={MAX_HEIGHT_CODE_BLOCK} class="diff-block">
|
||||
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { parseWriteFileMeta } from './parsers/write-file';
|
||||
import { parseWriteFileMeta, parseWriteFileTitleMeta } from './parsers/write-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||
@@ -17,7 +17,11 @@
|
||||
|
||||
let { isStreaming, onToggle, open, section }: Props = $props();
|
||||
|
||||
const writeFileMeta = $derived(parseWriteFileMeta(section));
|
||||
const writeFileMeta = $derived(parseWriteFileTitleMeta(section));
|
||||
// body-only: the full meta parses the embedded file content, and this
|
||||
// derived is read solely from the children snippet, which renders only
|
||||
// while the block is expanded
|
||||
const writeFileBody = $derived(parseWriteFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
@@ -45,7 +49,7 @@
|
||||
</div>
|
||||
{:else if meta}
|
||||
<SyntaxHighlightedCode
|
||||
code={meta.content}
|
||||
code={writeFileBody?.content ?? ''}
|
||||
language={meta.language}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
|
||||
+40
@@ -4,6 +4,7 @@
|
||||
// args-present check, JSON parse) - keeping them here lets each parser
|
||||
// stay focused on its own format quirks.
|
||||
|
||||
import { TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types/agentic';
|
||||
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
|
||||
@@ -28,6 +29,45 @@ function parseFinalToolArgs(blob: string): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Compiled per key on first use; the key set is tiny and fixed.
|
||||
const toolArgStringRegexes = new Map<string, RegExp>();
|
||||
|
||||
/**
|
||||
* Extract a string field from a JSON tool-args blob without parsing the
|
||||
* whole document. write_file and edit_file args embed full file contents,
|
||||
* yet the block title needs only the path; a targeted key match plus a
|
||||
* JSON.parse of the captured string literal alone keeps title rendering
|
||||
* O(path) instead of O(blob). Returns undefined when the key is missing
|
||||
* or its value is not a string; callers fall back to the full parse.
|
||||
*/
|
||||
export function extractToolArgString(
|
||||
toolArgs: string,
|
||||
keys: readonly string[]
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
let pattern = toolArgStringRegexes.get(key);
|
||||
|
||||
if (!pattern) {
|
||||
pattern = new RegExp(TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE.replace('{key}', key));
|
||||
toolArgStringRegexes.set(key, pattern);
|
||||
}
|
||||
|
||||
const match = pattern.exec(toolArgs);
|
||||
|
||||
if (!match) continue;
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(`"${match[1]}"`);
|
||||
|
||||
if (typeof value === 'string') return value;
|
||||
} catch {
|
||||
// fall through to the next key; the full parse is the fallback
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a section's toolArgs against an expected tool name. Returns
|
||||
* `null` when:
|
||||
|
||||
+45
-17
@@ -3,26 +3,12 @@
|
||||
// rendering), plus the result blob for `result` / `edits_applied` /
|
||||
// `error` fields.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { extractToolArgString, parseToolArgs } from './_shared';
|
||||
import { FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import type { AgenticSection, EditFileEdit, EditFileMeta, EditFileTitleMeta } from '$lib/types';
|
||||
import { tryParseToolResultObject } from '$lib/utils';
|
||||
|
||||
export type EditFileEdit = {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
};
|
||||
|
||||
export type EditFileMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
edits: EditFileEdit[];
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
|
||||
|
||||
@@ -79,3 +65,45 @@ export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-tier meta for edit_file blocks: everything the header and status
|
||||
* pill render, obtained without parsing the embedded edit strings. The path
|
||||
* comes from a targeted key extraction; the full parse runs only as a
|
||||
* fallback for arg shapes the extraction can't see.
|
||||
*/
|
||||
export function parseEditFileTitleMeta(section: AgenticSection): EditFileTitleMeta | null {
|
||||
if (section.toolName !== BuiltInTool.SERVER_EDIT_FILE || !section.toolArgs) return null;
|
||||
|
||||
let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
|
||||
|
||||
if (!rawPath) {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
|
||||
const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath;
|
||||
|
||||
if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath;
|
||||
}
|
||||
|
||||
if (!rawPath) return null;
|
||||
|
||||
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||
|
||||
let resultMessage: string | undefined;
|
||||
let editsApplied: number | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
if (typeof resultObj?.error === 'string') {
|
||||
errorMessage = resultObj.error;
|
||||
} else if (resultObj) {
|
||||
if (typeof resultObj.result === 'string') {
|
||||
resultMessage = resultObj.result;
|
||||
}
|
||||
|
||||
if (Number.isFinite(Number(resultObj.edits_applied))) {
|
||||
editsApplied = Number(resultObj.edits_applied);
|
||||
}
|
||||
}
|
||||
|
||||
return { editsApplied, errorMessage, fileName, filePath: rawPath, resultMessage };
|
||||
}
|
||||
|
||||
+14
-6
@@ -6,6 +6,7 @@
|
||||
// are handled.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { JSON_ARRAY_OPEN, JSON_OBJECT_OPEN } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
|
||||
@@ -38,14 +39,21 @@ export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMe
|
||||
// do we scan raw lines for the `Error:` prefix.
|
||||
let parsedObject: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
// Successful sandbox output is a JSON array, errors are objects; plain
|
||||
// text (huge console logs) fails the parse below anyway, so only try
|
||||
// when the blob starts with a JSON container
|
||||
const trimmedResult = toolResultString.trimStart();
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsedObject = parsed as Record<string, unknown>;
|
||||
if (trimmedResult[0] === JSON_OBJECT_OPEN || trimmedResult[0] === JSON_ARRAY_OPEN) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmedResult);
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsedObject = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsedObject = null;
|
||||
}
|
||||
} catch {
|
||||
parsedObject = null;
|
||||
}
|
||||
|
||||
if (typeof parsedObject?.error === 'string') {
|
||||
|
||||
+43
-13
@@ -3,22 +3,12 @@
|
||||
// finishes) and surfaces `bytes`, `result`, and `error` from the
|
||||
// result blob.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { extractToolArgString, parseToolArgs } from './_shared';
|
||||
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import type { AgenticSection, WriteFileMeta, WriteFileTitleMeta } from '$lib/types';
|
||||
import { getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
|
||||
|
||||
export type WriteFileMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
content: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
|
||||
|
||||
@@ -51,3 +41,43 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-tier meta for write_file blocks: everything the header and status
|
||||
* pill render, obtained without parsing the embedded file content. The path
|
||||
* comes from a targeted key extraction; the full parse runs only as a
|
||||
* fallback for arg shapes the extraction can't see.
|
||||
*/
|
||||
export function parseWriteFileTitleMeta(section: AgenticSection): WriteFileTitleMeta | null {
|
||||
if (section.toolName !== BuiltInTool.SERVER_WRITE_FILE || !section.toolArgs) return null;
|
||||
|
||||
let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
|
||||
|
||||
if (!rawPath) {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
|
||||
const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath;
|
||||
|
||||
if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath;
|
||||
}
|
||||
|
||||
if (!rawPath) return null;
|
||||
|
||||
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||
const language =
|
||||
getFileTypeByExtension(rawPath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ??
|
||||
CODE_BLOCK.DEFAULT_LANGUAGE;
|
||||
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||
const bytesWritten =
|
||||
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
|
||||
const resultMessage = typeof resultObj?.result === 'string' ? resultObj.result : undefined;
|
||||
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
|
||||
|
||||
return {
|
||||
bytesWritten,
|
||||
errorMessage,
|
||||
fileName,
|
||||
filePath: rawPath,
|
||||
language,
|
||||
resultMessage
|
||||
};
|
||||
}
|
||||
|
||||
+20
-25
@@ -46,49 +46,44 @@
|
||||
isLastAssistantMessage ? !!agenticStore.getLastError(message.convId) : false
|
||||
);
|
||||
|
||||
let permissionDismissed = $state(false);
|
||||
|
||||
const pendingPermission = $derived(
|
||||
isStreaming && isLastAssistantMessage
|
||||
? agenticStore.getPendingPermissionRequest(message.convId)
|
||||
: null
|
||||
);
|
||||
|
||||
let prevPendingRef: typeof pendingPermission = null;
|
||||
$effect(() => {
|
||||
if (pendingPermission !== prevPendingRef) {
|
||||
prevPendingRef = pendingPermission;
|
||||
// dismissal applies to the request object, so the next request ( new
|
||||
// identity ) shows the card again without any reset bookkeeping
|
||||
let dismissedPermission: typeof pendingPermission = $state(null);
|
||||
|
||||
if (pendingPermission) {
|
||||
permissionDismissed = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
const visiblePermission = $derived(
|
||||
pendingPermission && dismissedPermission !== pendingPermission ? pendingPermission : null
|
||||
);
|
||||
|
||||
function handlePermission(decision: ToolPermissionDecision) {
|
||||
permissionDismissed = true;
|
||||
dismissedPermission = pendingPermission;
|
||||
agenticStore.resolvePermission(message.convId, decision);
|
||||
}
|
||||
|
||||
let continueDismissed = $state(false);
|
||||
|
||||
const pendingContinue = $derived(
|
||||
isStreaming && isLastAssistantMessage
|
||||
? agenticStore.getPendingContinueRequest(message.convId)
|
||||
: false
|
||||
);
|
||||
|
||||
let prevContinueRef = false;
|
||||
$effect(() => {
|
||||
if (pendingContinue !== prevContinueRef) {
|
||||
prevContinueRef = pendingContinue;
|
||||
let continueDismissed = $state(false);
|
||||
|
||||
if (pendingContinue) {
|
||||
continueDismissed = false;
|
||||
}
|
||||
// the continue request is a plain boolean, so there is no identity to
|
||||
// compare against; clear the dismissal whenever no request is pending so
|
||||
// the next one starts from a clean state
|
||||
$effect(() => {
|
||||
if (!pendingContinue) {
|
||||
continueDismissed = false;
|
||||
}
|
||||
});
|
||||
|
||||
const showContinue = $derived(Boolean(pendingContinue) && !continueDismissed);
|
||||
|
||||
function handleContinue(shouldContinue: boolean) {
|
||||
continueDismissed = true;
|
||||
agenticStore.resolveContinue(message.convId, shouldContinue);
|
||||
@@ -238,15 +233,15 @@
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if pendingPermission && !permissionDismissed}
|
||||
{#if visiblePermission}
|
||||
<ChatMessageActionCardPermissionRequest
|
||||
onDecision={handlePermission}
|
||||
serverLabel={pendingPermission.serverLabel}
|
||||
toolName={pendingPermission.toolName}
|
||||
serverLabel={visiblePermission.serverLabel}
|
||||
toolName={visiblePermission.toolName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pendingContinue && !continueDismissed}
|
||||
{#if showContinue}
|
||||
<ChatMessageActionCardContinueRequest onDecision={handleContinue} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
|
||||
import LazyChatMessage from './LazyChatMessage.svelte';
|
||||
import { ChatMessageUserPending } from '$lib/components/app';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { agenticStore, chatStore, conversationsStore, settingsStore } from '$lib/stores';
|
||||
import type { ChatMessageActions } from '$lib/types';
|
||||
@@ -51,8 +52,9 @@
|
||||
newExtras?: DatabaseMessageExtra[]
|
||||
) => {
|
||||
onUserAction?.();
|
||||
// in-place edit: the store already updated activeMessages and no
|
||||
// branch is created, so sibling info stays valid without a refetch
|
||||
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
|
||||
refreshAllMessages();
|
||||
},
|
||||
|
||||
editWithBranching: async (
|
||||
@@ -72,7 +74,10 @@
|
||||
) => {
|
||||
onUserAction?.();
|
||||
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
|
||||
refreshAllMessages();
|
||||
|
||||
// only a branch changes sibling info; an in-place edit already
|
||||
// landed in activeMessages
|
||||
if (shouldBranch) refreshAllMessages();
|
||||
},
|
||||
|
||||
forkConversation: async (
|
||||
@@ -97,9 +102,17 @@
|
||||
const conversation = conversationsStore.activeConversation;
|
||||
|
||||
if (conversation) {
|
||||
conversationsStore.getConversationMessages(conversation.id).then((messages) => {
|
||||
allConversationMessages = messages;
|
||||
});
|
||||
// reuse the array loadConversation just read, when present; branch
|
||||
// actions fall through to a fresh fetch
|
||||
const preloaded = conversationsStore.consumeLastLoadedMessages(conversation.id);
|
||||
|
||||
if (preloaded) {
|
||||
allConversationMessages = preloaded;
|
||||
} else {
|
||||
conversationsStore.getConversationMessages(conversation.id).then((messages) => {
|
||||
allConversationMessages = messages;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
allConversationMessages = [];
|
||||
}
|
||||
@@ -224,48 +237,76 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
|
||||
<ChatMessage
|
||||
{chatActions}
|
||||
class="mx-auto mt-12 w-full max-w-3xl"
|
||||
{isLastAssistantMessage}
|
||||
{isLastUserMessage}
|
||||
{message}
|
||||
{nextAssistantMessage}
|
||||
{siblingInfo}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
|
||||
onDelete={() => agenticStore.clearSteeringMessage(convId)}
|
||||
onEdit={(newContent, extras) =>
|
||||
agenticStore.injectSteeringMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
<!-- Re-created per conversation, so the CSS fade-in below plays on every
|
||||
navigation into a chat route. -->
|
||||
{#key conversationsStore.activeConversation?.id ?? 'new'}
|
||||
<div class="chat-messages">
|
||||
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
|
||||
<LazyChatMessage
|
||||
{chatActions}
|
||||
class="mx-auto mt-12 w-full max-w-3xl"
|
||||
{isLastAssistantMessage}
|
||||
{isLastUserMessage}
|
||||
{message}
|
||||
{nextAssistantMessage}
|
||||
{siblingInfo}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = chatStore.getPendingMessageContent(convId)}
|
||||
{/each}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={chatStore.getPendingMessageExtras(convId)}
|
||||
onDelete={() => chatStore.clearPendingMessage(convId)}
|
||||
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{#if conversationsStore.activeConversation && agenticStore.getPendingSteeringMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = agenticStore.getPendingSteeringMessageContent(convId)}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
|
||||
onDelete={() => agenticStore.clearSteeringMessage(convId)}
|
||||
onEdit={(newContent, extras) =>
|
||||
agenticStore.injectSteeringMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{/if}
|
||||
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
|
||||
{@const convId = conversationsStore.activeConversation!.id}
|
||||
{@const pendingContent = chatStore.getPendingMessageContent(convId)}
|
||||
|
||||
{#if pendingContent}
|
||||
<ChatMessageUserPending
|
||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||
content={pendingContent}
|
||||
extras={chatStore.getPendingMessageExtras(convId)}
|
||||
onDelete={() => chatStore.clearPendingMessage(convId)}
|
||||
onEdit={(newContent, extras) =>
|
||||
chatStore.injectPendingMessage(convId, newContent, extras)}
|
||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
<style>
|
||||
/* Compositor-friendly opacity fade; the keyed block re-creates the list per
|
||||
* conversation, so the animation plays on every navigation into a chat. */
|
||||
.chat-messages {
|
||||
animation: chat-messages-fade-in 150ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes chat-messages-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-messages {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import ChatMessage from './ChatMessage/ChatMessage.svelte';
|
||||
import { chatStore } from '$lib/stores';
|
||||
import type { ChatMessageActions } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
chatActions: ChatMessageActions;
|
||||
class?: string;
|
||||
isLastAssistantMessage?: boolean;
|
||||
isLastUserMessage?: boolean;
|
||||
message: DatabaseMessage;
|
||||
nextAssistantMessage?: DatabaseMessage | null;
|
||||
siblingInfo?: ChatMessageSiblingInfo | null;
|
||||
toolMessages?: DatabaseMessage[];
|
||||
}
|
||||
|
||||
let {
|
||||
chatActions,
|
||||
class: className = '',
|
||||
isLastAssistantMessage = false,
|
||||
isLastUserMessage = false,
|
||||
message,
|
||||
nextAssistantMessage = null,
|
||||
siblingInfo = null,
|
||||
toolMessages = []
|
||||
}: Props = $props();
|
||||
|
||||
// A mounted message row is a whole component tree (contexts, effects,
|
||||
// collapsibles, markdown blocks), and the cycle collector, GC and layout
|
||||
// invalidation keep walking every live object and DOM node, even for
|
||||
// rows the user never scrolls to. Mount the real tree only when the row
|
||||
// approaches the viewport; until then the row is an empty placeholder
|
||||
// that reserves its size through content-visibility.
|
||||
let mounted = $state(false);
|
||||
let wrapperEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (mounted || !wrapperEl) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
mounted = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
// pre-mount a couple of viewport heights ahead of the scroll
|
||||
// position so a fast scroll never meets an empty row
|
||||
{ rootMargin: '200% 0px' }
|
||||
);
|
||||
|
||||
observer.observe(wrapperEl);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Flows that target a row by id (pending edit) expect the message
|
||||
// component and its effects to exist; mount the target row first
|
||||
$effect(() => {
|
||||
if (chatStore.pendingEditMessageId === message.id) {
|
||||
mounted = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={wrapperEl}
|
||||
class:chat-message--synthetic={Boolean(message.isSynthetic)}
|
||||
class="chat-message"
|
||||
>
|
||||
{#if mounted}
|
||||
<ChatMessage
|
||||
{chatActions}
|
||||
class={className}
|
||||
{isLastAssistantMessage}
|
||||
{isLastUserMessage}
|
||||
{message}
|
||||
{nextAssistantMessage}
|
||||
{siblingInfo}
|
||||
{toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The browser skips layout and paint for messages outside the
|
||||
* viewport. contain-intrinsic-size reuses the last rendered size
|
||||
* once known; 500px sizes messages that have never been rendered.
|
||||
*/
|
||||
.chat-message {
|
||||
--chat-message-intrinsic-size: 500px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
|
||||
}
|
||||
|
||||
/*
|
||||
* Synthetic rows (e.g. the working-directory change) are small, so an
|
||||
* accurate placeholder keeps the injected row from inflating the
|
||||
* auto-scroll offset; the 500px default is for ordinary bubbles.
|
||||
*/
|
||||
.chat-message--synthetic {
|
||||
--chat-message-intrinsic-size: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -315,13 +315,18 @@
|
||||
<div
|
||||
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
|
||||
class={[
|
||||
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
|
||||
// animate the centered->bottomed move with transform, not bottom:
|
||||
// layout-property transitions need the main thread every frame and
|
||||
// stutter while a long conversation loads; transform transitions
|
||||
// run on the compositor and stay smooth
|
||||
'pointer-events-none md:sticky fixed mt-auto transition-transform duration-200',
|
||||
deviceStore.isStandalone
|
||||
? 'bottom-6 right-4 left-4'
|
||||
: deviceStore.isIOSSafari
|
||||
? 'bottom-1 left-2 right-2'
|
||||
: 'bottom-2 right-2 left-2',
|
||||
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
|
||||
'md:bottom-4',
|
||||
isEmpty ? 'md:translate-y-[calc(-50dvh+8rem)] 2xl:translate-y-[calc(-50dvh+5rem)]' : ''
|
||||
]}
|
||||
>
|
||||
<ChatScreenGreeting {isEmpty} />
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/katex-custom.scss';
|
||||
import { getMarkdownProcessor, type MarkdownProcessor } from './markdown-processor';
|
||||
import {
|
||||
getCodeInfoFromTarget,
|
||||
getHastNodeId,
|
||||
getMdastNodeHash,
|
||||
isAppendMode
|
||||
} from './markdown-utils';
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
|
||||
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
|
||||
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { remarkLiteralHtml } from './plugins/remark/literal-html';
|
||||
import { browser } from '$app/environment';
|
||||
import {
|
||||
ActionIconCopyToClipboard,
|
||||
CodeBlockActions,
|
||||
@@ -38,10 +27,10 @@
|
||||
MERMAID_WRAPPER_CLASS,
|
||||
SETTINGS_KEYS,
|
||||
SVG,
|
||||
TOGGLE_SOURCE_BTN_CLASS
|
||||
TOGGLE_SOURCE_BTN_CLASS,
|
||||
UI_DATA_ATTRS
|
||||
} from '$lib/constants';
|
||||
import { BooleanString, ColorMode, UrlProtocol } from '$lib/enums';
|
||||
import { FileTypeText } from '$lib/enums/files.enums';
|
||||
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
@@ -58,17 +47,8 @@
|
||||
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
|
||||
import githubLightCss from 'highlight.js/styles/github.css?inline';
|
||||
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
import type { Root as MdastRoot } from 'mdast';
|
||||
import { mode } from 'mode-watcher';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { remark } from 'remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
@@ -144,44 +124,6 @@
|
||||
const transformCache = new SvelteMap<string, string>();
|
||||
let previousContent = '';
|
||||
|
||||
const themeStyleId = `highlight-theme-${(window.idxThemeStyle = (window.idxThemeStyle ?? 0) + 1)}`;
|
||||
|
||||
let processor = $derived(() => {
|
||||
void attachments;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
}
|
||||
|
||||
return proc
|
||||
.use(rehypeHighlight, {
|
||||
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
|
||||
languages: lowlightAll
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
|
||||
.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeRtlSupport) // Add bidirectional text support
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
});
|
||||
|
||||
/**
|
||||
* Removes click event listeners from copy and preview buttons.
|
||||
* Called on component destroy.
|
||||
@@ -201,33 +143,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes this component's highlight.js theme style from the document head.
|
||||
* Called on component destroy to clean up injected styles.
|
||||
*/
|
||||
function cleanupHighlightTheme() {
|
||||
if (!browser) return;
|
||||
|
||||
const existingTheme = document.getElementById(themeStyleId);
|
||||
|
||||
existingTheme?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the appropriate highlight.js theme based on dark/light mode.
|
||||
* Injects a scoped style element into the document head.
|
||||
* One shared style element for every markdown block, mirroring
|
||||
* SyntaxHighlightedCode.svelte. The old per-instance copies duplicated the
|
||||
* full theme CSS once per rendered message, which grows without bound in
|
||||
* long conversations.
|
||||
* @param isDark - Whether to load the dark theme (true) or light theme (false)
|
||||
*/
|
||||
function loadHighlightTheme(isDark: boolean) {
|
||||
if (!browser) return;
|
||||
|
||||
const existingTheme = document.getElementById(themeStyleId);
|
||||
|
||||
existingTheme?.remove();
|
||||
document
|
||||
.querySelectorAll(`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`)
|
||||
.forEach((style) => style.remove());
|
||||
|
||||
const style = document.createElement('style');
|
||||
|
||||
style.id = themeStyleId;
|
||||
style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
|
||||
style.textContent = isDark ? githubDarkCss : githubLightCss;
|
||||
|
||||
document.head.appendChild(style);
|
||||
@@ -247,7 +178,7 @@
|
||||
* @returns Object containing the HTML string and cache hash
|
||||
*/
|
||||
async function transformMdastNode(
|
||||
processorInstance: ReturnType<typeof processor>,
|
||||
processorInstance: MarkdownProcessor,
|
||||
node: unknown,
|
||||
index: number
|
||||
): Promise<{ html: string; hash: string }> {
|
||||
@@ -369,7 +300,7 @@
|
||||
|
||||
if (prefixMarkdown.trim()) {
|
||||
const normalizedPrefix = preprocessLaTeX(prefixMarkdown);
|
||||
const processorInstance = processor();
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalizedPrefix) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const nextBlocks: MarkdownBlock[] = [];
|
||||
@@ -419,7 +350,7 @@
|
||||
incompleteCodeBlock = null;
|
||||
|
||||
const normalized = preprocessLaTeX(markdown);
|
||||
const processorInstance = processor();
|
||||
const processorInstance = getMarkdownProcessor({ attachments, disableMath });
|
||||
const ast = processorInstance.parse(normalized) as MdastRoot;
|
||||
const mdastChildren = (ast as { children?: unknown[] }).children ?? [];
|
||||
const stableCount = Math.max(mdastChildren.length - 1, 0);
|
||||
@@ -858,7 +789,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
cleanupEventListeners();
|
||||
cleanupHighlightTheme();
|
||||
streamingAutoScroll.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Shared remark/rehype pipeline factory for MarkdownContent.
|
||||
//
|
||||
// The frozen plugin chain is expensive to build ( ~15 plugin instances ),
|
||||
// and MarkdownContent used to rebuild it on every processMarkdown call:
|
||||
// once per block at mount, and again on every coalesced chunk while
|
||||
// streaming. Pipelines without attachments are shared process-wide per
|
||||
// math flag; attachment-bearing pipelines are cached by the attachments
|
||||
// array identity, which changes whenever extras are updated.
|
||||
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeEnhanceSvgBlocks } from './plugins/rehype/enhance-svg-blocks';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
import { rehypeRtlSupport } from './plugins/rehype/rehype-rtl-support';
|
||||
import { rehypeResolveAttachmentImages } from './plugins/rehype/resolve-attachment-images';
|
||||
import { rehypeSvgPre } from './plugins/rehype/svg-pre';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { remarkLiteralHtml } from './plugins/remark/literal-html';
|
||||
import { FileTypeText } from '$lib/enums/files.enums';
|
||||
import type { DatabaseMessageExtra } from '$lib/types/database';
|
||||
import type { Root as HastRoot } from 'hast';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
import type { Root as MdastRoot } from 'mdast';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { remark } from 'remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
|
||||
export interface MarkdownProcessor {
|
||||
parse(markdown: string): MdastRoot;
|
||||
run(tree: MdastRoot): Promise<HastRoot>;
|
||||
stringify(tree: HastRoot): string;
|
||||
}
|
||||
|
||||
export interface MarkdownProcessorOptions {
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
disableMath?: boolean;
|
||||
}
|
||||
|
||||
const sharedPipelines = new Map<string, MarkdownProcessor>();
|
||||
const attachmentPipelines = new WeakMap<object, MarkdownProcessor>();
|
||||
|
||||
function buildPipeline({
|
||||
attachments,
|
||||
disableMath = false
|
||||
}: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
|
||||
}
|
||||
|
||||
proc = proc
|
||||
.use(remarkBreaks) // Convert line breaks to <br>
|
||||
// Treat raw HTML as literal text with preserved indentation
|
||||
.use(remarkLiteralHtml)
|
||||
.use(remarkRehype); // Convert Markdown AST to rehype
|
||||
|
||||
if (!disableMath) {
|
||||
proc = proc.use(rehypeKatex); // Render math using KaTeX
|
||||
}
|
||||
|
||||
const pipeline = proc
|
||||
.use(rehypeHighlight, {
|
||||
aliases: { [FileTypeText.XML]: [FileTypeText.SVELTE, FileTypeText.VUE] },
|
||||
languages: lowlightAll
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g. <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
.use(rehypeEnhanceMermaidBlocks) // Wrap mermaid blocks with header and actions
|
||||
.use(rehypeEnhanceSvgBlocks) // Wrap svg blocks with header and actions
|
||||
.use(rehypeResolveAttachmentImages, { attachments })
|
||||
.use(rehypeRtlSupport) // Add bidirectional text support
|
||||
.use(rehypeStringify, { allowDangerousHtml: true }); // Convert to HTML string
|
||||
|
||||
return pipeline as MarkdownProcessor;
|
||||
}
|
||||
|
||||
export function getMarkdownProcessor(options: MarkdownProcessorOptions): MarkdownProcessor {
|
||||
if (options.attachments && options.attachments.length > 0) {
|
||||
let cached = attachmentPipelines.get(options.attachments);
|
||||
|
||||
if (!cached) {
|
||||
cached = buildPipeline(options);
|
||||
attachmentPipelines.set(options.attachments, cached);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
const key = String(Boolean(options.disableMath));
|
||||
|
||||
let cached = sharedPipelines.get(key);
|
||||
|
||||
if (!cached) {
|
||||
cached = buildPipeline(options);
|
||||
sharedPipelines.set(key, cached);
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export * from './context-gauge-popup.constants';
|
||||
export * from './conversation-import.constants';
|
||||
export * from './binary-detection.constants';
|
||||
export * from './content-detection.constants';
|
||||
export * from './tool-call-args.constants';
|
||||
export * from './tool-ui.constants';
|
||||
export * from './cache.constants';
|
||||
export * from './chat-form.constants';
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Tool-args and tool-result parsing helpers: the file tools' path field
|
||||
// aliases, the JSON container gates for result blobs, and the targeted
|
||||
// string-field pattern used for cheap title-tier extraction.
|
||||
|
||||
/**
|
||||
* Field aliases the file tools accept for the path argument. Tool contracts
|
||||
* drifted over time: some models emit `file_path` / `filePath`.
|
||||
*/
|
||||
export const TOOL_ARG_PATH_KEYS: readonly string[] = ['path', 'file_path', 'filePath'];
|
||||
|
||||
/** Opening character of a JSON object; only an object root can carry fields. */
|
||||
export const JSON_OBJECT_OPEN = '{';
|
||||
|
||||
/** Opening character of a JSON array; successful sandbox output is one. */
|
||||
export const JSON_ARRAY_OPEN = '[';
|
||||
|
||||
/**
|
||||
* Matches `"<key>": "<value>"` in a JSON args blob ( whitespace between
|
||||
* tokens allowed ), capturing the raw string literal so only that literal
|
||||
* gets decoded; escaped quotes stay inside the value group. `{key}` is
|
||||
* replaced with the field name before use.
|
||||
*/
|
||||
export const TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE = '"{key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"';
|
||||
@@ -55,7 +55,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
string,
|
||||
{ response: string; messageId: string; model?: string | null }
|
||||
>();
|
||||
currentResponse = $state('');
|
||||
errorDialogState = $state<ErrorDialogState | null>(null);
|
||||
// true while the active conversation has a local pipe (send, attach or resume-wait)
|
||||
isLoading = $derived(this.activity.isLocal(conversationsStore.activeConversation?.id ?? ''));
|
||||
@@ -256,8 +255,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
}
|
||||
|
||||
this.chatStreamingStates.delete(convId);
|
||||
|
||||
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = '';
|
||||
}
|
||||
clearEditMode(): void {
|
||||
this.isEditModeActive = false;
|
||||
@@ -272,11 +269,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
this.pendingMessages.delete(convId);
|
||||
}
|
||||
|
||||
/** Reset per-view state when (re)mounting the empty chat screen. */
|
||||
clearUIState(): void {
|
||||
this.currentResponse = '';
|
||||
}
|
||||
|
||||
consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null {
|
||||
if (!this.pendingDraftMessage && this.pendingDraftFiles.length === 0) return null;
|
||||
|
||||
@@ -766,8 +758,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
model: model ?? this.chatStreamingStates.get(convId)?.model,
|
||||
response
|
||||
});
|
||||
|
||||
if (convId === conversationsStore.activeConversation?.id) this.currentResponse = response;
|
||||
}
|
||||
|
||||
setEditModeActive(handler: (files: File[]) => void): void {
|
||||
@@ -1244,7 +1234,6 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
|
||||
syncLoadingStateForChat(convId: string): void {
|
||||
const s = this.chatStreamingStates.get(convId);
|
||||
|
||||
this.currentResponse = s?.response || '';
|
||||
this.processing.setActiveConversation(convId);
|
||||
|
||||
// Sync streaming content to activeMessages so UI displays current content
|
||||
|
||||
@@ -52,6 +52,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Messages loadConversation just read, handed off once so the chat
|
||||
* screen can reuse them for sibling info instead of re-fetching the
|
||||
* whole conversation a second time.
|
||||
*/
|
||||
private lastLoadedMessages: { convId: string; messages: DatabaseMessage[] } | null = null;
|
||||
|
||||
/**
|
||||
* Memo of the last findMessageIndex() lookup. Streaming calls it once per
|
||||
* chunk for the same message, so a validated cache hit keeps that O(1)
|
||||
@@ -88,7 +95,13 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === id) {
|
||||
this.activeConversation = { ...this.activeConversation, ...updates };
|
||||
// field-wise, not object replacement: effects that track the active
|
||||
// conversation identity would otherwise refire on every rename or pin
|
||||
const target = this.activeConversation as unknown as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (target[key] !== value) target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,11 +215,8 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
const updates = await DatabaseService.bulkToggleConversationPins(convIds);
|
||||
const activeId = this.activeConversation?.id;
|
||||
|
||||
if (activeId && updates.has(activeId)) {
|
||||
this.activeConversation = {
|
||||
...this.activeConversation!,
|
||||
pinned: updates.get(activeId)!
|
||||
};
|
||||
if (this.activeConversation && activeId && updates.has(activeId)) {
|
||||
this.activeConversation.pinned = updates.get(activeId)!;
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.conversations.length; i++) {
|
||||
@@ -236,6 +246,17 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
this.preferences.resetPending();
|
||||
}
|
||||
|
||||
/** One-shot handoff of the messages the last loadConversation read. */
|
||||
consumeLastLoadedMessages(convId: string): DatabaseMessage[] | null {
|
||||
if (this.lastLoadedMessages?.convId !== convId) return null;
|
||||
|
||||
const messages = this.lastLoadedMessages.messages;
|
||||
|
||||
this.lastLoadedMessages = null;
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new conversation and navigates to it
|
||||
* @param name - Optional name for the conversation
|
||||
@@ -509,22 +530,15 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
// it doesn't belong to this conversation.
|
||||
this.preferences.pendingCwd = null;
|
||||
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
|
||||
// set conversation and messages in one sync block so effects never see
|
||||
// the new conversation with the previous conversation's messages
|
||||
this.lastLoadedMessages = { convId, messages: allMessages };
|
||||
this.activeConversation = conversation;
|
||||
|
||||
if (conversation.currNode) {
|
||||
const allMessages = await DatabaseService.getConversationMessages(convId);
|
||||
const filteredMessages = filterByLeafNodeId(
|
||||
allMessages,
|
||||
conversation.currNode,
|
||||
false
|
||||
) as DatabaseMessage[];
|
||||
|
||||
this.activeMessages = filteredMessages;
|
||||
} else {
|
||||
const messages = await DatabaseService.getConversationMessages(convId);
|
||||
|
||||
this.activeMessages = messages;
|
||||
}
|
||||
this.activeMessages = conversation.currNode
|
||||
? (filterByLeafNodeId(allMessages, conversation.currNode, false) as DatabaseMessage[])
|
||||
: allMessages;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -558,7 +572,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
const currentLeafNodeId = findLeafNode(allMessages, siblingId);
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: currentLeafNodeId };
|
||||
this.activeConversation.currNode = currentLeafNodeId;
|
||||
await this.refreshActiveMessages();
|
||||
|
||||
if (rootMessage && this.activeMessages.length > 0) {
|
||||
@@ -694,7 +708,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
}
|
||||
|
||||
if (this.activeConversation?.id === targetId) {
|
||||
this.activeConversation = { ...this.activeConversation, lastModified: now };
|
||||
this.activeConversation.lastModified = now;
|
||||
}
|
||||
|
||||
DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) =>
|
||||
@@ -710,7 +724,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
|
||||
if (!this.activeConversation) return;
|
||||
|
||||
await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
|
||||
this.activeConversation = { ...this.activeConversation, currNode: nodeId };
|
||||
this.activeConversation.currNode = nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -209,7 +209,16 @@ export type {
|
||||
export type { DesktopIconStripItem } from './navigation';
|
||||
|
||||
// Tools types
|
||||
export type { ToolEntry, ToolGroup, ToolUiEntry } from './tools';
|
||||
export type {
|
||||
EditFileEdit,
|
||||
EditFileMeta,
|
||||
EditFileTitleMeta,
|
||||
ToolEntry,
|
||||
ToolGroup,
|
||||
ToolUiEntry,
|
||||
WriteFileMeta,
|
||||
WriteFileTitleMeta
|
||||
} from './tools';
|
||||
|
||||
// Reasoning
|
||||
export type { ReasoningEffortLevel } from './reasoning';
|
||||
|
||||
Vendored
+47
@@ -31,3 +31,50 @@ export interface ToolGroup {
|
||||
serverId?: string;
|
||||
tools: ToolEntry[];
|
||||
}
|
||||
|
||||
export interface WriteFileMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
content: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Everything the write_file block title and status pill show; the full meta
|
||||
* ( with the embedded file content ) stays body-only so collapsed blocks
|
||||
* never parse the content blob. */
|
||||
export interface WriteFileTitleMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface EditFileEdit {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface EditFileMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
edits: EditFileEdit[];
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Everything the edit_file block title and status pill show; the full meta
|
||||
* ( with the embedded edit strings ) stays body-only so collapsed blocks
|
||||
* never parse the args blob. */
|
||||
export interface EditFileTitleMeta {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,89 @@ function deriveSingleTurnSections(
|
||||
return sections;
|
||||
}
|
||||
|
||||
interface TurnSectionsCacheEntry {
|
||||
content: string | undefined;
|
||||
extra: DatabaseMessageExtra[] | undefined;
|
||||
reasoningContent: string | undefined;
|
||||
toolCalls: string | undefined;
|
||||
toolMessageContents: (string | undefined)[];
|
||||
toolMessageExtras: (DatabaseMessageExtra[] | undefined)[];
|
||||
toolMessages: DatabaseMessage[];
|
||||
sections: AgenticSection[];
|
||||
}
|
||||
|
||||
const turnSectionsCache = new WeakMap<DatabaseMessage, TurnSectionsCacheEntry>();
|
||||
|
||||
function isTurnCacheValid(
|
||||
entry: TurnSectionsCacheEntry,
|
||||
message: DatabaseMessage,
|
||||
toolMessages: DatabaseMessage[]
|
||||
): boolean {
|
||||
if (
|
||||
entry.content !== message.content ||
|
||||
entry.reasoningContent !== message.reasoningContent ||
|
||||
entry.toolCalls !== message.toolCalls ||
|
||||
entry.extra !== message.extra
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.toolMessages.length !== toolMessages.length) return false;
|
||||
|
||||
for (let i = 0; i < toolMessages.length; i++) {
|
||||
if (entry.toolMessages[i] !== toolMessages[i]) return false;
|
||||
|
||||
if (entry.toolMessageContents[i] !== toolMessages[i].content) return false;
|
||||
|
||||
if (entry.toolMessageExtras[i] !== toolMessages[i].extra) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* deriveSingleTurnSections with structural reuse for completed turns.
|
||||
*
|
||||
* deriveAgenticSections runs in a $derived invalidated per streamed chunk, but
|
||||
* only the last turn actually changes. Messages mutate in place and are never
|
||||
* replaced, so a WeakMap keyed by the turn's assistant message plus reference
|
||||
* checks on every field deriveSingleTurnSections reads detects any change. A
|
||||
* cache hit also returns the same section objects, keeping downstream props
|
||||
* stable so tool blocks skip their per-chunk re-derive. The streaming turn
|
||||
* recomputes uncached on every chunk.
|
||||
*/
|
||||
function deriveTurnSections(
|
||||
message: DatabaseMessage,
|
||||
toolMessages: DatabaseMessage[],
|
||||
streamingToolCalls: ApiChatCompletionToolCall[],
|
||||
isStreaming: boolean
|
||||
): AgenticSection[] {
|
||||
if (isStreaming || streamingToolCalls.length > 0) {
|
||||
return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
|
||||
}
|
||||
|
||||
const cached = turnSectionsCache.get(message);
|
||||
|
||||
if (cached && isTurnCacheValid(cached, message, toolMessages)) {
|
||||
return cached.sections;
|
||||
}
|
||||
|
||||
const sections = deriveSingleTurnSections(message, toolMessages, [], false);
|
||||
|
||||
turnSectionsCache.set(message, {
|
||||
content: message.content,
|
||||
extra: message.extra,
|
||||
reasoningContent: message.reasoningContent,
|
||||
sections,
|
||||
toolCalls: message.toolCalls,
|
||||
toolMessageContents: toolMessages.map((tm) => tm.content),
|
||||
toolMessageExtras: toolMessages.map((tm) => tm.extra),
|
||||
toolMessages
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives display sections from structured message data.
|
||||
*
|
||||
@@ -132,13 +215,13 @@ export function deriveAgenticSections(
|
||||
const hasAssistantContinuations = toolMessages.some((m) => m.role === MessageRole.ASSISTANT);
|
||||
|
||||
if (!hasAssistantContinuations) {
|
||||
return deriveSingleTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
|
||||
return deriveTurnSections(message, toolMessages, streamingToolCalls, isStreaming);
|
||||
}
|
||||
|
||||
const sections: AgenticSection[] = [];
|
||||
const firstTurnToolMsgs = collectToolMessages(toolMessages, 0);
|
||||
|
||||
sections.push(...deriveSingleTurnSections(message, firstTurnToolMsgs));
|
||||
sections.push(...deriveTurnSections(message, firstTurnToolMsgs, [], false));
|
||||
|
||||
let i = firstTurnToolMsgs.length;
|
||||
|
||||
@@ -150,7 +233,7 @@ export function deriveAgenticSections(
|
||||
const isLastTurn = i + 1 + turnToolMsgs.length >= toolMessages.length;
|
||||
|
||||
sections.push(
|
||||
...deriveSingleTurnSections(
|
||||
...deriveTurnSections(
|
||||
msg,
|
||||
turnToolMsgs,
|
||||
isLastTurn ? streamingToolCalls : [],
|
||||
|
||||
@@ -105,18 +105,34 @@ export function filterByLeafNodeId(
|
||||
*/
|
||||
function findLeafNodeInMap(
|
||||
nodeMap: ReadonlyMap<string, DatabaseMessage>,
|
||||
messageId: string
|
||||
messageId: string,
|
||||
leafCache?: Map<string, string>
|
||||
): string {
|
||||
const path: string[] = [];
|
||||
|
||||
let currentNode: DatabaseMessage | undefined = nodeMap.get(messageId);
|
||||
|
||||
while (currentNode && currentNode.children.length > 0) {
|
||||
// Follow the last child (most recent branch)
|
||||
const cached = leafCache?.get(currentNode.id);
|
||||
|
||||
if (cached !== undefined) {
|
||||
for (const id of path) leafCache?.set(id, cached);
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
path.push(currentNode.id);
|
||||
const lastChildId = currentNode.children[currentNode.children.length - 1];
|
||||
|
||||
currentNode = nodeMap.get(lastChildId);
|
||||
}
|
||||
|
||||
return currentNode?.id ?? messageId;
|
||||
const leafId = currentNode?.id ?? messageId;
|
||||
|
||||
for (const id of path) leafCache?.set(id, leafId);
|
||||
|
||||
return leafId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +192,8 @@ export function findDescendantMessages(
|
||||
*/
|
||||
export function getMessageSiblings(
|
||||
nodeMap: ReadonlyMap<string, DatabaseMessage>,
|
||||
messageId: string
|
||||
messageId: string,
|
||||
leafCache?: Map<string, string>
|
||||
): ChatMessageSiblingInfo | null {
|
||||
const message = nodeMap.get(messageId);
|
||||
|
||||
@@ -212,7 +229,7 @@ export function getMessageSiblings(
|
||||
// Convert sibling message IDs to their corresponding leaf node IDs
|
||||
// This allows navigation between different conversation branches
|
||||
const siblingLeafIds = siblingIds.map((siblingId: string) =>
|
||||
findLeafNodeInMap(nodeMap, siblingId)
|
||||
findLeafNodeInMap(nodeMap, siblingId, leafCache)
|
||||
);
|
||||
// Find current message's position among siblings
|
||||
const currentIndex = siblingIds.indexOf(messageId);
|
||||
@@ -236,9 +253,12 @@ export function buildSiblingInfoMap(
|
||||
): Map<string, ChatMessageSiblingInfo> {
|
||||
const nodeMap = new Map(messages.map((msg) => [msg.id, msg] as const));
|
||||
const siblingMap = new Map<string, ChatMessageSiblingInfo>();
|
||||
// Leaf walks repeat along the same child chains for every message; memoize
|
||||
// them per build so each edge is walked once instead of O(messages^2)
|
||||
const leafCache = new Map<string, string>();
|
||||
|
||||
for (const msg of messages) {
|
||||
const info = getMessageSiblings(nodeMap, msg.id);
|
||||
const info = getMessageSiblings(nodeMap, msg.id, leafCache);
|
||||
|
||||
if (info) {
|
||||
siblingMap.set(msg.id, info);
|
||||
|
||||
@@ -285,7 +285,8 @@ export {
|
||||
extractSearchResults,
|
||||
extractSearchQuery,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from './search-results';
|
||||
|
||||
// Cache utilities
|
||||
|
||||
@@ -3,8 +3,14 @@ export function parseExecShellCommandError(
|
||||
): string | undefined {
|
||||
if (!toolResultString) return undefined;
|
||||
|
||||
// Exec results are usually large plain-text stdout; only a JSON object
|
||||
// root can carry an error field, so skip the parse otherwise
|
||||
const trimmed = toolResultString.trimStart();
|
||||
|
||||
if (trimmed[0] !== '{') return undefined;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
|
||||
@@ -15,15 +15,18 @@ export interface ExecShellExitStatus {
|
||||
}
|
||||
|
||||
// Anchor to the absolute end so intermediate "[exit code: N]" string content
|
||||
// (e.g. a shell echo) doesn't false-positive.
|
||||
// (e.g. a shell echo) doesn't false-positive. The marker is at most ~50 chars
|
||||
// with the timed-out suffix, so matching a tail slice keeps the cost constant
|
||||
// for megabyte exec outputs instead of scanning the whole blob.
|
||||
const EXIT_CODE_TAIL_REGEX = /\[exit code: (-?\d+)\](?: \[exit due to timed out\])?\s*$/;
|
||||
const EXIT_CODE_TAIL_SCAN = 128;
|
||||
|
||||
export function parseExecShellCommandExitStatus(
|
||||
toolResultString: string | undefined
|
||||
): ExecShellExitStatus | undefined {
|
||||
if (!toolResultString) return undefined;
|
||||
|
||||
const match = toolResultString.match(EXIT_CODE_TAIL_REGEX);
|
||||
const match = toolResultString.slice(-EXIT_CODE_TAIL_SCAN).match(EXIT_CODE_TAIL_REGEX);
|
||||
|
||||
if (!match) return undefined;
|
||||
|
||||
|
||||
@@ -156,6 +156,20 @@ function parseChunk(chunk: string): SearchResult | null {
|
||||
return result;
|
||||
}
|
||||
|
||||
const EMPTY_SEARCH_RESULTS: SearchResult[] = [];
|
||||
|
||||
/**
|
||||
* Cheap prefilter for the wire format: a parseable result needs both a
|
||||
* `Title:` and a `URL:` field line, so a blob missing either substring can
|
||||
* never yield a result. Two substring scans cost far less than the
|
||||
* line-split parse for the megabyte tool results exec and file tools emit.
|
||||
*/
|
||||
export function looksLikeSearchResult(text: string | undefined | null): boolean {
|
||||
if (!text) return false;
|
||||
|
||||
return text.includes('Title:') && text.includes('URL:');
|
||||
}
|
||||
|
||||
/** Bounded cache for extractSearchResults results. */
|
||||
const SEARCH_RESULTS_CACHE_MAX_SIZE = 32;
|
||||
const searchResultsCache = new Map<string, SearchResult[]>();
|
||||
@@ -168,7 +182,7 @@ const searchResultsCache = new Map<string, SearchResult[]>();
|
||||
* tool result strings.
|
||||
*/
|
||||
export function extractSearchResults(text: string | undefined | null): SearchResult[] {
|
||||
if (!text) return [];
|
||||
if (!text || !looksLikeSearchResult(text)) return EMPTY_SEARCH_RESULTS;
|
||||
|
||||
const cached = searchResultsCache.get(text);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// Each tool needs to surface fields like `error`, `result`, `bytes`,
|
||||
// `edits_applied` without repeating the try/JSON.parse/object guard inline.
|
||||
|
||||
import { JSON_OBJECT_OPEN } from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Parse a tool-result blob into a JSON object, or `null` if it isn't
|
||||
* one. Returns null for:
|
||||
@@ -16,8 +18,14 @@ export function tryParseToolResultObject(
|
||||
): Record<string, unknown> | null {
|
||||
if (!toolResultString) return null;
|
||||
|
||||
// Tool results are usually large plain text (file contents, stdout); only
|
||||
// a JSON object root can carry fields, so skip the parse otherwise
|
||||
const trimmed = toolResultString.trimStart();
|
||||
|
||||
if (trimmed[0] !== JSON_OBJECT_OPEN) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { APP_NAME, URL_PARAMS } from '$lib/constants';
|
||||
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { conversationsStore, modelsStore, serverStore } from '$lib/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
|
||||
@@ -77,7 +77,6 @@
|
||||
}
|
||||
|
||||
conversationsStore.clearActiveConversation();
|
||||
chatStore.clearUIState();
|
||||
|
||||
await modelsStore.fetch();
|
||||
|
||||
|
||||
@@ -290,3 +290,114 @@ describe('hasAgenticContent', () => {
|
||||
expect(hasAgenticContent(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn-section cache: completed turns are immutable, so repeated
|
||||
// derivations return the same section objects - which is what keeps tool
|
||||
// block props stable while another turn streams. Every field the cache
|
||||
// compares must invalidate it; a miss here renders stale content.
|
||||
|
||||
describe('completed turn section reuse', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{"path":"/a"}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
|
||||
function makeSession() {
|
||||
return {
|
||||
anchor: makeAssistant({
|
||||
content: 'answer',
|
||||
reasoningContent: 'thinking',
|
||||
toolCalls: toolCallsJson
|
||||
}),
|
||||
tools: [makeToolMsg({ content: 'tool result', extra: [{ type: 'file' } as never] })]
|
||||
};
|
||||
}
|
||||
|
||||
it('returns the same section objects for unchanged inputs', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
});
|
||||
|
||||
it('recomputes when the assistant content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.content = 'edited';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.some((s) => s.type === AgenticSectionType.TEXT && s.content === 'edited')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('recomputes when reasoning content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.reasoningContent = 'new thinking';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when toolCalls change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.toolCalls = '[]';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when a tool result or its extras change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].content = 'new tool result';
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(first);
|
||||
|
||||
const firstAfterContent = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].extra = [{ type: 'image' } as never];
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(firstAfterContent);
|
||||
});
|
||||
|
||||
it('never reuses the streaming turn', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('keeps completed turns stable while the last turn streams', () => {
|
||||
const anchor = makeAssistant({
|
||||
content: 'turn one',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const continuation = makeAssistant({ content: 'turn two', id: 'ast-2' });
|
||||
const tools = [
|
||||
makeToolMsg({ content: 'r1', id: 'tool-1', toolCallId: 'call_1' }),
|
||||
continuation,
|
||||
makeToolMsg({ content: 'r2', id: 'tool-2', toolCallId: 'call_2' })
|
||||
];
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
// turn one is complete: identical section objects across derivations
|
||||
expect(second.slice(0, 2)).toEqual(first.slice(0, 2));
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
|
||||
// the streaming last turn recomputed: fresh section objects
|
||||
expect(second[second.length - 1]).not.toBe(first[first.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Sibling-info correctness for buildSiblingInfoMap, including the memoized
|
||||
// leaf resolution. A wrong leaf id here breaks branch navigation, so the
|
||||
// deep-chain and multi-branch cases below pin the resolution down.
|
||||
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { buildSiblingInfoMap, findLeafNode } from '$lib/utils/branching';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function msg(id: string, parent: string | null, children: string[] = []): DatabaseMessage {
|
||||
return {
|
||||
children,
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
/** root -> m1 -> ... -> m depth, each node with a single child. */
|
||||
function linearChain(depth: number): DatabaseMessage[] {
|
||||
const messages = [msg('m0', null, ['m1'])];
|
||||
|
||||
for (let i = 1; i <= depth; i++) {
|
||||
messages.push(msg(`m${i}`, `m${i - 1}`, i < depth ? [`m${i + 1}`] : []));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe('buildSiblingInfoMap', () => {
|
||||
it('resolves the deepest leaf for every node of a long single chain', () => {
|
||||
const messages = linearChain(50);
|
||||
const map = buildSiblingInfoMap(messages);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every non-root message of the chain is an only child, and its
|
||||
// navigation target is the chain's deepest leaf
|
||||
for (const m of messages.slice(1)) {
|
||||
const info = map.get(m.id);
|
||||
|
||||
expect(info?.totalSiblings).toBe(1);
|
||||
expect(info?.siblingIds).toEqual([leafId]);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports sibling position and leaf targets on a branched tree', () => {
|
||||
// m0 -> m1, m4 ; m1 -> m2 ; m2 -> m3, m6 ; m4 -> m5
|
||||
const root = msg('m0', null, ['m1', 'm4']);
|
||||
const m1 = msg('m1', 'm0', ['m2']);
|
||||
const m2 = msg('m2', 'm1', ['m3', 'm6']);
|
||||
const m3 = msg('m3', 'm2');
|
||||
const m4 = msg('m4', 'm0', ['m5']);
|
||||
const m5 = msg('m5', 'm4');
|
||||
const m6 = msg('m6', 'm2');
|
||||
const map = buildSiblingInfoMap([root, m1, m2, m3, m4, m5, m6]);
|
||||
|
||||
// m1 and m4 share the root as parent; their nav targets are the
|
||||
// leaves of their subtrees ( m6 for the first branch, m5 for the second )
|
||||
expect(map.get(m1.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
expect(map.get(m4.id)).toMatchObject({
|
||||
currentIndex: 1,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
|
||||
// m3 and m6 are siblings under m2; both are leaves
|
||||
expect(map.get(m3.id)?.siblingIds).toEqual([m3.id, m6.id]);
|
||||
expect(map.get(m6.id)?.currentIndex).toBe(1);
|
||||
|
||||
// the root has no parent and reports itself
|
||||
expect(map.get(root.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [root.id],
|
||||
totalSiblings: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('agrees with findLeafNode for arbitrary nodes', () => {
|
||||
const messages = linearChain(20);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every node of the chain resolves to the deepest leaf
|
||||
for (const m of messages) {
|
||||
expect(findLeafNode(messages, m.id), `leaf of ${m.id}`).toBe(leafId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Field updates to the active conversation must keep the object identity
|
||||
// stable: effects that track the identity ( the chat screen's sibling-info
|
||||
// refresh ) refire on every identity change, which used to trigger a full
|
||||
// message refetch on every send and tool result.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/database.service', () => ({
|
||||
DatabaseService: {
|
||||
getConversation: vi.fn(),
|
||||
getConversationMessages: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
updateCurrentNode: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
|
||||
|
||||
const getConversationMock = vi.mocked(DatabaseService.getConversation);
|
||||
const getMessagesMock = vi.mocked(DatabaseService.getConversationMessages);
|
||||
const updateCurrentNodeMock = vi.mocked(DatabaseService.updateCurrentNode);
|
||||
|
||||
function makeConversation(overrides: Partial<DatabaseConversation> = {}): DatabaseConversation {
|
||||
return {
|
||||
currNode: 'node-1',
|
||||
id: 'conv-1',
|
||||
lastModified: 1000,
|
||||
name: 'conversation',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function loadActive(conversation: DatabaseConversation, messages: DatabaseMessage[]) {
|
||||
getConversationMock.mockResolvedValue(conversation);
|
||||
getMessagesMock.mockResolvedValue(messages);
|
||||
|
||||
expect(await conversationsStore.loadConversation(conversation.id)).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getConversationMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
updateCurrentNodeMock.mockReset();
|
||||
updateCurrentNodeMock.mockResolvedValue(undefined);
|
||||
vi.mocked(DatabaseService.updateConversation).mockReset();
|
||||
vi.mocked(DatabaseService.updateConversation).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('active conversation identity', () => {
|
||||
it('hands the load read off exactly once', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toEqual([]);
|
||||
// a second consume is a miss: branch actions must fall back to a refetch
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('writes currNode in place on updateCurrentNode', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
await conversationsStore.updateCurrentNode('node-2');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.currNode).toBe('node-2');
|
||||
});
|
||||
|
||||
it('writes renamed and pinned fields in place on applyConversationUpdate', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.applyConversationUpdate('conv-1', { name: 'renamed', pinned: true });
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.name).toBe('renamed');
|
||||
expect(conversationsStore.activeConversation?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('writes lastModified in place on updateConversationTimestamp', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.updateConversationTimestamp('conv-1');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.lastModified).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -71,3 +71,21 @@ describe('isExitCodeSummaryLine', () => {
|
||||
expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExecShellCommandExitStatus tail scan', () => {
|
||||
it('finds the marker at the end of a blob larger than the tail window', () => {
|
||||
// the parser matches only the last ~128 chars; a marker past that
|
||||
// window must still parse, and an earlier fake must not match
|
||||
const blob = `${'the shell prints [exit code: 1] mid-stream\n'.repeat(2000)}[exit code: 0]`;
|
||||
const status = parseExecShellCommandExitStatus(blob);
|
||||
|
||||
expect(status?.code).toBe(0);
|
||||
expect(status?.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps rejecting markers that are not at the absolute end', () => {
|
||||
const blob = `${'stdout\n'.repeat(2000)}[exit code: 0]\nsome trailing log line`;
|
||||
|
||||
expect(parseExecShellCommandExitStatus(blob)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -119,3 +120,27 @@ describe('isWebSearchToolName', () => {
|
||||
expect(isWebSearchToolName('exec_shell_command')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSearchResults prefilter', () => {
|
||||
it('returns the shared empty array for blobs without the wire format', () => {
|
||||
// exec/file tool results never carry Title:/URL: field lines; the
|
||||
// cheap prefilter must skip the line-split parse for them
|
||||
const stdout = `${'make[1]: entering directory\n'.repeat(5000)}`;
|
||||
|
||||
expect(extractSearchResults(stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty result when only one required field is present', () => {
|
||||
expect(extractSearchResults('URL: https://example.com')).toEqual([]);
|
||||
expect(extractSearchResults('Title: only a title')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeSearchResult', () => {
|
||||
it('requires both Title and URL field markers', () => {
|
||||
expect(looksLikeSearchResult('Title: a\nURL: https://b')).toBe(true);
|
||||
expect(looksLikeSearchResult('URL: https://b')).toBe(false);
|
||||
expect(looksLikeSearchResult('plain stdout')).toBe(false);
|
||||
expect(looksLikeSearchResult(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,3 +28,15 @@ describe('tryParseToolResultObject', () => {
|
||||
expect(tryParseToolResultObject('{bad')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseToolResultObject gating', () => {
|
||||
it('parses JSON objects that start after leading whitespace', () => {
|
||||
expect(tryParseToolResultObject('\n {"result":"ok"}')).toEqual({ result: 'ok' });
|
||||
});
|
||||
|
||||
it('skips the parse for large plain-text results', () => {
|
||||
// most tool results are file contents or stdout; the gate avoids a
|
||||
// doomed JSON.parse over the whole blob
|
||||
expect(tryParseToolResultObject(`${'stdout line\n'.repeat(2000)}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
||||
import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
import {
|
||||
parseEditFileMeta,
|
||||
parseEditFileTitleMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
|
||||
import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
|
||||
import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
|
||||
@@ -7,10 +10,10 @@ import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMes
|
||||
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
type WriteFileMeta
|
||||
parseWriteFileTitleMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import type { AgenticSection, WriteFileMeta } from '$lib/types';
|
||||
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -223,6 +226,113 @@ describe('parseWriteFileMeta', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWriteFileTitleMeta', () => {
|
||||
it('matches the full meta for path, language and result fields', () => {
|
||||
const args = JSON.stringify({ content: 'x'.repeat(50_000), path: '/foo.ts' });
|
||||
const toolResult = '{"result":"wrote","bytes":42}';
|
||||
const section = makeSection(
|
||||
{ toolArgs: args, toolName: BuiltInTool.SERVER_WRITE_FILE, toolResult },
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
const full = parseWriteFileMeta(section);
|
||||
const title = parseWriteFileTitleMeta(section);
|
||||
|
||||
expect(title?.filePath).toBe(full?.filePath);
|
||||
expect(title?.fileName).toBe(full?.fileName);
|
||||
expect(title?.language).toBe(full?.language);
|
||||
expect(title?.bytesWritten).toBe(full?.bytesWritten);
|
||||
expect(title?.resultMessage).toBe(full?.resultMessage);
|
||||
expect(title?.errorMessage).toBe(full?.errorMessage);
|
||||
});
|
||||
|
||||
it('extracts a path with escaped characters without parsing the content blob', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/a\\nb\\"c/d.ts","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE
|
||||
},
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/a\nb"c/d.ts');
|
||||
});
|
||||
|
||||
it('falls back to the full parse for args the extractor can not see', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
// key written with an escaped unicode escape sequence in the name
|
||||
toolArgs: '{"\\u0070ath":"/foo.ts","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE
|
||||
},
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.ts');
|
||||
});
|
||||
|
||||
it('accepts partial args like the full parser', () => {
|
||||
const section = makeSection(
|
||||
{ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE },
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.t');
|
||||
});
|
||||
|
||||
it('returns null for sections with a different tool name', () => {
|
||||
expect(
|
||||
parseWriteFileTitleMeta(
|
||||
makeSection({
|
||||
toolArgs: '{"path":"/x","content":"y"}',
|
||||
toolName: BuiltInTool.SERVER_READ_FILE
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEditFileTitleMeta', () => {
|
||||
it('matches the full meta for path and result fields', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"}]}' + ' '.repeat(0),
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"result":"ok","edits_applied":1}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
const full = parseEditFileMeta(section);
|
||||
const title = parseEditFileTitleMeta(section);
|
||||
|
||||
expect(title?.filePath).toBe(full?.filePath);
|
||||
expect(title?.fileName).toBe(full?.fileName);
|
||||
expect(title?.editsApplied).toBe(full?.editsApplied);
|
||||
expect(title?.resultMessage).toBe(full?.resultMessage);
|
||||
expect(title?.errorMessage).toBe(full?.errorMessage);
|
||||
});
|
||||
|
||||
it('surfaces errorMessage from the result blob without parsing args', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo.ts","edits":[]}',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"error":"permission denied"}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
|
||||
expect(parseEditFileTitleMeta(section)?.errorMessage).toBe('permission denied');
|
||||
});
|
||||
|
||||
it('returns null when args have no path-like field', () => {
|
||||
expect(
|
||||
parseEditFileTitleMeta(
|
||||
makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEditFileMeta', () => {
|
||||
it('parses edits array and applies editsApplied from the result', () => {
|
||||
const section = makeSection(
|
||||
|
||||
Reference in New Issue
Block a user