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.
185 lines
6.0 KiB
185 lines
6.0 KiB
import { streamText, type LanguageModel, type ModelMessage } from "ai";
|
|
import { resolveModelForUser, resolveModelAny, toLanguageModel } from "#server/service/llm/model-resolver";
|
|
import { buildCharacterSystemPrompt } from "./persona";
|
|
import {
|
|
getMessagesByCharacterSession,
|
|
getMaxCharacterSortOrder,
|
|
saveCharacterMessage,
|
|
touchCharacterSession,
|
|
countCharacterMessagesByRole,
|
|
updateCharacterSession,
|
|
} from "./session";
|
|
import { registerCharacterAbort, unregisterCharacterAbort } from "./abort";
|
|
import type { CharacterCardRow, CharacterSessionRow } from "./types";
|
|
import log4js from "logger";
|
|
|
|
const logger = log4js.getLogger("APP");
|
|
|
|
export interface CharacterChatEngineParams {
|
|
card: CharacterCardRow;
|
|
session: CharacterSessionRow;
|
|
user: { id: number } | null;
|
|
content: string;
|
|
preferredModelId?: number | null;
|
|
globalDefaultModelId?: number | null;
|
|
requestId?: string;
|
|
onClientClose?: (cb: () => void) => void;
|
|
}
|
|
|
|
export interface CharacterChatEngineResult {
|
|
response: Response;
|
|
userMessageId: string | null;
|
|
}
|
|
|
|
function truncateFirstLine(text: string): string {
|
|
const firstLine = text.split("\n")[0]?.trim() ?? "";
|
|
if (firstLine.length <= 50) return firstLine;
|
|
return firstLine.slice(0, 50) + "…";
|
|
}
|
|
|
|
/**
|
|
* 角色对话引擎(纯文本流式)。
|
|
*
|
|
* 与 agent 的 chat-engine 完全解耦:不支持工具调用、审批、思考模式、
|
|
* 持久记忆注入、多步推理。只做一件事 —— 用组装好的人设上下文
|
|
* (buildCharacterSystemPrompt)做角色扮演式流式回复。
|
|
*/
|
|
export async function executeCharacterChat(
|
|
params: CharacterChatEngineParams,
|
|
): Promise<CharacterChatEngineResult> {
|
|
const { card, session, user, content, requestId = "-" } = params;
|
|
const sessionId = session.id;
|
|
const textContent = content.trim();
|
|
|
|
// 1. 模型解析(复用 llm 原语):session > 本次请求覆盖 > card > 全局默认
|
|
const targetModelId =
|
|
session.modelId ??
|
|
params.preferredModelId ??
|
|
card.defaultModelId ??
|
|
params.globalDefaultModelId ??
|
|
null;
|
|
|
|
if (!targetModelId) {
|
|
throw createError({ statusCode: 400, statusMessage: "未配置模型,请先在设置中选择模型" });
|
|
}
|
|
|
|
const resolvedModel = user
|
|
? await resolveModelForUser(targetModelId, user.id)
|
|
: await resolveModelAny(targetModelId);
|
|
|
|
if (!resolvedModel) {
|
|
throw createError({ statusCode: 404, statusMessage: "模型不存在或无权访问" });
|
|
}
|
|
if (!resolvedModel.provider.apiKey) {
|
|
throw createError({ statusCode: 400, statusMessage: "供应商未配置 API Key" });
|
|
}
|
|
|
|
const languageModel: LanguageModel = toLanguageModel(resolvedModel);
|
|
|
|
// 2. 上下文自主设计:由角色卡组装 system prompt
|
|
const systemPrompt = buildCharacterSystemPrompt(card);
|
|
|
|
// 3. 持久化用户消息
|
|
const sortOrder = (await getMaxCharacterSortOrder(sessionId)) + 1;
|
|
const userMessage = await saveCharacterMessage({
|
|
sessionId,
|
|
role: "user",
|
|
content: textContent,
|
|
sortOrder,
|
|
});
|
|
|
|
// 首条用户消息 → 用首行作为会话标题(无需额外 LLM 调用)
|
|
const userCount = await countCharacterMessagesByRole(sessionId, "user");
|
|
if (userCount === 1) {
|
|
const title = truncateFirstLine(textContent);
|
|
if (title) {
|
|
await updateCharacterSession(sessionId, { title });
|
|
}
|
|
}
|
|
|
|
// 4. 组装历史消息(纯文本 user/assistant)
|
|
const history = await getMessagesByCharacterSession(sessionId, { limit: 100, latest: true });
|
|
const messages: ModelMessage[] = history
|
|
.filter((m) => m.id !== userMessage.id && (m.role === "user" || m.role === "assistant"))
|
|
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content } as ModelMessage));
|
|
messages.push({ role: "user", content: textContent } as ModelMessage);
|
|
|
|
// 5. 流式生成
|
|
const controller = new AbortController();
|
|
registerCharacterAbort(sessionId, controller);
|
|
params.onClientClose?.(() => {
|
|
if (!controller.signal.aborted) {
|
|
controller.abort();
|
|
}
|
|
});
|
|
|
|
let streamedText = "";
|
|
|
|
logger.info(
|
|
"[%s] [CHAR-ENGINE] card=%s sessionId=%s model=%d",
|
|
requestId,
|
|
card.slug,
|
|
sessionId,
|
|
resolvedModel.dbId,
|
|
);
|
|
|
|
const result = streamText({
|
|
model: languageModel,
|
|
system: systemPrompt,
|
|
messages,
|
|
abortSignal: controller.signal,
|
|
onChunk: ({ chunk }) => {
|
|
if (chunk.type === "text-delta") {
|
|
streamedText += chunk.text;
|
|
}
|
|
},
|
|
onFinish: async ({ text }) => {
|
|
try {
|
|
if (controller.signal.aborted) {
|
|
unregisterCharacterAbort(sessionId);
|
|
return;
|
|
}
|
|
const finalText = text.trim();
|
|
if (finalText) {
|
|
const assistantSortOrder = (await getMaxCharacterSortOrder(sessionId)) + 1;
|
|
await saveCharacterMessage({
|
|
sessionId,
|
|
role: "assistant",
|
|
content: finalText,
|
|
sortOrder: assistantSortOrder,
|
|
});
|
|
}
|
|
await touchCharacterSession(sessionId);
|
|
unregisterCharacterAbort(sessionId);
|
|
} catch (e) {
|
|
logger.error("[%s] [CHAR-ENGINE] onFinish error: %s", requestId, e instanceof Error ? e.message : String(e));
|
|
unregisterCharacterAbort(sessionId);
|
|
}
|
|
},
|
|
onAbort: async () => {
|
|
try {
|
|
const partial = streamedText.trim();
|
|
if (partial) {
|
|
const assistantSortOrder = (await getMaxCharacterSortOrder(sessionId)) + 1;
|
|
await saveCharacterMessage({
|
|
sessionId,
|
|
role: "assistant",
|
|
content: partial,
|
|
sortOrder: assistantSortOrder,
|
|
});
|
|
}
|
|
await touchCharacterSession(sessionId);
|
|
unregisterCharacterAbort(sessionId);
|
|
} catch (e) {
|
|
logger.error("[%s] [CHAR-ENGINE] onAbort error: %s", requestId, e instanceof Error ? e.message : String(e));
|
|
unregisterCharacterAbort(sessionId);
|
|
}
|
|
},
|
|
});
|
|
|
|
const response = result.toUIMessageStreamResponse({
|
|
headers: { "X-User-Message-Id": userMessage.id },
|
|
});
|
|
|
|
return { response, userMessageId: userMessage.id };
|
|
}
|
|
|