- 帧壳 `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 读端点
95 lines
4.6 KiB
TypeScript
95 lines
4.6 KiB
TypeScript
import { useCallback, useState } from "react";
|
||
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, 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
|
||
* stream render into that bubble. If the REST trigger fails, the failure is
|
||
* surfaced inline on the bubble rather than left streaming forever.
|
||
*
|
||
* The behavior mode is chosen by the agent / backend hot-switch (design/01 §3);
|
||
* each message carries the mode in effect when the turn is sent, and the bubble
|
||
* 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, opts?: { parallel?: boolean }) => Promise<void>;
|
||
sending: boolean;
|
||
} {
|
||
const [sending, setSending] = useState(false);
|
||
const sessionId = useAppStore((s) => s.activeSessionId);
|
||
const addUserMessage = useAppStore((s) => s.addUserMessage);
|
||
const beginAssistant = useAppStore((s) => s.beginAssistant);
|
||
const endStream = useAppStore((s) => s.endStream);
|
||
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, opts: { parallel?: boolean } = {}) => {
|
||
const trimmed = text.trim();
|
||
if ((!trimmed && !image) || sending) return;
|
||
setSending(true);
|
||
if (!isWsOpen()) connectWs();
|
||
|
||
// The gateway's chat body still requires a non-empty text, so a pure image
|
||
// turn (vision) falls back to a default prompt to keep the trigger valid.
|
||
const prompt = trimmed || (image ? "分析这张图片" : "");
|
||
// 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();
|
||
// 幂等键(契约 §4.3):随上行一起发,服务端判重命中会回 `chat_duplicate` 帧或
|
||
// `{duplicate:true,messageId}`(REST 面),本端据此把本地气泡对齐到既有 id ——
|
||
// 重发/双发不会多出一条气泡。
|
||
const clientMsgId = uid();
|
||
addUserMessage(trimmed, undefined, image, clientId, clientMsgId);
|
||
beginAssistant(mode, clientId);
|
||
|
||
try {
|
||
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.
|
||
const msg = err instanceof Error ? err.message : "触发失败";
|
||
appendChunk(`[stream 触发失败] ${msg}`);
|
||
appendTaskLog("main", `[error] ${msg}\n`);
|
||
addTool({ name: "stream_trigger", summary: "失败", ok: false, detail: msg, at: now() });
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
},
|
||
[sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog, markLocalDelivery, alignDuplicateMessage]
|
||
);
|
||
|
||
return { send, sending };
|
||
}
|