AX 爱鲜报

综合 / 开发工具 · 2026-10-22 14:27 · 1 阅读 · 0 赞

把 Claude Code 搬进浏览器:自研 Web 交互工作台实战

基于 @anthropic-ai/claude-agent-sdk,用 Node + WebSocket 把 Claude Code 从终端搬到浏览器,实现流式输出、工具调用可视化与权限审批回灌,让 Agent 工作流可观察、可拦截、可编排。

一、前言

哈喽,我是 alien。在上一篇文章《求求你别再只用 Claude Code 写代码了!解锁一下它全能玩法?》里,我们介绍了 Claude Code 的一些其他使用场景。今天顺着这个思路,聊一聊怎样在浏览器里更便捷地使用 Claude Code,同时分享一些基于它的奇思妙想。

通过本文你能收获:

  • Claude 运行到终端之外的新思路;
  • claude-agent-sdk 的介绍与实战;
  • 如何让 Claude Code 的工作流变得可控。

在使用传统 Claude Code 的过程中,我们常遇到下面两类困扰。

场景一:对非研发用户不友好

Claude Code 本质上是一个 agent,除了写代码,也能帮我们处理流程化的工作。但对非研发工程师来说,它的上手成本比直接用豆包之类的 bot 高不少——skills 管理、MCP 管理、模型切换等等都是门槛。那么能不能把 Claude Code 搬进浏览器,做一个对用户更友好的交互界面?

场景二:结果难以干预

Claude Code 一般通过 CLI 在终端里运行、用命令交互,我们很难干涉它返回的结果。尤其是想基于 Claude Code Agent 自定义工作流,或者对返回结果做二次封装和加工时,就更麻烦了。

be92acd4b5ba2028da191e2acba7eb44.png

下面就从这两个问题入手,看看怎样把 Claude Code 搬进浏览器,并对它的启动、流程和输出结果做到有效控制。

二、基本实现思路

2.1 基本原理图

Claude Code 本身基于 Node.js 运行时。想更灵活地使用它,可以直接走 CLI;远程场景下,可以用 Docker 等方案给它提供一个稳定的沙箱环境。先看设计原理:

0355f3b3a4f70085cbeb41c1a2a5319e.png

  • 创建一个 Node 服务,把 canUseTool 审批请求异步推到浏览器,由人/机器人决策后回灌;
  • 用 WebSocket 连接 Node 和浏览器(也可以用 HTTP 流式响应);
  • Node 接收 Web 端的 query 信息,处理后传给 Agent SDK;
  • Agent SDK 负责处理 Claude Code 子进程逻辑。

一次端到端交互大致如下:

fa2478d820a3be412df47cdc4340730a.png

整个流程里非常关键的一环就是 claude-agent-sdk,先来了解它。

2.2 claude-agent-sdk

@anthropic-ai/claude-agent-sdk 是 Anthropic 官方出品的库,可以理解为「把 Claude Code 这个 CLI 当成一个可被 JS 程序驱动的高级对象」。

与 @anthropic-ai/sdk 不同,claude-agent-sdk 在它之上封装了:

  • Claude Code 的完整工具集(Read / Edit / Write / Bash / Grep / Glob / WebFetch ...);
  • Skills / MCP / CLAUDE.md 的加载与解析;
  • 多轮会话持久化(session_id + --resume 同款语义);
  • 权限审批(canUseTool 异步回调);
  • 流式事件(stream_event 透传原始 Anthropic 事件)。

这样就能通过 API 的方式操作 Claude Code CLI,还能读取当前应用上的 Claude Code 配置,以及它注册的 skills 和 plugins。

基本用法:

import { query } from '@anthropic-ai/claude-agent-sdk';

const stream = query({
  prompt: '在当前目录新建 hello.txt',
  options: {
    cwd: '/path/to/project',
    settingSources: ['user', 'project', 'local'],
    includePartialMessages: true,
    permissionMode: 'default',
    canUseTool: async (toolName, input, ctx) => { /* ... */ },
  },
});

