前端沙箱 IDE
构建类似 IDE 的 UI,用于由沙箱环境支持的编码代理
从"聊天窗口"到"完整开发环境"的关键升级聊天窗口困局
编码代理需要的不仅仅是一个聊天窗口——它需要 IDE 体验
纯聊天模式的困境
- 代理写的代码全在消息气泡里,没有文件浏览器
- 想看修改了什么?只能逐条翻消息——没有差异视图
- 代理运行代码的输出散落各处,无法集中查看
- 多个文件被修改时,没有全局变更概览
- 用户无法实时看到代理在做什么,缺少透明度
沙箱 IDE 的解法
- 连接沙箱环境,提供文件树侧边栏
- 实时同步文件变更,展示统一差异视图
- 三面板布局:文件树 + 代码查看器 + 聊天
- 变更文件摘要,一目了然增删行数统计
- 代理工作时实时刷新,IDE 体验丝滑
三层架构
沙箱模式由三个层次协同工作
1. 深度代理 + 沙箱后端
代理自动从沙箱获取文件系统工具:read_file、write_file、edit_file、execute。无需手动配置工具,后端自动注入。
自动工具注入
2. 自定义 API 服务器
通过 langgraph.json 的 http.app 字段暴露 Hono 应用,提供前端可以调用的文件浏览端点。代理后端和 API 共享同一个沙箱解析逻辑。
Hono 路由层
3. IDE 前端
三面板布局——文件树、代码/差异查看器、聊天面板。使用 useStream 进行代理对话,通过自定义 API 端点浏览文件。代理工作时实时同步。
实时同步 UI
getOrCreateSandboxForThread 函数,通过线程元数据中的沙箱 ID 作为唯一可信来源,确保两者始终解析到同一个沙箱。
沙箱生命周期
理解作用域策略——决定了谁共享沙箱、沙箱存活多久
| 作用域 | 隔离级别 | 适用场景 | 持久化 |
|---|---|---|---|
| 线程级(推荐) | 每线程独立沙箱 | 大多数应用,对话间完全隔离 | 线程删除时清理 |
| 代理级 | 同助手共享 | 持久项目环境,跨对话保留变更 | 助手存在时持久 |
| 用户级 | 每用户独立 | 需要自定义身份验证和用户标识 | 用户账户存在时持久 |
| 会话级 | 前端会话 | 演示或原型开发,无 LangGraph 线程 | 浏览器会话内 |
// 线程级作用域——从线程元数据读取沙箱 ID
import { getConfig } from "@langchain/langgraph";
function getSandboxBackendForThread() {
// getConfig() 在运行时获取当前 LangGraph 调用的配置
const config = getConfig();
// 从 configurable 中读取线程 ID,用于沙箱隔离
const threadId = config.configurable?.thread_id;
return getOrCreateSandboxForThread(threadId);
}
// 代理级作用域——同助手共享一个沙箱
function getSandboxBackendForAssistant() {
const config = getConfig();
// 使用 assistant_id 作为沙箱标识,同助手共享环境
const assistantId = config.metadata?.assistant_id;
return getOrCreateSandboxForAssistant(assistantId);
}
// 用户级作用域——每个用户拥有独立沙箱
function getSandboxBackendForUser() {
const config = getConfig();
// 使用 user_id 实现用户间的沙箱隔离
const userId = config.configurable?.user_id;
return getOrCreateSandboxForUser(userId);
}
// 会话级——前端生成随机 ID,浏览器关闭后丢失
const sessionId = crypto.randomUUID();
fetch(`/api/sandbox/tree?sessionId=${sessionId}`);
getConfig() 按线程解析沙箱,确保隔离和安全。
线程级解析
推荐方式——每个对话拥有独立的沙箱环境
优势
- 对话间完全隔离——一个线程中的文件更改不影响另一个
- 沙箱状态在页面重新加载后依然存在(同一线程 = 同一沙箱)
- 清理简单:线程删除时,沙箱一并删除
- 唯一可信来源:线程元数据中的沙箱 ID,不需要内存缓存
核心逻辑
- 沙箱 ID 存储在线程的
metadata.sandbox_id - 首次运行时创建沙箱并写入元数据
- 后续运行时从元数据读取并重连
- 代理后端和 API 服务器共享同一解析函数
设置代理
选择沙箱提供者并按线程解析——避免模块级状态
选择沙箱提供者——基本创建方式
// 最简方式:使用 LangSmith 沙箱
import { createDeepAgent, LangSmithSandbox } from "deepagents";
// 创建一个新的沙箱实例(LangSmith 托管的隔离容器)
const sandbox = await LangSmithSandbox.create();
// 创建代理并绑定沙箱后端
export const agent = createDeepAgent({
model: "google-genai:gemini-3.1-pro-preview", // 指定使用的 LLM 模型
backend: sandbox, // 沙箱作为文件系统和执行后端
systemPrompt: "You are an expert developer working on a project in /app.",
});
// 创建后代理自动获得以下工具:
// read_file, write_file, edit_file, ls, glob, grep, execute
按线程解析——生产环境推荐
import { createDeepAgent, LangSmithSandbox } from "deepagents";
import { getConfig } from "@langchain/langgraph";
// 按线程获取或创建沙箱——核心解析逻辑
async function getOrCreateSandboxForThread(threadId) {
const client = new Client({ apiUrl: "http://localhost:2024" });
// 通过线程 ID 获取线程对象,从中读取已保存的沙箱 ID
const thread = await client.threads.get(threadId);
const sandboxId = thread.metadata?.sandbox_id;
if (sandboxId) {
// 已有沙箱:重新连接到同一实例
return new LangSmithSandbox({
sandbox: await new SandboxClient().getSandbox(sandboxId),
});
}
// 首次运行:创建新沙箱并初始化项目文件
const sandbox = await LangSmithSandbox.create({
templateName: "my-template", // 基于预配置的容器镜像创建
});
await seedSandbox(sandbox); // 上传初始项目文件
// 将沙箱 ID 持久化到线程元数据,确保后续运行能重连
await client.threads.update(threadId, {
metadata: { sandbox_id: sandbox.id },
});
return sandbox;
}
// 运行时按线程解析的沙箱——不在模块级创建
const sandbox = new LangSmithSandbox({
// resolve 函数在每次 Agent 运行时调用,动态获取对应线程的沙箱
resolve: async () => {
const config = getConfig();
const threadId = config.configurable?.thread_id;
if (!threadId) throw new Error("No thread_id");
return getOrCreateSandboxForThread(threadId);
},
});
export const agent = createDeepAgent({
model: "google-genai:gemini-3.1-pro-preview",
backend: sandbox, // 传入支持动态解析的沙箱实例
systemPrompt: "You are an expert developer working on a project in /app.",
});
read_file、write_file、edit_file、ls、glob、grep)和 execute 工具。无需额外配置,后端自动注入。
初始化沙箱
在代理运行之前,用项目文件填充沙箱
上传初始文件
使用 uploadFiles 将项目骨架填充到沙箱中。从空沙箱开始会让人困惑,上传可运行的起始项目让用户和代理立即拥有上下文。
安装依赖
上传 package.json 后运行 sandbox.execute("cd /app && npm install"),在代理启动前安装好依赖。
// 定义要上传到沙箱的初始项目文件
const SEED_FILES = {
"package.json": JSON.stringify({
name: "my-app",
version: "1.0.0"
}, null, 2),
"src/index.js": 'console.log("Hello");',
};
// 将文件内容编码为二进制并上传到沙箱的 /app 目录
const encoder = new TextEncoder();
await sandbox.uploadFiles(
Object.entries(SEED_FILES).map(([path, content]) => [
`/app/${path}`, // 沙箱内的目标路径
encoder.encode(content), // 将字符串编码为 Uint8Array
]),
);
// 在沙箱中执行 npm install 安装依赖
await sandbox.execute("cd /app && npm install");
templateName,uploadFiles 在运行时在该镜像基础上填充或更新项目文件。
文件浏览 API
通过 Hono 应用暴露沙箱文件系统给前端
// src/api/app.ts —— 自定义 Hono API 服务器
import { Hono } from "hono";
import { getOrCreateSandboxForThread } from "./utils.js";
export const app = new Hono();
// 获取文件树:列出沙箱中指定目录的文件和子目录
app.get("/api/sandbox/:threadId/tree", async (c) => {
const threadId = c.req.param("threadId");
const rootPath = c.req.query("filePath") || "/app"; // 默认浏览 /app 目录
// 根据线程 ID 获取对应的沙箱实例
const sandbox = await getOrCreateSandboxForThread(threadId);
// 在沙箱中执行 find 命令,获取文件类型、大小和路径
const result = await sandbox.execute(
`find '${rootPath}' -printf '%y\\t%s\\t%p\\n' 2>/dev/null | sort`,
);
// 解析 find 命令的输出,构建文件树结构
const entries = result.output.trim().split("\n")
.filter(Boolean)
.map((line) => {
const [typeChar, sizeStr, fullPath] = line.split("\t");
return {
name: fullPath.split("/").pop(), // 文件/目录名
type: typeChar === "d" ? "directory" : "file", // 区分文件和目录
path: fullPath, // 完整路径
size: parseInt(sizeStr, 10) || 0, // 文件大小(字节)
};
});
return c.json({ path: rootPath, entries, sandboxId: sandbox.id });
});
// 获取文件内容:读取沙箱中单个文件的内容
app.get("/api/sandbox/:threadId/file", async (c) => {
const threadId = c.req.param("threadId");
const filePath = c.req.query("filePath");
if (!filePath) return c.json({ error: "filePath is required" }, 400);
const sandbox = await getOrCreateSandboxForThread(threadId);
// 从沙箱下载文件(返回二进制内容)
const results = await sandbox.downloadFiles([filePath]);
const file = results[0];
if (file.error) return c.json({ error: file.error }, 404);
// 将二进制内容解码为文本返回
const content = new TextDecoder().decode(file.content);
return c.json({ path: filePath, content });
});
http.app 中定义的自定义路由优先于默认的 LangGraph 路由。不要意外覆盖 /threads 或 /runs 等内置端点。
配置部署
同时注册代理图和 API 服务器
{
"node_version": "22", // Node.js 运行时版本
"graphs": {
// 注册代理图:键名是 assistant ID,值指向导出 agent 变量的文件
"coding_agent": "./src/agents/my-agent.ts:agent"
},
"env": ".env", // 环境变量文件
"http": {
// 自定义 HTTP 路由:Hono 应用在默认路由旁运行
"app": "./src/api/app.ts:app"
}
}
graphs 字段
注册代理图,键名是 assistant ID,值指向导出 agent 的文件路径。前端通过 assistantId: "coding_agent" 引用。
http.app 字段
告诉 LangGraph 平台在默认路由旁边提供你的自定义 Hono 路由。本地开发时地址是 http://localhost:2024。
getOrCreateSandboxForThread,通过线程元数据中的沙箱 ID 确保一致性。不需要内存缓存。
线程创建
页面加载时创建线程并持久化到 sessionStorage
const THREAD_KEY = "sandbox-thread-id"; // sessionStorage 存储键
function IDEPreview() {
// 尝试从 sessionStorage 恢复之前的线程 ID(页面刷新后重连)
const [threadId, setThreadId] = useState(
() => sessionStorage.getItem(THREAD_KEY),
);
// 线程 ID 变更时同步更新 sessionStorage
const updateThreadId = useCallback((id) => {
setThreadId(id);
if (id) sessionStorage.setItem(THREAD_KEY, id);
else sessionStorage.removeItem(THREAD_KEY);
}, []);
// 建立与 Agent 的流式连接,绑定到指定线程
const stream = useStream({
apiUrl: AGENT_URL,
assistantId: "coding_agent", // langgraph.json 中注册的图名
threadId, // 指定线程 ID,实现会话延续
onThreadId: updateThreadId, // 当 LangGraph 返回线程 ID 时回调
});
// 首次挂载时,如果没有线程则创建新线程
useEffect(() => {
if (threadId) return;
stream.client.threads.create()
.then((t) => updateThreadId(t.thread_id));
}, [stream.client, threadId, updateThreadId]);
// 使用线程 ID 获取沙箱文件树和文件内容
const { tree, files } = useSandboxFiles(threadId);
}
// "新线程"按钮——清除当前线程,触发新线程和沙箱创建
function handleNewThread() {
stream.switchThread(null);
updateThreadId(null);
}
文件状态管理
跟踪两个快照:原始状态和当前状态
获取文件树
通过 API 端点获取沙箱中的文件结构,过滤掉 node_modules 等干扰目录。
获取文件内容
通过 API 端点获取单个文件的内容,用于代码查看器和差异对比。
const AGENT_URL = "http://localhost:2024";
// 获取沙箱中的文件树结构
async function fetchTree(threadId) {
// 调用自定义 API 端点,路径中包含线程 ID 以定位沙箱
const res = await fetch(
`${AGENT_URL}/api/sandbox/${encodeURIComponent(threadId)}` +
`/tree?filePath=/app`,
);
const data = await res.json();
// 过滤掉 node_modules 等干扰目录,保持文件树简洁
return data.entries.filter(
(e) => !e.path.includes("node_modules")
);
}
// 获取沙箱中单个文件的内容
async function fetchFile(threadId, path) {
const res = await fetch(
`${AGENT_URL}/api/sandbox/${encodeURIComponent(threadId)}` +
`/file?filePath=${encodeURIComponent(path)}`,
);
const data = await res.json();
return data.content ?? null; // 文件不存在时返回 null
}
实时文件同步
代理工作时实时更新文件,而不是等运行结束
关键在于监听流消息中的 ToolMessage,当文件修改工具完成时立即刷新。
import { useStream } from "@langchain/react";
import { ToolMessage, AIMessage } from "langchain";
// 这些工具会修改文件,完成后需要刷新 UI
const FILE_MUTATING_TOOLS = new Set([
"write_file", "edit_file", "execute"
]);
export function IDEPreview() {
const stream = useStream({
apiUrl: AGENT_URL,
assistantId: "coding_agent",
});
// 记录已处理的工具调用 ID,避免重复刷新
const processedIds = useRef(new Set());
useEffect(() => {
// 第一遍:从 AI 消息中提取文件修改相关的工具调用信息
const toolCallMap = new Map();
for (const msg of stream.messages) {
if (!AIMessage.isInstance(msg)) continue;
for (const tc of msg.tool_calls ?? []) {
if (tc.id && FILE_MUTATING_TOOLS.has(tc.name)) {
toolCallMap.set(tc.id, { name: tc.name, args: tc.args });
}
}
}
// 第二遍:当 ToolMessage 出现时(工具执行完毕),立即刷新对应文件
for (const msg of stream.messages) {
if (!ToolMessage.isInstance(msg)) continue;
const id = msg.id ?? msg.tool_call_id;
if (!id || processedIds.current.has(id)) continue;
const call = toolCallMap.get(msg.tool_call_id);
if (!call) continue;
processedIds.current.add(id);
// 根据工具类型选择刷新策略
if (call.name === "write_file" || call.name === "edit_file") {
refreshSingleFile(call.args.path); // 只刷新被修改的文件
} else if (call.name === "execute") {
refreshAllFiles(); // Shell 命令可能修改任何文件,全量刷新
}
}
}, [stream.messages]);
}
Vue 实现
import { useStream } from "@langchain/vue";
import { ToolMessage, AIMessage } from "langchain";
import { watch } from "vue";
const stream = useStream({
apiUrl: AGENT_URL,
assistantId: "coding_agent",
});
const processedIds = new Set();
// Vue 中使用 watch 监听消息变化(deep: true 深度监听)
watch(() => stream.messages.value, (messages) => {
// 从 AI 消息中提取文件修改相关的工具调用
const toolCallMap = new Map();
for (const msg of messages) {
if (AIMessage.isInstance(msg)) {
for (const tc of msg.tool_calls ?? []) {
if (tc.id && FILE_MUTATING_TOOLS.has(tc.name))
toolCallMap.set(tc.id, { name: tc.name, args: tc.args });
}
}
}
// 当工具执行完毕时,根据工具类型刷新文件
for (const msg of messages) {
if (!ToolMessage.isInstance(msg)) continue;
const id = msg.id ?? msg.tool_call_id;
if (!id || processedIds.has(id)) continue;
const call = toolCallMap.get(msg.tool_call_id);
if (!call) continue;
processedIds.add(id);
if (call.name === "write_file" || call.name === "edit_file")
refreshSingleFile(call.args.path);
else if (call.name === "execute") refreshAllFiles();
}
}, { deep: true });
Svelte 实现
import { useStream } from "@langchain/svelte";
import { ToolMessage, AIMessage } from "langchain";
const processedIds = new Set();
const { messages, submit } = useStream({
apiUrl: AGENT_URL,
assistantId: "coding_agent",
});
// Svelte 5 中使用 $effect 响应式追踪 store 变化
$effect(() => {
const msgs = $messages; // $ 前缀自动订阅 store
// 从 AI 消息中提取文件修改相关的工具调用
const toolCallMap = new Map();
for (const msg of msgs) {
if (AIMessage.isInstance(msg)) {
for (const tc of msg.tool_calls ?? []) {
if (tc.id && FILE_MUTATING_TOOLS.has(tc.name))
toolCallMap.set(tc.id, { name: tc.name, args: tc.args });
}
}
}
// 当工具执行完毕时,根据工具类型刷新文件
for (const msg of msgs) {
if (!ToolMessage.isInstance(msg)) continue;
const id = msg.id ?? msg.tool_call_id;
if (!id || processedIds.has(id)) continue;
const call = toolCallMap.get(msg.tool_call_id);
if (!call) continue;
processedIds.add(id);
if (call.name === "write_file" || call.name === "edit_file")
refreshSingleFile(call.args.path);
else if (call.name === "execute") refreshAllFiles();
}
});
Angular 实现
import { Component, effect } from "@angular/core";
import { useStream } from "@langchain/angular";
import { ToolMessage, AIMessage } from "langchain";
@Component({
selector: "app-ide-preview",
template: `<!-- ... -->`,
})
// Angular 中 useStream 返回 Signal,通过 effect 追踪变化
export class IdePreviewComponent {
stream = useStream({
apiUrl: AGENT_URL,
assistantId: "coding_agent",
});
private processedIds = new Set<string>();
constructor() {
// Angular effect 在 Signal 值变化时自动触发
effect(() => {
const messages = this.stream.messages(); // Signal 函数调用获取当前值
// 从 AI 消息中提取文件修改相关的工具调用
const toolCallMap = new Map();
for (const msg of messages) {
if (AIMessage.isInstance(msg)) {
for (const tc of msg.tool_calls ?? []) {
if (tc.id && FILE_MUTATING_TOOLS.has(tc.name))
toolCallMap.set(tc.id, { name: tc.name, args: tc.args });
}
}
}
// 当工具执行完毕时,根据工具类型刷新文件
for (const msg of messages) {
if (!ToolMessage.isInstance(msg)) continue;
const id = msg.id ?? msg.tool_call_id;
if (!id || this.processedIds.has(id)) continue;
const call = toolCallMap.get(msg.tool_call_id);
if (!call) continue;
this.processedIds.add(id);
if (call.name === "write_file" || call.name === "edit_file")
this.refreshSingleFile(call.args.path);
else if (call.name === "execute") this.refreshAllFiles();
}
});
}
}
write_file 和 edit_file 只刷新对应文件(精确高效),execute 刷新全部文件(因为 Shell 命令可能修改任何文件)。
检测文件变更
对比原始快照和当前状态,识别代理修改了什么
// 对比原始快照和当前状态,检测 Agent 修改了哪些文件
function detectChanges(current, original) {
const changed = new Set();
// 检查新增或内容发生变化的文件
for (const [path, content] of Object.entries(current)) {
if (original[path] !== content) changed.add(path);
}
// 检查被 Agent 删除的文件(原快照中存在但当前不存在)
for (const path of Object.keys(original)) {
if (!(path in current)) changed.add(path);
}
return changed;
}
变更文件摘要
显示所有修改文件的摘要,包含行级别的增删计数。类似于 git status——给用户一个快速概览:
- 文件路径
- +N 新增行数
- -N 删除行数
差异视图展示
当用户点击代理修改过的文件时,默认显示差异视图而不是完整代码。这才是用户关心的内容。
各框架差异库:
| 框架 | 库 | 组件 |
|---|---|---|
| React | @pierre/diffs |
<FileDiff> + parseDiffFromFile |
| Vue | @git-diff-view/vue |
<DiffView> + generateDiffFile |
| Svelte | @git-diff-view/svelte |
<DiffView> + generateDiffFile |
| Angular | ngx-diff |
<ngx-unified-diff> [[before] [after]] |
渲染差异视图
使用框架对应的差异库渲染统一差异
// React 示例:使用 @pierre/diffs 渲染统一差异视图
import { FileDiff } from "@pierre/diffs/react";
import { parseDiffFromFile } from "@pierre/diffs";
function DiffPanel({ original, current, fileName }) {
// 将修改前后的文件内容传给差异解析器
const diff = parseDiffFromFile(
{ name: fileName, contents: original }, // 修改前的文件
{ name: fileName, contents: current }, // 修改后的文件
);
return (
<FileDiff
fileDiff={diff}
options={{
theme: "github-dark", // 深色主题
diffStyle: "unified", // 统一差异格式(而非并排)
diffIndicators: "bars" // 用竖线标记变更区域
}}
/>
);
}
// 变更文件摘要组件:显示所有被修改文件的增删行数
function ChangedFilesSummary({ changedFiles, files, originalFiles, onSelect }) {
const stats = [...changedFiles].map((path) => {
const oldLines = (originalFiles[path] ?? "").split("\n");
const newLines = (files[path] ?? "").split("\n");
return { path, additions, deletions };
});
return (
<div>
<h3>{stats.length} 个文件已变更</h3>
{stats.map((file) => (
// 点击文件名切换差异视图
<button key={file.path} onClick={() => onSelect(file.path)}>
{file.path}
<span className="text-green-400">+{file.additions}</span>
<span className="text-red-400">-{file.deletions}</span>
</button>
))}
</div>
);
}
@iconify-json/vscode-icons),修改过的文件上显示橙色圆点。
三面板布局
文件树 + 代码/差异查看器 + 聊天——并排排列
| 面板 | 宽度 | 用途 |
|---|---|---|
| 文件树 | 固定 208px | 浏览沙箱文件,查看变更指示(橙色圆点),VS Code 风格图标 |
| 代码 / 差异 | 弹性填充 | 查看文件内容或统一差异。选择已修改文件自动切换到差异标签页 |
| 聊天 | 固定 320px | 与代理交互,发送指令,查看回复 |
// 三面板布局:文件树 + 代码查看器 + 聊天
<div className="flex h-screen">
{/* 左侧:文件树侧边栏,固定 208px 宽度 */}
<div className="w-52 shrink-0">
<FileTree />
<ChangedFilesSummary />
</div>
{/* 中间:代码/差异查看器,flex-1 自动填充剩余空间 */}
<CodePanel /* flex-1 */ />
{/* 右侧:聊天面板,固定 320px 宽度 */}
<div className="w-80 shrink-0">
<ChatPanel />
</div>
</div>
@iconify-json/vscode-icons),修改过的文件上显示橙色圆点。选择已修改的文件会自动切换到差异标签页。
使用场景
在以下情况下,沙箱是合适的选择
编码代理
需要创建、修改和运行代码,且需要超越聊天的可视化界面。代理在沙箱中执行,前端实时展示文件变更。
核心场景
代码审查工作流
代理建议代码更改,用户在接受前审查差异。变更文件摘要提供类似 git status 的快速概览。
安全可控
教学与学习应用
AI 助手帮助用户逐步构建项目,在上下文中展示更改。学生可以实时看到 AI 的每一步操作。
渐进式
原型设计工具
用户用自然语言描述功能,实时观看代理实现。从想法到可运行代码的快速迭代。
快速迭代
最佳实践
生产环境中的关键建议
沙箱管理
- 使用线程级作用域沙箱,将沙箱 ID 存储在线程元数据中,通过
getConfig()运行时解析 - 共享
getOrCreateSandboxForThread,代理后端和 API 服务器用同一函数解析沙箱 - 持久化
threadId到sessionStorage,页面重新加载时重连到同一线程和沙箱 - 用真实项目初始化沙箱,从空沙箱开始会让人困惑
前端体验
- 每个工具调用时同步文件,不要只在运行结束时刷新,让 IDE 有实时感
- 已变更文件默认显示差异,用户点击修改过的文件时首先显示差异
- 只读操作显示简洁摘要,如"读取 router.js 第 1-42 行",不在聊天中显示完整输出
- 过滤
node_modules,没人想浏览数千个依赖文件
本章回顾
到这里,你已经掌握了沙箱 IDE 的完整构建方法
你学到了什么
- 沙箱 IDE 的三层架构:代理 + API 服务器 + 前端
- 四种作用域策略:线程级、代理级、用户级、会话级
- 通过
getConfig()在运行时按线程解析沙箱 - 使用 Hono 构建文件浏览 API
- 通过监听
ToolMessage实现实时文件同步 - 三面板布局:文件树 + 差异查看器 + 聊天
关键设计原则
- 线程元数据中的沙箱 ID 是唯一可信来源
- 代理后端和 API 服务器共享解析函数
- 每个工具调用时刷新,不等运行结束
- 已变更文件默认显示差异
- 用真实项目初始化沙箱,不从空白开始
- 过滤干扰目录,保持文件树简洁