AI: can we streamline the edit-file just like the create_file tool, in the front end ?

like stream the code-edit output of the agent; Then we should be able to show the diff as well, when the tool complete. We get the original file content, and the full updated code (output of morph) and some how send it to the front end.

the front end should show this properly , concisely so user can see the changes in green / red.

This shouldn't change the content feed to the model btw. Like it will pollute the context.

make a plan what to do first, not make changes yet
This commit is contained in:
LE Quoc Dat 2025-07-28 20:46:21 +02:00
parent 2608ad2ef1
commit 9a0dc4e200
6 changed files with 374 additions and 62 deletions

View File

@ -525,10 +525,13 @@ def authenticate_user(username, password):
# AI editing successful
await self.sandbox.fs.upload_file(new_content.encode(), full_path)
message = f"File '{target_file}' edited successfully."
return self.success_response(message)
# Return rich data for frontend diff view
return self.success_response({
"message": f"File '{target_file}' edited successfully.",
"file_path": target_file,
"original_content": original_content,
"updated_content": new_content
})
except Exception as e:
logger.error(f"Unhandled error in edit_file: {str(e)}", exc_info=True)

View File

@ -1637,23 +1637,35 @@ class ResponseProcessor:
# Determine message role based on strategy
result_role = "user" if strategy == "user_message" else "assistant"
# Create the new structured tool result format
structured_result = self._create_structured_tool_result(tool_call, result, parsing_details)
# Create two versions of the structured result
# 1. Rich version for the frontend
structured_result_for_frontend = self._create_structured_tool_result(tool_call, result, parsing_details, for_llm=False)
# 2. Concise version for the LLM
structured_result_for_llm = self._create_structured_tool_result(tool_call, result, parsing_details, for_llm=True)
# Add the message with the appropriate role to the conversation history
# This allows the LLM to see the tool result in subsequent interactions
result_message = {
result_message_for_llm = {
"role": result_role,
"content": json.dumps(structured_result)
"content": json.dumps(structured_result_for_llm)
}
message_obj = await self.add_message(
thread_id=thread_id,
type="tool",
content=result_message,
content=result_message_for_llm, # Save the LLM-friendly version
is_llm_message=True,
metadata=metadata
)
return message_obj # Return the full message object
# If the message was saved, modify it in-memory for the frontend before returning
if message_obj:
result_message_for_frontend = {
"role": result_role,
"content": json.dumps(structured_result_for_frontend)
}
message_obj['content'] = result_message_for_frontend
return message_obj # Return the modified message object
except Exception as e:
logger.error(f"Error adding tool result: {str(e)}", exc_info=True)
self.trace.event(name="error_adding_tool_result", level="ERROR", status_message=(f"Error adding tool result: {str(e)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details})
@ -1676,13 +1688,14 @@ class ResponseProcessor:
self.trace.event(name="failed_even_with_fallback_message", level="ERROR", status_message=(f"Failed even with fallback message: {str(e2)}"), metadata={"tool_call": tool_call, "result": result, "strategy": strategy, "assistant_message_id": assistant_message_id, "parsing_details": parsing_details})
return None # Return None on error
def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: ToolResult, parsing_details: Optional[Dict[str, Any]] = None):
def _create_structured_tool_result(self, tool_call: Dict[str, Any], result: ToolResult, parsing_details: Optional[Dict[str, Any]] = None, for_llm: bool = False):
"""Create a structured tool result format that's tool-agnostic and provides rich information.
Args:
tool_call: The original tool call that was executed
result: The result from the tool execution
parsing_details: Optional parsing details for XML calls
for_llm: If True, creates a concise version for the LLM context.
Returns:
Structured dictionary containing tool execution information
@ -1692,7 +1705,6 @@ class ResponseProcessor:
xml_tag_name = tool_call.get("xml_tag_name")
arguments = tool_call.get("arguments", {})
tool_call_id = tool_call.get("id")
logger.info(f"Creating structured tool result for tool_call: {tool_call}")
# Process the output - if it's a JSON string, parse it back to an object
output = result.output if hasattr(result, 'output') else str(result)
@ -1708,6 +1720,11 @@ class ResponseProcessor:
# If parsing fails, keep the original string
pass
# If this is for the LLM and it's an edit_file tool, create a concise output
if for_llm and function_name == 'edit_file' and isinstance(output, dict):
output_for_llm = {"message": output.get("message", "File edited successfully.")}
output = output_for_llm
# Create the structured result
structured_result_v1 = {
"tool_execution": {
@ -1717,54 +1734,12 @@ class ResponseProcessor:
"arguments": arguments,
"result": {
"success": result.success if hasattr(result, 'success') else True,
"output": output, # Now properly structured for frontend
"output": output, # This will be either rich or concise based on `for_llm`
"error": getattr(result, 'error', None) if hasattr(result, 'error') else None
},
# "execution_details": {
# "timestamp": datetime.now(timezone.utc).isoformat(),
# "parsing_details": parsing_details
# }
}
}
# STRUCTURED_OUTPUT_TOOLS = {
# "str_replace",
# "get_data_provider_endpoints",
# }
# summary_output = result.output if hasattr(result, 'output') else str(result)
# if xml_tag_name:
# status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed"
# summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}"
# else:
# status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed"
# summary = f"Function '{function_name}' {status}. Output: {summary_output}"
# if self.is_agent_builder:
# return summary
# if function_name in STRUCTURED_OUTPUT_TOOLS:
# return structured_result_v1
# else:
# return summary
summary_output = result.output if hasattr(result, 'output') else str(result)
success_status = structured_result_v1["tool_execution"]["result"]["success"]
# # Create a more comprehensive summary for the LLM
# if xml_tag_name:
# status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed"
# summary = f"Tool '{xml_tag_name}' {status}. Output: {summary_output}"
# else:
# status = "completed successfully" if structured_result_v1["tool_execution"]["result"]["success"] else "failed"
# summary = f"Function '{function_name}' {status}. Output: {summary_output}"
# if self.is_agent_builder:
# return summary
# elif function_name == "get_data_provider_endpoints":
# logger.info(f"Returning sumnary for data provider call: {summary}")
# return summary
return structured_result_v1
def _create_tool_context(self, tool_call: Dict[str, Any], tool_index: int, assistant_message_id: Optional[str] = None, parsing_details: Optional[Dict[str, Any]] = None) -> ToolExecutionContext:

View File

@ -9,6 +9,7 @@ const FILE_OPERATION_TOOLS = new Set([
'Delete File',
'Full File Rewrite',
'Read File',
'AI File Edit',
]);
interface ShowToolStreamProps {
@ -38,6 +39,18 @@ export const ShowToolStream: React.FC<ShowToolStreamProps> = ({
}
const toolName = extractToolNameFromStream(content);
const isEditFile = toolName === 'AI File Edit';
// Extract code_edit content for streaming
const codeEditContent = React.useMemo(() => {
if (!isEditFile || !content) return '';
const match = content.match(/<code_edit>([\s\S]*)/);
if (match) {
// Remove closing tag if present
return match[1].replace(/<\/code_edit>[\s\S]*$/, '');
}
return '';
}, [content, isEditFile]);
// Time-based logic - show streaming content after 1500ms
useEffect(() => {
@ -97,7 +110,7 @@ export const ShowToolStream: React.FC<ShowToolStreamProps> = ({
const paramDisplay = extractPrimaryParam(toolName, content);
// Always show tool button, conditionally show content below for file operations only
if (showExpanded && isFileOperationTool) {
if (showExpanded && (isFileOperationTool || isEditFile)) {
return (
<div className="my-1">
{shouldShowContent ? (
@ -126,7 +139,7 @@ export const ShowToolStream: React.FC<ShowToolStreamProps> = ({
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)'
}}
>
{content}
{isEditFile ? codeEditContent : content}
</div>
{/* Top gradient */}
<div className={`absolute top-0 left-0 right-0 h-8 pointer-events-none transition-all duration-500 ease-in-out ${shouldShowContent

View File

@ -0,0 +1,233 @@
import React, { useState } from 'react';
import {
FileDiff,
CheckCircle,
AlertTriangle,
Loader2,
File,
ChevronDown,
ChevronUp,
Minus,
Plus,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
extractFileEditData,
generateLineDiff,
calculateDiffStats,
LineDiff,
DiffStats
} from './_utils';
import { formatTimestamp, getToolTitle } from '../utils';
import { ToolViewProps } from '../types';
import { LoadingState } from '../shared/LoadingState';
import ReactDiffViewer from 'react-diff-viewer-continued';
const UnifiedDiffView: React.FC<{ oldCode: string; newCode: string }> = ({ oldCode, newCode }) => (
<ReactDiffViewer
oldValue={oldCode}
newValue={newCode}
splitView={false}
useDarkTheme={document.documentElement.classList.contains('dark')}
styles={{
variables: {
dark: {
color: '#e2e8f0',
background: '#09090b',
addedBackground: '#104a32',
addedColor: '#6ee7b7',
removedBackground: '#5c1a2e',
removedColor: '#fca5a5',
},
},
diffContainer: {
backgroundColor: 'var(--card)',
border: 'none',
},
gutter: {
backgroundColor: 'var(--muted)',
'&:hover': {
backgroundColor: 'var(--accent)',
},
},
line: {
fontFamily: 'monospace',
},
}}
/>
);
const SplitDiffView: React.FC<{ oldCode: string; newCode: string }> = ({ oldCode, newCode }) => (
<ReactDiffViewer
oldValue={oldCode}
newValue={newCode}
splitView={true}
useDarkTheme={document.documentElement.classList.contains('dark')}
styles={{
variables: {
dark: {
color: '#e2e8f0',
background: '#09090b',
addedBackground: '#104a32',
addedColor: '#6ee7b7',
removedBackground: '#5c1a2e',
removedColor: '#fca5a5',
},
},
diffContainer: {
backgroundColor: 'var(--card)',
border: 'none',
},
gutter: {
backgroundColor: 'var(--muted)',
'&:hover': {
backgroundColor: 'var(--accent)',
},
},
line: {
fontFamily: 'monospace',
},
}}
/>
);
const ErrorState: React.FC = () => (
<div className="flex flex-col items-center justify-center h-full py-12 px-6 bg-gradient-to-b from-white to-zinc-50 dark:from-zinc-950 dark:to-zinc-900">
<div className="text-center w-full max-w-xs">
<AlertTriangle className="h-16 w-16 mx-auto mb-6 text-amber-500" />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-2">
Invalid File Edit
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
Could not extract the file changes from the tool result.
</p>
</div>
</div>
);
export function FileEditToolView({
name = 'edit-file',
assistantContent,
toolContent,
assistantTimestamp,
toolTimestamp,
isSuccess = true,
isStreaming = false,
}: ToolViewProps): JSX.Element {
const [viewMode, setViewMode] = useState<'unified' | 'split'>('unified');
const {
filePath,
originalContent,
updatedContent,
actualIsSuccess,
actualToolTimestamp,
} = extractFileEditData(
assistantContent,
toolContent,
isSuccess,
toolTimestamp,
assistantTimestamp
);
const toolTitle = getToolTitle(name);
const lineDiff = originalContent && updatedContent ? generateLineDiff(originalContent, updatedContent) : [];
const stats: DiffStats = calculateDiffStats(lineDiff);
const shouldShowError = !isStreaming && (!originalContent || !updatedContent);
return (
<Card className="gap-0 flex border shadow-none border-t border-b-0 border-x-0 p-0 rounded-none flex-col h-full overflow-hidden bg-card">
<CardHeader className="h-14 bg-zinc-50/80 dark:bg-zinc-900/80 backdrop-blur-sm border-b p-2 px-4 space-y-2">
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<div className="relative p-2 rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20">
<FileDiff className="w-5 h-5 text-blue-500 dark:text-blue-400" />
</div>
<CardTitle className="text-base font-medium text-zinc-900 dark:text-zinc-100">
{toolTitle}
</CardTitle>
</div>
{!isStreaming && (
<Badge
variant="secondary"
className={
actualIsSuccess
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
}
>
{actualIsSuccess ? (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
) : (
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
)}
{actualIsSuccess ? 'Edit applied' : 'Edit failed'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="p-0 h-full flex-1 overflow-hidden relative">
{isStreaming ? (
<LoadingState
icon={FileDiff}
iconColor="text-blue-500 dark:text-blue-400"
bgColor="bg-gradient-to-b from-blue-100 to-blue-50 shadow-inner dark:from-blue-800/40 dark:to-blue-900/60 dark:shadow-blue-950/20"
title="Applying File Edit"
filePath={filePath || 'Processing file...'}
progressText="Analyzing changes"
subtitle="Please wait while the file is being modified"
/>
) : shouldShowError ? (
<ErrorState />
) : (
<div className="h-full flex flex-col">
<div className="p-3 border-b border-zinc-200 dark:border-zinc-800 bg-accent flex items-center justify-between">
<div className="flex items-center">
<File className="h-4 w-4 mr-2 text-zinc-500 dark:text-zinc-400" />
<code className="text-sm font-mono text-zinc-700 dark:text-zinc-300">
{filePath || 'Unknown file'}
</code>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center text-xs text-zinc-500 dark:text-zinc-400 gap-3">
<div className="flex items-center">
<Plus className="h-3.5 w-3.5 text-emerald-500 mr-1" />
<span>{stats.additions}</span>
</div>
<div className="flex items-center">
<Minus className="h-3.5 w-3.5 text-red-500 mr-1" />
<span>{stats.deletions}</span>
</div>
</div>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'unified' | 'split')} className="w-auto">
<TabsList className="h-7 p-0.5">
<TabsTrigger value="unified" className="text-xs h-6 px-2">Unified</TabsTrigger>
<TabsTrigger value="split" className="text-xs h-6 px-2">Split</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<ScrollArea className="flex-1">
{viewMode === 'unified' ? (
<UnifiedDiffView oldCode={originalContent!} newCode={updatedContent!} />
) : (
<SplitDiffView oldCode={originalContent!} newCode={updatedContent!} />
)}
</ScrollArea>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -1,6 +1,6 @@
import { LucideIcon, FilePen, Replace, Trash2, FileCode, FileSpreadsheet, File } from 'lucide-react';
export type FileOperation = 'create' | 'rewrite' | 'delete' | 'edit';
export type FileOperation = 'create' | 'rewrite' | 'delete' | 'edit' | 'str-replace';
export interface OperationConfig {
icon: LucideIcon;
@ -77,12 +77,99 @@ export const getLanguageFromFileName = (fileName: string): string => {
return extensionMap[extension] || 'text';
};
export interface ExtractedEditData {
filePath: string | null;
originalContent: string | null;
updatedContent: string | null;
success?: boolean;
timestamp?: string;
}
export const extractFileEditData = (
assistantContent: any,
toolContent: any,
isSuccess: boolean,
toolTimestamp?: string,
assistantTimestamp?: string
): {
filePath: string | null;
originalContent: string | null;
updatedContent: string | null;
actualIsSuccess: boolean;
actualToolTimestamp?: string;
actualAssistantTimestamp?: string;
} => {
let filePath: string | null = null;
let originalContent: string | null = null;
let updatedContent: string | null = null;
let actualIsSuccess = isSuccess;
let actualToolTimestamp = toolTimestamp;
let actualAssistantTimestamp = assistantTimestamp;
const parseOutput = (output: any) => {
if (typeof output === 'string') {
try {
return JSON.parse(output);
} catch {
return null;
}
}
return output;
};
const extractData = (content: any) => {
const parsed = typeof content === 'string' ? parseContent(content) : content;
if (parsed?.tool_execution) {
const args = parsed.tool_execution.arguments || {};
const output = parseOutput(parsed.tool_execution.result?.output);
return {
filePath: args.target_file || output?.file_path || null,
originalContent: output?.original_content || null,
updatedContent: output?.updated_content || null,
success: parsed.tool_execution.result?.success,
timestamp: parsed.tool_execution.execution_details?.timestamp,
};
}
return {};
};
const toolData = extractData(toolContent);
const assistantData = extractData(assistantContent);
filePath = toolData.filePath || assistantData.filePath;
originalContent = toolData.originalContent || assistantData.originalContent;
updatedContent = toolData.updatedContent || assistantData.updatedContent;
if (toolData.success !== undefined) {
actualIsSuccess = toolData.success;
actualToolTimestamp = toolData.timestamp || toolTimestamp;
} else if (assistantData.success !== undefined) {
actualIsSuccess = assistantData.success;
actualAssistantTimestamp = assistantData.timestamp || assistantTimestamp;
}
return { filePath, originalContent, updatedContent, actualIsSuccess, actualToolTimestamp, actualAssistantTimestamp };
};
const parseContent = (content: any): any => {
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (e) {
return content;
}
}
return content;
};
export const getOperationType = (name?: string, assistantContent?: any): FileOperation => {
if (name) {
if (name.includes('create')) return 'create';
if (name.includes('rewrite')) return 'rewrite';
if (name.includes('delete')) return 'delete';
if (name.includes('edit')) return 'edit';
if (name.includes('edit-file')) return 'edit'; // Specific for edit_file
if (name.includes('str-replace')) return 'str-replace';
}
if (!assistantContent) return 'create';

View File

@ -5,6 +5,7 @@ import { BrowserToolView } from '../BrowserToolView';
import { CommandToolView } from '../command-tool/CommandToolView';
import { ExposePortToolView } from '../expose-port-tool/ExposePortToolView';
import { FileOperationToolView } from '../file-operation/FileOperationToolView';
import { FileEditToolView } from '../file-operation/FileEditToolView';
import { StrReplaceToolView } from '../str-replace/StrReplaceToolView';
import { WebCrawlToolView } from '../WebCrawlToolView';
import { WebScrapeToolView } from '../web-scrape-tool/WebScrapeToolView';
@ -56,7 +57,7 @@ const defaultRegistry: ToolViewRegistryType = {
'delete-file': FileOperationToolView,
'full-file-rewrite': FileOperationToolView,
'read-file': FileOperationToolView,
'edit-file': FileOperationToolView,
'edit-file': FileEditToolView,
'str-replace': StrReplaceToolView,