for await (const msg of stream) {
  // msg 是 SDKMessage 的子类型之一
  console.log(msg);
}

options 的核心参数:

  1. cwd: string —— 工作目录;
  2. settingSources: ('user' | 'project' | 'local')[] —— 加载哪些配置,如 settingSources: ['user', 'project', 'local'];
值 读什么
'user' ~/.claude/ 全局配置
'project' cwd 下的 .claude/
'local' .claude/settings.local.json
  1. includePartialMessages: true —— 流式逐字的关键;
  2. permissionMode: 'default' —— 权限模式;
  3. canUseTool —— 异步权限回调;
  4. signal: AbortSignal —— 回合中断。

了解 SDK 之后,看一下简要的实现逻辑:

f23c9d10cefe05244cf2560cc9eb8f81.png

简单说明:

  • 模型用的是 MiniMax-M3,整体体验不错,写代码、做一些日常流程化的工作基本够用;
  • 为了演示对 Claude Code 的「劫持」效果,我对每轮对话的时长和 token 做了简单记录。提前声明:token 消耗数据不一定准确。

2.3 简要实现

2.3.1 整体流程

0355f3b3a4f70085cbeb41c1a2a5319e.png

一次 user_message 的链路:

  1. 浏览器输入 → user_message 帧 → Node;
  2. query({ prompt, options }) 起 SDK 会话 → spawn Claude Code 子进程;
  3. SDKMessage 流式回灌 → normalize() 归一化 → 帧推到浏览器;
  4. text_delta 帧逐字渲染;
  5. 危险工具触发 canUseTool → permission_request 帧推到浏览器;
  6. 用户决策 → permission_response 回灌 → Promise resolve;
  7. 回合结束 → turn_result + done 帧 → 记录 sessionId 供下次续接。

2.3.2 Node 端代码片段(core/session.js)

这个文件干两件事:驱动 Claude Code + 把审批推到浏览器等回灌。其他都是细节。

import { query } from '@anthropic-ai/claude-agent-sdk';

class CodeSession {
  constructor({ cwd, onFrame }) {
    this.cwd = cwd;
    this.onFrame = onFrame;        // 回帧的回调,由 server.js 注入成 ws.send
    this.sessionId = null;         // 首轮拿到后存下来,后续 resume 用
    this._permIdSeq = 0;           // 审批请求自增 id,用来对账
    this._pendingPerms = new Map(); // id -> resolve 函数,审批回灌时取
    this._abort = null;            // 当前回合的 AbortController
  }

  async send(prompt) {
    this._abort = new AbortController();
    const options = {
      cwd: this.cwd,
      // 关键:不传这个字段,SDK 不会读 ~/.claude/,你的全局 skills/MCP 全没
      settingSources: ['user', 'project', 'local'],
      includePartialMessages: true,   // 不开就没法逐字流式,只能拿到整块
      permissionMode: 'default',      // 危险工具走下面那个回调
      canUseTool: this._canUseTool.bind(this),
      signal: this._abort.signal,
    };
    if (this.sessionId) options.resume = this.sessionId;  // 第二轮起续接

    try {
      const stream = query({ prompt, options });
      for await (const msg of stream) {
        // 只在第一次出现时记录,后续消息不再覆盖
        if (msg.session_id && !this.sessionId) this.sessionId = msg.session_id;
        for (const frame of normalize(msg)) this.onFrame(frame);
      }
      this.onFrame({ type: 'done', sessionId: this.sessionId });
    } catch (err) {
      // 用户点停止 ≠ 错误。前端要的不是弹错,是"已取消"提示
      if (this._abort.signal.aborted) {
        this.onFrame({ type: 'done', aborted: true });
      } else {
        this.onFrame({ type: 'error', message: err.message });
      }
    }
  }

  stop() { this._abort?.abort(); }

  resolvePermission(id, decision) {
    // 浏览器回灌的入口,从 Map 里捞对应的 resolve 函数
    this._pendingPerms.get(id)?.(decision);
  }

