You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

171 lines
4.7 KiB

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<CharacterMessage[]>([]);
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,
};
}