第 24 章

前端沙箱 IDE

构建类似 IDE 的 UI,用于由沙箱环境支持的编码代理

从"聊天窗口"到"完整开发环境"的关键升级

聊天窗口困局

编码代理需要的不仅仅是一个聊天窗口——它需要 IDE 体验

纯聊天模式的困境

  • 代理写的代码全在消息气泡里,没有文件浏览器
  • 想看修改了什么?只能逐条翻消息——没有差异视图
  • 代理运行代码的输出散落各处,无法集中查看
  • 多个文件被修改时,没有全局变更概览
  • 用户无法实时看到代理在做什么,缺少透明度

沙箱 IDE 的解法

  • 连接沙箱环境,提供文件树侧边栏
  • 实时同步文件变更,展示统一差异视图
  • 三面板布局:文件树 + 代码查看器 + 聊天
  • 变更文件摘要,一目了然增删行数统计
  • 代理工作时实时刷新,IDE 体验丝滑
问题核心:编码代理处理的是"文件"——创建、修改、运行——但纯聊天界面无法直观展示这些操作。沙箱 IDE 将代理连接到隔离的执行环境,并通过文件浏览器、差异面板和实时同步,让用户像用 VS Code 一样与代理协作。

三层架构

沙箱模式由三个层次协同工作

1. 深度代理 + 沙箱后端

代理自动从沙箱获取文件系统工具:read_file、write_file、edit_file、execute。无需手动配置工具,后端自动注入。

自动工具注入

2. 自定义 API 服务器

通过 langgraph.json 的 http.app 字段暴露 Hono 应用,提供前端可以调用的文件浏览端点。代理后端和 API 共享同一个沙箱解析逻辑。

Hono 路由层

3. IDE 前端

三面板布局——文件树、代码/差异查看器、聊天面板。使用 useStream 进行代理对话,通过自定义 API 端点浏览文件。代理工作时实时同步。

实时同步 UI

图:沙箱 IDE 架构——三层数据流
关键设计:代理后端和 API 服务器共享同一个 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");
LangSmith 沙箱注意:容器镜像和资源限制来自沙箱快照。创建沙箱时传入 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。

两者共享同一个沙箱:代理后端和 API 服务器都调用 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);
}
为什么用 sessionStorage:页面重新加载时会重新连接到同一线程和沙箱,而不是创建新的。这样用户刷新页面后不会丢失工作状态。

文件状态管理

跟踪两个快照:原始状态和当前状态

获取文件树

通过 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
}
线程 ID 包含在 API URL 中,确保请求始终访问正确的沙箱。每次代理运行前快照当前文件内容,运行后与快照对比以识别变更。

实时文件同步

代理工作时实时更新文件,而不是等运行结束

关键在于监听流消息中的 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>
  );
}
差异优先原则:已变更文件默认显示差异视图,未变更文件显示完整代码。VS Code 风格的文件图标(使用 @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>
交互细节:文件树显示 VS Code 风格图标(使用 @iconify-json/vscode-icons),修改过的文件上显示橙色圆点。选择已修改的文件会自动切换到差异标签页。

使用场景

在以下情况下,沙箱是合适的选择

编码代理

需要创建、修改和运行代码,且需要超越聊天的可视化界面。代理在沙箱中执行,前端实时展示文件变更。

核心场景

代码审查工作流

代理建议代码更改,用户在接受前审查差异。变更文件摘要提供类似 git status 的快速概览。

安全可控

教学与学习应用

AI 助手帮助用户逐步构建项目,在上下文中展示更改。学生可以实时看到 AI 的每一步操作。

渐进式

原型设计工具

用户用自然语言描述功能,实时观看代理实现。从想法到可运行代码的快速迭代。

快速迭代

最佳实践

生产环境中的关键建议

沙箱管理

  • 使用线程级作用域沙箱,将沙箱 ID 存储在线程元数据中,通过 getConfig() 运行时解析
  • 共享 getOrCreateSandboxForThread,代理后端和 API 服务器用同一函数解析沙箱
  • 持久化 threadId 到 sessionStorage,页面重新加载时重连到同一线程和沙箱
  • 用真实项目初始化沙箱,从空沙箱开始会让人困惑

前端体验

  • 每个工具调用时同步文件,不要只在运行结束时刷新,让 IDE 有实时感
  • 已变更文件默认显示差异,用户点击修改过的文件时首先显示差异
  • 只读操作显示简洁摘要,如"读取 router.js 第 1-42 行",不在聊天中显示完整输出
  • 过滤 node_modules,没人想浏览数千个依赖文件
核心理念:沙箱 IDE 的目标是让用户像使用本地 IDE 一样与编码代理协作——实时、透明、可控。每个设计决策都应该服务于这个目标。

本章回顾

到这里,你已经掌握了沙箱 IDE 的完整构建方法

你学到了什么

  • 沙箱 IDE 的三层架构:代理 + API 服务器 + 前端
  • 四种作用域策略:线程级、代理级、用户级、会话级
  • 通过 getConfig() 在运行时按线程解析沙箱
  • 使用 Hono 构建文件浏览 API
  • 通过监听 ToolMessage 实现实时文件同步
  • 三面板布局:文件树 + 差异查看器 + 聊天

关键设计原则

  • 线程元数据中的沙箱 ID 是唯一可信来源
  • 代理后端和 API 服务器共享解析函数
  • 每个工具调用时刷新,不等运行结束
  • 已变更文件默认显示差异
  • 用真实项目初始化沙箱,不从空白开始
  • 过滤干扰目录,保持文件树简洁
核心结论:沙箱 IDE 将编码代理从"聊天工具"升级为"开发环境"。用户不再需要逐条翻消息看代码——他们可以像用 VS Code 一样浏览文件、查看差异、审查变更。这种体验的差距,正是"玩具"和"产品"之间的距离。