前端测试websocket

可以让前端团队重做登录页和对话页,同时复用现有后端协议。目前 DeepSeek Harness 的对接方式是:HTTP 负责登录、发送消息和操作;两条 WebSocket 负责接收实时事件。

思考、正文、Skill 的新设计,应通过"事件 → 页面展示模型"的转换来实现。下面按当前工作区代码给出可直接交给前端团队的对接说明。

首先约定连接方式和接口。

建议前端页面和 API 使用同一个 HTTPS 域名,例如:

text 复制代码
页面        https://chat.example.com/
登录        https://chat.example.com/api/v1/auths/signin/employee
HTTP RPC    https://chat.example.com/api/session.prompt
会话事件    wss://chat.example.com/api/events.mux
宿主事件    wss://chat.example.com/api/events.host

这里的域名是示例,需要替换成联调环境地址。

功能 请求 说明
登录状态 GET /api/v1/auths/session 已登录返回 200,未登录返回 401
员工登录 POST /api/v1/auths/signin/employee JSON:{userid,password}
退出登录 POST /api/v1/auths/logout 成功返回 204
连接握手 POST /api/host.describe 验证 HTTP 通道和宿主能力
会话列表 POST /api/session.list 获取当前用户会话
创建会话 POST /api/session.create 返回 sessionId
获取历史 POST /api/session.history 分页返回会话事件
发送消息 POST /api/session.prompt 返回受理结果;正文从 WS 接收
Skill 列表 POST /api/skill.list 参数包含 sessionId
平台任务列表 POST /api/platformTask.list 多用户平台的排队及执行状态
取消平台任务 POST /api/platformTask.cancel 参数为 taskId
停止普通会话当前轮次 POST /api/session.cancel 参数为 sessionId
回答审批或追问 POST /api/respond 回传服务端请求的原 rpcId
会话实时事件 WS /api/events.mux 正文、思考、工具、审批等
宿主实时事件 WS /api/events.host 会话新增、运行状态、宿主错误等

不要调用 socket.send() 发送聊天消息,也不要发送 JSON 心跳。 当前两条 WS 都是只下行连接,收到客户端业务消息会以 1008 / downlink only 关闭。也不需要为每个会话单独建立 WS,events.mux 会聚合会话事件。

协议依据:连接说明会话接口


登录后,由浏览器携带 Cookie 建立连接。

登录请求:

http 复制代码
POST /api/v1/auths/signin/employee
Content-Type: application/json

{
  "userid": "联调员工账号",
  "password": "账号密码"
}

成功响应:

json 复制代码
{
  "authenticated": true
}

服务端通过 Set-Cookie 写入:

text 复制代码
__Host-dsh_session=...; Secure; HttpOnly; SameSite=Strict; Path=/

前端无需读取 Cookie,也不要把它复制到 localStorage、WS URL 或业务参数。浏览器 WS 握手会按照 Cookie 策略携带凭据;原生 WebSocket 构造函数没有自定义 Authorization 请求头参数。WebSocket 标准

多用户平台还需要后端启用认证与用户网关隔离。登录成功本身不能证明用户数据已经隔离。 当前平台网关从认证会话推导用户身份,再路由到对应运行时;前端只提交会话或任务标识,不提交用于选择用户、Pod 或运行时的地址。

依据:认证实现用户网关


前端可以先用下面的最小示例验证完整链路。

这是浏览器端 JavaScript 接线示例。它验证登录后的连接、发送和收帧;正式产品的重连和事件合并规则见后文。

