feat: add wechat publish recovery and conversation memory

This commit is contained in:
john
2026-06-29 06:59:37 +08:00
parent 615a1e9bf4
commit 66acbc902c
12 changed files with 2308 additions and 38 deletions
+371
View File
@@ -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,
};
}
+152
View File
@@ -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;
});
+46
View File
@@ -232,6 +232,52 @@ export async function migrateSchema(pool) {
) 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 = [
['user_confirmed_at', 'BIGINT NULL AFTER expires_at'],
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
+40
View File
@@ -1032,3 +1032,43 @@ CREATE TABLE IF NOT EXISTS h5_experience (
KEY idx_h5_experience_scope_updated (scope, updated_at),
FULLTEXT KEY ftx_h5_experience (title, body)
) 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
View File
@@ -99,6 +99,7 @@ import { createScheduleService } from './schedule-service.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
import { createConversationMemoryService } from './conversation-memory.mjs';
import { createExperienceService } from './experience-service.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { isNativeH5ApiPath } from './policies.mjs';
@@ -134,6 +135,7 @@ function parseApiTargets() {
}
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_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
@@ -215,6 +217,7 @@ if (ACCESS_PASSWORD) {
let userAuth = null;
let tkmindProxy = null;
let sessionSnapshotService = null;
let conversationMemoryService = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
@@ -516,7 +519,10 @@ async function bootstrapUserAuth() {
void llmProviderService.syncSelectedToGoosed().catch((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({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
@@ -525,6 +531,7 @@ async function bootstrapUserAuth() {
llmProviderService,
subscriptionService,
sessionSnapshotService,
conversationMemoryService,
localFetchAsset: mindSpaceAssets
? async (userId, assetId) => {
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
@@ -537,12 +544,22 @@ async function bootstrapUserAuth() {
config: WECHAT_MP_CONFIG,
userAuth,
apiFetch: tkmindProxy.apiFetch,
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
sessionApiFetch: async (sessionId, pathname, init) => {
const target = await tkmindProxy.resolveTarget(sessionId);
return tkmindProxy.apiFetchTo(target, pathname, init);
},
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
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 }) => {
if (!wechatMpService?.enabled) return;
@@ -4806,10 +4823,10 @@ app.get('*', (_req, res) => {
});
userAuthReady.then((enabled) => {
app.listen(PORT, '127.0.0.1', () => {
console.log(`TKMind H5 @ http://127.0.0.1:${PORT}`);
app.listen(PORT, HOST, () => {
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
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`);
});
});
+2
View File
@@ -82,6 +82,7 @@ export async function reconcileAgentSession(
sessionPolicy,
sandboxConstraints = null,
userContext = null,
userMemories = null,
tolerateInvalidWorkingDir = false,
},
) {
@@ -186,6 +187,7 @@ export async function reconcileAgentSession(
sessionPolicy,
sandboxConstraints: sandboxText,
userContext,
userMemories,
});
if (memoryEntries.length > 0) {
+13 -1
View File
@@ -62,7 +62,9 @@ function resolveDisplayTitle(session, messages) {
return deriveTitleFromMessages(messages);
}
export function createSessionSnapshotService(pool) {
export function createSessionSnapshotService(pool, options = {}) {
const conversationMemoryService = options.conversationMemoryService ?? null;
function isEnabled() {
return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== '0';
}
@@ -138,6 +140,16 @@ export function createSessionSnapshotService(pool) {
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) {
// Non-fatal: log and continue; caller falls back to Goose.
console.warn('[snapshot] save failed:', err instanceof Error ? err.message : err);
+73 -1
View File
@@ -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 primaryTarget = targets[0] ?? apiTarget ?? '';
let rrIdx = 0;
@@ -345,6 +355,51 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
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) {
if (targets.length <= 1 || !sessionId) return primaryTarget;
try {
@@ -420,6 +475,9 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const userMemories = conversationMemoryService?.listMemories
? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => [])
: [];
await reconcileAgentSession(
(pathname, init) => apiFetch(target, apiSecret, pathname, init),
sessionId,
@@ -428,6 +486,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
tolerateInvalidWorkingDir: true,
userMemories,
userContext: publishLayout
? {
userId,
@@ -521,12 +580,18 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
}
}
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);
try {
await reconcileAgentSession(api, session.id, {
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userMemories,
userContext: publishLayout
? {
userId: req.currentUser.id,
@@ -587,6 +652,11 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
const workingDir = await userAuth.resolveWorkingDir(req.currentUser.id);
const sessionPolicy = await userAuth.getAgentSessionPolicy(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 {
await reconcileAgentSession(
(pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init),
@@ -596,6 +666,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
tolerateInvalidWorkingDir: true,
userMemories,
userContext: publishLayout
? {
userId: req.currentUser.id,
@@ -911,6 +982,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
proxyFallback,
proxySessionEvents,
resolveTarget,
startSessionForUser,
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
};
+29
View File
@@ -127,11 +127,33 @@ export function renderUserMemoryProfileForHarness(profile, context = {}) {
].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({
workingDir,
sessionPolicy,
sandboxConstraints = null,
userContext = null,
userMemories = null,
}) {
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;
}
+13
View File
@@ -86,6 +86,19 @@ test('buildSessionMemoryEntries injects sandbox, guidance, and profile', () => {
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', () => {
const text = renderMemoryStoreGuidance({ addressName: '小陈' });
assert.match(text, /小陈/);
+543 -30
View File
@@ -35,6 +35,19 @@ const SESSION_WORKSPACE_TOOL_NAMES = new Set([
'list_dir',
]);
function escapeRegExp(value) {
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
if (cdataMatch) return cdataMatch[1];
@@ -310,7 +323,13 @@ function extractHtmlWriteTargets(messages = []) {
const args = toolCall?.arguments ?? {};
const action = String(args.action ?? '').toLowerCase();
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;
const candidate = String(args.path ?? '').trim();
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
@@ -325,6 +344,19 @@ function looksLikeHtmlGenerationIntent(text) {
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) {
const normalized = String(text ?? '').trim();
return /^(?:已完成|完成了|完成)$/u.test(normalized);
@@ -343,6 +375,39 @@ function isSuspiciousBareCompletionReply(reply, intent) {
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) {
const owner = path.basename(path.resolve(workingDir));
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
@@ -351,7 +416,9 @@ function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
function ensurePublicHtmlArtifact(htmlPath, 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 (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
@@ -371,13 +438,307 @@ function ensurePublicHtmlArtifact(htmlPath, workingDir) {
};
}
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
const baseText = String(reply?.text ?? '').trim();
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
const artifacts = [];
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
for (const target of htmlTargets) {
const artifact = ensurePublicHtmlArtifact(target, workingDir);
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;
return baseText
? `${baseText}\n\n查看页面:\n${url}`
@@ -520,6 +881,31 @@ export async function guardMissingPublicHtmlLinks(
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 };
function isQuestionStatusProbe(text) {
@@ -676,6 +1062,15 @@ function normalizeWechatInboundIntent(inbound) {
function buildWechatAgentPrompt(intent) {
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)
? [
'【日程技能要求】这条消息涉及待办、提醒或日程。',
@@ -737,6 +1132,7 @@ function buildWechatAgentPrompt(intent) {
}
const content = String(intent?.agentText ?? intent?.content ?? '').trim();
const lines = [];
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
lines.push(
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
@@ -951,9 +1347,11 @@ export function createWechatMpService({
config,
userAuth,
apiFetch,
startAgentSession = null,
sessionApiFetch = null,
scheduleService = null,
applySessionLlmProvider = null,
refreshSessionSnapshot = null,
wechatFetch = undiciFetch,
linkExists = defaultPublicHtmlLinkExists,
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 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 accessToken = await getStableAccessToken();
for (const chunk of chunks) {
@@ -1216,26 +1626,28 @@ export function createWechatMpService({
if (!gate.ok) {
throw new Error(gate.message || '当前用户无法使用聊天能力');
}
const started = await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...(sessionPolicy.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
: {}),
}),
}),
);
const started = startAgentSession
? await startAgentSession({ userId, workingDir, sessionPolicy })
: await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...(sessionPolicy.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
: {}),
}),
}),
);
const sessionId = started?.id;
if (!sessionId) {
throw new Error('公众号专属 Agent 会话创建失败');
}
// `/agent/start` already persists the owning user and goosed node via the
// portal proxy. Re-registering here without the node can overwrite the
// correct mapping back to node 0 in multi-goosed production.
if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') {
await userAuth.registerAgentSession(userId, sessionId);
}
await reconcileAgentSession(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1263,6 +1675,15 @@ export function createWechatMpService({
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 addressName = resolveWechatAddressName(user);
if (!addressName) return;
@@ -1376,6 +1797,7 @@ export function createWechatMpService({
].join('\n');
};
try {
const requestStartedAt = Date.now();
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1383,17 +1805,62 @@ export function createWechatMpService({
buildWechatAgentPrompt(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');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
workingDir,
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 };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -1409,6 +1876,7 @@ export function createWechatMpService({
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user);
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1416,17 +1884,62 @@ export function createWechatMpService({
buildWechatAgentPrompt(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('本轮命中了被旧指令污染的专属会话,请稍后重试');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
workingDir,
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 };
}
throw err;
+1005 -2
View File
File diff suppressed because it is too large Load Diff