Files
Cyrene_v2/frontend/web/src/lib/useChat.ts
T
AskaEth eaada3af5d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 读端点
2026-10-05 11:09:24 +08:00

95 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}