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