前端测试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/chunk → block-start 创建对应类型的内容块
assistant/chunk → reasoning-delta 追加到思考区域
assistant/chunk → text-delta 追加到正文区域
assistant/chunk → tool-call-delta 展示工具参数准备过程;参数可能尚不是完整 JSON
assistant/chunk → block-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.mux 的 since 恢复参数尚未实现,不能假设断线后的事件会自动补发。推荐流程:

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.ok 和 result.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,方便双方定位一次请求。

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

相关推荐
树下水月26 分钟前
Typora破解
linux·服务器·前端
用户5508492902561 小时前
CSS布局实战:从Flex到Grid的完整避坑指南
前端
用户5508492902561 小时前
前端接口请求层怎么封装?一个可落地的请求方案
前端
MetWeave1 小时前
跟着一条 SPECI 走流水线:从电报到界面
前端
Helix2501 小时前
Chrome 开发者工具进阶:长截图、网络调试与性能分析
前端·chrome·chrome devtools·使用技巧·性能分析·开发者工具·长截图
liangshanbo12152 小时前
面试题:Webpack 的 publicPath 有什么作用?
前端·webpack·node.js
mjhcsp2 小时前
DeepSeek V4.1 Flash 批量处理效能实测
java·前端·数据库
CHEEVEN_QY3 小时前
新能源汽车电池托盘FSW量产的工艺稳定性控制
java·前端·汽车
Alice-YUE3 小时前
前端 × AI:从 Cursor 到 Transformer,一份完整认知路径
前端·人工智能·transformer·ai编程·前端开发·cursor
Code_Solitude3 小时前
C语言:关于二维数组的作业总结
java·c语言·前端