507 lines
17 KiB
JavaScript
507 lines
17 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import { Agent, fetch as undiciFetch } from 'undici';
|
|
import { jsonrepair } from 'jsonrepair';
|
|
import {
|
|
decryptSecret,
|
|
normalizeApiUrl,
|
|
resolveChatCompletionsUrl,
|
|
} from './llm-providers.mjs';
|
|
import { deriveUserFacingText } from './conversation-display.mjs';
|
|
|
|
const httpsDispatcher = new Agent({
|
|
connect: { rejectUnauthorized: false },
|
|
});
|
|
|
|
const MAX_MESSAGE_TEXT = 12000;
|
|
const MAX_ANALYSIS_MESSAGES = 8;
|
|
const MAX_MEMORY_TEXT = 1000;
|
|
const LABELS = new Set([
|
|
'preference',
|
|
'habit',
|
|
'interest',
|
|
'goal',
|
|
'fact',
|
|
'experience',
|
|
'knowledge',
|
|
]);
|
|
|
|
function resolveEncryptionKey(explicitKey) {
|
|
return (
|
|
explicitKey ??
|
|
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
|
|
process.env.TKMIND_SERVER__SECRET_KEY ??
|
|
'local-dev-secret'
|
|
);
|
|
}
|
|
|
|
function stableId(...parts) {
|
|
return crypto
|
|
.createHash('sha256')
|
|
.update(parts.map((part) => String(part ?? '')).join('\0'))
|
|
.digest('hex');
|
|
}
|
|
|
|
function parseJsonObject(text) {
|
|
const source = String(text ?? '').trim();
|
|
if (!source) return null;
|
|
try {
|
|
return JSON.parse(source);
|
|
} catch {
|
|
try {
|
|
return JSON.parse(jsonrepair(source));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractJsonObject(text) {
|
|
const direct = parseJsonObject(text);
|
|
if (direct && typeof direct === 'object') return direct;
|
|
const source = String(text ?? '').trim();
|
|
const start = source.indexOf('{');
|
|
const end = source.lastIndexOf('}');
|
|
if (start < 0 || end <= start) return null;
|
|
return parseJsonObject(source.slice(start, end + 1));
|
|
}
|
|
|
|
export function extractConversationMessageText(message) {
|
|
const content = Array.isArray(message?.content) ? message.content : [];
|
|
const chunks = [];
|
|
for (const item of content) {
|
|
if (item?.type === 'text' && typeof item.text === 'string') chunks.push(item.text);
|
|
if (item?.type === 'image' && item.url) chunks.push(`[image] ${item.url}`);
|
|
}
|
|
const fromContent = chunks.join('\n').trim();
|
|
if (fromContent) return fromContent.slice(0, MAX_MESSAGE_TEXT);
|
|
const displayText = message?.metadata?.displayText;
|
|
return typeof displayText === 'string' ? displayText.trim().slice(0, MAX_MESSAGE_TEXT) : '';
|
|
}
|
|
|
|
function normalizeMessage(sessionId, userId, message, index, now) {
|
|
const role = (String(message?.role ?? '').trim() || 'unknown').toLowerCase().slice(0, 32);
|
|
const text = extractConversationMessageText(message);
|
|
if (!text) return null;
|
|
const rawMessageId = String(message?.id ?? message?.message_id ?? '').trim();
|
|
const messageKey = (rawMessageId || `idx:${index}:${stableId(role, text).slice(0, 16)}`).slice(0, 128);
|
|
return {
|
|
id: stableId(userId, sessionId, messageKey),
|
|
userId,
|
|
sessionId,
|
|
messageKey,
|
|
sequenceNo: index,
|
|
role,
|
|
text,
|
|
rawJson: JSON.stringify(message),
|
|
createdAt: Number(message?.created_at ?? message?.createdAt ?? now) || now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
|
|
function buildMemoryPrompt(messages) {
|
|
const transcript = messages
|
|
.map((message, index) => {
|
|
const text = String(message.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 1500);
|
|
return `${index + 1}. [${message.role}] ${text}`;
|
|
})
|
|
.join('\n');
|
|
return [
|
|
'你是 TKMind 的用户长期记忆提取器。',
|
|
'请从下面的用户对话中提取可以长期复用的个人记忆。',
|
|
'只记录用户明确表达或强证据支持的信息,不要猜测,不要记录临时闲聊。',
|
|
'不要记录密码、密钥、身份证、手机号、银行卡等敏感信息。',
|
|
'只返回 JSON 对象,不要 Markdown,不要解释。',
|
|
'格式:{"memories":[{"label":"preference|habit|interest|goal|fact|experience|knowledge","text":"一句完整中文记忆","confidence":0.0-1.0}]}',
|
|
'最多返回 5 条。没有值得记录的内容时返回 {"memories":[]}',
|
|
'',
|
|
transcript,
|
|
].join('\n');
|
|
}
|
|
|
|
function normalizeMemoryItem(item, fallbackLabel = 'fact') {
|
|
const text = String(item?.text ?? item?.memory ?? item?.content ?? '').trim();
|
|
if (!text) return null;
|
|
const labelRaw = String(item?.label ?? item?.category ?? fallbackLabel).trim().toLowerCase();
|
|
const label = LABELS.has(labelRaw) ? labelRaw : fallbackLabel;
|
|
const confidence = Math.max(0, Math.min(1, Number(item?.confidence ?? 0.6)));
|
|
return {
|
|
label,
|
|
text: text.slice(0, MAX_MEMORY_TEXT),
|
|
confidence: Number.isFinite(confidence) ? confidence : 0.6,
|
|
};
|
|
}
|
|
|
|
function fallbackMemoriesFromMessages(messages) {
|
|
const memories = [];
|
|
const patterns = [
|
|
{ label: 'preference', re: /(?:我喜欢|我偏好|我更喜欢|以后.*(?:用|叫|按)|不要再|别再)(.+)/ },
|
|
{ label: 'interest', re: /(?:我对|我关注|我感兴趣|我想了解)(.+)/ },
|
|
{ label: 'goal', re: /(?:我的目标是|我想要|我希望|我打算|我计划|我想去|打算去|准备去)(.+)/ },
|
|
{ label: 'habit', re: /(?:我通常|我习惯|我一般)(.+)/ },
|
|
{ label: 'fact', re: /(?:我是|我叫|我来自|我在)(.{2,40})/ },
|
|
{ label: 'fact', re: /(?:请记住|记住).{0,12}(?:别名|叫做|是)([^。!?\s]{2,40})/ },
|
|
{ label: 'experience', re: /(?:我们|我).{0,8}(?:去|到|在).{2,40}(?:玩|旅游|旅行|出差|度假)/ },
|
|
];
|
|
for (const message of messages) {
|
|
const text = String(message.text ?? '').replace(/\s+/g, ' ').trim();
|
|
for (const { label, re } of patterns) {
|
|
const match = text.match(re);
|
|
if (!match?.[0]) continue;
|
|
memories.push({
|
|
label,
|
|
text: `用户${match[0].replace(/[。!?\s]+$/g, '')}`,
|
|
confidence: 0.45,
|
|
});
|
|
if (memories.length >= 3) return memories;
|
|
}
|
|
}
|
|
return memories;
|
|
}
|
|
|
|
function resolveMemoryExtractionModelConfig(env = process.env) {
|
|
const providerKeyId = String(
|
|
env?.USER_CONVERSATION_MEMORY_MODEL_PROVIDER_KEY_ID
|
|
?? env?.MEMORY_EXTRACTION_MODEL_PROVIDER_KEY_ID
|
|
?? env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID
|
|
?? '',
|
|
).trim() || null;
|
|
const model = String(
|
|
env?.USER_CONVERSATION_MEMORY_MODEL
|
|
?? env?.MEMORY_EXTRACTION_MODEL
|
|
?? env?.MEMIND_CHAT_ROUTER_MODEL
|
|
?? '',
|
|
).trim() || null;
|
|
return { providerKeyId, model };
|
|
}
|
|
|
|
function parseExtractionMemories(rawText) {
|
|
const parsed = extractJsonObject(rawText);
|
|
const rawItems = Array.isArray(parsed?.memories) ? parsed.memories : [];
|
|
return rawItems.map((item) => normalizeMemoryItem(item)).filter(Boolean);
|
|
}
|
|
|
|
export function createConversationMemoryService(pool, options = {}) {
|
|
const now = options.now ?? (() => Date.now());
|
|
const fetchImpl = options.fetch ?? undiciFetch;
|
|
const llmProviderService = options.llmProviderService ?? null;
|
|
const resolveEffectiveEnv = options.getEffectiveEnv ?? (async () => process.env);
|
|
const encryptionKey = options.encryptionKey;
|
|
const llmWarningIntervalMs = Math.max(
|
|
0,
|
|
Number(options.llmWarningIntervalMs ?? process.env.USER_CONVERSATION_MEMORY_LLM_WARN_INTERVAL_MS ?? 60000) || 0,
|
|
);
|
|
let lastLlmExtractionWarningAt = 0;
|
|
|
|
function warnLlmExtractionFailed(err) {
|
|
const ts = now();
|
|
if (
|
|
lastLlmExtractionWarningAt > 0 &&
|
|
llmWarningIntervalMs > 0 &&
|
|
ts - lastLlmExtractionWarningAt < llmWarningIntervalMs
|
|
) return;
|
|
lastLlmExtractionWarningAt = ts;
|
|
console.warn('[conversation-memory] LLM extraction failed:', err instanceof Error ? err.message : err);
|
|
}
|
|
|
|
function isEnabled() {
|
|
return process.env.USER_CONVERSATION_MEMORY_ENABLED !== '0';
|
|
}
|
|
|
|
function llmEnabled() {
|
|
return process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED !== '0';
|
|
}
|
|
|
|
async function saveConversationMessages(sessionId, userId, messages = []) {
|
|
if (!isEnabled() || !pool || !sessionId || !userId || !Array.isArray(messages)) return [];
|
|
const ts = now();
|
|
const normalized = messages
|
|
.map((message, index) => normalizeMessage(sessionId, userId, message, index, ts))
|
|
.filter(Boolean);
|
|
if (!normalized.length) return [];
|
|
|
|
await pool.query(
|
|
`INSERT INTO h5_conversation_messages
|
|
(id, user_id, agent_session_id, message_key, sequence_no, role, text, raw_json,
|
|
created_at, updated_at)
|
|
VALUES ?
|
|
ON DUPLICATE KEY UPDATE
|
|
sequence_no = VALUES(sequence_no),
|
|
role = VALUES(role),
|
|
text = VALUES(text),
|
|
raw_json = VALUES(raw_json),
|
|
updated_at = VALUES(updated_at)`,
|
|
[
|
|
normalized.map((message) => [
|
|
message.id,
|
|
message.userId,
|
|
message.sessionId,
|
|
message.messageKey,
|
|
message.sequenceNo,
|
|
message.role,
|
|
message.text,
|
|
message.rawJson,
|
|
message.createdAt,
|
|
message.updatedAt,
|
|
]),
|
|
],
|
|
);
|
|
return normalized;
|
|
}
|
|
|
|
async function loadUnanalyzedUserMessages(userId, limit = MAX_ANALYSIS_MESSAGES) {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, user_id, agent_session_id, message_key, sequence_no, role, text, created_at
|
|
FROM h5_conversation_messages
|
|
WHERE user_id = ? AND role = 'user' AND analyzed_at IS NULL
|
|
ORDER BY created_at ASC, sequence_no ASC
|
|
LIMIT ?`,
|
|
[userId, Math.max(1, limit)],
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async function markAnalyzed(messageIds) {
|
|
if (!messageIds.length) return;
|
|
await pool.query(
|
|
`UPDATE h5_conversation_messages SET analyzed_at = ? WHERE id IN (?)`,
|
|
[now(), messageIds],
|
|
);
|
|
}
|
|
|
|
async function selectedProvider() {
|
|
const [rows] = await pool.query(
|
|
`SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = 'active' LIMIT 1`,
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async function extractWithLlm(messages) {
|
|
const prompt = buildMemoryPrompt(messages);
|
|
const effectiveEnv = await resolveEffectiveEnv();
|
|
const { providerKeyId, model } = resolveMemoryExtractionModelConfig(effectiveEnv);
|
|
|
|
if (llmProviderService?.createChatCompletion) {
|
|
const completion = await llmProviderService.createChatCompletion({
|
|
providerKeyId: providerKeyId || undefined,
|
|
model: model || undefined,
|
|
temperature: 0,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
});
|
|
if (!completion?.ok) return null;
|
|
const memories = parseExtractionMemories(completion.reply);
|
|
return memories.length ? memories : [];
|
|
}
|
|
|
|
const row = await selectedProvider();
|
|
if (!row) return null;
|
|
const apiUrl = normalizeApiUrl(row.api_url);
|
|
const url = resolveChatCompletionsUrl(apiUrl);
|
|
if (!url) return null;
|
|
let apiKey;
|
|
try {
|
|
apiKey = decryptSecret(
|
|
{
|
|
ciphertext: row.api_key_ciphertext,
|
|
iv: row.api_key_iv,
|
|
tag: row.api_key_tag,
|
|
},
|
|
resolveEncryptionKey(encryptionKey),
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const upstream = await fetchImpl(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: model || row.default_model,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
stream: false,
|
|
temperature: 0,
|
|
...(row.relay_provider ? { provider: row.relay_provider } : {}),
|
|
}),
|
|
dispatcher: url.startsWith('https://') ? httpsDispatcher : undefined,
|
|
});
|
|
const text = await upstream.text().catch(() => '');
|
|
if (!upstream.ok) return null;
|
|
const payload = extractJsonObject(text);
|
|
const content = payload?.choices?.[0]?.message?.content ?? payload?.content ?? text;
|
|
return parseExtractionMemories(content);
|
|
}
|
|
|
|
async function storeMemories(userId, sourceMessageIds, memories, rawJson = null) {
|
|
const rows = [];
|
|
const ts = now();
|
|
const sourceSessionId = sourceMessageIds[0]?.agent_session_id ?? null;
|
|
const evidenceMessageId = sourceMessageIds[0]?.id ?? null;
|
|
for (const item of memories) {
|
|
const normalized = normalizeMemoryItem(item);
|
|
if (!normalized) continue;
|
|
const hash = stableId(userId, normalized.label, normalized.text);
|
|
rows.push([
|
|
stableId('memory', hash),
|
|
userId,
|
|
normalized.label,
|
|
hash,
|
|
normalized.text,
|
|
evidenceMessageId,
|
|
sourceSessionId,
|
|
normalized.confidence,
|
|
rawJson ? JSON.stringify(rawJson) : null,
|
|
ts,
|
|
ts,
|
|
]);
|
|
}
|
|
if (!rows.length) return 0;
|
|
await pool.query(
|
|
`INSERT INTO h5_user_memory_items
|
|
(id, user_id, label, memory_hash, memory_text, evidence_message_id,
|
|
source_session_id, confidence, raw_json, created_at, updated_at)
|
|
VALUES ?
|
|
ON DUPLICATE KEY UPDATE
|
|
confidence = GREATEST(confidence, VALUES(confidence)),
|
|
updated_at = VALUES(updated_at)`,
|
|
[rows],
|
|
);
|
|
return rows.length;
|
|
}
|
|
|
|
async function analyzeMessageBatch(userId, messages) {
|
|
if (!isEnabled() || !pool || !userId || !messages.length) {
|
|
return { ok: true, analyzed: 0, memories: 0 };
|
|
}
|
|
let memories = null;
|
|
if (llmEnabled()) {
|
|
try {
|
|
memories = await extractWithLlm(messages);
|
|
} catch (err) {
|
|
warnLlmExtractionFailed(err);
|
|
}
|
|
}
|
|
if (!memories?.length) memories = fallbackMemoriesFromMessages(messages);
|
|
const stored = await storeMemories(userId, messages, memories);
|
|
await markAnalyzed(messages.map((message) => message.id));
|
|
return { ok: true, analyzed: messages.length, memories: stored };
|
|
}
|
|
|
|
async function analyzeUser(userId) {
|
|
if (!isEnabled() || !pool || !userId) return { ok: false, reason: 'disabled' };
|
|
const messages = await loadUnanalyzedUserMessages(userId);
|
|
if (!messages.length) return { ok: true, analyzed: 0, memories: 0 };
|
|
return analyzeMessageBatch(userId, messages);
|
|
}
|
|
|
|
async function saveAndAnalyze(sessionId, userId, messages = []) {
|
|
const saved = await saveConversationMessages(sessionId, userId, messages);
|
|
if (!saved.length) return { saved: 0, analyzed: 0, memories: 0 };
|
|
const userSaved = saved
|
|
.filter((message) => message.role === 'user')
|
|
.map((message) => ({
|
|
id: message.id,
|
|
user_id: message.userId,
|
|
agent_session_id: message.sessionId,
|
|
message_key: message.messageKey,
|
|
sequence_no: message.sequenceNo,
|
|
role: message.role,
|
|
text: message.text,
|
|
created_at: message.createdAt,
|
|
}));
|
|
const result = await analyzeMessageBatch(userId, userSaved);
|
|
return {
|
|
saved: saved.length,
|
|
analyzed: result.analyzed ?? 0,
|
|
memories: result.memories ?? 0,
|
|
};
|
|
}
|
|
|
|
async function listMemories(userId, { limit = 50 } = {}) {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, label, memory_text, confidence, source_session_id, created_at, updated_at
|
|
FROM h5_user_memory_items
|
|
WHERE user_id = ? AND status = 'active'
|
|
ORDER BY updated_at DESC
|
|
LIMIT ?`,
|
|
[userId, Math.max(1, Math.min(200, Number(limit) || 50))],
|
|
);
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
label: row.label,
|
|
text: row.memory_text,
|
|
confidence: Number(row.confidence ?? 0),
|
|
sourceSessionId: row.source_session_id ?? null,
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at),
|
|
}));
|
|
}
|
|
|
|
async function loadSessionListSummaries(userId, sessionIds = []) {
|
|
if (!isEnabled() || !pool || !userId || !Array.isArray(sessionIds) || sessionIds.length === 0) {
|
|
return new Map();
|
|
}
|
|
const ids = [...new Set(sessionIds.filter(Boolean))];
|
|
if (!ids.length) return new Map();
|
|
const placeholders = ids.map(() => '?').join(', ');
|
|
const [rows] = await pool.query(
|
|
`SELECT agent_session_id, role, text, raw_json, sequence_no, created_at, updated_at
|
|
FROM h5_conversation_messages
|
|
WHERE user_id = ? AND agent_session_id IN (${placeholders})
|
|
ORDER BY sequence_no ASC, created_at ASC`,
|
|
[userId, ...ids],
|
|
);
|
|
const buckets = new Map();
|
|
for (const row of rows) {
|
|
const sessionId = String(row.agent_session_id ?? '').trim();
|
|
if (!sessionId) continue;
|
|
if (!buckets.has(sessionId)) {
|
|
buckets.set(sessionId, { messages: [], maxUpdatedAt: 0 });
|
|
}
|
|
const bucket = buckets.get(sessionId);
|
|
bucket.messages.push(row);
|
|
bucket.maxUpdatedAt = Math.max(
|
|
bucket.maxUpdatedAt,
|
|
Number(row.updated_at ?? row.created_at ?? 0),
|
|
);
|
|
}
|
|
const summaries = new Map();
|
|
for (const sessionId of ids) {
|
|
const bucket = buckets.get(sessionId);
|
|
if (!bucket || bucket.messages.length === 0) continue;
|
|
const firstUser = bucket.messages.find((row) => String(row.role ?? '') === 'user');
|
|
let title = '';
|
|
if (firstUser) {
|
|
let text = String(firstUser.text ?? '').trim();
|
|
if (firstUser.raw_json) {
|
|
try {
|
|
const parsed = JSON.parse(firstUser.raw_json);
|
|
text = extractConversationMessageText(parsed) || text;
|
|
} catch {
|
|
// keep text column fallback
|
|
}
|
|
}
|
|
title = deriveUserFacingText(text).slice(0, 80);
|
|
}
|
|
summaries.set(sessionId, {
|
|
title: title || sessionId,
|
|
messageCount: bucket.messages.length,
|
|
updatedAt: bucket.maxUpdatedAt
|
|
? new Date(bucket.maxUpdatedAt).toISOString()
|
|
: undefined,
|
|
});
|
|
}
|
|
return summaries;
|
|
}
|
|
|
|
return {
|
|
isEnabled,
|
|
saveConversationMessages,
|
|
analyzeUser,
|
|
saveAndAnalyze,
|
|
listMemories,
|
|
loadSessionListSummaries,
|
|
};
|
|
}
|