feat: add wechat publish recovery and conversation memory
This commit is contained in:
@@ -0,0 +1,371 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
|
import { jsonrepair } from 'jsonrepair';
|
||||||
|
import {
|
||||||
|
decryptSecret,
|
||||||
|
normalizeApiUrl,
|
||||||
|
resolveChatCompletionsUrl,
|
||||||
|
} from './llm-providers.mjs';
|
||||||
|
|
||||||
|
const insecureDispatcher = 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: /(?:我通常|我习惯|我一般)(.+)/ },
|
||||||
|
];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConversationMemoryService(pool, options = {}) {
|
||||||
|
const now = options.now ?? (() => Date.now());
|
||||||
|
const fetchImpl = options.fetch ?? undiciFetch;
|
||||||
|
const encryptionKey = options.encryptionKey;
|
||||||
|
|
||||||
|
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 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: row.default_model,
|
||||||
|
messages: [{ role: 'user', content: buildMemoryPrompt(messages) }],
|
||||||
|
stream: false,
|
||||||
|
temperature: 0,
|
||||||
|
...(row.relay_provider ? { provider: row.relay_provider } : {}),
|
||||||
|
}),
|
||||||
|
dispatcher: url.startsWith('https://') ? insecureDispatcher : 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;
|
||||||
|
const parsed = extractJsonObject(content);
|
||||||
|
const rawItems = Array.isArray(parsed?.memories) ? parsed.memories : [];
|
||||||
|
return rawItems.map((item) => normalizeMemoryItem(item)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 };
|
||||||
|
let memories = null;
|
||||||
|
if (llmEnabled()) {
|
||||||
|
try {
|
||||||
|
memories = await extractWithLlm(messages);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[conversation-memory] LLM extraction failed:', err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!memories) 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 saveAndAnalyze(sessionId, userId, messages = []) {
|
||||||
|
const saved = await saveConversationMessages(sessionId, userId, messages);
|
||||||
|
if (!saved.length) return { saved: 0, analyzed: 0, memories: 0 };
|
||||||
|
const result = await analyzeUser(userId);
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEnabled,
|
||||||
|
saveConversationMessages,
|
||||||
|
analyzeUser,
|
||||||
|
saveAndAnalyze,
|
||||||
|
listMemories,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createConversationMemoryService, extractConversationMessageText } from './conversation-memory.mjs';
|
||||||
|
|
||||||
|
function createPool() {
|
||||||
|
const state = {
|
||||||
|
messages: [],
|
||||||
|
memories: [],
|
||||||
|
analyzed: new Set(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
async query(sql, params = []) {
|
||||||
|
if (sql.includes('INSERT INTO h5_conversation_messages')) {
|
||||||
|
for (const row of params[0]) {
|
||||||
|
const [
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
messageKey,
|
||||||
|
sequenceNo,
|
||||||
|
role,
|
||||||
|
text,
|
||||||
|
rawJson,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
] = row;
|
||||||
|
const existing = state.messages.find(
|
||||||
|
(item) => item.agent_session_id === sessionId && item.message_key === messageKey,
|
||||||
|
);
|
||||||
|
const next = {
|
||||||
|
id,
|
||||||
|
user_id: userId,
|
||||||
|
agent_session_id: sessionId,
|
||||||
|
message_key: messageKey,
|
||||||
|
sequence_no: sequenceNo,
|
||||||
|
role,
|
||||||
|
text,
|
||||||
|
raw_json: rawJson,
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
analyzed_at: existing?.analyzed_at ?? null,
|
||||||
|
};
|
||||||
|
if (existing) Object.assign(existing, next);
|
||||||
|
else state.messages.push(next);
|
||||||
|
}
|
||||||
|
return [{ affectedRows: params[0].length }];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM h5_conversation_messages')) {
|
||||||
|
const [userId, limit] = params;
|
||||||
|
return [
|
||||||
|
state.messages
|
||||||
|
.filter((item) => item.user_id === userId && item.role === 'user' && item.analyzed_at == null)
|
||||||
|
.slice(0, limit),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO h5_user_memory_items')) {
|
||||||
|
for (const row of params[0]) {
|
||||||
|
const [
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
label,
|
||||||
|
memoryHash,
|
||||||
|
memoryText,
|
||||||
|
evidenceMessageId,
|
||||||
|
sourceSessionId,
|
||||||
|
confidence,
|
||||||
|
rawJson,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
] = row;
|
||||||
|
state.memories.push({
|
||||||
|
id,
|
||||||
|
user_id: userId,
|
||||||
|
label,
|
||||||
|
memory_hash: memoryHash,
|
||||||
|
memory_text: memoryText,
|
||||||
|
evidence_message_id: evidenceMessageId,
|
||||||
|
source_session_id: sourceSessionId,
|
||||||
|
confidence,
|
||||||
|
raw_json: rawJson,
|
||||||
|
created_at: createdAt,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [{ affectedRows: params[0].length }];
|
||||||
|
}
|
||||||
|
if (sql.includes('UPDATE h5_conversation_messages SET analyzed_at')) {
|
||||||
|
const [analyzedAt, ids] = params;
|
||||||
|
for (const item of state.messages) {
|
||||||
|
if (ids.includes(item.id)) item.analyzed_at = analyzedAt;
|
||||||
|
}
|
||||||
|
return [{ affectedRows: ids.length }];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM h5_user_memory_items')) {
|
||||||
|
const [userId, limit] = params;
|
||||||
|
return [
|
||||||
|
state.memories
|
||||||
|
.filter((item) => item.user_id === userId)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((item) => ({ ...item, status: 'active' })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM h5_llm_provider_keys')) return [[]];
|
||||||
|
throw new Error(`Unexpected SQL: ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('extractConversationMessageText reads text content and display text', () => {
|
||||||
|
assert.equal(
|
||||||
|
extractConversationMessageText({ content: [{ type: 'text', text: ' hello ' }] }),
|
||||||
|
'hello',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
extractConversationMessageText({ content: [], metadata: { displayText: 'fallback' } }),
|
||||||
|
'fallback',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveAndAnalyze stores messages and fallback memories', async () => {
|
||||||
|
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
|
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0';
|
||||||
|
const pool = createPool();
|
||||||
|
const service = createConversationMemoryService(pool, { now: () => 1000 });
|
||||||
|
|
||||||
|
const result = await service.saveAndAnalyze('session-1', 'user-1', [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text: '我喜欢简洁直接的回答,也关注 AI 产品设计。' }],
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm2',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text: '好的。' }],
|
||||||
|
metadata: { userVisible: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(result.saved, 2);
|
||||||
|
assert.equal(result.analyzed, 1);
|
||||||
|
assert.equal(result.memories, 1);
|
||||||
|
assert.equal(pool.state.messages.length, 2);
|
||||||
|
assert.equal(pool.state.memories[0].label, 'preference');
|
||||||
|
assert.match(pool.state.memories[0].memory_text, /简洁直接/);
|
||||||
|
assert.equal(pool.state.messages.find((item) => item.message_key === 'm1')?.analyzed_at, 1000);
|
||||||
|
|
||||||
|
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
|
||||||
|
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
|
||||||
|
});
|
||||||
@@ -232,6 +232,52 @@ export async function migrateSchema(pool) {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Per-user conversation memory: raw dialogue rows plus lightweight extracted
|
||||||
|
// memory items. This is intentionally additive and can be disabled by env.
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS h5_conversation_messages (
|
||||||
|
id CHAR(64) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
agent_session_id VARCHAR(128) NOT NULL,
|
||||||
|
message_key VARCHAR(128) NOT NULL,
|
||||||
|
sequence_no INT NOT NULL DEFAULT 0,
|
||||||
|
role VARCHAR(32) NOT NULL,
|
||||||
|
text MEDIUMTEXT NOT NULL,
|
||||||
|
raw_json LONGTEXT NULL,
|
||||||
|
analyzed_at BIGINT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uq_h5_conversation_message (agent_session_id, message_key),
|
||||||
|
KEY idx_h5_conversation_user_created (user_id, created_at),
|
||||||
|
KEY idx_h5_conversation_user_analyzed (user_id, analyzed_at),
|
||||||
|
CONSTRAINT fk_h5_conversation_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_h5_conversation_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS h5_user_memory_items (
|
||||||
|
id CHAR(64) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
label ENUM('preference', 'habit', 'interest', 'goal', 'fact', 'experience', 'knowledge') NOT NULL DEFAULT 'fact',
|
||||||
|
memory_hash CHAR(64) NOT NULL,
|
||||||
|
memory_text TEXT NOT NULL,
|
||||||
|
evidence_message_id CHAR(64) NULL,
|
||||||
|
source_session_id VARCHAR(128) NULL,
|
||||||
|
confidence DECIMAL(4,3) NOT NULL DEFAULT 0.600,
|
||||||
|
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
|
||||||
|
raw_json JSON NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uq_h5_user_memory_hash (user_id, memory_hash),
|
||||||
|
KEY idx_h5_user_memory_user_label (user_id, label, updated_at),
|
||||||
|
KEY idx_h5_user_memory_session (source_session_id),
|
||||||
|
CONSTRAINT fk_h5_user_memory_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_h5_user_memory_evidence FOREIGN KEY (evidence_message_id) REFERENCES h5_conversation_messages(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_h5_user_memory_session FOREIGN KEY (source_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`);
|
||||||
|
|
||||||
const publishColumns = [
|
const publishColumns = [
|
||||||
['user_confirmed_at', 'BIGINT NULL AFTER expires_at'],
|
['user_confirmed_at', 'BIGINT NULL AFTER expires_at'],
|
||||||
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
|
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
|
||||||
|
|||||||
+40
@@ -1032,3 +1032,43 @@ CREATE TABLE IF NOT EXISTS h5_experience (
|
|||||||
KEY idx_h5_experience_scope_updated (scope, updated_at),
|
KEY idx_h5_experience_scope_updated (scope, updated_at),
|
||||||
FULLTEXT KEY ftx_h5_experience (title, body)
|
FULLTEXT KEY ftx_h5_experience (title, body)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS h5_conversation_messages (
|
||||||
|
id CHAR(64) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
agent_session_id VARCHAR(128) NOT NULL,
|
||||||
|
message_key VARCHAR(128) NOT NULL,
|
||||||
|
sequence_no INT NOT NULL DEFAULT 0,
|
||||||
|
role VARCHAR(32) NOT NULL,
|
||||||
|
text MEDIUMTEXT NOT NULL,
|
||||||
|
raw_json LONGTEXT NULL,
|
||||||
|
analyzed_at BIGINT NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uq_h5_conversation_message (agent_session_id, message_key),
|
||||||
|
KEY idx_h5_conversation_user_created (user_id, created_at),
|
||||||
|
KEY idx_h5_conversation_user_analyzed (user_id, analyzed_at),
|
||||||
|
CONSTRAINT fk_h5_conversation_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_h5_conversation_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS h5_user_memory_items (
|
||||||
|
id CHAR(64) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
label ENUM('preference', 'habit', 'interest', 'goal', 'fact', 'experience', 'knowledge') NOT NULL DEFAULT 'fact',
|
||||||
|
memory_hash CHAR(64) NOT NULL,
|
||||||
|
memory_text TEXT NOT NULL,
|
||||||
|
evidence_message_id CHAR(64) NULL,
|
||||||
|
source_session_id VARCHAR(128) NULL,
|
||||||
|
confidence DECIMAL(4,3) NOT NULL DEFAULT 0.600,
|
||||||
|
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
|
||||||
|
raw_json JSON NULL,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
UNIQUE KEY uq_h5_user_memory_hash (user_id, memory_hash),
|
||||||
|
KEY idx_h5_user_memory_user_label (user_id, label, updated_at),
|
||||||
|
KEY idx_h5_user_memory_session (source_session_id),
|
||||||
|
CONSTRAINT fk_h5_user_memory_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_h5_user_memory_evidence FOREIGN KEY (evidence_message_id) REFERENCES h5_conversation_messages(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_h5_user_memory_session FOREIGN KEY (source_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
+21
-4
@@ -99,6 +99,7 @@ import { createScheduleService } from './schedule-service.mjs';
|
|||||||
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
|
||||||
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
|
||||||
import { createSessionSnapshotService } from './session-snapshot.mjs';
|
import { createSessionSnapshotService } from './session-snapshot.mjs';
|
||||||
|
import { createConversationMemoryService } from './conversation-memory.mjs';
|
||||||
import { createExperienceService } from './experience-service.mjs';
|
import { createExperienceService } from './experience-service.mjs';
|
||||||
import { attachAsrRoutes } from './asr-proxy.mjs';
|
import { attachAsrRoutes } from './asr-proxy.mjs';
|
||||||
import { isNativeH5ApiPath } from './policies.mjs';
|
import { isNativeH5ApiPath } from './policies.mjs';
|
||||||
@@ -134,6 +135,7 @@ function parseApiTargets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PORT = Number(process.env.H5_PORT ?? 8081);
|
const PORT = Number(process.env.H5_PORT ?? 8081);
|
||||||
|
const HOST = String(process.env.H5_HOST ?? '127.0.0.1').trim() || '127.0.0.1';
|
||||||
const API_TARGETS = parseApiTargets();
|
const API_TARGETS = parseApiTargets();
|
||||||
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
|
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
|
||||||
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||||
@@ -215,6 +217,7 @@ if (ACCESS_PASSWORD) {
|
|||||||
let userAuth = null;
|
let userAuth = null;
|
||||||
let tkmindProxy = null;
|
let tkmindProxy = null;
|
||||||
let sessionSnapshotService = null;
|
let sessionSnapshotService = null;
|
||||||
|
let conversationMemoryService = null;
|
||||||
let mindSpace = null;
|
let mindSpace = null;
|
||||||
let mindSpaceAssets = null;
|
let mindSpaceAssets = null;
|
||||||
let mindSpaceAudit = null;
|
let mindSpaceAudit = null;
|
||||||
@@ -516,7 +519,10 @@ async function bootstrapUserAuth() {
|
|||||||
void llmProviderService.syncSelectedToGoosed().catch((err) => {
|
void llmProviderService.syncSelectedToGoosed().catch((err) => {
|
||||||
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
sessionSnapshotService = createSessionSnapshotService(pool);
|
conversationMemoryService = createConversationMemoryService(pool);
|
||||||
|
sessionSnapshotService = createSessionSnapshotService(pool, {
|
||||||
|
conversationMemoryService,
|
||||||
|
});
|
||||||
tkmindProxy = createTkmindProxy({
|
tkmindProxy = createTkmindProxy({
|
||||||
apiTarget: API_TARGET,
|
apiTarget: API_TARGET,
|
||||||
apiTargets: API_TARGETS,
|
apiTargets: API_TARGETS,
|
||||||
@@ -525,6 +531,7 @@ async function bootstrapUserAuth() {
|
|||||||
llmProviderService,
|
llmProviderService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
sessionSnapshotService,
|
sessionSnapshotService,
|
||||||
|
conversationMemoryService,
|
||||||
localFetchAsset: mindSpaceAssets
|
localFetchAsset: mindSpaceAssets
|
||||||
? async (userId, assetId) => {
|
? async (userId, assetId) => {
|
||||||
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
|
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
|
||||||
@@ -537,12 +544,22 @@ async function bootstrapUserAuth() {
|
|||||||
config: WECHAT_MP_CONFIG,
|
config: WECHAT_MP_CONFIG,
|
||||||
userAuth,
|
userAuth,
|
||||||
apiFetch: tkmindProxy.apiFetch,
|
apiFetch: tkmindProxy.apiFetch,
|
||||||
|
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
|
||||||
|
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
|
||||||
sessionApiFetch: async (sessionId, pathname, init) => {
|
sessionApiFetch: async (sessionId, pathname, init) => {
|
||||||
const target = await tkmindProxy.resolveTarget(sessionId);
|
const target = await tkmindProxy.resolveTarget(sessionId);
|
||||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||||
},
|
},
|
||||||
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
||||||
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
|
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
|
||||||
|
refreshSessionSnapshot:
|
||||||
|
sessionSnapshotService?.isEnabled()
|
||||||
|
? (sessionId, userId) =>
|
||||||
|
sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => {
|
||||||
|
const target = await tkmindProxy.resolveTarget(sessionId);
|
||||||
|
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
userAuth.setRechargeNotifier(async ({ userId, title, body }) => {
|
userAuth.setRechargeNotifier(async ({ userId, title, body }) => {
|
||||||
if (!wechatMpService?.enabled) return;
|
if (!wechatMpService?.enabled) return;
|
||||||
@@ -4806,10 +4823,10 @@ app.get('*', (_req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
userAuthReady.then((enabled) => {
|
userAuthReady.then((enabled) => {
|
||||||
app.listen(PORT, '127.0.0.1', () => {
|
app.listen(PORT, HOST, () => {
|
||||||
console.log(`TKMind H5 @ http://127.0.0.1:${PORT}`);
|
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
|
||||||
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
|
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
|
||||||
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
|
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
|
||||||
console.log(`Wiki @ http://127.0.0.1:${PORT}/${PUBLISH_ROOT_DIR}/wiki`);
|
console.log(`Wiki @ http://${HOST}:${PORT}/${PUBLISH_ROOT_DIR}/wiki`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export async function reconcileAgentSession(
|
|||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints = null,
|
sandboxConstraints = null,
|
||||||
userContext = null,
|
userContext = null,
|
||||||
|
userMemories = null,
|
||||||
tolerateInvalidWorkingDir = false,
|
tolerateInvalidWorkingDir = false,
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -186,6 +187,7 @@ export async function reconcileAgentSession(
|
|||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints: sandboxText,
|
sandboxConstraints: sandboxText,
|
||||||
userContext,
|
userContext,
|
||||||
|
userMemories,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (memoryEntries.length > 0) {
|
if (memoryEntries.length > 0) {
|
||||||
|
|||||||
+13
-1
@@ -62,7 +62,9 @@ function resolveDisplayTitle(session, messages) {
|
|||||||
return deriveTitleFromMessages(messages);
|
return deriveTitleFromMessages(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionSnapshotService(pool) {
|
export function createSessionSnapshotService(pool, options = {}) {
|
||||||
|
const conversationMemoryService = options.conversationMemoryService ?? null;
|
||||||
|
|
||||||
function isEnabled() {
|
function isEnabled() {
|
||||||
return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0';
|
return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0';
|
||||||
}
|
}
|
||||||
@@ -138,6 +140,16 @@ export function createSessionSnapshotService(pool) {
|
|||||||
now,
|
now,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
if (conversationMemoryService?.isEnabled?.()) {
|
||||||
|
void conversationMemoryService
|
||||||
|
.saveAndAnalyze(sessionId, userId, messages)
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn(
|
||||||
|
'[snapshot] conversation memory sync failed:',
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Non-fatal: log and continue; caller falls back to Goose.
|
// Non-fatal: log and continue; caller falls back to Goose.
|
||||||
console.warn('[snapshot] save failed:', err instanceof Error ? err.message : err);
|
console.warn('[snapshot] save failed:', err instanceof Error ? err.message : err);
|
||||||
|
|||||||
+73
-1
@@ -266,7 +266,17 @@ export async function buildVisionPayload({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth, llmProviderService, localFetchAsset, subscriptionService, sessionSnapshotService }) {
|
export function createTkmindProxy({
|
||||||
|
apiTarget,
|
||||||
|
apiTargets,
|
||||||
|
apiSecret,
|
||||||
|
userAuth,
|
||||||
|
llmProviderService,
|
||||||
|
localFetchAsset,
|
||||||
|
subscriptionService,
|
||||||
|
sessionSnapshotService,
|
||||||
|
conversationMemoryService,
|
||||||
|
}) {
|
||||||
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
|
const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
|
||||||
const primaryTarget = targets[0] ?? apiTarget ?? '';
|
const primaryTarget = targets[0] ?? apiTarget ?? '';
|
||||||
let rrIdx = 0;
|
let rrIdx = 0;
|
||||||
@@ -345,6 +355,51 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
|||||||
return primaryTarget;
|
return primaryTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startSessionForUser(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
workingDir,
|
||||||
|
sessionPolicy = null,
|
||||||
|
recipe = null,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const startTarget = await pickTarget();
|
||||||
|
const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(workingDir ? { working_dir: workingDir } : {}),
|
||||||
|
enable_context_memory: sessionPolicy?.enableContextMemory,
|
||||||
|
...(sessionPolicy?.extensionOverrides
|
||||||
|
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||||||
|
: {}),
|
||||||
|
...(recipe ? { recipe } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const text = await upstream.text();
|
||||||
|
if (!upstream.ok) {
|
||||||
|
throw new Error(text || `创建会话失败 (${upstream.status})`);
|
||||||
|
}
|
||||||
|
const session = JSON.parse(text);
|
||||||
|
if (!session?.id) {
|
||||||
|
throw new Error('创建会话失败:缺少 session id');
|
||||||
|
}
|
||||||
|
await userAuth.registerAgentSession(userId, session.id, startTarget);
|
||||||
|
if (sessionPolicy?.gooseMode) {
|
||||||
|
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: session.id,
|
||||||
|
goose_mode: sessionPolicy.gooseMode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!modeRes.ok) {
|
||||||
|
const modeText = await modeRes.text().catch(() => '');
|
||||||
|
throw new Error(modeText || `设置会话模式失败 (${modeRes.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveTarget(sessionId) {
|
async function resolveTarget(sessionId) {
|
||||||
if (targets.length <= 1 || !sessionId) return primaryTarget;
|
if (targets.length <= 1 || !sessionId) return primaryTarget;
|
||||||
try {
|
try {
|
||||||
@@ -420,6 +475,9 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
|||||||
const workingDir = await userAuth.resolveWorkingDir(userId);
|
const workingDir = await userAuth.resolveWorkingDir(userId);
|
||||||
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
|
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
|
||||||
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
||||||
|
const userMemories = conversationMemoryService?.listMemories
|
||||||
|
? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => [])
|
||||||
|
: [];
|
||||||
await reconcileAgentSession(
|
await reconcileAgentSession(
|
||||||
(pathname, init) => apiFetch(target, apiSecret, pathname, init),
|
(pathname, init) => apiFetch(target, apiSecret, pathname, init),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -428,6 +486,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
|||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints: publishLayout?.constraints ?? null,
|
sandboxConstraints: publishLayout?.constraints ?? null,
|
||||||
tolerateInvalidWorkingDir: true,
|
tolerateInvalidWorkingDir: true,
|
||||||
|
userMemories,
|
||||||
userContext: publishLayout
|
userContext: publishLayout
|
||||||
? {
|
? {
|
||||||
userId,
|
userId,
|
||||||
@@ -521,12 +580,18 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id);
|
const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id);
|
||||||
|
const userMemories = conversationMemoryService?.listMemories
|
||||||
|
? await conversationMemoryService
|
||||||
|
.listMemories(req.currentUser.id, { limit: 40 })
|
||||||
|
.catch(() => [])
|
||||||
|
: [];
|
||||||
const api = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init);
|
const api = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init);
|
||||||
try {
|
try {
|
||||||
await reconcileAgentSession(api, session.id, {
|
await reconcileAgentSession(api, session.id, {
|
||||||
workingDir,
|
workingDir,
|
||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints: publishLayout?.constraints ?? null,
|
sandboxConstraints: publishLayout?.constraints ?? null,
|
||||||
|
userMemories,
|
||||||
userContext: publishLayout
|
userContext: publishLayout
|
||||||
? {
|
? {
|
||||||
userId: req.currentUser.id,
|
userId: req.currentUser.id,
|
||||||
@@ -587,6 +652,11 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
|||||||
const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id);
|
const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id);
|
||||||
const sessionPolicy = await userAuth.getAgentSessionPolicy(req.currentUser.id);
|
const sessionPolicy = await userAuth.getAgentSessionPolicy(req.currentUser.id);
|
||||||
const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id);
|
const publishLayout = await userAuth.getUserPublishLayout(req.currentUser.id);
|
||||||
|
const userMemories = conversationMemoryService?.listMemories
|
||||||
|
? await conversationMemoryService
|
||||||
|
.listMemories(req.currentUser.id, { limit: 40 })
|
||||||
|
.catch(() => [])
|
||||||
|
: [];
|
||||||
try {
|
try {
|
||||||
await reconcileAgentSession(
|
await reconcileAgentSession(
|
||||||
(pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init),
|
(pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init),
|
||||||
@@ -596,6 +666,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
|||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints: publishLayout?.constraints ?? null,
|
sandboxConstraints: publishLayout?.constraints ?? null,
|
||||||
tolerateInvalidWorkingDir: true,
|
tolerateInvalidWorkingDir: true,
|
||||||
|
userMemories,
|
||||||
userContext: publishLayout
|
userContext: publishLayout
|
||||||
? {
|
? {
|
||||||
userId: req.currentUser.id,
|
userId: req.currentUser.id,
|
||||||
@@ -911,6 +982,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
|
|||||||
proxyFallback,
|
proxyFallback,
|
||||||
proxySessionEvents,
|
proxySessionEvents,
|
||||||
resolveTarget,
|
resolveTarget,
|
||||||
|
startSessionForUser,
|
||||||
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
|
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
|
||||||
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
|
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,11 +127,33 @@ export function renderUserMemoryProfileForHarness(profile, context = {}) {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStoredUserMemoryLines(memories) {
|
||||||
|
const items = Array.isArray(memories) ? memories : [];
|
||||||
|
if (items.length === 0) return ['- (暂无已沉淀的对话记忆)'];
|
||||||
|
return items.slice(0, 50).map((item) => {
|
||||||
|
const label = item?.label ? `[${item.label}] ` : '';
|
||||||
|
const text = String(item?.text ?? item?.memory_text ?? item?.memoryText ?? '').trim();
|
||||||
|
return `- ${label}${text}`;
|
||||||
|
}).filter((line) => line.trim() !== '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderStoredUserMemoriesForHarness(memories) {
|
||||||
|
return [
|
||||||
|
'## TKMind 已沉淀用户记忆',
|
||||||
|
'',
|
||||||
|
'以下内容来自用户历史对话的长期记忆抽取,仅用于改善当前用户体验。',
|
||||||
|
'不要把这些内容透露给其他用户;如果用户要求忘记或删除,应遵从并更新对应记忆。',
|
||||||
|
'',
|
||||||
|
...renderStoredUserMemoryLines(memories),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSessionMemoryEntries({
|
export function buildSessionMemoryEntries({
|
||||||
workingDir,
|
workingDir,
|
||||||
sessionPolicy,
|
sessionPolicy,
|
||||||
sandboxConstraints = null,
|
sandboxConstraints = null,
|
||||||
userContext = null,
|
userContext = null,
|
||||||
|
userMemories = null,
|
||||||
}) {
|
}) {
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
|
||||||
@@ -179,6 +201,13 @@ export function buildSessionMemoryEntries({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(userMemories) && userMemories.length > 0) {
|
||||||
|
entries.push({
|
||||||
|
title: 'TKMind 已沉淀用户记忆',
|
||||||
|
content: renderStoredUserMemoriesForHarness(userMemories),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,19 @@ test('buildSessionMemoryEntries injects sandbox, guidance, and profile', () => {
|
|||||||
assert.match(entries[3].content, /Bob/);
|
assert.match(entries[3].content, /Bob/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildSessionMemoryEntries injects stored conversation memories', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tkmind-memory-'));
|
||||||
|
const sessionPolicy = buildAgentExtensionPolicy(DEFAULT_USER_CAPABILITIES);
|
||||||
|
const entries = buildSessionMemoryEntries({
|
||||||
|
workingDir: root,
|
||||||
|
sessionPolicy,
|
||||||
|
userContext: { userId: 'user-3', displayName: 'Carol', username: 'carol' },
|
||||||
|
userMemories: [{ label: 'interest', text: '用户关注 AI 产品设计' }],
|
||||||
|
});
|
||||||
|
assert.equal(entries.at(-1).title, 'TKMind 已沉淀用户记忆');
|
||||||
|
assert.match(entries.at(-1).content, /AI 产品设计/);
|
||||||
|
});
|
||||||
|
|
||||||
test('renderMemoryStoreGuidance scopes memory to one user', () => {
|
test('renderMemoryStoreGuidance scopes memory to one user', () => {
|
||||||
const text = renderMemoryStoreGuidance({ addressName: '小陈' });
|
const text = renderMemoryStoreGuidance({ addressName: '小陈' });
|
||||||
assert.match(text, /小陈/);
|
assert.match(text, /小陈/);
|
||||||
|
|||||||
+543
-30
@@ -35,6 +35,19 @@ const SESSION_WORKSPACE_TOOL_NAMES = new Set([
|
|||||||
'list_dir',
|
'list_dir',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function escapeRegExp(value) {
|
||||||
|
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
function parseXmlField(xml, field) {
|
function parseXmlField(xml, field) {
|
||||||
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
|
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
|
||||||
if (cdataMatch) return cdataMatch[1];
|
if (cdataMatch) return cdataMatch[1];
|
||||||
@@ -310,7 +323,13 @@ function extractHtmlWriteTargets(messages = []) {
|
|||||||
const args = toolCall?.arguments ?? {};
|
const args = toolCall?.arguments ?? {};
|
||||||
const action = String(args.action ?? '').toLowerCase();
|
const action = String(args.action ?? '').toLowerCase();
|
||||||
const writeLikeDeveloper = name === 'developer' && action === 'write';
|
const writeLikeDeveloper = name === 'developer' && action === 'write';
|
||||||
const writeLikeSandbox = (name === 'write_file' || name === 'edit_file') && typeof args.path === 'string';
|
const normalizedName = String(name ?? '').trim();
|
||||||
|
const writeLikeSandbox =
|
||||||
|
(normalizedName === 'write_file' ||
|
||||||
|
normalizedName === 'edit_file' ||
|
||||||
|
normalizedName.endsWith('__write_file') ||
|
||||||
|
normalizedName.endsWith('__edit_file')) &&
|
||||||
|
typeof args.path === 'string';
|
||||||
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
|
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
|
||||||
const candidate = String(args.path ?? '').trim();
|
const candidate = String(args.path ?? '').trim();
|
||||||
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
|
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
|
||||||
@@ -325,6 +344,19 @@ function looksLikeHtmlGenerationIntent(text) {
|
|||||||
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
|
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usedStaticPagePublishSkill(messages = []) {
|
||||||
|
return messages.some((message) =>
|
||||||
|
message?.content?.some((item) => {
|
||||||
|
if (item?.type !== 'toolRequest') return false;
|
||||||
|
const toolCall = item.toolCall?.value;
|
||||||
|
const name = String(toolCall?.name ?? '').trim();
|
||||||
|
const args = toolCall?.arguments ?? {};
|
||||||
|
const skillName = String(args.name ?? '').trim();
|
||||||
|
return name === 'load_skill' && skillName === 'static-page-publish';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isBareCompletionText(text) {
|
function isBareCompletionText(text) {
|
||||||
const normalized = String(text ?? '').trim();
|
const normalized = String(text ?? '').trim();
|
||||||
return /^(?:已完成|完成了|完成)$/u.test(normalized);
|
return /^(?:已完成|完成了|完成)$/u.test(normalized);
|
||||||
@@ -343,6 +375,39 @@ function isSuspiciousBareCompletionReply(reply, intent) {
|
|||||||
return !hasAnyToolRequest(reply?.messages ?? []);
|
return !hasAnyToolRequest(reply?.messages ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function looksLikePublishSuccessClaim(text) {
|
||||||
|
const normalized = String(text ?? '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasAnyValidPublishedHtmlLink(text, linkExists) {
|
||||||
|
const value = String(text ?? '');
|
||||||
|
for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) {
|
||||||
|
try {
|
||||||
|
if (await linkExists(match[0])) return true;
|
||||||
|
} catch {
|
||||||
|
// treat lookup failures as missing links
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) {
|
||||||
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||||||
|
const text = String(reply?.text ?? '').trim();
|
||||||
|
if (!looksLikePublishSuccessClaim(text)) return false;
|
||||||
|
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
|
||||||
|
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingRequiredPublishSkill(reply, intent) {
|
||||||
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
|
||||||
|
const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0;
|
||||||
|
if (!wroteHtml) return false;
|
||||||
|
return !usedStaticPagePublishSkill(reply?.messages ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
||||||
const owner = path.basename(path.resolve(workingDir));
|
const owner = path.basename(path.resolve(workingDir));
|
||||||
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
|
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
|
||||||
@@ -351,7 +416,9 @@ function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
|
|||||||
|
|
||||||
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
||||||
const workspaceRoot = path.resolve(workingDir);
|
const workspaceRoot = path.resolve(workingDir);
|
||||||
const source = path.resolve(htmlPath);
|
const source = path.isAbsolute(String(htmlPath ?? ''))
|
||||||
|
? path.resolve(String(htmlPath))
|
||||||
|
: path.resolve(workspaceRoot, String(htmlPath ?? ''));
|
||||||
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
|
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
|
||||||
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
|
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
|
||||||
|
|
||||||
@@ -371,13 +438,307 @@ function ensurePublicHtmlArtifact(htmlPath, workingDir) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
|
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
|
||||||
const baseText = String(reply?.text ?? '').trim();
|
const artifacts = [];
|
||||||
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
|
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
|
||||||
for (const target of htmlTargets) {
|
for (const target of htmlTargets) {
|
||||||
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
||||||
if (!artifact) continue;
|
if (!artifact) continue;
|
||||||
const url = buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl);
|
artifacts.push({
|
||||||
|
...artifact,
|
||||||
|
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return artifacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRequestedHtmlTarget(text) {
|
||||||
|
const value = String(text ?? '');
|
||||||
|
if (!value) return '';
|
||||||
|
const explicitPublicMatch = value.match(/(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i);
|
||||||
|
if (explicitPublicMatch?.[1]) return explicitPublicMatch[1];
|
||||||
|
const bareMatch = value.match(/(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i);
|
||||||
|
if (bareMatch?.[1]) return `public/${bareMatch[1]}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl }) {
|
||||||
|
const target = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
|
||||||
|
if (!target) return [];
|
||||||
|
const artifact = ensurePublicHtmlArtifact(target, workingDir);
|
||||||
|
if (!artifact) return [];
|
||||||
|
return [{
|
||||||
|
...artifact,
|
||||||
|
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilenameSlug(value) {
|
||||||
|
const normalized = String(value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return normalized || 'simple-page';
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferFallbackHtmlName(intent, reply) {
|
||||||
|
const explicit = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const replyText = String(reply?.text ?? '');
|
||||||
|
const linkMatch = replyText.match(/([a-z0-9._-]+\.html)\b/i);
|
||||||
|
if (linkMatch?.[1]) return `public/${linkMatch[1]}`;
|
||||||
|
const source = `${String(intent?.agentText ?? '')}\n${replyText}`;
|
||||||
|
if (/泰国|thailand/i.test(source)) return 'public/thailand-guide.html';
|
||||||
|
if (/夏日|summer/i.test(source)) return 'public/summer-guide.html';
|
||||||
|
const topic = source
|
||||||
|
.replace(/https?:\/\/\S+/g, '')
|
||||||
|
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 6)
|
||||||
|
.join('-');
|
||||||
|
return `public/${sanitizeFilenameSlug(topic)}.html`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFallbackPageTitle(intent, reply) {
|
||||||
|
const lines = String(reply?.text ?? '')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line && !/^https?:\/\//i.test(line) && !/^\[.+\]\(https?:\/\//i.test(line));
|
||||||
|
const preferred = lines.find(
|
||||||
|
(line) =>
|
||||||
|
!/^(已完成|页面已生成|点击下方链接查看|查看页面|还在处理|明白|开始了)$/u.test(
|
||||||
|
line.replace(/[🌴✨⭐️]/gu, '').trim(),
|
||||||
|
) &&
|
||||||
|
!/成功发布|已发布|好消息|页面都已经/u.test(line),
|
||||||
|
);
|
||||||
|
if (preferred) return preferred.replace(/[🌴✨⭐️]/gu, '').trim();
|
||||||
|
if (/泰国|thailand/i.test(String(intent?.agentText ?? ''))) return '泰国简易攻略';
|
||||||
|
return '简版页面';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyUrl(text) {
|
||||||
|
return /https?:\/\/\S+/i.test(String(text ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyPublicHtmlLink(text) {
|
||||||
|
return [...String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)].length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackHtmlDocument(intent, reply, title) {
|
||||||
|
const source = `${String(intent?.agentText ?? '')}\n${String(reply?.text ?? '')}`;
|
||||||
|
if (/泰国|thailand/i.test(source)) {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="泰国旅行快速上手版,适合先看一遍再出发。">
|
||||||
|
<meta name="mindspace-cover" content='{"tag":"旅行","emoji":"🇹🇭","accent":"#ff8a3d","accent2":"#185a9d","subtitle":"轻量版泰国出行建议"}'>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light; --bg:#fff7ef; --card:#ffffff; --ink:#18212f; --muted:#5b6472; --accent:#ff8a3d; --accent2:#185a9d; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; font-family:"PingFang SC","Noto Sans SC",sans-serif; background:linear-gradient(180deg,#fff4e8 0%,#f7fbff 100%); color:var(--ink); }
|
||||||
|
main { max-width:760px; margin:0 auto; padding:40px 20px 72px; }
|
||||||
|
.hero { background:linear-gradient(135deg,var(--accent),#ffd36e 58%,#fff 100%); border-radius:28px; padding:28px; box-shadow:0 20px 50px rgba(24,33,47,.12); }
|
||||||
|
.eyebrow { font-size:13px; letter-spacing:.08em; text-transform:uppercase; opacity:.72; }
|
||||||
|
h1 { margin:10px 0 12px; font-size:34px; line-height:1.15; }
|
||||||
|
.summary { margin:0; max-width:520px; line-height:1.7; }
|
||||||
|
section { margin-top:18px; background:var(--card); border-radius:22px; padding:22px; box-shadow:0 14px 34px rgba(24,33,47,.08); }
|
||||||
|
h2 { margin:0 0 12px; font-size:20px; }
|
||||||
|
ul { margin:0; padding-left:20px; line-height:1.8; }
|
||||||
|
.grid { display:grid; gap:18px; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); }
|
||||||
|
.tip { color:var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="hero">
|
||||||
|
<div class="eyebrow">Thailand Quick Guide</div>
|
||||||
|
<h1>${escapeHtml(title)}</h1>
|
||||||
|
<p class="summary">适合第一次去泰国时先快速浏览:先定城市,再定节奏,预算和注意事项尽量简单清楚,出发前照着检查一遍就够了。</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<section>
|
||||||
|
<h2>推荐路线</h2>
|
||||||
|
<ul>
|
||||||
|
<li>曼谷 2 天:寺庙、夜市、商场和城市观景台。</li>
|
||||||
|
<li>清迈 2 到 3 天:古城慢逛、咖啡馆、夜间集市。</li>
|
||||||
|
<li>海岛 2 到 3 天:普吉、甲米或苏梅,选一个就够。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>预算参考</h2>
|
||||||
|
<ul>
|
||||||
|
<li>住宿:普通酒店每晚约 200 到 500 元人民币。</li>
|
||||||
|
<li>餐饮:街边小吃和简餐通常比较友好。</li>
|
||||||
|
<li>交通:市内尽量打正规车或用常见打车软件。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section>
|
||||||
|
<h2>出发前准备</h2>
|
||||||
|
<ul>
|
||||||
|
<li>提前确认签证或入境政策,护照有效期留足。</li>
|
||||||
|
<li>准备一点现金,热门景点与夜市会更方便。</li>
|
||||||
|
<li>防晒、驱蚊、轻便衣物尽量提前备好。</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>注意事项</h2>
|
||||||
|
<ul>
|
||||||
|
<li>尊重寺庙着装要求,进入室内前留意是否需要脱鞋。</li>
|
||||||
|
<li>海岛项目先问清价格和往返方式,避免临时加价。</li>
|
||||||
|
<li>如果行程只想轻松一点,城市和海岛不要排太满。</li>
|
||||||
|
</ul>
|
||||||
|
<p class="tip">这是一个简版攻略页,后续如果你要,我也可以继续扩成 5 天行程版、夜市清单版或海岛专版。</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = `根据你的要求临时补出的简版页面:${String(intent?.agentText ?? '').trim() || '已生成可访问页面'}`;
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<meta name="description" content="${escapeHtml(description)}">
|
||||||
|
<meta name="mindspace-cover" content='{"tag":"页面","emoji":"📄","accent":"#2f6fed","accent2":"#0f172a","subtitle":"服务号自动补出简版页面"}'>
|
||||||
|
<style>
|
||||||
|
body { margin:0; font-family:"PingFang SC","Noto Sans SC",sans-serif; background:#f5f7fb; color:#172033; }
|
||||||
|
main { max-width:760px; margin:0 auto; padding:48px 20px 72px; }
|
||||||
|
article { background:#fff; border-radius:24px; padding:28px; box-shadow:0 18px 44px rgba(15,23,42,.08); }
|
||||||
|
h1 { margin:0 0 12px; font-size:32px; }
|
||||||
|
p, li { line-height:1.8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<article>
|
||||||
|
<h1>${escapeHtml(title)}</h1>
|
||||||
|
<p>${escapeHtml(description)}</p>
|
||||||
|
<ul>
|
||||||
|
<li>这是服务号兜底生成的简版页面,先确保你能直接打开和分享。</li>
|
||||||
|
<li>如果你还想加图片、配色、分栏或更完整内容,可以继续在当前话题上细化。</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackPublishedHtmlArtifact(intent, reply, { workingDir, publicBaseUrl }) {
|
||||||
|
if (!looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)) return null;
|
||||||
|
const relativePath = inferFallbackHtmlName(intent, reply);
|
||||||
|
const absolutePath = path.resolve(workingDir, relativePath);
|
||||||
|
const workspaceRoot = path.resolve(workingDir);
|
||||||
|
if (absolutePath !== workspaceRoot && !absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) return null;
|
||||||
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
||||||
|
const title = extractFallbackPageTitle(intent, reply);
|
||||||
|
fs.writeFileSync(absolutePath, buildFallbackHtmlDocument(intent, reply, title), 'utf8');
|
||||||
|
const artifact = ensurePublicHtmlArtifact(relativePath, workingDir);
|
||||||
|
if (!artifact) return null;
|
||||||
|
return {
|
||||||
|
...artifact,
|
||||||
|
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
|
||||||
|
fallbackTitle: title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRecentPublishedHtmlArtifacts(
|
||||||
|
intent,
|
||||||
|
{ workingDir, publicBaseUrl, sinceMs = 0, limit = 20 } = {},
|
||||||
|
) {
|
||||||
|
const publicRoot = path.join(path.resolve(workingDir), 'public');
|
||||||
|
if (!fs.existsSync(publicRoot) || !fs.statSync(publicRoot).isDirectory()) return [];
|
||||||
|
|
||||||
|
const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
|
||||||
|
const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, '').replace(/\\/g, '/') : '';
|
||||||
|
const artifacts = [];
|
||||||
|
const stack = [publicRoot];
|
||||||
|
while (stack.length > 0 && artifacts.length < limit) {
|
||||||
|
const current = stack.pop();
|
||||||
|
let entries = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const absolutePath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(absolutePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue;
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(absolutePath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue;
|
||||||
|
const relativePath = path.relative(path.resolve(workingDir), absolutePath);
|
||||||
|
if (!relativePath || relativePath.startsWith('..')) continue;
|
||||||
|
artifacts.push({
|
||||||
|
localPath: absolutePath,
|
||||||
|
relativePath,
|
||||||
|
url: buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl),
|
||||||
|
mtimeMs: Number(stat.mtimeMs ?? 0),
|
||||||
|
matchesExpected: expectedRelativePath
|
||||||
|
? relativePath.replace(/\\/g, '/') === expectedRelativePath
|
||||||
|
: false,
|
||||||
|
});
|
||||||
|
if (artifacts.length >= limit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.matchesExpected !== right.matchesExpected) return left.matchesExpected ? -1 : 1;
|
||||||
|
return right.mtimeMs - left.mtimeMs;
|
||||||
|
})
|
||||||
|
.map(({ mtimeMs, matchesExpected, ...artifact }) => artifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewritePublishedHtmlLinks(text, artifacts = []) {
|
||||||
|
const value = String(text ?? '');
|
||||||
|
if (!value || artifacts.length === 0) return value;
|
||||||
|
let next = value;
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
const canonicalUrl = String(artifact?.url ?? '').trim();
|
||||||
|
const filename = String(artifact?.relativePath ?? '').replace(/\\/g, '/').split('/').pop();
|
||||||
|
if (!canonicalUrl || !filename) continue;
|
||||||
|
const wrongPublicLinkPattern = new RegExp(
|
||||||
|
`https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
const wrongTkmindHtmlLinkPattern = new RegExp(
|
||||||
|
`https?:\\/\\/(?:[^\\s./]+\\.)*tkmind\\.cn\\/[^\n\\s)]*${escapeRegExp(filename)}\\b`,
|
||||||
|
'gi',
|
||||||
|
);
|
||||||
|
next = next.replace(wrongPublicLinkPattern, canonicalUrl);
|
||||||
|
next = next.replace(wrongTkmindHtmlLinkPattern, canonicalUrl);
|
||||||
|
next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match) => {
|
||||||
|
if (match === canonicalUrl) return match;
|
||||||
|
const matchedFilename = String(match).split('/').pop();
|
||||||
|
return matchedFilename === filename ? canonicalUrl : match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) {
|
||||||
|
const artifacts = Array.isArray(providedArtifacts)
|
||||||
|
? providedArtifacts
|
||||||
|
: collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl });
|
||||||
|
const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim();
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
const url = artifact.url;
|
||||||
if (baseText.includes(url)) return baseText;
|
if (baseText.includes(url)) return baseText;
|
||||||
return baseText
|
return baseText
|
||||||
? `${baseText}\n\n查看页面:\n${url}`
|
? `${baseText}\n\n查看页面:\n${url}`
|
||||||
@@ -520,6 +881,31 @@ export async function guardMissingPublicHtmlLinks(
|
|||||||
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
|
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downgradePrematurePublishClaims(text) {
|
||||||
|
const value = String(text ?? '');
|
||||||
|
if (!value.includes('页面生成未完成')) return value;
|
||||||
|
return value
|
||||||
|
.replace(/(^|\n)([^\n]*页面都已经成功发布了[^\n]*)(?=\n|$)/g, '$1页面暂未生成完成,这次我先不发送失效链接。')
|
||||||
|
.replace(/(^|\n)([^\n]*主题页面已发布[^\n]*)(?=\n|$)/g, '$1🌴 夏日主题页面生成未完成')
|
||||||
|
.replace(/页面已创建完毕/g, '页面生成未完成')
|
||||||
|
.replace(/页面已创建/g, '页面生成未完成')
|
||||||
|
.replace(/发布成功/g, '生成未完成')
|
||||||
|
.replace(/已发布成功/g, '生成未完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteFallbackPageSuccessText(text, fallbackArtifact) {
|
||||||
|
if (!fallbackArtifact) return String(text ?? '');
|
||||||
|
const fallbackTitle = String(fallbackArtifact.fallbackTitle ?? '简版页面').trim() || '简版页面';
|
||||||
|
return [
|
||||||
|
'已为你补出一个可直接打开的简版页面。',
|
||||||
|
fallbackTitle,
|
||||||
|
'查看页面:',
|
||||||
|
fallbackArtifact.url,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
export { maybeAttachPublishedHtmlLink };
|
export { maybeAttachPublishedHtmlLink };
|
||||||
|
|
||||||
function isQuestionStatusProbe(text) {
|
function isQuestionStatusProbe(text) {
|
||||||
@@ -676,6 +1062,15 @@ function normalizeWechatInboundIntent(inbound) {
|
|||||||
|
|
||||||
function buildWechatAgentPrompt(intent) {
|
function buildWechatAgentPrompt(intent) {
|
||||||
const msgType = String(intent?.msgType ?? 'text');
|
const msgType = String(intent?.msgType ?? 'text');
|
||||||
|
const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)
|
||||||
|
? [
|
||||||
|
'【页面发布技能要求】这条消息是在生成可访问 HTML 页面。',
|
||||||
|
'开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。',
|
||||||
|
'随后必须用 `write_file` / `edit_file` 写入 `public/*.html`。',
|
||||||
|
'最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
: '';
|
||||||
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
|
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
|
||||||
? [
|
? [
|
||||||
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
'【日程技能要求】这条消息涉及待办、提醒或日程。',
|
||||||
@@ -737,6 +1132,7 @@ function buildWechatAgentPrompt(intent) {
|
|||||||
}
|
}
|
||||||
const content = String(intent?.agentText ?? intent?.content ?? '').trim();
|
const content = String(intent?.agentText ?? intent?.content ?? '').trim();
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
if (pagePublishHint) lines.push(pagePublishHint);
|
||||||
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
|
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
|
||||||
lines.push(
|
lines.push(
|
||||||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||||||
@@ -951,9 +1347,11 @@ export function createWechatMpService({
|
|||||||
config,
|
config,
|
||||||
userAuth,
|
userAuth,
|
||||||
apiFetch,
|
apiFetch,
|
||||||
|
startAgentSession = null,
|
||||||
sessionApiFetch = null,
|
sessionApiFetch = null,
|
||||||
scheduleService = null,
|
scheduleService = null,
|
||||||
applySessionLlmProvider = null,
|
applySessionLlmProvider = null,
|
||||||
|
refreshSessionSnapshot = null,
|
||||||
wechatFetch = undiciFetch,
|
wechatFetch = undiciFetch,
|
||||||
linkExists = defaultPublicHtmlLinkExists,
|
linkExists = defaultPublicHtmlLinkExists,
|
||||||
logger = console,
|
logger = console,
|
||||||
@@ -1089,9 +1487,21 @@ export function createWechatMpService({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendCustomerServiceText = async (openid, content, user = null) => {
|
const sendCustomerServiceText = async (openid, content, user = null, { verifiedHtmlUrls = [] } = {}) => {
|
||||||
const formatted = formatWechatOutboundText(content, user);
|
const formatted = formatWechatOutboundText(content, user);
|
||||||
const guarded = await guardMissingPublicHtmlLinks(formatted, { linkExists });
|
const verifiedUrlSet = new Set(
|
||||||
|
verifiedHtmlUrls
|
||||||
|
.map((url) => String(url ?? '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const guarded = downgradePrematurePublishClaims(
|
||||||
|
await guardMissingPublicHtmlLinks(formatted, {
|
||||||
|
linkExists: async (url) => {
|
||||||
|
if (verifiedUrlSet.has(String(url ?? '').trim())) return true;
|
||||||
|
return linkExists(url);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
const chunks = splitWechatText(guarded);
|
const chunks = splitWechatText(guarded);
|
||||||
const accessToken = await getStableAccessToken();
|
const accessToken = await getStableAccessToken();
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
@@ -1216,26 +1626,28 @@ export function createWechatMpService({
|
|||||||
if (!gate.ok) {
|
if (!gate.ok) {
|
||||||
throw new Error(gate.message || '当前用户无法使用聊天能力');
|
throw new Error(gate.message || '当前用户无法使用聊天能力');
|
||||||
}
|
}
|
||||||
const started = await readJsonResponse(
|
const started = startAgentSession
|
||||||
await apiFetch('/agent/start', {
|
? await startAgentSession({ userId, workingDir, sessionPolicy })
|
||||||
method: 'POST',
|
: await readJsonResponse(
|
||||||
body: JSON.stringify({
|
await apiFetch('/agent/start', {
|
||||||
working_dir: workingDir,
|
method: 'POST',
|
||||||
enable_context_memory: sessionPolicy.enableContextMemory,
|
body: JSON.stringify({
|
||||||
...(sessionPolicy.extensionOverrides
|
working_dir: workingDir,
|
||||||
? { extension_overrides: sessionPolicy.extensionOverrides }
|
enable_context_memory: sessionPolicy.enableContextMemory,
|
||||||
: {}),
|
...(sessionPolicy.extensionOverrides
|
||||||
}),
|
? { extension_overrides: sessionPolicy.extensionOverrides }
|
||||||
}),
|
: {}),
|
||||||
);
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const sessionId = started?.id;
|
const sessionId = started?.id;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
throw new Error('公众号专属 Agent 会话创建失败');
|
throw new Error('公众号专属 Agent 会话创建失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// `/agent/start` already persists the owning user and goosed node via the
|
if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') {
|
||||||
// portal proxy. Re-registering here without the node can overwrite the
|
await userAuth.registerAgentSession(userId, sessionId);
|
||||||
// correct mapping back to node 0 in multi-goosed production.
|
}
|
||||||
await reconcileAgentSession(
|
await reconcileAgentSession(
|
||||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1263,6 +1675,15 @@ export function createWechatMpService({
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
|
||||||
|
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
|
||||||
|
try {
|
||||||
|
await refreshSessionSnapshot(sessionId, userId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn?.('WeChat MP snapshot refresh failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const rememberWechatUserContext = async (sessionId, user) => {
|
const rememberWechatUserContext = async (sessionId, user) => {
|
||||||
const addressName = resolveWechatAddressName(user);
|
const addressName = resolveWechatAddressName(user);
|
||||||
if (!addressName) return;
|
if (!addressName) return;
|
||||||
@@ -1376,6 +1797,7 @@ export function createWechatMpService({
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
|
const requestStartedAt = Date.now();
|
||||||
const reply = await executeSessionReply(
|
const reply = await executeSessionReply(
|
||||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1383,17 +1805,62 @@ export function createWechatMpService({
|
|||||||
buildWechatAgentPrompt(intent),
|
buildWechatAgentPrompt(intent),
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent),
|
||||||
);
|
);
|
||||||
if (isSuspiciousBareCompletionReply(reply, intent)) {
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||||
|
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
});
|
||||||
|
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
sinceMs: requestStartedAt,
|
||||||
|
});
|
||||||
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists);
|
||||||
|
const shouldFallbackFromReply =
|
||||||
|
!hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply);
|
||||||
|
const fallbackArtifact =
|
||||||
|
expectedArtifacts.length === 0 &&
|
||||||
|
recentArtifacts.length === 0 &&
|
||||||
|
shouldFallbackFromReply &&
|
||||||
|
collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0
|
||||||
|
? createFallbackPublishedHtmlArtifact(intent, reply, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
isMissingRequiredPublishSkill(reply, intent) ||
|
||||||
|
isSuspiciousBareCompletionReply(reply, intent) ||
|
||||||
|
(expectedArtifacts.length === 0 &&
|
||||||
|
recentArtifacts.length === 0 &&
|
||||||
|
!fallbackArtifact &&
|
||||||
|
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })))
|
||||||
|
) {
|
||||||
throw new Error('stale_session_poisoned_completion');
|
throw new Error('stale_session_poisoned_completion');
|
||||||
}
|
}
|
||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
|
||||||
}
|
}
|
||||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
||||||
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
});
|
});
|
||||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
|
if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact);
|
||||||
|
const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts;
|
||||||
|
const fallbackArtifacts = publishedArtifacts.length > 0
|
||||||
|
? []
|
||||||
|
: (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts);
|
||||||
|
const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts;
|
||||||
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
artifacts: verifiedArtifacts,
|
||||||
|
});
|
||||||
|
const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact);
|
||||||
|
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
||||||
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, {
|
||||||
|
verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url),
|
||||||
|
});
|
||||||
return { sessionId };
|
return { sessionId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -1409,6 +1876,7 @@ export function createWechatMpService({
|
|||||||
await ensureSessionProvider(sessionId);
|
await ensureSessionProvider(sessionId);
|
||||||
await rememberWechatUserContext(sessionId, user);
|
await rememberWechatUserContext(sessionId, user);
|
||||||
const retryId = crypto.randomUUID();
|
const retryId = crypto.randomUUID();
|
||||||
|
const retryStartedAt = Date.now();
|
||||||
const reply = await executeSessionReply(
|
const reply = await executeSessionReply(
|
||||||
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
(pathname, init) => fetchForSession(sessionId, pathname, init),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1416,17 +1884,62 @@ export function createWechatMpService({
|
|||||||
buildWechatAgentPrompt(intent),
|
buildWechatAgentPrompt(intent),
|
||||||
buildIntentMetadata(intent),
|
buildIntentMetadata(intent),
|
||||||
);
|
);
|
||||||
if (isSuspiciousBareCompletionReply(reply, intent)) {
|
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
|
||||||
|
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
});
|
||||||
|
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
sinceMs: retryStartedAt,
|
||||||
|
});
|
||||||
|
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists);
|
||||||
|
const shouldFallbackFromReply =
|
||||||
|
!hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply);
|
||||||
|
const fallbackArtifact =
|
||||||
|
expectedArtifacts.length === 0 &&
|
||||||
|
recentArtifacts.length === 0 &&
|
||||||
|
shouldFallbackFromReply &&
|
||||||
|
collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0
|
||||||
|
? createFallbackPublishedHtmlArtifact(intent, reply, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
isMissingRequiredPublishSkill(reply, intent) ||
|
||||||
|
isSuspiciousBareCompletionReply(reply, intent) ||
|
||||||
|
(expectedArtifacts.length === 0 &&
|
||||||
|
recentArtifacts.length === 0 &&
|
||||||
|
!fallbackArtifact &&
|
||||||
|
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })))
|
||||||
|
) {
|
||||||
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
|
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
|
||||||
}
|
}
|
||||||
if (reply.tokenState) {
|
if (reply.tokenState) {
|
||||||
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
|
||||||
}
|
}
|
||||||
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
|
||||||
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
|
workingDir,
|
||||||
publicBaseUrl: config.publicBaseUrl,
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
});
|
});
|
||||||
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
|
if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact);
|
||||||
|
const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts;
|
||||||
|
const fallbackArtifacts = publishedArtifacts.length > 0
|
||||||
|
? []
|
||||||
|
: (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts);
|
||||||
|
const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts;
|
||||||
|
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
|
||||||
|
workingDir,
|
||||||
|
publicBaseUrl: config.publicBaseUrl,
|
||||||
|
artifacts: verifiedArtifacts,
|
||||||
|
});
|
||||||
|
const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact);
|
||||||
|
await refreshWechatSessionSnapshot(sessionId, user.userId);
|
||||||
|
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, {
|
||||||
|
verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url),
|
||||||
|
});
|
||||||
return { sessionId };
|
return { sessionId };
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
+1005
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user