mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
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
This commit is contained in:
+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}
|
||||
|
||||
+36
@@ -28,6 +28,42 @@ 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: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
let pattern = toolArgStringRegexes.get(key);
|
||||
|
||||
if (!pattern) {
|
||||
pattern = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
|
||||
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:
|
||||
|
||||
+56
-1
@@ -3,7 +3,7 @@
|
||||
// rendering), plus the result blob for `result` / `edits_applied` /
|
||||
// `error` fields.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { extractToolArgString, parseToolArgs } from './_shared';
|
||||
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
@@ -23,6 +23,19 @@ export type EditFileMeta = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
/** Everything the 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 type EditFileTitleMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
const PATH_KEYS = ['path', 'file_path', 'filePath'];
|
||||
|
||||
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
|
||||
|
||||
@@ -79,3 +92,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, 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 };
|
||||
}
|
||||
|
||||
+55
-1
@@ -3,7 +3,7 @@
|
||||
// finishes) and surfaces `bytes`, `result`, and `error` from the
|
||||
// result blob.
|
||||
|
||||
import { parseToolArgs } from './_shared';
|
||||
import { extractToolArgString, parseToolArgs } from './_shared';
|
||||
import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
@@ -19,6 +19,20 @@ export type WriteFileMeta = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
/** Everything the 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 type WriteFileTitleMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
const PATH_KEYS = ['path', 'file_path', 'filePath'];
|
||||
|
||||
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
|
||||
|
||||
@@ -51,3 +65,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, 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,6 +10,7 @@ import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMes
|
||||
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
parseWriteFileTitleMeta,
|
||||
type WriteFileMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
@@ -223,6 +227,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