Files
TJWaterAgent/src/routes/chatSession.ts
T
jiang 801f611ce5
Agent CI/CD / docker-image (push) Successful in 1m38s
Agent CI/CD / deploy-fallback-log (push) Has been skipped
fix(chat): restore forked context
2026-06-08 19:33:13 +08:00

360 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
};