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; 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 }; }