js 复制代码
// 所有请求均访问页面同源地址。
async function rpc(method, payload, rpcId = crypto.randomUUID()) {
  const response = await fetch(`/api/${method}`, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      type: "client-request",
      rpcId,
      method,
      payload,
    }),
  });

  // 平台 session.prompt 成功可以返回 202,所以不要只判断 status === 200。
  if (!response.ok) {
    throw new Error(`${method}: HTTP ${response.status}`);
  }

  const envelope = await response.json();

  if (
    envelope.type !== "server-response" ||
    envelope.rpcId !== rpcId
  ) {
    throw new Error(`${method}: 响应协议不匹配`);
  }

  if (!envelope.result.ok) {
    throw new Error(
      `${envelope.result.error.code}: ${envelope.result.error.message}`
    );
  }

  return envelope.result.value;
}

function openDownlink(path, onFrame) {
  const url = new URL(path, location.origin);
  url.protocol = location.protocol === "https:" ? "wss:" : "ws:";

  const socket = new WebSocket(url);

  socket.addEventListener("message", ({ data }) => {
    try {
      const envelope = JSON.parse(data);
      if (
        envelope.type !== "server-request" ||
        envelope.method !== envelope.payload?.type
      ) {
        throw new Error("事件协议不匹配");
      }
      onFrame(envelope);
    } catch (error) {
      console.error("收帧或事件处理失败", error);
    }
  });

  const ready = new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      socket.close();
      reject(new Error(`${path}: 连接超时`));
    }, 10_000);

    socket.addEventListener("open", () => {
      clearTimeout(timer);
      resolve();
    }, { once: true });

    socket.addEventListener("close", () => {
      clearTimeout(timer);
      reject(new Error(`${path}: 连接已关闭`));
    }, { once: true });
  });

  return { socket, ready };
}

// 在登录成功后调用;onFrame 交给事件适配层处理。
async function connectChat(onFrame) {
  const mux = openDownlink("/api/events.mux", onFrame);
  const host = openDownlink("/api/events.host", onFrame);

  try {
    await Promise.all([
      mux.ready,
      host.ready,
      rpc("host.describe", {}),
    ]);

    return {
      close() {
        mux.socket.close();
        host.socket.close();
      },
    };
  } catch (error) {
    mux.socket.close();
    host.socket.close();
    throw error;
  }
}

// 联调时先打印事件元信息,确认通路;正式使用时替换为 store.dispatch。
const connection = await connectChat((envelope) => {
  console.debug(
    envelope.method,
    envelope.payload.sessionId,
    envelope.payload.event?.seq
  );
});

const { sessionId } = await rpc("session.create", {});

// 一次用户发送动作分配一个 rpcId。
const promptRpcId = crypto.randomUUID();

const receipt = await rpc("session.prompt", {
  sessionId,
  mode: "queue",
  content: [{ type: "text", text: "请用三句话介绍这个系统。" }],
  clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}, promptRpcId);

console.log("消息受理结果", receipt);

普通会话可能返回:

json 复制代码
{
  "accepted": true
}

当前多用户平台网关的典型响应是 HTTP 202 ,其中 result.value 为:

json 复制代码
{
  "accepted": true,
  "taskId": "8e742ce0-5d2d-4a21-8b93-588074db90fb",
  "state": "queued"
}

此时页面应显示"排队中"。后续用 platformTask.list 获取任务状态,按需轮询;进入执行后,再显示正在生成的内容。受理成功不代表已经开始生成,也不代表回答完成。


思考、正文和 Skill 应怎样对应新版组件。

实际 WS 数据有两层:外层 RPC 信封,内层事件。下面是一条思考增量的示例:

json 复制代码
{
  "type": "server-request",
  "rpcId": "push-001",
  "method": "session/event",
  "payload": {
    "type": "session/event",
    "sessionId": "session-demo",
    "event": {
      "type": "assistant/chunk",
      "seq": 12,
      "time": 1789200000000,
      "data": {
        "turn": 1,
        "step": 1,
        "chunk": {
          "type": "reasoning-delta",
          "index": 0,
          "text": "先核对已有资料。"
        }
      }
    }
  }
}

正文增量仍然是 assistant/chunk,其 data.chunk 变成:

json 复制代码
{
  "type": "text-delta",
  "index": 1,
  "text": "这个系统支持多用户登录和对话。"
}

