mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 01:22:54 +00:00
🎞️ fix: Stream File Authoring Previews from Partial Tool Args (#13634)
* 🌊 fix: Stream File Authoring Previews from Partial Tool Args * 🧹 chore: Sort Imports in FileAuthoringCall * 👁️ feat: Keep File Authoring Input Visible After Completion
This commit is contained in:
parent
793cbd49f0
commit
c216b3ce5b
4 changed files with 241 additions and 25 deletions
|
|
@ -1,12 +1,12 @@
|
|||
import { useMemo } from 'react';
|
||||
import { FilePenLine, FilePlus2 } from 'lucide-react';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import parseJsonField, { parseJsonFieldOccurrences } from './parseJsonField';
|
||||
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
|
||||
import useToolCallState from './useToolCallState';
|
||||
import useLazyHighlight from './useLazyHighlight';
|
||||
import CodeWindowHeader from './CodeWindowHeader';
|
||||
import { AttachmentGroup } from './Attachment';
|
||||
import parseJsonField from './parseJsonField';
|
||||
import { langFromPath } from './ReadFileCall';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -85,12 +85,21 @@ function buildEditArgsPreview(args: ToolCallArgs): string {
|
|||
return formatEditPreview(edits);
|
||||
}
|
||||
|
||||
const oldText = parsed ? textValue(parsed.old_text) : parseJsonField(args, 'old_text');
|
||||
const newText = parsed ? textValue(parsed.new_text) : parseJsonField(args, 'new_text');
|
||||
if (!oldText && !newText) {
|
||||
return '';
|
||||
if (parsed) {
|
||||
const oldText = textValue(parsed.old_text);
|
||||
const newText = textValue(parsed.new_text);
|
||||
return oldText || newText ? formatEditPreview([{ oldText, newText }]) : '';
|
||||
}
|
||||
return formatEditPreview([{ oldText, newText }]);
|
||||
|
||||
/** Partial JSON during streaming: pair up field occurrences in document order, covering both single-replacement and batched `edits` args */
|
||||
const oldTexts = parseJsonFieldOccurrences(args, 'old_text');
|
||||
const newTexts = parseJsonFieldOccurrences(args, 'new_text');
|
||||
const editCount = Math.max(oldTexts.length, newTexts.length);
|
||||
const edits = Array.from({ length: editCount }, (_, index) => ({
|
||||
oldText: oldTexts[index] ?? '',
|
||||
newText: newTexts[index] ?? '',
|
||||
})).filter((edit) => edit.oldText || edit.newText);
|
||||
return formatEditPreview(edits);
|
||||
}
|
||||
|
||||
export default function FileAuthoringCall({
|
||||
|
|
@ -119,13 +128,16 @@ export default function FileAuthoringCall({
|
|||
const editArgsPreview = useMemo(() => buildEditArgsPreview(args), [args]);
|
||||
const fileName = filePath.split('/').pop() || filePath;
|
||||
const fileLang = useMemo(() => langFromPath(filePath), [filePath]);
|
||||
const argsPreview = isCreate ? authoredContent : editArgsPreview;
|
||||
const outputIsDiff = hasDiff(output);
|
||||
const preview = isCreate ? output || authoredContent : output || editArgsPreview;
|
||||
const previewIsDiff = outputIsDiff || (!isCreate && !!editArgsPreview && !output);
|
||||
/** A diff in the output supersedes the args preview — it carries the input with real file context */
|
||||
const preview = outputIsDiff ? output : argsPreview || output;
|
||||
const showOutputSection = !!output && preview !== output;
|
||||
const previewIsDiff = outputIsDiff || (!isCreate && !!editArgsPreview && preview !== output);
|
||||
let previewLang = 'plaintext';
|
||||
if (previewIsDiff) {
|
||||
previewLang = 'diff';
|
||||
} else if (isCreate && authoredContent && !output) {
|
||||
} else if (isCreate && authoredContent && preview === authoredContent) {
|
||||
previewLang = fileLang;
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +186,16 @@ export default function FileAuthoringCall({
|
|||
{highlighted ?? preview}
|
||||
</code>
|
||||
</pre>
|
||||
{showOutputSection && (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-[300px] overflow-auto whitespace-pre-wrap break-words border-t border-border-light px-3 py-2.5 font-mono text-xs',
|
||||
hasError ? 'text-red-600 dark:text-red-400' : 'text-text-primary',
|
||||
)}
|
||||
>
|
||||
{output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ describe('FileAuthoringCall', () => {
|
|||
expect(screen.getByText(/Use this skill for testing/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows completed create_file output instead of full content args', () => {
|
||||
it('keeps authored content visible alongside the output after create_file completes', () => {
|
||||
render(
|
||||
<FileAuthoringCall
|
||||
toolName="create_file"
|
||||
|
|
@ -92,15 +92,67 @@ describe('FileAuthoringCall', () => {
|
|||
isSubmitting={false}
|
||||
args={{
|
||||
file_path: 'skills/demo/SKILL.md',
|
||||
content: '# Demo\n\nLarge generated body should not stay mounted after completion.',
|
||||
content: '# Demo\n\nLarge generated body stays visible after completion.',
|
||||
}}
|
||||
output="Created skills/demo/SKILL.md (4096 chars)."
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('progress-text')).toHaveTextContent('Created SKILL.md');
|
||||
expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md');
|
||||
expect(screen.getByText(/Large generated body stays visible/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Created skills/demo/SKILL.md (4096 chars).')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Large generated body/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers the output diff over the args preview after edit_file completes', () => {
|
||||
const output = [
|
||||
'Edited skills/demo/SKILL.md (exact match).',
|
||||
'',
|
||||
'--- skills/demo/SKILL.md',
|
||||
'+++ skills/demo/SKILL.md',
|
||||
'@@ -1,1 +1,1 @@',
|
||||
'-old line',
|
||||
'+new line',
|
||||
].join('\n');
|
||||
render(
|
||||
<FileAuthoringCall
|
||||
toolName="edit_file"
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
args={{
|
||||
file_path: 'skills/demo/SKILL.md',
|
||||
old_text: 'old line',
|
||||
new_text: 'new line',
|
||||
}}
|
||||
output={output}
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
|
||||
expect(preview).toHaveTextContent('-old line');
|
||||
expect(preview).toHaveTextContent('+new line');
|
||||
expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the attempted input alongside the error output when edit_file fails', () => {
|
||||
render(
|
||||
<FileAuthoringCall
|
||||
toolName="edit_file"
|
||||
initialProgress={1}
|
||||
isSubmitting={false}
|
||||
args={{
|
||||
file_path: 'skills/demo/SKILL.md',
|
||||
old_text: 'missing text',
|
||||
new_text: 'replacement',
|
||||
}}
|
||||
output="Error: old_text matched 0 locations in skills/demo/SKILL.md."
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
|
||||
expect(preview).toHaveTextContent('-missing text');
|
||||
expect(preview).toHaveTextContent('+replacement');
|
||||
expect(screen.getByText(/matched 0 locations/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows edit_file replacement args while the call is in progress', () => {
|
||||
|
|
@ -127,6 +179,64 @@ describe('FileAuthoringCall', () => {
|
|||
expect(preview).toHaveTextContent('+description: New behavior');
|
||||
});
|
||||
|
||||
it('streams create_file content from partial JSON string args during run_step_delta', () => {
|
||||
render(
|
||||
<FileAuthoringCall
|
||||
toolName="create_file"
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
args={'{"file_path":"skills/demo/SKILL.md","content":"# Demo\\n\\nStreaming body so far'}
|
||||
output=""
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('progress-text')).toHaveTextContent('Creating SKILL.md');
|
||||
expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md');
|
||||
expect(screen.getByText(/Streaming body so far/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('streams edit_file replacement preview from partial JSON string args', () => {
|
||||
render(
|
||||
<FileAuthoringCall
|
||||
toolName="edit_file"
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
args={
|
||||
'{"file_path":"skills/demo/SKILL.md","old_text":"description: Old behavior","new_text":"description: New beh'
|
||||
}
|
||||
output=""
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
|
||||
expect(preview).toHaveTextContent('-description: Old behavior');
|
||||
expect(preview).toHaveTextContent('+description: New beh');
|
||||
});
|
||||
|
||||
it('streams batched edit_file previews from a partial edits array', () => {
|
||||
const args =
|
||||
'{"file_path":"skills/demo/SKILL.md","edits":[' +
|
||||
'{"old_text":"first old","new_text":"first new"},' +
|
||||
'{"old_text":"second old","new_text":"second n';
|
||||
render(
|
||||
<FileAuthoringCall
|
||||
toolName="edit_file"
|
||||
initialProgress={0.5}
|
||||
isSubmitting={true}
|
||||
args={args}
|
||||
output=""
|
||||
/>,
|
||||
);
|
||||
|
||||
const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code');
|
||||
expect(preview).toHaveTextContent('--- old_text 1');
|
||||
expect(preview).toHaveTextContent('-first old');
|
||||
expect(preview).toHaveTextContent('+first new');
|
||||
expect(preview).toHaveTextContent('--- old_text 2');
|
||||
expect(preview).toHaveTextContent('-second old');
|
||||
expect(preview).toHaveTextContent('+second n');
|
||||
});
|
||||
|
||||
it('shows batched edit_file replacements from edits args while the call is in progress', () => {
|
||||
render(
|
||||
<FileAuthoringCall
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import parseJsonField, { areToolCallArgsComplete } from '../parseJsonField';
|
||||
import parseJsonField, {
|
||||
parseJsonFieldOccurrences,
|
||||
areToolCallArgsComplete,
|
||||
} from '../parseJsonField';
|
||||
|
||||
describe('parseJsonField', () => {
|
||||
describe('object args', () => {
|
||||
|
|
@ -100,6 +103,71 @@ describe('parseJsonField', () => {
|
|||
expect(parseJsonField(partial, 'command')).toBe('tab\\there');
|
||||
});
|
||||
});
|
||||
|
||||
describe('in-progress streaming fields — unterminated values', () => {
|
||||
it('extracts a field whose closing quote has not streamed in yet', () => {
|
||||
const partial = '{"file_path":"skills/demo/SKILL.md","content":"# Demo\\nline two';
|
||||
expect(parseJsonField(partial, 'content')).toBe('# Demo\nline two');
|
||||
});
|
||||
|
||||
it('grows the extracted value as more deltas arrive', () => {
|
||||
const full = '{"file_path":"a.md","content":"# Title\\n\\nBody text here"}';
|
||||
const lengths = [40, 48, 56, full.length];
|
||||
const values = lengths.map((len) => parseJsonField(full.slice(0, len), 'content'));
|
||||
expect(values[values.length - 1]).toBe('# Title\n\nBody text here');
|
||||
values.slice(1).forEach((value, index) => {
|
||||
expect(value.startsWith(values[index])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a dangling backslash from a partially streamed escape', () => {
|
||||
const partial = '{"content":"line one\\';
|
||||
expect(parseJsonField(partial, 'content')).toBe('line one');
|
||||
});
|
||||
|
||||
it('unescapes a complete escaped backslash at the stream edge', () => {
|
||||
const partial = '{"content":"path C:\\\\';
|
||||
expect(parseJsonField(partial, 'content')).toBe('path C:\\');
|
||||
});
|
||||
|
||||
it('handles escaped quotes inside an unterminated value', () => {
|
||||
const partial = '{"command":"say \\"hi';
|
||||
expect(parseJsonField(partial, 'command')).toBe('say "hi');
|
||||
});
|
||||
|
||||
it('returns empty string when the value has not opened yet', () => {
|
||||
expect(parseJsonField('{"content":', 'content')).toBe('');
|
||||
expect(parseJsonField('{"content":"', 'content')).toBe('');
|
||||
});
|
||||
|
||||
it('does not run past a completed field into later args', () => {
|
||||
const partial = '{"old_text":"keep this","new_text":"and th';
|
||||
expect(parseJsonField(partial, 'old_text')).toBe('keep this');
|
||||
expect(parseJsonField(partial, 'new_text')).toBe('and th');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJsonFieldOccurrences', () => {
|
||||
it('returns empty array for non-string args', () => {
|
||||
expect(parseJsonFieldOccurrences(undefined, 'old_text')).toEqual([]);
|
||||
expect(parseJsonFieldOccurrences({ old_text: 'x' }, 'old_text')).toEqual([]);
|
||||
expect(parseJsonFieldOccurrences('', 'old_text')).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts repeated fields from a partial edits array in document order', () => {
|
||||
const partial =
|
||||
'{"file_path":"a.md","edits":[' +
|
||||
'{"old_text":"first old","new_text":"first new"},' +
|
||||
'{"old_text":"second old","new_text":"second n';
|
||||
expect(parseJsonFieldOccurrences(partial, 'old_text')).toEqual(['first old', 'second old']);
|
||||
expect(parseJsonFieldOccurrences(partial, 'new_text')).toEqual(['first new', 'second n']);
|
||||
});
|
||||
|
||||
it('extracts a single top-level field', () => {
|
||||
const partial = '{"file_path":"a.md","old_text":"only one';
|
||||
expect(parseJsonFieldOccurrences(partial, 'old_text')).toEqual(['only one']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('areToolCallArgsComplete', () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ export function areToolCallArgsComplete(args: ToolCallArgs): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
/** Matches `"field":"value"`, tolerating a missing closing quote and a dangling escape at the end of partially streamed args. */
|
||||
function fieldRegex(field: string, flags?: string): RegExp {
|
||||
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)(?:"|\\\\?$)`, flags);
|
||||
}
|
||||
|
||||
function unescapeJsonString(value: string): string {
|
||||
return value.replace(/\\(.)/g, (_, c: string) => {
|
||||
if (c === 'n') {
|
||||
return '\n';
|
||||
}
|
||||
if (c === '"' || c === '\\') {
|
||||
return c;
|
||||
}
|
||||
return `\\${c}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Extracts a string field from tool call args, handling object, JSON string, and partial-JSON fallback. */
|
||||
export default function parseJsonField(args: ToolCallArgs, field: string): string {
|
||||
if (typeof args === 'object' && args !== null) {
|
||||
|
|
@ -28,19 +46,17 @@ export default function parseJsonField(args: ToolCallArgs, field: string): strin
|
|||
} catch {
|
||||
// partial JSON during streaming; fall through to regex
|
||||
}
|
||||
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
|
||||
const match = args?.match(re);
|
||||
const match = args?.match(fieldRegex(field));
|
||||
if (!match) {
|
||||
return '';
|
||||
}
|
||||
return match[1].replace(/\\(.)/g, (_, c: string) => {
|
||||
if (c === 'n') {
|
||||
return '\n';
|
||||
}
|
||||
if (c === '"' || c === '\\') {
|
||||
return c;
|
||||
}
|
||||
return `\\${c}`;
|
||||
});
|
||||
return unescapeJsonString(match[1]);
|
||||
}
|
||||
|
||||
/** Extracts every occurrence of a string field from partially streamed JSON args, in document order. */
|
||||
export function parseJsonFieldOccurrences(args: ToolCallArgs, field: string): string[] {
|
||||
if (typeof args !== 'string' || args.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(args.matchAll(fieldRegex(field, 'g')), (match) => unescapeJsonString(match[1]));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue