360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import { logger } from "../logger.js";
|
||
import { type SessionTurnRecord } from "../sessions/transcriptStore.js";
|
||
import { MemoryStore } from "../memory/store.js";
|
||
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||
|
||
import { collectTextContent } from "./chatStream.js";
|
||
|
||
const TITLE_PROMPT_TIMEOUT_MS = 5000;
|
||
const TITLE_CONTEXT_MESSAGE_LIMIT = 40;
|
||
const TITLE_CONTEXT_CHAR_LIMIT = 2400;
|
||
const TITLE_CONTEXT_MESSAGE_CHAR_LIMIT = 240;
|
||
const RESTORE_TURN_LIMIT = 8;
|
||
const RESTORE_MESSAGE_CHAR_LIMIT = 480;
|
||
const RESTORE_CONTEXT_CHAR_LIMIT = 3200;
|
||
const DEFAULT_SESSION_TITLE = "新对话";
|
||
|
||
const buildSessionTitle = (message: string) => {
|
||
const normalized = message.replace(/\s+/g, " ").trim();
|
||
if (!normalized) {
|
||
return DEFAULT_SESSION_TITLE;
|
||
}
|
||
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
||
};
|
||
|
||
const appendTitleContextMessage = (
|
||
lines: string[],
|
||
role: "用户" | "助手",
|
||
content: string | undefined,
|
||
maxLength = TITLE_CONTEXT_MESSAGE_CHAR_LIMIT,
|
||
) => {
|
||
const normalized = content?.replace(/\s+/g, " ").trim();
|
||
if (!normalized) {
|
||
return;
|
||
}
|
||
lines.push(`${role}:${normalized.slice(0, maxLength)}`);
|
||
};
|
||
|
||
const buildTitleConversationContext = async (
|
||
runtime: OpencodeRuntimeAdapter,
|
||
sessionId: string,
|
||
) => {
|
||
const messages = await runtime.messages(sessionId, TITLE_CONTEXT_MESSAGE_LIMIT);
|
||
const recentMessages = messages
|
||
.filter(
|
||
(message) =>
|
||
message.info.role === "user" || message.info.role === "assistant",
|
||
)
|
||
.map((message) => ({
|
||
role: message.info.role,
|
||
content: collectTextContent(message.parts)
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.slice(0, TITLE_CONTEXT_MESSAGE_CHAR_LIMIT),
|
||
}))
|
||
.filter((message) => message.content.length > 0);
|
||
|
||
if (recentMessages.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const formattedMessages = recentMessages.map(
|
||
(message) => `${message.role === "user" ? "用户" : "助手"}:${message.content}`,
|
||
);
|
||
const fullConversation = formattedMessages.join("\n");
|
||
if (fullConversation.length <= TITLE_CONTEXT_CHAR_LIMIT) {
|
||
return fullConversation;
|
||
}
|
||
|
||
const headCount = Math.min(4, formattedMessages.length);
|
||
const tailCount = Math.min(8, Math.max(0, formattedMessages.length - headCount));
|
||
const middleOmitted = formattedMessages.length > headCount + tailCount;
|
||
const summary = [
|
||
...formattedMessages.slice(0, headCount),
|
||
...(middleOmitted ? ["……(中间省略若干轮对话)"] : []),
|
||
...formattedMessages.slice(-tailCount),
|
||
].join("\n");
|
||
|
||
return summary.slice(0, TITLE_CONTEXT_CHAR_LIMIT);
|
||
};
|
||
|
||
const normalizeGeneratedTitle = (rawTitle: string, fallback: string) => {
|
||
const normalized = rawTitle
|
||
.replace(/\s+/g, " ")
|
||
.replace(/^标题[::]\s*/i, "")
|
||
.replace(/["'“”‘’`]/g, "")
|
||
.replace(/[。!?!?,,、;;::]+$/g, "")
|
||
.trim();
|
||
if (!normalized) {
|
||
return fallback;
|
||
}
|
||
return normalized.length > 24 ? `${normalized.slice(0, 24)}...` : normalized;
|
||
};
|
||
|
||
export const shouldGenerateSessionTitle = (options: {
|
||
recentTurnCount: number;
|
||
isTitleManuallyEdited: boolean;
|
||
}) => options.recentTurnCount <= 1 && !options.isTitleManuallyEdited;
|
||
|
||
export const generateSessionTitle = async (
|
||
runtime: OpencodeRuntimeAdapter,
|
||
options: {
|
||
sessionId: string;
|
||
latestUserMessage: string;
|
||
latestAssistantMessage?: string;
|
||
fallbackTitle?: string;
|
||
},
|
||
) => {
|
||
const fallbackTitle = options.fallbackTitle?.trim();
|
||
const fallback =
|
||
fallbackTitle && fallbackTitle !== DEFAULT_SESSION_TITLE
|
||
? fallbackTitle
|
||
: buildSessionTitle(options.latestUserMessage);
|
||
let titleSessionId: string | undefined;
|
||
try {
|
||
const scopedContext: string[] = [];
|
||
appendTitleContextMessage(scopedContext, "用户", options.latestUserMessage, 480);
|
||
appendTitleContextMessage(scopedContext, "助手", options.latestAssistantMessage, 960);
|
||
const conversation =
|
||
scopedContext.length > 0
|
||
? scopedContext.join("\n")
|
||
: await buildTitleConversationContext(runtime, options.sessionId);
|
||
if (!conversation) {
|
||
return fallback;
|
||
}
|
||
|
||
const titleSession = await runtime.createSession(`title-${Date.now().toString(36)}`);
|
||
titleSessionId = titleSession.id;
|
||
const request = runtime
|
||
.prompt(
|
||
titleSession.id,
|
||
[
|
||
"你是会话标题生成器。",
|
||
"请根据下面整段多轮对话生成一个 8-16 字中文标题。",
|
||
"要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
|
||
"优先概括用户当前真实需求和助手最终结论。",
|
||
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
|
||
"不要直接照抄用户任一条消息原文。",
|
||
"只输出标题本身。",
|
||
"",
|
||
conversation,
|
||
].join("\n"),
|
||
)
|
||
.then(async () => {
|
||
await runtime.waitForSessionIdle(titleSession.id, TITLE_PROMPT_TIMEOUT_MS);
|
||
const messages = await runtime.messages(titleSession.id, 20);
|
||
const assistantMessage = [...messages]
|
||
.reverse()
|
||
.find((message) => message.info.role === "assistant");
|
||
const title = collectTextContent(assistantMessage?.parts ?? []);
|
||
return normalizeGeneratedTitle(title, fallback);
|
||
});
|
||
|
||
const timeout = new Promise<string>((resolve) => {
|
||
setTimeout(() => resolve(fallback), TITLE_PROMPT_TIMEOUT_MS);
|
||
});
|
||
|
||
return await Promise.race([request, timeout]);
|
||
} catch (error) {
|
||
logger.warn({ err: error }, "failed to generate session title, using fallback");
|
||
return fallback;
|
||
} finally {
|
||
if (titleSessionId) {
|
||
await runtime.abortSession(titleSessionId).catch((error) => {
|
||
logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session");
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
export const getConversationTurnStats = async (
|
||
runtime: OpencodeRuntimeAdapter,
|
||
sessionId: string,
|
||
) => {
|
||
const messages = await runtime.messages(sessionId, 12);
|
||
return messages.reduce(
|
||
(stats, message) => {
|
||
if (message.info.role === "user") {
|
||
stats.userMessageCount += 1;
|
||
} else if (message.info.role === "assistant") {
|
||
stats.assistantMessageCount += 1;
|
||
}
|
||
return stats;
|
||
},
|
||
{
|
||
userMessageCount: 0,
|
||
assistantMessageCount: 0,
|
||
},
|
||
);
|
||
};
|
||
|
||
export const buildPromptWithLearningContext = async (
|
||
memoryStore: MemoryStore,
|
||
actorKey: string,
|
||
projectKey: string,
|
||
options: {
|
||
recentTurns: SessionTurnRecord[];
|
||
persistedMessages?: unknown[];
|
||
message: string;
|
||
restoreConversation?: boolean;
|
||
},
|
||
) => {
|
||
const snapshot = await memoryStore.buildPromptSnapshot({ actorKey, projectKey });
|
||
const restoredConversation = options.restoreConversation === false
|
||
? ""
|
||
: buildRestoredConversationFromMessages(options.persistedMessages) ||
|
||
buildRestoredConversationContext(options.recentTurns);
|
||
if (!snapshot && !restoredConversation) {
|
||
return options.message;
|
||
}
|
||
return [snapshot, restoredConversation, `[Current user request]\n${options.message}`]
|
||
.filter(Boolean)
|
||
.join("\n\n");
|
||
};
|
||
|
||
export const shouldRestoreConversationForRuntime = (options: {
|
||
hadExistingSessionRecord: boolean;
|
||
runtimeHasConversation: boolean;
|
||
}) => !options.hadExistingSessionRecord || !options.runtimeHasConversation;
|
||
|
||
const buildRestoredConversationContext = (recentTurns: SessionTurnRecord[]) => {
|
||
const formattedTurns = recentTurns
|
||
.slice(-RESTORE_TURN_LIMIT)
|
||
.flatMap((turn) => [
|
||
`用户:${compactMessage(turn.userMessage)}`,
|
||
`助手:${compactMessage(turn.assistantMessage)}`,
|
||
])
|
||
.filter((entry) => entry.length > 0);
|
||
|
||
if (formattedTurns.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const conversation = formattedTurns.join("\n");
|
||
const trimmedConversation =
|
||
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
|
||
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
|
||
: conversation;
|
||
|
||
return [
|
||
"[Previous conversation context]",
|
||
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
|
||
trimmedConversation,
|
||
].join("\n");
|
||
};
|
||
|
||
const compactMessage = (value: string) => {
|
||
const normalized = value.replace(/\s+/g, " ").trim();
|
||
if (!normalized) {
|
||
return "";
|
||
}
|
||
return normalized.length > RESTORE_MESSAGE_CHAR_LIMIT
|
||
? `${normalized.slice(0, RESTORE_MESSAGE_CHAR_LIMIT - 3)}...`
|
||
: normalized;
|
||
};
|
||
|
||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||
|
||
const isSyntheticAssistantError = (content: string) =>
|
||
/^⚠️\s*\*\*(请求已中断|错误[::]?)/.test(content);
|
||
|
||
export const extractLatestFrontendTurn = (messages: unknown[] | undefined) => {
|
||
if (!Array.isArray(messages) || messages.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||
const assistant = messages[index];
|
||
if (!isObjectRecord(assistant) || assistant.role !== "assistant") {
|
||
continue;
|
||
}
|
||
const assistantMessage =
|
||
typeof assistant.content === "string"
|
||
? assistant.content.replace(/\s+/g, " ").trim()
|
||
: "";
|
||
if (!assistantMessage || isSyntheticAssistantError(assistantMessage)) {
|
||
continue;
|
||
}
|
||
|
||
const user = messages
|
||
.slice(0, index)
|
||
.reverse()
|
||
.find((message) => isObjectRecord(message) && message.role === "user");
|
||
if (!isObjectRecord(user) || typeof user.content !== "string") {
|
||
continue;
|
||
}
|
||
const userMessage = user.content.replace(/\s+/g, " ").trim();
|
||
if (!userMessage) {
|
||
continue;
|
||
}
|
||
|
||
return {
|
||
assistantMessage,
|
||
toolCallCount: estimateFrontendToolCallCount(assistant),
|
||
userMessage,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const buildRestoredConversationFromMessages = (messages: unknown[] | undefined) => {
|
||
if (!Array.isArray(messages) || messages.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const formattedMessages = messages
|
||
.slice(-(RESTORE_TURN_LIMIT * 2 + 2))
|
||
.flatMap((message) => {
|
||
if (!isObjectRecord(message)) {
|
||
return [];
|
||
}
|
||
|
||
const role = message.role;
|
||
const content = message.content;
|
||
if ((role !== "user" && role !== "assistant") || typeof content !== "string") {
|
||
return [];
|
||
}
|
||
|
||
const normalizedContent = compactMessage(content);
|
||
if (!normalizedContent) {
|
||
return [];
|
||
}
|
||
|
||
if (role === "assistant" && isSyntheticAssistantError(normalizedContent)) {
|
||
return [];
|
||
}
|
||
|
||
return [`${role === "user" ? "用户" : "助手"}:${normalizedContent}`];
|
||
});
|
||
|
||
if (formattedMessages.length === 0) {
|
||
return "";
|
||
}
|
||
|
||
const conversation = formattedMessages.join("\n");
|
||
const trimmedConversation =
|
||
conversation.length > RESTORE_CONTEXT_CHAR_LIMIT
|
||
? `${conversation.slice(0, RESTORE_CONTEXT_CHAR_LIMIT - 3)}...`
|
||
: conversation;
|
||
|
||
return [
|
||
"[Previous conversation context]",
|
||
"以下为当前前端对话线程中最近的历史对话,请延续其中已确认的目标、约束、结论与引用结果。",
|
||
trimmedConversation,
|
||
].join("\n");
|
||
};
|
||
|
||
const estimateFrontendToolCallCount = (assistant: Record<string, unknown>) => {
|
||
const progress = Array.isArray(assistant.progress) ? assistant.progress : [];
|
||
const artifacts = Array.isArray(assistant.artifacts) ? assistant.artifacts : [];
|
||
const toolProgressCount = progress.filter(
|
||
(item) =>
|
||
isObjectRecord(item) &&
|
||
(item.phase === "tool" ||
|
||
(typeof item.id === "string" && item.id.startsWith("tool-"))),
|
||
).length;
|
||
return Math.max(toolProgressCount, artifacts.length);
|
||
};
|