Centralize session scope, chat projections and typed task bridges. Keep historical interaction replies authoritative and publish terminal state to subscribers. Validate Server contracts before replacing generated artifacts.
372 lines
12 KiB
TypeScript
372 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 isGenericTaskCollaborationTitle = (title?: string) =>
|
||
!title?.trim() || ["任务协作", "任务协作问答", DEFAULT_SESSION_TITLE].includes(title.trim());
|
||
|
||
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;
|
||
taskTitle?: string;
|
||
},
|
||
) => {
|
||
const fallbackTitle = options.fallbackTitle?.trim();
|
||
const taskTitle = options.taskTitle?.replace(/\s+/g, " ").trim().slice(0, 120);
|
||
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 字中文标题。",
|
||
"要求:简洁、具体、可读、避免标点、不要引号、不要解释。",
|
||
"优先概括用户当前真实需求和助手最终结论。",
|
||
"忽略系统提示、历史记忆、学习上下文、工具日志等元信息。",
|
||
"不要直接照抄用户任一条消息原文。",
|
||
"只输出标题本身。",
|
||
...(taskTitle ? [`关联任务:${taskTitle}`, "结合关联任务与对话内容区分当前调查,避免使用任务协作等通用名称。"] : []),
|
||
"",
|
||
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 ?? []);
|
||
const normalizedTitle = normalizeGeneratedTitle(title, fallback);
|
||
return taskTitle && isGenericTaskCollaborationTitle(normalizedTitle) ? fallback : normalizedTitle;
|
||
});
|
||
|
||
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.removeSession(titleSessionId).catch((error) => {
|
||
logger.debug({ sessionId: titleSessionId, err: error }, "failed to cleanup title session");
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
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;
|
||
|
||
export const buildForkedSessionUiState = (
|
||
sourceState: { messages?: unknown[] } | null | undefined,
|
||
input: {
|
||
keepMessageCount: number;
|
||
targetSessionId: string;
|
||
},
|
||
) => ({
|
||
sessionId: input.targetSessionId,
|
||
isTitleManuallyEdited: false,
|
||
messages: Array.isArray(sourceState?.messages)
|
||
? sourceState.messages.slice(0, input.keepMessageCount)
|
||
: [],
|
||
});
|
||
|
||
export const resolveRequestedStreamSessionId = (input: {
|
||
sessionId?: string;
|
||
promptText: string;
|
||
createClientSessionId: () => string;
|
||
}) => {
|
||
const sessionId = input.sessionId?.trim();
|
||
if (sessionId) {
|
||
return sessionId;
|
||
}
|
||
return undefined;
|
||
};
|
||
|
||
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);
|
||
};
|