Files
Cyrene_v2/frontend/web/src/lib/useChat.ts
T
AskaEth 747b9ad414
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 聊天请求携带行为模式 mode(A3) — useChat 读当前 mode 经 REST 发送
- api.ts chatStream 新增可选 mode,空则不写键(后端回落chat)
- useChat.ts 发送时带上 useModeStore 当前 mode,使非chat模式消息在Web端真正落库
- 修正 docblock(移除虚构的WS chat回退描述)
2026-09-08 21:07:59 +08:00

67 lines
3.0 KiB
TypeScript

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