  // 整份代码最绕的一段,但逻辑不复杂:
  // SDK 调这个函数要个 Promise(异步),我们就地造一个推到浏览器,等回灌
  _canUseTool(toolName, input, ctx) {
    const id = ++this._permIdSeq;
    this.onFrame({ type: 'permission_request', id, toolName, input });

    return new Promise((resolve) => {
      const finish = (decision) => {
        this._pendingPerms.delete(id);
        // 注意:allow 分支必须回传 updatedInput,
        // SDK runtime 用 Zod 强校验,只给 behavior 会炸
        resolve(
          decision === 'allow'
            ? { behavior: 'allow', updatedInput: input }
            : { behavior: 'deny', message: '用户在浏览器端拒绝' }
        );
      };
      this._pendingPerms.set(id, finish);
      // 5 分钟没人理就当拒绝,防止浏览器挂着导致 Promise 永远不结算
      setTimeout(() => finish('deny'), 5 * 60 * 1000);
    });
  }
}

// 归一化:把 SDK 的多类型事件压平成前端好处理的几种帧
// 不做这层,前端要认识 20+ 种 SDK 内部事件,改造不动
function normalize(msg) {
  if (msg.type === 'system' && msg.subtype === 'init') {
    return [{ type: 'system_init', model: msg.model, tools: msg.tools }];
  }
  // 流式文本:只挑这一种 delta,其他 stream_event 类型丢掉
  if (msg.type === 'stream_event' && msg.event?.delta?.type === 'text_delta') {
    return [{ type: 'text_delta', text: msg.event.delta.text }];
  }
  // assistant 消息里文本块已经由上面推过了,这里只取工具调用
  if (msg.type === 'assistant') {
    return msg.message.content
      .filter(b => b.type === 'tool_use')
      .map(b => ({ type: 'tool_use', id: b.id, name: b.name, input: b.input }));
  }
  if (msg.type === 'user') {
    return msg.message.content
      .filter(b => b.type === 'tool_result')
      .map(b => ({ type: 'tool_result', id: b.tool_use_id, content: b.content }));
  }
  if (msg.type === 'result') {
    return [{ type: 'turn_result', cost: msg.total_cost_usd, usage: msg.usage }];
  }
  return [];  // 其他控制类事件一律不转发,免得前端噪音
}

几个值得说的点:

  • settingSources 不传或传空数组 = 拿不到全局 skills/MCP,这是 claude code for web「零配置」的关键开关。
  • includePartialMessages: true 是流式的命门,关掉就只有完整块,逐字渲染只能装样子。
  • canUseTool 里 allow 必须回 updatedInput,SDK runtime 的 Zod schema 比 .d.ts 严格——只看类型定义写代码必踩这个坑。
  • 5 分钟超时是为了防止浏览器关掉、SDK 内部 Promise 永远挂着。这不是 nice-to-have,是必须。
  • normalize() 是解耦的关键:前端只认 N 种帧,不感知 SDK 内部协议。以后换 SDK 版本、加 MCP、改流式策略,前端都不用动。

2.3.3 前端代码片段(web/app.js)

前端就一个 dispatch:收到帧,switch 到对应渲染函数。没有业务逻辑,所有「判断」都在 Node 端做完了。

const ws = new WebSocket('ws://127.0.0.1:1717');

