编码代理需要的不仅仅是聊天窗口。它们需要一个文件浏览器、一个代码 查看器,以及差异面板,IDE 体验。这种模式将深度 代理连接到 沙箱 以便它可以读取、 并在隔离环境中写入和执行代码,然后公开沙箱 文件系统通过自定义 API 服务器,以便前端可以显示 代理工作时的实时文件。
本页介绍 **三面板 UI** (文件树、代码查看器和聊天)以及 **自定义 API 路由** 将沙箱文件系统暴露给它。对于沙箱 提供商、生命周期范围设置、种子文件、密钥、部署和生产 useStream 配置,请参阅 进入生产环境.
架构
此设置包含三个部分:
1. **带沙箱后端的深度代理:** 代理自动从 (read_file, write_file, edit_file, execute沙箱获取文件系统工具 沙箱
2. **自定义 API 服务器** — 通过 langgraph.json's http.app 字段公开的 FastAPI 应用,提供前端可调用的文件浏览端点
3. **三面板前端:** A file tree, code/diff viewer, and chat panel 在代理进行更改时实时同步文件
%%{
init: {
"fontFamily": "monospace",
"flowchart": {
"curve": "curve"
}
}
}%%
graph LR
UI["IDE Frontend"]
API["API Server"]
AGENT["createDeepAgent()"]
SANDBOX["Sandbox"]
UI --"useStream()"--> AGENT
UI --"/sandbox/:threadId/*"--> API
AGENT --"read/write/execute"--> SANDBOX
API --"ls / read"--> SANDBOX
classDef blueHighlight fill:#E5F4FF,stroke:#006DDD,color:#030710;
classDef greenHighlight fill:#F6FFDB,stroke:#6E8900,color:#2E3900;
classDef purpleHighlight fill:#EBD0F0,stroke:#885270,color:#441E33;
classDef orangeHighlight fill:#FDF3FF,stroke:#7E65AE,color:#504B5F;
class UI blueHighlight;
class AGENT greenHighlight;
class SANDBOX purpleHighlight;
class API orangeHighlight;
沙箱生命周期
在连接前端之前,选择沙箱的存活时间和共享对象。 请参阅 沙箱生命周期 了解线程范围 与助手范围沙箱的对比、异步 图工厂 设置、TTL 行为和 SDK 调用示例。
本指南使用 **线程范围沙箱** 作为默认设置。前端和 自定义 API 服务器都从 LangGraph 线程 ID 解析沙箱。这保持对话隔离并 允许页面重新加载时重新连接到相同环境 保存线程 ID.
sequenceDiagram
participant FE as Frontend
participant LG as LangGraph API
participant HTTP as API Server
participant SB as Sandbox
Note over FE: Page loads
FE->>LG: POST /threads
LG-->>FE: threadId
FE->>HTTP: GET /sandbox/:threadId/tree
HTTP->>LG: threads.get(threadId) → metadata.sandbox_id
alt No sandbox yet
HTTP->>SB: LangSmithSandbox.create()
HTTP->>LG: threads.update(threadId, metadata.sandbox_id)
else Existing sandbox
HTTP->>SB: connect(sandbox_id)
end
HTTP-->>FE: file tree
Note over FE: User sends message
FE->>LG: POST /threads/:threadId/runs/stream
LG->>LG: backend reads thread_id from config
LG->>SB: connect to same sandbox
对于 multi-tenant 应用, 在后端工厂中按用户或助手范围设置沙箱。对于 没有 LangGraph 线程的演示,请在 API URL。会话 ID 不会在浏览器会话之间保持。
连接代理和 API 服务器
使用以下内容配置深度代理 沙盒后端 如 执行环境. 代理获取文件系统工具和 execute 工具自动;无需额外 工具配置。
构建此 UI 在生产设置之上增加一个要求: **自定义 API 服务器** 它在代理图之外运行,因此代理 后端和文件浏览路由必须解析 **相同的沙盒** 用于 每个线程。将沙盒 ID 存储在线程元数据中并共享单个 它们之间的查找函数。
从线程元数据解析沙盒
种子项目文件
在代理运行之前,使用以下方式上传启动文件 uploadFiles / upload_files。请参阅 文件传输 了解种子模式、提供商示例以及同步 记忆 or 技能 到 沙盒中。对于 LangSmith 沙盒,在创建容器时传递 templateName 来自 沙盒快照 。
代理可以读取和写入文件,但前端还需要直接访问
代理可以读写文件,但前端也需要直接访问 浏览沙箱文件系统。添加一个自定义 FastAPI API 服务器 并通过 http.app 字段公开它 langgraph.json.
创建 API 服务器
沙箱 API 端点使用线程 ID 作为 URL 路径参数。这样 确保前端始终访问当前 对话的正确沙箱,使用相同的 get_or_create_sandbox_for_thread 函数作为 代理的后端:
# src/api/server.py
from fastapi import FastAPI, Query, Path
from utils import get_or_create_sandbox_for_thread
app = FastAPI()
@app.get("/sandbox/{thread_id}/tree")
async def list_tree(
thread_id: str = Path(...),
filePath: str = Query("/app"),
):
sandbox = await get_or_create_sandbox_for_thread(thread_id)
result = await sandbox.aexecute(
f"find {filePath} -printf '%y\\t%s\\t%p\\n' 2>/dev/null | sort"
)
entries = []
for line in result.output.strip().split("\n"):
if not line:
continue
type_char, size_str, full_path = line.split("\t")
entries.append({
"name": full_path.split("/")[-1],
"type": "directory" if type_char == "d" else "file",
"path": full_path,
"size": int(size_str),
})
return {"path": filePath, "entries": entries, "sandboxId": sandbox.id}
@app.get("/sandbox/{thread_id}/file")
async def read_file(
thread_id: str = Path(...),
filePath: str = Query(...),
):
sandbox = await get_or_create_sandbox_for_thread(thread_id)
results = await sandbox.adownload_files([filePath])
return {"path": filePath, "content": results[0].content.decode()}
配置 langgraph.json
注册代理图和 API 服务器。 http.app 字段告诉 LangGraph 平台将您的自定义路由与默认路由一起提供服务。 参见 应用结构 和 LangSmith 部署 了解完整的 langgraph.json options.
{
"graphs": {
"deep_agent_ide": "./src/agents/my_agent.py:agent"
},
"env": ".env",
"http": {
"app": "./src/api/server.py:app"
}
}
您的自定义路由可在与 LangGraph API 相同的主机上访问。对于 使用 langgraph dev进行本地开发,那是 http://localhost:2024.
构建前端
The frontend has three panels: a file tree sidebar, a code/diff viewer, and a 聊天面板。它使用 useStream 进行代理对话,使用自定义 API 端点进行文件浏览。
对于生产部署,请将 apiUrl 指向您的 LangSmith 部署,并在每次运行中传递一个稳定的 thread_id 。请参阅 前端 in 进入生产环境 了解这些设置 以及如何 调用代理 使用 thread_id 和运行时 context.
线程创建
在页面加载时创建 LangGraph 线程,并将其 ID 持久化到 sessionStorage 中,以便页面重新加载时重新连接到同一沙箱:
const THREAD_KEY = "sandbox-thread-id";
function IDEPreview() {
const [threadId, setThreadId] = useState<string | null>(
() => sessionStorage.getItem(THREAD_KEY),
);
const updateThreadId = useCallback((id: string | null) => {
setThreadId(id);
if (id) sessionStorage.setItem(THREAD_KEY, id);
else sessionStorage.removeItem(THREAD_KEY);
}, []);
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "deep_agent_ide",
threadId,
onThreadId: updateThreadId,
});
// Create thread on first mount
useEffect(() => {
if (threadId) return;
stream.client.threads.create().then((t) => updateThreadId(t.thread_id));
}, [stream.client, threadId, updateThreadId]);
// Pass threadId to sandbox file hooks
const { tree, files } = useSandboxFiles(threadId);
// ...
}
「新建线程」按钮会清除存储的 ID,以便下次挂载时创建 新线程(和沙盒):
function handleNewThread() {
updateThreadId(null);
}
文件状态管理
跟踪沙盒文件系统的两个快照:原始状态(代理运行之前 ),以及当前状态(实时更新)。线程 ID 被包含 在 API URL 中,因此请求始终发送到正确的沙盒:
const AGENT_URL = "http://localhost:2024";
async function fetchTree(threadId: string): Promise<FileEntry[]> {
const res = await fetch(
`${AGENT_URL}/sandbox/${encodeURIComponent(threadId)}/tree?filePath=/app`,
);
const data = await res.json();
return data.entries.filter((e: FileEntry) => !e.path.includes("node_modules"));
}
async function fetchFile(threadId: string, path: string): Promise<string | null> {
const res = await fetch(
`${AGENT_URL}/sandbox/${encodeURIComponent(threadId)}/file?filePath=${encodeURIComponent(path)}`,
);
const data = await res.json();
return data.content ?? null;
}
实时文件同步
IDE 体验的关键是在代理工作时 **更新文件**,而不是 在它完成后。监听流消息中的 ToolMessage 实例 (来自修改文件的工具)。当 write_file or edit_file 工具调用 完成时,刷新该特定文件。当 execute 完成时,刷新 所有内容(因为 shell 命令可能修改任何文件):
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "deep_agent_ide",
});
const processedIds = useRef(new Set<string>());
useEffect(() => {
// Build a map of file-mutating tool calls from AI messages
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 });
}
}
}
// When a ToolMessage appears for a file-mutating tool, refresh
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 ?? call.args.file_path);
} else if (call.name === "execute") {
refreshTreeAndFiles();
}
}
}, [stream.messages]);
}
<script setup lang="ts">
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);
const processedIds = new Set<string>();
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "deep_agent_ide",
});
watch(
() => stream.messages.value,
(messages) => {
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 ?? call.args.file_path);
} else if (call.name === "execute") {
refreshTreeAndFiles();
}
}
},
{ deep: true },
);
</script>
<script lang="ts">
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);
const processedIds = new Set<string>();
const stream = useStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "deep_agent_ide",
});
$effect(() => {
const msgs = stream.messages;
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 ?? call.args.file_path);
} else if (call.name === "execute") {
refreshTreeAndFiles();
}
}
});
</script>
const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]);
@Component({
selector: "app-ide-preview",
template: `<!-- ... -->`,
})
stream = injectStream<typeof myAgent>({
apiUrl: AGENT_URL,
assistantId: "deep_agent_ide",
});
private processedIds = new Set<string>();
constructor() {
effect(() => {
const messages = this.stream.messages();
const toolCallMap = new Map();
for (const msg of messages) {
if (AIMessage.isInstance(msg)) {
for (const tc of (msg as AIMessage).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 as ToolMessage).id ?? (msg as ToolMessage).tool_call_id;
if (!id || this.processedIds.has(id)) continue;
const call = toolCallMap.get((msg as ToolMessage).tool_call_id);
if (!call) continue;
this.processedIds.add(id);
if (call.name === "write_file" || call.name === "edit_file") {
this.refreshSingleFile(call.args.path ?? call.args.file_path);
} else if (call.name === "execute") {
this.refreshTreeAndFiles();
}
}
});
}
}
检测已更改的文件
在每次代理运行前,快照当前文件内容。文件刷新后, 与快照对比以识别哪些文件已更改:
function detectChanges(current: FileSnapshot, original: FileSnapshot): Set<string> {
const changed = new Set<string>();
for (const [path, content] of Object.entries(current)) {
if (original[path] !== content) changed.add(path);
}
for (const path of Object.keys(original)) {
if (!(path in current)) changed.add(path);
}
return changed;
}
当用户选择已更改的文件时,默认显示差异视图,以便他们 立即看到代理修改的内容。
显示差异
使用适合框架的差异库来渲染统一差异:
| 框架 | 库 | 组件 |
|---|---|---|
| React | @pierre/diffs | ` 与 parseDiffFromFile` |
| Vue | @git-diff-view/vue | ` 与 generateDiffFile 从 @git-diff-view/file` |
| Svelte | @git-diff-view/svelte | ` 与 generateDiffFile 从 @git-diff-view/file` |
| Angular | ngx-diff | <ngx-unified-diff> 与 [before] 和 [after] |
示例与 @pierre/diffs (React):
function DiffPanel({ original, current, fileName }) {
const diff = parseDiffFromFile(
{ name: fileName, contents: original },
{ name: fileName, contents: current },
);
return (
);
}
已更改文件摘要
Show a summary of all modified files with line-level addition/deletion counts. 这为用户提供了代理影响的快速概览——类似于 git status:
function ChangedFilesSummary({ changedFiles, files, originalFiles, onSelect }) {
const stats = [...changedFiles].map((path) => {
const oldLines = (originalFiles[path] ?? "").split("\n");
const newLines = (files[path] ?? "").split("\n");
// Compute additions/deletions by comparing lines
return { path, additions, deletions };
});
return (
<h3>{stats.length} Files Changed</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>
))}
);
}
使用场景
沙盒是正确的选择当:
- **编码代理** 创建、修改和运行代码的需要一个可视化界面 超越聊天 - **代码审查工作流** 其中代理建议更改,用户 在接受之前审查差异 - **教程或学习应用** 其中AI助手帮助用户构建 逐步展示项目,在上下文中显示更改 - **原型设计工具** 用户以自然语言描述功能, 并实时观看智能体实现这些功能
最佳实践
Frontend-specific:
- **保持持久化 threadId in sessionStorage** 这样页面重新加载后可以重新连接到 同一线程和沙箱,而不是创建新的。 - **在每个相关工具调用时同步文件**,而不只是在线程完成时同步。请监听 write_file, edit_file、 execute 工具消息并立即刷新。 - **默认使用差异视图显示变更文件**。当用户点击 被智能体修改过的文件时,先展示差异——这才是用户关心的。 - **为只读操作显示简洁的工具结果**。不要直接输出 的完整输出 read_file 到聊天中,而是显示一行摘要,如 Read router.js L1-42。仅对修改类工具保留完整输出显示。 - **过滤 node_modules 文件树中的**没人愿意浏览 数千个依赖文件。获取文件树时将其过滤掉。
后端和沙箱:
- **使用线程作用域的沙箱** 处理生产环境应用。参见 沙箱生命周期. - **共享沙箱解析** 在智能体后端和 API 服务器之间通过 线程元数据实现,这样双方都能解析相同环境,无需内存缓存。 - **使用真实项目初始化沙箱**。参见 文件传输. - **将密钥与沙箱隔离**。使用 沙箱认证代理 而非环境变量或文件上传来管理 API 密钥。 - **启动前添加防护栏**。配置 速率限制, 错误处理、 数据隐私 中间件 以保护自主编码智能体。
相关资源
Going to production
使用持久化沙箱、认证、防护栏和生产级 useStream settings.
Sandboxes
沙箱提供商、安全模型和文件传输 API。
Frontend overview
其他深度智能体 UI 模式:子智能体流、待办列表和自定义状态。
Application structure
完整 langgraph.json 参考,包括自定义 http.app routes.