From 9a0dc4e200ba9dac201535f2b6e3a7786492832a Mon Sep 17 00:00:00 2001 From: LE Quoc Dat Date: Mon, 28 Jul 2025 20:46:21 +0200 Subject: [PATCH] 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 --- backend/agent/tools/sb_files_tool.py | 11 +- backend/agentpress/response_processor.py | 81 +++--- .../thread/content/ShowToolStream.tsx | 17 +- .../file-operation/FileEditToolView.tsx | 233 ++++++++++++++++++ .../tool-views/file-operation/_utils.ts | 91 ++++++- .../tool-views/wrapper/ToolViewRegistry.tsx | 3 +- 6 files changed, 374 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/thread/tool-views/file-operation/FileEditToolView.tsx diff --git a/backend/agent/tools/sb_files_tool.py b/backend/agent/tools/sb_files_tool.py index 847f54b3..e0b66ac1 100644 --- a/backend/agent/tools/sb_files_tool.py +++ b/backend/agent/tools/sb_files_tool.py @@ -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) diff --git a/backend/agentpress/response_processor.py b/backend/agentpress/response_processor.py index 84059a77..ff2e7ea1 100644 --- a/backend/agentpress/response_processor.py +++ b/backend/agentpress/response_processor.py @@ -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) @@ -1707,7 +1719,12 @@ class ResponseProcessor: except Exception: # 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,53 +1734,11 @@ 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 diff --git a/frontend/src/components/thread/content/ShowToolStream.tsx b/frontend/src/components/thread/content/ShowToolStream.tsx index bd76d7de..a393c293 100644 --- a/frontend/src/components/thread/content/ShowToolStream.tsx +++ b/frontend/src/components/thread/content/ShowToolStream.tsx @@ -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 = ({ } 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(/([\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 = ({ 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 (
{shouldShowContent ? ( @@ -126,7 +139,7 @@ export const ShowToolStream: React.FC = ({ WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)' }} > - {content} + {isEditFile ? codeEditContent : content}
{/* Top gradient */}
= ({ oldCode, newCode }) => ( + +); + +const SplitDiffView: React.FC<{ oldCode: string; newCode: string }> = ({ oldCode, newCode }) => ( + +); + +const ErrorState: React.FC = () => ( +
+
+ +

+ Invalid File Edit +

+

+ Could not extract the file changes from the tool result. +

+
+
+); + +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 ( + + +
+
+
+ +
+ + {toolTitle} + +
+ + {!isStreaming && ( + + {actualIsSuccess ? ( + + ) : ( + + )} + {actualIsSuccess ? 'Edit applied' : 'Edit failed'} + + )} +
+
+ + + {isStreaming ? ( + + ) : shouldShowError ? ( + + ) : ( +
+
+
+ + + {filePath || 'Unknown file'} + +
+ +
+
+
+ + {stats.additions} +
+
+ + {stats.deletions} +
+
+ setViewMode(v as 'unified' | 'split')} className="w-auto"> + + Unified + Split + + +
+
+ + {viewMode === 'unified' ? ( + + ) : ( + + )} + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/thread/tool-views/file-operation/_utils.ts b/frontend/src/components/thread/tool-views/file-operation/_utils.ts index d9519cc4..cb6a735c 100644 --- a/frontend/src/components/thread/tool-views/file-operation/_utils.ts +++ b/frontend/src/components/thread/tool-views/file-operation/_utils.ts @@ -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'; diff --git a/frontend/src/components/thread/tool-views/wrapper/ToolViewRegistry.tsx b/frontend/src/components/thread/tool-views/wrapper/ToolViewRegistry.tsx index 5ea4afcc..5484cea8 100644 --- a/frontend/src/components/thread/tool-views/wrapper/ToolViewRegistry.tsx +++ b/frontend/src/components/thread/tool-views/wrapper/ToolViewRegistry.tsx @@ -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,