ws.onmessage = (e) => {
  const frame = JSON.parse(e.data);
  switch (frame.type) {
    case 'system_init':
      // 会话刚建立时来一次,告诉前端当前用啥模型、能调啥工具
      renderStatus(`Model: ${frame.model} · ${frame.tools.length} tools`);
      break;
    case 'text_delta':
      // 流式逐字的核心:每来一点就追加到当前 assistant 气泡
      appendToCurrentBubble(frame.text);
      break;
    case 'tool_use':
      // Claude 想调工具了,开个折叠卡片占位,等结果回填
      createToolCard(frame.id, frame.name, frame.input);
      break;
    case 'tool_result':
      // 工具跑完了,把结果塞回刚才那张卡片
      fillToolCard(frame.id, frame.content);
      break;
    case 'permission_request':
      // 危险操作:弹横条,等用户点;点了之后把决定发回 Node
      showApprovalBar(frame, (decision) => {
        ws.send(JSON.stringify({
          type: 'permission_response',
          id: frame.id,
          decision,                // 'allow' | 'deny'
        }));
      });
      break;
    case 'turn_result':
      // 整轮跑完的账单:花了多少钱、用多少 token
      renderMeta({ cost: frame.cost, duration: frame.duration });
      break;
    case 'done':
      unlockInput();
      if (frame.aborted) showBanner('已取消');
      break;
    case 'error':
      showError(frame.message);
      unlockInput();
      break;
  }
};

sendBtn.onclick = () => {
  const text = inputEl.value.trim();
  if (!text) return;
  lockInput();
  ws.send(JSON.stringify({ type: 'user_message', text }));
};

stopBtn.onclick = () => ws.send(JSON.stringify({ type: 'stop' });

几个值得说的点:

  • 这个文件几乎没有状态,就是一个 switch。所有「Claude 在干嘛 / 该不该锁输入框 / 该不该渲染」的状态机都在 Node 端,前端只负责表现。
  • permission_request 的处理是唯一一处异步等待——其他都是收到帧立刻渲染。这里要把 UI 锁住等用户点,点了才发回去。
  • text_delta 真正实现时要做 buffer 和安全边界——别一收到就 innerHTML += text,半截反引号会让 Markdown 解析器发疯。这块代码省略了,见项目里的 app.js。
  • aborted 字段决定 done 是「用户主动停」还是「正常结束」,UI 上要区分(已取消 vs 解锁输入框)。

2.3.4 协议帧(双向)

协议设计原则:每条消息只做一件事。浏览器发 3 种(Node 只关心「你要啥」+「你决定了啥」+「你要停」),Node 发 8 种(浏览器要感知生命周期里的关键节点)。

浏览器 → Node(3 种):

类型 字段 用途
user_message text 用户输入
permission_response id, decision 审批回灌
stop — 中断当前回合

Node → 浏览器(8 种):

类型 字段 用途
system_init model, tools, mcpServers 会话初始化(每连接来一次)
text_delta text 流式文本增量(一次回合里推 N 次)
tool_use id, name, input 工具调用开始
tool_result id, content, isError 工具执行结果
permission_request id, toolName, input 请求审批
turn_result cost, usage, duration, numTurns 整轮账单
done sessionId, aborted? 回合结束
error message 异常

协议本身没有 schema 文件,字段约定靠代码。新增帧的成本很低(加 case 即可),改字段名才会炸到对方——所以字段名一旦定下来就别乱动。

三、完成效果演示

ezgif-4b2348b4197f2003.gif

四、总结

代码仓库地址:https://github.com/GoodLuckAlien/claude-code-for-web

写到这里,Claude Code 已经从一条冰冷的终端命令,变成了浏览器里一扇可以随手推开的窗。

回头看这条链路,其实并没有多玄妙:Node 端是心脏,浏览器是脸,WebSocket 是血管,协议帧是神经末梢。我们没有重新发明 Agent,只是把原本埋在终端黑盒里的「启动 / 审批 / 输出」三件事,拆成了一段段可以观察、可以拦截、可以编排的帧。

技术这件事常常如此——真正难的从来不是造一个能跑的东西,而是让一个能跑的东西变得「可被使用」。把 CLI 塞进 Web 是一小步,但它撬动的,是 Agent 走向普通用户、走向真实工作流的可能。

如果这篇文章给了你一点新思路,欢迎点赞 / 收藏,你的支持是我继续把 Claude Code 玩出花的底气 🚀。

标签 AI Agent Node.js Claude Code WebSocket

评论

登录后才可评论

0 条评论

  • 暂无评论,来写第一条吧。