feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提
- 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` /
  `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡
- 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤
- `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标
- `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效)
- 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变)
- 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言)
- 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result
  等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
This commit is contained in:
AskaEth committed 2026-10-05 11:09:24 +08:00
1 parent 6a545badbd
commit eaada3af5d
34 files changed
+2457 -223

No files matched your search

+36 -8
View File
@@ -1,10 +1,10 @@
import { useCallback, useState } from "react";
import { chatStream } from "./api";
import { ApiError, chatStream } from "./api";
import { connectWs, isWsOpen } from "./ws";
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { getClientId } from "./client";
import { now } from "./util";
import { now, uid } from "./util";
/** Drives one user turn: push the user bubble, blanket a streaming assistant
* bubble, trigger the backend loop over REST (`stream:true`), and let the WS
@@ -16,7 +16,7 @@ import { now } from "./util";
* renders per that message's mode. We read the current UI mode here and pass it
* on the turn so the backend persists it alongside the message (A3). */
export function useSendChat(): {
send: (text: string, image?: string) => Promise<void>;
send: (text: string, image?: string, opts?: { parallel?: boolean }) => Promise<void>;
sending: boolean;
} {
const [sending, setSending] = useState(false);
@@ -27,10 +27,12 @@ export function useSendChat(): {
const appendChunk = useAppStore((s) => s.appendChunk);
const addTool = useAppStore((s) => s.addTool);
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
const markLocalDelivery = useAppStore((s) => s.markLocalDelivery);
const alignDuplicateMessage = useAppStore((s) => s.alignDuplicateMessage);
const mode = useModeStore((s) => s.mode);
const send = useCallback(
async (text: string, image?: string) => {
async (text: string, image?: string, opts: { parallel?: boolean } = {}) => {
const trimmed = text.trim();
if ((!trimmed && !image) || sending) return;
setSending(true);
@@ -42,15 +44,41 @@ export function useSendChat(): {
// Tag this browser's persistent client id onto the optimistic bubbles so the
// source label shows immediately and matches the persisted turn (design/16 §4.5).
const clientId = getClientId();
addUserMessage(trimmed, undefined, image, clientId);
// 幂等键(契约 §4.3):随上行一起发,服务端判重命中会回 `chat_duplicate` 帧或
// `{duplicate:true,messageId}`(REST 面),本端据此把本地气泡对齐到既有 id ——
// 重发/双发不会多出一条气泡。
const clientMsgId = uid();
addUserMessage(trimmed, undefined, image, clientId, clientMsgId);
beginAssistant(mode, clientId);
try {
await chatStream(sessionId, prompt, image, mode);
const res = await chatStream(
sessionId,
prompt,
image,
mode,
clientMsgId,
opts.parallel
);
// 响应面得如实消费(契约 §4.6):202 排队不是错误。
// `pending_ack` 帧是队列深度的真源,这里只做「万一那条帧没到」的兜底标注;
// `duplicate` 同理(同一条事实的 REST 面)。
if (res.duplicate && res.messageId) {
alignDuplicateMessage(clientMsgId, res.messageId, sessionId);
} else if (res.queued) {
markLocalDelivery("queued");
}
} catch (err: unknown) {
endStream();
// ⑥ 被拒:`409 chat_busy`(队列满)—— 这不是「触发失败」,气泡要明确说
// 「未送达」,让人知道这句话没进去(而不是只看到一行错误日志)。
if (err instanceof ApiError && err.code === "chat_busy") {
markLocalDelivery("rejected", { rejectReason: "会话正忙:排队已满,这条未送达" });
appendTaskLog("main", "[chat_busy] 排队已满,本条未送达\n");
return;
}
// Backend loop not wired or gateway unreachable: show the failure inline
// rather than leaving a permanently-streaming bubble.
endStream();
const msg = err instanceof Error ? err.message : "触发失败";
appendChunk(`[stream 触发失败] ${msg}`);
appendTaskLog("main", `[error] ${msg}\n`);
@@ -59,7 +87,7 @@ export function useSendChat(): {
setSending(false);
}
},
[sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog]
[sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog, markLocalDelivery, alignDuplicateMessage]
);
return { send, sending };