feat(ui): 添加文件 Diff 查看功能
当 AI 执行 write_file 或 edit_file 工具时,在工具结果中显示 View Diff 按钮, 点击后在 IDE 面板中显示文件修改前后的对比视图。 主要改动: - core: edit_file/write_file 工具返回 fileDiff 元数据 - ui: 新增 DiffEditor 组件用于显示文件差异 - ui: ChatMessage 添加 View Diff 按钮 - ui: IDE 组件支持 Diff 视图切换 - ui: useChat hook 处理 fileDiff 回调
This commit is contained in:
@@ -123,9 +123,22 @@ export const editFileTool: ToolWithMetadata = {
|
||||
output += `\n\n⚠️ 代码检查发现问题,请修复:${result.diagnostics}`;
|
||||
}
|
||||
|
||||
// 构建 diff 元数据
|
||||
const fileName = path.basename(absolutePath);
|
||||
const metadata: Record<string, unknown> = {
|
||||
fileDiff: {
|
||||
path: absolutePath,
|
||||
name: fileName,
|
||||
originalContent: result.originalContent ?? '',
|
||||
newContent: result.newContent ?? '',
|
||||
operation: 'edit',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output,
|
||||
metadata,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -92,9 +92,22 @@ export const writeFileTool: ToolWithMetadata = {
|
||||
output += `\n\n⚠️ 代码检查发现问题,请修复:${result.diagnostics}`;
|
||||
}
|
||||
|
||||
// 构建 diff 元数据
|
||||
const fileName = path.basename(absolutePath);
|
||||
const metadata: Record<string, unknown> = {
|
||||
fileDiff: {
|
||||
path: absolutePath,
|
||||
name: fileName,
|
||||
originalContent: result.originalContent ?? '',
|
||||
newContent: result.newContent ?? content,
|
||||
operation: 'write',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output,
|
||||
metadata,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/merge": "^6.10.2",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -39,6 +41,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@@ -153,6 +153,8 @@ export type {
|
||||
SystemCommandListResponse,
|
||||
// Active file types
|
||||
ActiveFileInfo,
|
||||
// File diff types
|
||||
FileDiffInfo,
|
||||
} from './types.js';
|
||||
|
||||
// API Configuration
|
||||
|
||||
@@ -57,6 +57,8 @@ export interface ToolMessagePart {
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
/** 文件 Diff 信息(write_file/edit_file 工具) */
|
||||
fileDiff?: FileDiffInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1158,3 +1160,21 @@ export interface ActiveFileInfo {
|
||||
language: string;
|
||||
}
|
||||
|
||||
// ============ 文件 Diff 相关 ============
|
||||
|
||||
/** 文件 Diff 信息(用于编辑器显示) */
|
||||
export interface FileDiffInfo {
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 文件名 */
|
||||
name: string;
|
||||
/** 原始内容(修改前) */
|
||||
originalContent: string;
|
||||
/** 新内容(修改后) */
|
||||
newContent: string;
|
||||
/** 操作类型 */
|
||||
operation: 'write' | 'edit';
|
||||
/** 工具调用 ID */
|
||||
toolCallId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
GitCompare,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState, forwardRef } from 'react';
|
||||
@@ -22,7 +23,7 @@ import { fadeInUp, smoothTransition } from '../utils/animations';
|
||||
import { getAgentDisplayName } from '../utils/agent';
|
||||
import { Markdown } from './Markdown';
|
||||
import { FileMentionText } from './FileMentionTag';
|
||||
import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart, QuestionMessagePart } from '../api/types.js';
|
||||
import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart, QuestionMessagePart, FileDiffInfo } from '../api/types.js';
|
||||
import { AskUserQuestion } from './AskUserQuestion.js';
|
||||
|
||||
interface ChatMessageProps {
|
||||
@@ -31,10 +32,12 @@ interface ChatMessageProps {
|
||||
isStreaming?: boolean;
|
||||
/** 回答问题的回调(用于 ask_user_question 工具) */
|
||||
onAnswerQuestion?: (questionPartId: string, answers: string[]) => void;
|
||||
/** 查看文件 Diff 的回调 */
|
||||
onViewDiff?: (diff: FileDiffInfo) => void;
|
||||
}
|
||||
|
||||
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||
({ message, isStreaming = false, onAnswerQuestion }, ref) => {
|
||||
({ message, isStreaming = false, onAnswerQuestion, onViewDiff }, ref) => {
|
||||
const isUser = message.role === 'user';
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -83,7 +86,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||
</div>
|
||||
);
|
||||
case 'tool':
|
||||
return <ToolPartItem key={part.id} part={part} />;
|
||||
return <ToolPartItem key={part.id} part={part} onViewDiff={onViewDiff} />;
|
||||
case 'question': {
|
||||
// 问题组件:即使在流式输出时也允许用户回答(除非已回答)
|
||||
const questionPart = part as QuestionMessagePart;
|
||||
@@ -250,15 +253,21 @@ export function TypingIndicator({ agentName }: TypingIndicatorProps) {
|
||||
*/
|
||||
interface ToolPartItemProps {
|
||||
part: ToolMessagePart;
|
||||
/** 查看文件 Diff 的回调 */
|
||||
onViewDiff?: (diff: FileDiffInfo) => void;
|
||||
}
|
||||
|
||||
function ToolPartItem({ part }: ToolPartItemProps) {
|
||||
function ToolPartItem({ part, onViewDiff }: ToolPartItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDetails =
|
||||
Object.keys(part.arguments).length > 0 ||
|
||||
part.result !== undefined ||
|
||||
part.error !== undefined;
|
||||
|
||||
// 判断是否为文件操作工具且有 diff 数据
|
||||
const isFileOperation = part.toolName === 'write_file' || part.toolName === 'edit_file';
|
||||
const hasDiff = isFileOperation && part.fileDiff && part.status === 'completed';
|
||||
|
||||
return (
|
||||
<div className="border border-line rounded-lg overflow-hidden bg-surface-subtle/30">
|
||||
{/* 头部:工具名称、状态、时长 */}
|
||||
@@ -278,6 +287,21 @@ function ToolPartItem({ part }: ToolPartItemProps) {
|
||||
{part.duration && (
|
||||
<span className="text-xs text-fg-subtle">{formatDuration(part.duration)}</span>
|
||||
)}
|
||||
{/* View Diff 按钮 */}
|
||||
{hasDiff && onViewDiff && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDiff(part.fileDiff!);
|
||||
}}
|
||||
className="px-2 py-0.5 text-xs bg-primary-500/10 text-primary-400 rounded hover:bg-primary-500/20 transition-colors"
|
||||
>
|
||||
<GitCompare size={12} className="inline mr-1" />
|
||||
View Diff
|
||||
</motion.button>
|
||||
)}
|
||||
{hasDetails && (
|
||||
<span className="text-fg-subtle">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* DiffEditor Component
|
||||
*
|
||||
* 基于 CodeMirror Merge View 的 Diff 编辑器
|
||||
* 用于显示文件修改前后的对比
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { MergeView } from '@codemirror/merge';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { X, FileCode, GitCompare } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '../utils/cn.js';
|
||||
import { useTheme } from '../hooks/useTheme.js';
|
||||
import type { FileDiffInfo } from '../api/types.js';
|
||||
|
||||
interface DiffEditorProps {
|
||||
/** Diff 信息 */
|
||||
diff: FileDiffInfo;
|
||||
/** 关闭回调 */
|
||||
onClose?: () => void;
|
||||
/** 容器类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 根据文件扩展名获取语言
|
||||
function getLanguageExtension(filename: string) {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
switch (ext) {
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return javascript({ jsx: true });
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return javascript({ jsx: true, typescript: true });
|
||||
case 'py':
|
||||
return python();
|
||||
case 'json':
|
||||
return json();
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return html();
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return css();
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return markdown();
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 计算变更统计
|
||||
function computeChangeStats(original: string, modified: string) {
|
||||
const originalLines = original.split('\n');
|
||||
const modifiedLines = modified.split('\n');
|
||||
|
||||
// 简单统计:计算行数差异
|
||||
const additions = Math.max(0, modifiedLines.length - originalLines.length);
|
||||
const deletions = Math.max(0, originalLines.length - modifiedLines.length);
|
||||
|
||||
// 更精确的统计需要实际的 diff 算法
|
||||
// 这里用简化版本
|
||||
let changed = 0;
|
||||
const minLen = Math.min(originalLines.length, modifiedLines.length);
|
||||
for (let i = 0; i < minLen; i++) {
|
||||
if (originalLines[i] !== modifiedLines[i]) {
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
additions: additions + changed,
|
||||
deletions: deletions + changed,
|
||||
totalLines: modifiedLines.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function DiffEditor({ diff, onClose, className }: DiffEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mergeViewRef = useRef<MergeView | null>(null);
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// 语言扩展
|
||||
const languageExtension = useMemo(
|
||||
() => getLanguageExtension(diff.name),
|
||||
[diff.name]
|
||||
);
|
||||
|
||||
// 变更统计
|
||||
const stats = useMemo(
|
||||
() => computeChangeStats(diff.originalContent, diff.newContent),
|
||||
[diff.originalContent, diff.newContent]
|
||||
);
|
||||
|
||||
// ESC 键关闭 Diff 视图
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && onClose) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// 创建 MergeView
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// 清理之前的实例
|
||||
if (mergeViewRef.current) {
|
||||
mergeViewRef.current.destroy();
|
||||
}
|
||||
|
||||
// 创建基础扩展
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
EditorView.editable.of(false), // 只读模式
|
||||
EditorState.readOnly.of(true),
|
||||
...(Array.isArray(languageExtension) ? languageExtension : [languageExtension]),
|
||||
];
|
||||
|
||||
// 深色主题
|
||||
if (resolvedTheme === 'dark') {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
// 创建 MergeView
|
||||
const mergeView = new MergeView({
|
||||
a: {
|
||||
doc: diff.originalContent,
|
||||
extensions,
|
||||
},
|
||||
b: {
|
||||
doc: diff.newContent,
|
||||
extensions,
|
||||
},
|
||||
parent: containerRef.current,
|
||||
collapseUnchanged: { margin: 3, minSize: 4 },
|
||||
gutter: true,
|
||||
highlightChanges: true,
|
||||
});
|
||||
|
||||
mergeViewRef.current = mergeView;
|
||||
|
||||
return () => {
|
||||
mergeView.destroy();
|
||||
};
|
||||
}, [diff.originalContent, diff.newContent, languageExtension, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-line bg-surface-subtle">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 文件图标和名称 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GitCompare size={16} className="text-primary-500" />
|
||||
<span className="font-medium text-fg">{diff.name}</span>
|
||||
<span className="text-xs text-fg-muted">({diff.operation === 'write' ? 'Write' : 'Edit'})</span>
|
||||
</div>
|
||||
|
||||
{/* 变更统计 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-green-500">+{stats.additions}</span>
|
||||
<span className="text-red-500">-{stats.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 关闭按钮 */}
|
||||
{onClose && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded hover:bg-surface-muted transition-colors"
|
||||
title="关闭 Diff 视图"
|
||||
>
|
||||
<X size={16} className="text-fg-muted" />
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签栏 */}
|
||||
<div className="flex border-b border-line bg-surface-subtle">
|
||||
<div className="flex-1 px-4 py-2 text-sm text-fg-muted border-r border-line">
|
||||
<FileCode size={14} className="inline mr-2" />
|
||||
Original
|
||||
</div>
|
||||
<div className="flex-1 px-4 py-2 text-sm text-fg-muted">
|
||||
<FileCode size={14} className="inline mr-2" />
|
||||
Modified
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff 视图 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ minHeight: 0 }}
|
||||
/>
|
||||
|
||||
{/* 路径提示 */}
|
||||
<div className="px-4 py-1.5 border-t border-line bg-surface-subtle text-xs text-fg-muted truncate">
|
||||
{diff.path}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,14 @@
|
||||
* 整合文件浏览器和代码编辑器的 IDE 组件
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '../utils/cn.js';
|
||||
import { readFile, getWorkingDirectory } from '../api/client.js';
|
||||
import { FileExplorer } from './FileExplorer.js';
|
||||
import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js';
|
||||
import type { ActiveFileInfo } from '../api/types.js';
|
||||
import { DiffEditor } from './DiffEditor.js';
|
||||
import type { ActiveFileInfo, FileDiffInfo } from '../api/types.js';
|
||||
|
||||
// localStorage 键名
|
||||
const STORAGE_KEY_TABS = 'ai-assistant-editor-tabs';
|
||||
@@ -28,12 +29,28 @@ interface IDEProps {
|
||||
sidebarWidth?: number;
|
||||
/** 当前活动文件变化回调 */
|
||||
onActiveFileChange?: (file: ActiveFileInfo | null) => void;
|
||||
/** 要显示的 Diff 信息(外部传入) */
|
||||
pendingDiff?: FileDiffInfo | null;
|
||||
/** Diff 关闭回调 */
|
||||
onDiffClose?: () => void;
|
||||
}
|
||||
|
||||
export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEProps) {
|
||||
/** IDE 组件暴露的方法 */
|
||||
export interface IDEHandle {
|
||||
/** 显示文件 diff */
|
||||
showDiff: (diff: FileDiffInfo) => void;
|
||||
/** 关闭 diff 视图 */
|
||||
closeDiff: () => void;
|
||||
}
|
||||
|
||||
export const IDE = forwardRef<IDEHandle, IDEProps>(function IDE(
|
||||
{ className, sidebarWidth = 256, onActiveFileChange, pendingDiff, onDiffClose },
|
||||
ref
|
||||
) {
|
||||
const [tabs, setTabs] = useState<EditorTab[]>([]);
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [workingDirectory, setWorkingDirectory] = useState<string>('');
|
||||
const [currentDiff, setCurrentDiff] = useState<FileDiffInfo | null>(null);
|
||||
const isRestoringTabs = useRef(false);
|
||||
const hasRestoredTabs = useRef(false);
|
||||
|
||||
@@ -235,6 +252,47 @@ export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEPr
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 显示 diff 视图
|
||||
const showDiff = useCallback((diff: FileDiffInfo) => {
|
||||
setCurrentDiff(diff);
|
||||
|
||||
// 如果文件已在编辑器中打开,更新其内容
|
||||
setTabs((prev) =>
|
||||
prev.map((tab) => {
|
||||
if (tab.path === diff.path) {
|
||||
return {
|
||||
...tab,
|
||||
content: diff.newContent,
|
||||
originalContent: diff.newContent,
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 关闭 diff 视图
|
||||
const closeDiff = useCallback(() => {
|
||||
setCurrentDiff(null);
|
||||
onDiffClose?.();
|
||||
}, [onDiffClose]);
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
showDiff,
|
||||
closeDiff,
|
||||
}), [showDiff, closeDiff]);
|
||||
|
||||
// 处理外部传入的 pendingDiff
|
||||
useEffect(() => {
|
||||
if (pendingDiff) {
|
||||
showDiff(pendingDiff);
|
||||
}
|
||||
}, [pendingDiff, showDiff]);
|
||||
|
||||
// 判断是否显示 diff 视图
|
||||
const showDiffView = currentDiff !== null;
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full', className)}>
|
||||
{/* 文件浏览器 */}
|
||||
@@ -245,17 +303,24 @@ export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEPr
|
||||
<FileExplorer onFileSelect={handleFileSelect} workingDirectory={workingDirectory} />
|
||||
</div>
|
||||
|
||||
{/* 代码编辑器 */}
|
||||
{/* 代码编辑器 / Diff 视图 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<CodeEditor
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onTabChange={handleTabChange}
|
||||
onTabClose={handleTabClose}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
{showDiffView ? (
|
||||
<DiffEditor
|
||||
diff={currentDiff}
|
||||
onClose={closeDiff}
|
||||
/>
|
||||
) : (
|
||||
<CodeEditor
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
onTabChange={handleTabChange}
|
||||
onTabClose={handleTabClose}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
SubagentToolEndPayload,
|
||||
SubagentState,
|
||||
SubagentToolInfo,
|
||||
FileDiffInfo,
|
||||
} from '../api/types.js';
|
||||
|
||||
interface UseChatOptions {
|
||||
@@ -35,6 +36,8 @@ interface UseChatOptions {
|
||||
onConfigError?: (error: ConfigErrorPayload) => void;
|
||||
/** 切换会话回调(如 :new 命令创建新会话) */
|
||||
onSessionSwitch?: (newSessionId: string) => void;
|
||||
/** 文件 diff 回调(当 AI 写入/编辑文件时触发) */
|
||||
onFileDiff?: (diff: FileDiffInfo) => void;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
@@ -54,7 +57,7 @@ interface ChatState {
|
||||
currentSubagent: SubagentState | null;
|
||||
}
|
||||
|
||||
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError, onSessionSwitch }: UseChatOptions) {
|
||||
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError, onSessionSwitch, onFileDiff }: UseChatOptions) {
|
||||
const [state, setState] = useState<ChatState>({
|
||||
messages: [],
|
||||
isConnected: false,
|
||||
@@ -80,11 +83,13 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
const onSessionUpdatedRef = useRef(onSessionUpdated);
|
||||
const onConfigErrorRef = useRef(onConfigError);
|
||||
const onSessionSwitchRef = useRef(onSessionSwitch);
|
||||
const onFileDiffRef = useRef(onFileDiff);
|
||||
onErrorRef.current = onError;
|
||||
onSessionNotFoundRef.current = onSessionNotFound;
|
||||
onSessionUpdatedRef.current = onSessionUpdated;
|
||||
onConfigErrorRef.current = onConfigError;
|
||||
onSessionSwitchRef.current = onSessionSwitch;
|
||||
onFileDiffRef.current = onFileDiff;
|
||||
|
||||
// 加载历史消息
|
||||
const loadMessages = useCallback(async () => {
|
||||
@@ -244,6 +249,34 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
|
||||
case 'tool_end': {
|
||||
const payload = message.payload as ToolEndPayload;
|
||||
|
||||
// 检查是否为文件写入/编辑工具,提取 diff 信息
|
||||
let fileDiffInfo: FileDiffInfo | undefined;
|
||||
if (payload.status === 'completed' && payload.result) {
|
||||
const result = payload.result as {
|
||||
metadata?: {
|
||||
fileDiff?: {
|
||||
path: string;
|
||||
name: string;
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
operation: 'write' | 'edit';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
if (result.metadata?.fileDiff) {
|
||||
fileDiffInfo = {
|
||||
...result.metadata.fileDiff,
|
||||
toolCallId: payload.id,
|
||||
};
|
||||
// 异步触发回调,避免阻塞状态更新
|
||||
if (onFileDiffRef.current) {
|
||||
setTimeout(() => onFileDiffRef.current?.(fileDiffInfo!), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => {
|
||||
if (!prev.streamingMessage) return prev;
|
||||
|
||||
@@ -303,6 +336,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
||||
result: payload.result,
|
||||
error: payload.error,
|
||||
duration: payload.duration,
|
||||
fileDiff: fileDiffInfo,
|
||||
} as ToolMessagePart;
|
||||
}
|
||||
return part;
|
||||
|
||||
@@ -201,6 +201,8 @@ export type {
|
||||
DiagnosticsResponse,
|
||||
// Active file types
|
||||
ActiveFileInfo,
|
||||
// File diff types
|
||||
FileDiffInfo,
|
||||
} from './api/client.js';
|
||||
|
||||
// Primitives (shadcn/ui style)
|
||||
@@ -254,7 +256,8 @@ export { DiagnosticsIndicator, DiagnosticsIndicatorCompact } from './components/
|
||||
export { SessionPanel } from './components/SessionPanel.js';
|
||||
export { FileExplorer } from './components/FileExplorer.js';
|
||||
export { CodeEditor, getLanguageFromFilename, type EditorTab } from './components/CodeEditor.js';
|
||||
export { IDE } from './components/IDE.js';
|
||||
export { DiffEditor } from './components/DiffEditor.js';
|
||||
export { IDE, type IDEHandle } from './components/IDE.js';
|
||||
export { StatusBar } from './components/StatusBar.js';
|
||||
export { Resizer } from './components/Resizer.js';
|
||||
export { ToolbarOverflowMenu, type ToolbarMenuItem } from './components/ToolbarOverflowMenu.js';
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
createSession,
|
||||
type Session,
|
||||
type ActiveFileInfo,
|
||||
type FileDiffInfo,
|
||||
} from '@ai-assistant/ui';
|
||||
import { ChatPage } from './pages/Chat';
|
||||
|
||||
@@ -53,6 +54,9 @@ export function App() {
|
||||
return saved !== 'false'; // 默认开启
|
||||
});
|
||||
|
||||
// Diff 显示状态(当 AI 编辑/写入文件时触发)
|
||||
const [pendingDiff, setPendingDiff] = useState<FileDiffInfo | null>(null);
|
||||
|
||||
// 持久化自动附加开关状态
|
||||
useEffect(() => {
|
||||
localStorage.setItem('ai-assistant-auto-attach-file', String(autoAttachActiveFile));
|
||||
@@ -121,6 +125,16 @@ export function App() {
|
||||
setCurrentSessionId(newSessionId);
|
||||
}, []);
|
||||
|
||||
// 文件 diff 回调(当 AI 编辑/写入文件时触发)
|
||||
const handleFileDiff = useCallback((diff: FileDiffInfo) => {
|
||||
setPendingDiff(diff);
|
||||
}, []);
|
||||
|
||||
// Diff 关闭回调
|
||||
const handleDiffClose = useCallback(() => {
|
||||
setPendingDiff(null);
|
||||
}, []);
|
||||
|
||||
// 处理面板宽度调整
|
||||
const handleResize = useCallback((delta: number) => {
|
||||
setIdePanelWidth((prev) => {
|
||||
@@ -159,7 +173,11 @@ export function App() {
|
||||
className="hidden md:flex flex-col"
|
||||
style={{ width: `${idePanelWidth}%` }}
|
||||
>
|
||||
<IDE onActiveFileChange={setActiveFile} />
|
||||
<IDE
|
||||
onActiveFileChange={setActiveFile}
|
||||
pendingDiff={pendingDiff}
|
||||
onDiffClose={handleDiffClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 可拖拽分割线 */}
|
||||
@@ -191,6 +209,8 @@ export function App() {
|
||||
activeFile={activeFile}
|
||||
autoAttachActiveFile={autoAttachActiveFile}
|
||||
onAutoAttachActiveFileToggle={setAutoAttachActiveFile}
|
||||
onFileDiff={handleFileDiff}
|
||||
onViewDiff={handleFileDiff}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DiagnosticsIndicator,
|
||||
ToolbarOverflowMenu,
|
||||
type ActiveFileInfo,
|
||||
type FileDiffInfo,
|
||||
} from '@ai-assistant/ui';
|
||||
|
||||
interface ChatPageProps {
|
||||
@@ -43,6 +44,10 @@ interface ChatPageProps {
|
||||
autoAttachActiveFile?: boolean;
|
||||
/** 自动附加开关变更回调 */
|
||||
onAutoAttachActiveFileToggle?: (enabled: boolean) => void;
|
||||
/** 文件 diff 回调(当 AI 写入/编辑文件时触发) */
|
||||
onFileDiff?: (diff: FileDiffInfo) => void;
|
||||
/** 查看文件 diff 回调(点击 View Diff 按钮) */
|
||||
onViewDiff?: (diff: FileDiffInfo) => void;
|
||||
}
|
||||
|
||||
export function ChatPage({
|
||||
@@ -63,6 +68,8 @@ export function ChatPage({
|
||||
activeFile,
|
||||
autoAttachActiveFile,
|
||||
onAutoAttachActiveFileToggle,
|
||||
onFileDiff,
|
||||
onViewDiff,
|
||||
}: ChatPageProps) {
|
||||
const {
|
||||
messages,
|
||||
@@ -100,6 +107,7 @@ export function ChatPage({
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
onFileDiff,
|
||||
});
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
@@ -208,13 +216,13 @@ export function ChatPage({
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} />
|
||||
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} onViewDiff={onViewDiff ?? onFileDiff} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 流式消息 - 复用 ChatMessage 组件 */}
|
||||
{streamingMessage && (
|
||||
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} />
|
||||
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} onViewDiff={onViewDiff ?? onFileDiff} />
|
||||
)}
|
||||
|
||||
{/* 子 Agent 进度显示 */}
|
||||
|
||||
Generated
+20
@@ -252,6 +252,12 @@ importers:
|
||||
'@codemirror/lang-python':
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
'@codemirror/merge':
|
||||
specifier: ^6.10.2
|
||||
version: 6.11.2
|
||||
'@codemirror/state':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/theme-one-dark':
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
@@ -285,6 +291,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
codemirror:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2
|
||||
framer-motion:
|
||||
specifier: ^12.23.26
|
||||
version: 12.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -1007,6 +1016,9 @@ packages:
|
||||
'@codemirror/lint@6.9.2':
|
||||
resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==}
|
||||
|
||||
'@codemirror/merge@6.11.2':
|
||||
resolution: {integrity: sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==}
|
||||
|
||||
'@codemirror/search@6.5.11':
|
||||
resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==}
|
||||
|
||||
@@ -5988,6 +6000,14 @@ snapshots:
|
||||
'@codemirror/view': 6.39.4
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/merge@6.11.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.3
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.39.4
|
||||
'@lezer/highlight': 1.2.3
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/search@6.5.11':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
|
||||
Reference in New Issue
Block a user