import { ref } from "vue"; import { parseJsonEventStream, uiMessageChunkSchema } from "ai"; import { useAuthSession } from "./useAuthSession"; export interface CharacterMessage { id: string; role: "user" | "assistant"; content: string; createdAt?: string; } export interface UseCharacterChatOptions { cardSlug: () => string; sessionId: () => string | null; modelId: () => number | null; onStreamComplete?: () => void; } function generateId(): string { return Date.now().toString(36) + Math.random().toString(36).slice(2); } /** * 角色对话(纯文本流式),与 useAgentChat 解耦: * 不处理工具/思考/审批/图片,只处理 text-delta。 */ export function useCharacterChat(options: UseCharacterChatOptions) { const { cardSlug, sessionId, modelId, onStreamComplete } = options; const auth = useAuthSession(); const messages = ref([]); const isLoading = ref(false); const errorMessage = ref(""); const isStopped = ref(false); let abortController: AbortController | null = null; async function loadMessages(sid: string) { messages.value = []; errorMessage.value = ""; try { const res = await $fetch<{ code: number; data: { messages: CharacterMessage[] } }>( `/api/characters/${cardSlug()}/sessions/${sid}/messages`, ); messages.value = res.data?.messages ?? []; } catch { messages.value = []; } } async function processStream(res: Response, assistantIdx: number) { if (!res.body) throw new Error("响应体为空"); const chunkStream = parseJsonEventStream({ stream: res.body, schema: uiMessageChunkSchema, }); const reader = chunkStream.getReader(); for (;;) { const { done, value: parsed } = await reader.read(); if (done) break; if (!parsed.success) continue; const chunk = parsed.value; if (chunk.type === "text-delta") { const msg = messages.value[assistantIdx]; if (msg) msg.content += chunk.delta; } else if (chunk.type === "error") { errorMessage.value = chunk.errorText || "流式响应出错"; } } } async function send(content: string) { const textContent = content.trim(); const sid = sessionId(); if (!textContent || sid === null || isLoading.value) return; if (!modelId()) { errorMessage.value = "请先选择模型后再发送消息"; const { $toast } = useNuxtApp(); $toast?.error?.("请先选择模型后再发送消息"); return; } errorMessage.value = ""; isStopped.value = false; messages.value.push({ id: generateId(), role: "user", content: textContent }); const userIdx = messages.value.length - 1; messages.value.push({ id: generateId(), role: "assistant", content: "" }); const assistantIdx = messages.value.length - 1; isLoading.value = true; abortController = new AbortController(); try { const res = await fetch(`/api/characters/${cardSlug()}/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sid, content: textContent, modelId: modelId() }), signal: abortController.signal, credentials: "include", }); const dbUserMessageId = res.headers.get("X-User-Message-Id"); if (dbUserMessageId && userIdx >= 0) { messages.value[userIdx]!.id = dbUserMessageId; } if (!res.ok) { const errText = await res.text(); throw new Error(errText || `请求失败 (${res.status})`); } await processStream(res, assistantIdx); onStreamComplete?.(); } catch (err: any) { if (err.name === "AbortError") { isStopped.value = true; } else { errorMessage.value = err.message || "请求失败"; const msg = messages.value[assistantIdx]; if (msg && !msg.content) { messages.value.splice(assistantIdx, 1); } } } finally { isLoading.value = false; abortController = null; } } function stopGeneration() { if (abortController) { abortController.abort(); abortController = null; } } async function stopAndSave() { const sid = sessionId(); stopGeneration(); isStopped.value = true; if (sid) { try { await $fetch(`/api/characters/${cardSlug()}/chat/stop`, { method: "POST", body: { sessionId: sid }, }); } catch { } } } function clear() { messages.value = []; errorMessage.value = ""; isStopped.value = false; } return { messages, isLoading, errorMessage, isStopped, send, stopGeneration, stopAndSave, clear, loadMessages, }; }