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 { 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. Falls back to the WS `chat` channel when the * REST trigger fails so the demo still works without a wired loop. * * The behavior mode is NOT chosen by the user at send time — the agent / backend * hot-switches it (design/01 §3), and each message carries its own mode, so the * bubble renders per that message's mode. Until the backend advertises a * per-message mode, a new assistant bubble defaults to the current UI mode. */ export function useSendChat(): { send: (text: string, image?: string) => Promise; sending: boolean; } { const [sending, setSending] = useState(false); const sessionId = useAppStore((s) => s.sessionId); 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 ? "分析这张图片" : ""); addUserMessage(trimmed, undefined, image); beginAssistant(mode); try { await chatStream(sessionId, prompt, image); } 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 }; }