前端适配层可以按下表处理:

后端事件 新版 UI 行为
assistant/chunkblock-start 创建对应类型的内容块
assistant/chunkreasoning-delta 追加到思考区域
assistant/chunktext-delta 追加到正文区域
assistant/chunktool-call-delta 展示工具参数准备过程;参数可能尚不是完整 JSON
assistant/chunkblock-end 用完整块校准该内容块
assistant/message 用完整消息定稿,替换对应流式临时内容
tool/call 创建工具调用卡片
tool/result 按调用 ID 更新工具结果
turn/end 按结束原因显示完成、取消、失败等状态
host/agent-error 显示没有完整轮次位置的运行错误
approval/requested 在对话页显示审批卡片
question/requested 在对话页显示追问表单

这里有四个必须对齐的细节:

  • 内容块至少按 sessionId + turn + step + index 关联,不能把所有文本追加到全局"最后一个气泡"。
  • 去重用 sessionId + event.seq 。不同会话的 seq 可以相同。
  • assistant/message 是完整结果,不能再追加一遍,否则正文会重复。
  • 一轮可能包含多次模型调用和工具调用。单个块结束、单个消息完成,都不等于整轮完成。

思考区域仅展示后端实际提供的 reasoning 内容;没有 reasoning 时正常显示正文,不构造虚假的思考文本。

如果继续使用现有客户端架构,建议保留连接层和会话事件组装层,只替换渲染组件。其现有逻辑已经覆盖流式累积、历史分页和内容定稿:客户端运行时说明


Skill 要区分"加载了指令"和"任务完成"。

当前 Skill 有两种主要路径。

用户主动选择 Skill 时,先请求目录:

js 复制代码
const { skills } = await rpc("skill.list", { sessionId });

假设目录实际返回了 report-summary,前端可以发送:

js 复制代码
await rpc("session.prompt", {
  sessionId,
  mode: "queue",
  content: [{
    type: "text",
    text: "/report-summary 请总结下面这份报告:......",
  }],
});

report-summary 只是示例,必须换成目录中存在的名字。

后端加载后,会出现 user/message 事件,其 event.data.source 为:

json 复制代码
{
  "kind": "skill-invocation",
  "name": "report-summary",
  "form": "instructions"
}

这个事件应映射为"已加载 report-summary"或可折叠的 Skill 指令卡片。虽然它使用 user/message 类型,它的来源不是用户输入,不应显示为一条普通用户气泡。

模型自主加载 Skill 时,则会出现:

text 复制代码
tool/call,name = "skill"
        ↓
tool/result

此路径按工具调用 ID 关联加载结果。工具结果中的 toolCallId 位于:

text 复制代码
event.data.message.content[0].toolCallId

建议新版设计明确区分:

展示文案 依据
已选择 Skill 用户在输入框选择,属于前端草稿状态
已加载 Skill 显式调用的 skill-invocation,或成功的 skill 工具结果
加载失败 skill 工具失败等明确错误
正在生成回答 会话或平台任务处于执行状态
本轮完成 turn/end,平台任务状态也应最终收敛

当前没有通用的 skill.start / skill.progress / skill.end 事件。 如果新设计要求"Skill 完成 60%""Skill 的三个步骤分别完成",需要后端新增可验证的进度数据;仅靠前端动画无法获得真实进度。

依据:Skill 接口Skill 来源映射


刷新和断线恢复,要按"重新连接 + 历史校准"联调。

当前 events.muxsince 恢复参数尚未实现,不能假设断线后的事件会自动补发。推荐流程:

text 复制代码
任意一条 WS 断开
    ↓
结束当前连接批次,关闭另一条 WS
    ↓
