我把 Claude Code 搬到网页!自研高颜值 Web 交互工作台

一 前言

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

通过本篇文档你能收获到哪些内容?

  • claude 运行到终端的新思路;
  • claude-agent-sdk 介绍和使用实战;
  • 如何让 claude code 的工作流变的可控制的。

我们在使用传统的 claude code 过程中,可能有如下使用困扰场景:

场景一:对于一个非研发的使用者来说不友好

我们都知道 Claude Code 本质上是一个 agent, agent 除了写代码之外,也能够帮我们做一些流程化的与工作相关的事情,来为工作赋能,对于非研发工程师来说,Claude Code 上手成本可能要比使用豆包 bot 交互要复杂很多,包括 skills 管理,mcp 管理,模型切换等等。

那么可以不可以把 Claude Code 搬进到浏览器中,编程对于用户更友好的终端交互界面。

场景二: Claude Code 一般场景下都是通过 Cli 运行在终端,通过终端命令进行交互的。这样我们对于 Claude Code 返回的结果很难去干涉,尤其是我们想要基于 Claude Code Agent 自定义一个工作流,或者是基于 Claude Code 返回的结果进行二次封装或者加工。

那么我们今天针对如上两个问题入手,看一下怎么样把 Claude Code 搬进浏览器,并且可能做到对 Claude Code 的启动,流程,输出结果进行有效控制。

二 基本实现思路

2.1 基本原理图

Claude Code 本身基于 Node.js 运行时环境,如果想要更灵活的运用 Claude Code ,本可以直接用 Cli 的方式,远程的场景下,可以通过 docker 等方案给 Claude Code 提供一个稳定的沙箱环境。 我们直接来看一下设计原理:

  • 创建一个 Node 服务,异步化 canUseTool------审批请求推到浏览器,人/机器人决策后回灌;
  • 通过 WebSocket 链接 Node 和浏览器,这些也可以用 http 流式响应;
  • Node 接收 web 端的 query 信息,处理 query, 传递给 Agent SDK;
  • Agent SDK 处理 Claude Code 子进程逻辑;

一次端到端的交互应该如下所示:

整个流程中非常关键的一环就是 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 ,同样还可以读当前应用上的 Claude Code 配置,以及 Claude Code 注册的 skills 和 plugins。

我们来看一下基本使用:

js 复制代码
  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 ------ 工作目录;
    1. settingSources: ('user' | 'project' | 'local')\[\] ------ 加载哪些配置 settingSources: ['user', 'project', 'local']
读什么
'user' ~/.claude/ 全局配置
'project' cwd 下的 .claude/
'local' .claude/settings.local.json
    1. includePartialMessages: true ------ 流式逐字的关键;
    1. permissionMode: 'default' ------ 权限模式;
    1. canUseTool ------ 异步权限回调;
    1. signal: AbortSignal ------ 回合中断;

介绍 claude-agent-sdk ,我们来看一下简要的实现逻辑:

这里简单说明一下:

  • 关于模型用的是 MiniMax-M3 整体使用下来的感觉还不错,写代码/做一些日常流程化的工作基本够用;
  • 为了演示一下对 Claude Code 的劫持效果,我这里对于每轮对话的时长和 token 做了一个简单的记录;提前声明:关于 token 消耗这块不一定准确;

2.3 简要实现

2.3.1 整体流程

一次 user_message 的链路:

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

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

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

js 复制代码
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 是流式的命门,关掉就只有完整块,逐字渲染只能装样子。
  • canUseToolallow 必须回 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 端做完了。

js 复制代码
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 即可),改字段名才会炸到对方------所以字段名一旦定下来就别乱动。

三 完成效果演示:

四 总结

代码仓库地址:github.com/GoodLuckAli...

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

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

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

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

相关推荐
mixuecoding1 小时前
零成本搭建全球科技热点情报站:12 个平台,6 小时,0 元
前端
用户059540174461 小时前
用了3年Mock,才发现Redis记忆存储的测试一直漏掉了60%的边界场景
前端·css
石小石Orz1 小时前
AI具身交互:实现一个会说话的3D虚拟伴侣
前端·人工智能·后端
Muen2 小时前
iOS设计模式-外观Facade
前端
Cobyte2 小时前
21.Vue Vapor 组件的实现原理
前端·javascript·vue.js
前端双越老师2 小时前
我从 0 开发的 AI Agent 智语项目发布了
前端·node.js·agent
橙某人2 小时前
LogicFlow 工作流撤销与重做:从「全量快照」到「命令模式」🎯
前端·vue.js
沉默王二2 小时前
DeepSeek这次招得太猛了,36个岗位,80%都要会Agent!
agent·ai编程·deepseek