可以让前端团队重做登录页和对话页,同时复用现有后端协议。目前 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,方便双方定位一次请求。
以上协议已经对照当前工作区源码;尚未连接你们的实际联调环境,因此线上版本、账号和部署隔离效果仍需按这些步骤验证。