import { useCallback, useState } from "react"; import { 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"; /** 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) => 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 mode = useModeStore((s) => s.mode); const send = useCallback( async (text: string, image?: string) => { 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(); addUserMessage(trimmed, undefined, image, clientId); beginAssistant(mode, clientId); try { await chatStream(sessionId, prompt, image, mode); } catch (err: unknown) { // 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`); addTool({ name: "stream_trigger", summary: "失败", ok: false, detail: msg, at: now() }); } finally { setSending(false); } }, [sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog] ); return { send, sending }; }