检查登录状态
    ├─ 401:清理当前用户状态,回到登录页
    └─ 仍有效:退避重连两条 WS,并重新执行 host.describe
                    ↓
             接收并暂存实时事件
                    ↓
             重新拉取会话列表、当前会话历史、平台任务状态
                    ↓
             按 sessionId + seq 合并、排序、去重
                    ↓
             重建当前显示窗口,恢复实时更新

历史请求:

js 复制代码
const history = await rpc("session.history", {
  sessionId,
  maxMessages: 30,
});

继续向前翻页:

js 复制代码
await rpc("session.history", {
  sessionId,
  beforeSeq: oldestLoadedSeq,
  maxMessages: 30,
});

其中返回的是 events: [{event, view?}, ...]。历史和实时事件应进入同一套 reducer/组装逻辑,确保刷新前后显示一致。

还需要处理:

  • 重连期间保留输入框草稿,但不要自动重新发送提示词。
  • 当前平台以用户身份和 rpcId 识别重复提交;相同请求重试应保留原 rpcId 和原内容。不要把此能力泛化为所有接口都有幂等保证。
  • 两条 WS 之间没有统一到达顺序;状态事件可能先于消息事件到达。
  • 退出或切换账号时,清除连接、会话缓存、事件缓冲和待处理审批,防止旧账号数据短暂闪现。
  • 平台任务取消用 platformTask.cancel;取消响应可能只是已记录取消意图,应等待最终状态。session.cancel 停止当前轮次,但会保留待处理输入队列。

事件与恢复约定:事件协议


前后端分开开发时,先打通同源代理。

建议把新版前端放到联调域名下,将 /api/ 转发给多用户平台网关。当前后端会检查 Host 和 Origin,仅增加 CORS 响应头不足以解决对接。

Nginx 示例,后端地址和超时时间按环境替换:

nginx 复制代码
# 放在 http 块内
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

# 放在已配置 TLS 的 server 块内
location /api/ {
    # 无尾部 /,保留原始 /api/... 路径
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    proxy_read_timeout 3600s;
}

同时把实际对外域名配置到后端 trustedHosts;开发代理也要保证后端看到的 Host 与浏览器 Origin 的 authority 一致。

Nginx 的 Upgrade 请求头需要显式转发;默认空闲读取超时可能导致约 60 秒后断开,应结合部署的超时和服务端心跳策略处理。Nginx 官方说明


联调建议分三轮,每轮都有明确验收结果。

第一轮只验证通路。准备两个独立账号 A、B,使用不同浏览器 Profile 或隔离的浏览器上下文登录。浏览器开发者工具应能观察到:

text 复制代码
登录                         200
登录状态                     200
events.mux                   101
events.host                  101
host.describe                200
session.create               200
session.prompt               200 或平台的 202
WS 收到当前 sessionId 的事件
最终出现 turn/end 或明确错误

若 WS 失败,按现象定位:

现象 优先检查
登录 200,后续请求 401 Secure Cookie 是否被保存、是否使用 HTTPS、是否同源
HTTP 或 WS 握手 403 trustedHosts、Host、Origin、代理配置
WS 路径普通 GET 返回 426 未发生 WebSocket Upgrade
WS 连接后以 1008 关闭 是否调用了 socket.send(),包括 JSON 心跳
周期性约 60 秒断开 代理空闲超时
session.prompt 返回 202,没有立即输出 任务排队、调度或用户运行时准备状态
HTTP 200,但业务失败 检查 result.okresult.error

第二轮测试新版展示。建议录制脱敏事件,再用同一适配层回放,避免每次依赖模型生成相同结果。至少准备"纯正文、思考后正文、Skill 加载、工具失败、取消、审批、断线重叠事件"几组数据。模型是否返回思考内容具有条件性,确定性的组件测试应使用 fixture。

第三轮执行以下验收用例:

