feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
- 帧壳 `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:
1 parent
6a545badbd
commit
eaada3af5d
34 files changed
+2457
-223
No files matched your search
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user