用例 前置条件与操作 验收结果
基础对话 A 新建会话,发送"只回复联调成功" 一个用户气泡、一个正常回答;结束后停止加载
思考与正文 使用支持 reasoning 的模型发送测试问题 reasoning 与正文分别展示;无 reasoning 时页面正常
显式 Skill 从真实目录选 Skill,发送 /技能名 任务 出现对应 Skill 来源;不误显示成普通用户输入
模型加载 Skill 触发模型调用 skill 工具 调用与结果正确关联;加载完成不误标整轮完成
多步工具调用 发送需要工具后继续回答的任务 工具卡片与后续正文保持顺序,消息不覆盖
排队 创建超过可立即执行数量的任务 202 显示排队;开始、完成和失败状态可收敛
停止生成 生成途中点击停止 已生成内容保留;显示取消,不能显示成功
刷新恢复 生成途中刷新页面 恢复已有内容,继续接收,正文不重复
断线恢复 生成中断网,再恢复 两条 WS 重建,历史校准后内容不丢、不重复
多用户隔离 A、B 分别发送不同标记;B 请求 A 的会话 ID 列表、历史、WS 和操作均不能泄漏或改变 A 的数据
同用户双标签 A 在两个标签页打开同一会话,从一个发送 另一个同步更新;发送动作不被重复执行
登录失效 流式生成中退出或使会话失效 旧 WS 终止、停止接收数据、清除账号缓存
审批与追问 触发审批/问题,回答后刷新 能继续执行;已解决交互不重复提交
长文本渲染 输出代码块、表格、长正文及 HTML 字符串 滚动稳定、Markdown 正常,HTML 不作为任意脚本执行

审批回复要使用服务端原始 rpcId

js 复制代码
await fetch("/api/respond", {
  method: "POST",
  credentials: "same-origin",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    type: "client-response",
    rpcId: approvalEnvelope.rpcId,
    result: {
      ok: true,
      value: {
        sessionId: approvalEnvelope.payload.sessionId,
        approvalId: approvalEnvelope.payload.approvalId,
        outcome: "allowed-once",
      },
    },
  }),
});

之后检查返回的 accepted,并通过 approval/resolved 更新最终状态。即使产品只有登录和对话两页,这类交互也需要在对话页内有承载位置。

前端团队开始前,后端应交付:联调域名、对应部署版本、两个测试账号、实际 Skill 清单、上述接口与事件样例 。前端交付:登录状态管理、连接与恢复逻辑、事件适配层、新版组件及测试记录 。联调记录建议包含 rpcId、sessionId、taskId、event.seq,方便双方定位一次请求。

以上协议已经对照当前工作区源码;尚未连接你们的实际联调环境,因此线上版本、账号和部署隔离效果仍需按这些步骤验证。

相关推荐
南京兴帝文化传媒有限公司1 小时前
基于地图平台的本地商户信息优化:药店夜间服务标注与客户转化实操
前端·javascript·数据库·人工智能·geo 优化·geo优化避坑·ai搜索获客
八荒启·交互动画1 小时前
Web特效04——GPUvs CPU,为什么图形计算要交给GPU,什么是“并行计算”
前端
tachibana21 小时前
WebSocket 和 SSE 通信的区别及局限性
网络·人工智能·websocket·网络协议·ai·llm·agent
呃呃呃呃ex1 小时前
2026年古法编程的末法时代,如何评估自己完成迅速转行
前端·后端
南京兴帝文化传媒有限公司2 小时前
地图SEO与AI搜索优化结合实践:宁国摄影工作室本地获客案例分析
大数据·前端·人工智能·geo 优化·geo优化避坑
用户921080262862 小时前
前端 Vue 专栏 07:模板编译、虚拟 DOM、Diff 与 key
前端
光影少年2 小时前
react navite process.nextTick 和 Promise.then 的执行顺序
前端·react native·react.js
雪芽蓝域zzs2 小时前
第四十二节:全局字典封装(后端字典,下拉选择复用)
开发语言·前端·javascript
用户64596598710882 小时前
Nginx + Vue Router 基础:SPA History 部署、反向代理与请求分流
前端