diff --git a/conversation-memory.mjs b/conversation-memory.mjs
new file mode 100644
index 0000000..11322e6
--- /dev/null
+++ b/conversation-memory.mjs
@@ -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,
+ };
+}
diff --git a/conversation-memory.test.mjs b/conversation-memory.test.mjs
new file mode 100644
index 0000000..9a43033
--- /dev/null
+++ b/conversation-memory.test.mjs
@@ -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;
+});
diff --git a/db.mjs b/db.mjs
index a371908..1f9ff44 100644
--- a/db.mjs
+++ b/db.mjs
@@ -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'],
diff --git a/schema.sql b/schema.sql
index bd1976d..e408cd4 100644
--- a/schema.sql
+++ b/schema.sql
@@ -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;
diff --git a/server.mjs b/server.mjs
index 5c1a97b..6fa81a2 100644
--- a/server.mjs
+++ b/server.mjs
@@ -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`);
});
});
diff --git a/session-reconcile.mjs b/session-reconcile.mjs
index ac882f0..4034f97 100644
--- a/session-reconcile.mjs
+++ b/session-reconcile.mjs
@@ -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) {
diff --git a/session-snapshot.mjs b/session-snapshot.mjs
index 0a1b933..b2de37e 100644
--- a/session-snapshot.mjs
+++ b/session-snapshot.mjs
@@ -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);
diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs
index 0aa4261..0ebf587 100644
--- a/tkmind-proxy.mjs
+++ b/tkmind-proxy.mjs
@@ -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),
};
diff --git a/user-memory-profile.mjs b/user-memory-profile.mjs
index 7abbd5b..411fc82 100644
--- a/user-memory-profile.mjs
+++ b/user-memory-profile.mjs
@@ -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;
}
diff --git a/user-memory-profile.test.mjs b/user-memory-profile.test.mjs
index 94eb73f..a9e86e5 100644
--- a/user-memory-profile.test.mjs
+++ b/user-memory-profile.test.mjs
@@ -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, /小陈/);
diff --git a/wechat-mp.mjs b/wechat-mp.mjs
index 73f126b..5c8ee4e 100644
--- a/wechat-mp.mjs
+++ b/wechat-mp.mjs
@@ -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('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><\\/${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 `
+
+
+
+
+ ${escapeHtml(title)}
+
+
+
+
+
+
+
+
Thailand Quick Guide
+
${escapeHtml(title)}
+
适合第一次去泰国时先快速浏览:先定城市,再定节奏,预算和注意事项尽量简单清楚,出发前照着检查一遍就够了。
+
+
+
+ 推荐路线
+
+ - 曼谷 2 天:寺庙、夜市、商场和城市观景台。
+ - 清迈 2 到 3 天:古城慢逛、咖啡馆、夜间集市。
+ - 海岛 2 到 3 天:普吉、甲米或苏梅,选一个就够。
+
+
+
+ 预算参考
+
+ - 住宿:普通酒店每晚约 200 到 500 元人民币。
+ - 餐饮:街边小吃和简餐通常比较友好。
+ - 交通:市内尽量打正规车或用常见打车软件。
+
+
+
+
+ 出发前准备
+
+ - 提前确认签证或入境政策,护照有效期留足。
+ - 准备一点现金,热门景点与夜市会更方便。
+ - 防晒、驱蚊、轻便衣物尽量提前备好。
+
+
+
+ 注意事项
+
+ - 尊重寺庙着装要求,进入室内前留意是否需要脱鞋。
+ - 海岛项目先问清价格和往返方式,避免临时加价。
+ - 如果行程只想轻松一点,城市和海岛不要排太满。
+
+ 这是一个简版攻略页,后续如果你要,我也可以继续扩成 5 天行程版、夜市清单版或海岛专版。
+
+
+
+`;
+ }
+
+ const description = `根据你的要求临时补出的简版页面:${String(intent?.agentText ?? '').trim() || '已生成可访问页面'}`;
+ return `
+
+
+
+
+ ${escapeHtml(title)}
+
+
+
+
+
+
+
+ ${escapeHtml(title)}
+ ${escapeHtml(description)}
+
+ - 这是服务号兜底生成的简版页面,先确保你能直接打开和分享。
+ - 如果你还想加图片、配色、分栏或更完整内容,可以继续在当前话题上细化。
+
+
+
+
+`;
+}
+
+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;
diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs
index 52c47fd..08dc0d1 100644
--- a/wechat-mp.test.mjs
+++ b/wechat-mp.test.mjs
@@ -138,6 +138,80 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a
assert.match(guarded, /missing\.html/);
});
+test('wechat mp service replaces blocked publish claims with a directly accessible fallback page', async () => {
+ const wechatCalls = [];
+ const service = createBoundWechatService({
+ config: {
+ publicBaseUrl: 'https://m.tkmind.cn',
+ },
+ sessionApiFetch: async (sessionId, pathname) => {
+ assert.equal(sessionId, 'session-1');
+ if (pathname === '/sessions/session-1/events') {
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-1","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"好消息!m.tkmind.cn 其实是可以访问的,页面都已经成功发布了!以下是您的夏日主题页面:\\n\\n🌴 夏日主题页面已发布\\n\\nhttps://m.tkmind.cn/MindSpace/john/public/summer-breeze-journal.html"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-1","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ if (pathname === '/sessions/session-1/reply') {
+ return new Response(JSON.stringify({ status: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
+ },
+ wechatFetch: async (url, init = {}) => {
+ wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ },
+ userAuth: {
+ async resolveWorkingDir() {
+ return '/tmp/user-1';
+ },
+ },
+ });
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = () => 'req-1';
+ try {
+ const result = await service.handleInboundMessage(inboundXml({ content: '帮我生成一个夏日页面' }), {
+ timestamp: '1710000000',
+ nonce: 'nonce',
+ signature: signatureFor('token', '1710000000', 'nonce'),
+ });
+ assert.equal(result.status, 200);
+ await result.task;
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ }
+
+ const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
+ const payload = JSON.parse(sendCall[2]);
+ assert.doesNotMatch(payload.text.content, /成功发布|主题页面已发布/);
+ assert.match(payload.text.content, /可直接打开的简版页面|查看页面/);
+ assert.match(payload.text.content, /summer-breeze-journal\.html/);
+});
+
test('maybeAttachPublishedHtmlLink copies generated root html into public and returns link', async (t) => {
const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-public-');
const htmlPath = `${workspaceRoot}/hello.html`;
@@ -180,6 +254,158 @@ test('maybeAttachPublishedHtmlLink copies generated root html into public and re
);
});
+test('maybeAttachPublishedHtmlLink recognizes sandbox-fs write_file html outputs', async () => {
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-sandbox-fs-');
+ const htmlPath = `${workspaceRoot}/public/codex-verify.html`;
+ fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true });
+ fs.writeFileSync(htmlPath, 'Codex Verify');
+
+ const text = await maybeAttachPublishedHtmlLink(
+ {
+ text: '页面已生成 ✅',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'toolRequest',
+ toolCall: {
+ value: {
+ name: 'sandbox-fs__write_file',
+ arguments: {
+ path: 'public/codex-verify.html',
+ content: 'Codex Verify',
+ },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ workingDir: workspaceRoot,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ },
+ );
+
+ assert.match(
+ text,
+ /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/codex-verify\.html/,
+ );
+});
+
+test('maybeAttachPublishedHtmlLink rewrites wrong public html links to the canonical published url', async () => {
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-rewrite-link-');
+ const htmlPath = `${workspaceRoot}/public/summer-breeze-journal.html`;
+ fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true });
+ fs.writeFileSync(htmlPath, 'Summer');
+
+ const text = await maybeAttachPublishedHtmlLink(
+ {
+ text: '页面已发布:https://m.tkmind.cn/public/summer-breeze-journal.html',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'toolRequest',
+ toolCall: {
+ value: {
+ name: 'sandbox-fs__write_file',
+ arguments: {
+ path: 'public/summer-breeze-journal.html',
+ content: 'Summer',
+ },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ workingDir: workspaceRoot,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ },
+ );
+
+ assert.doesNotMatch(text, /https:\/\/m\.tkmind\.cn\/public\/summer-breeze-journal\.html/);
+ assert.match(
+ text,
+ /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/summer-breeze-journal\.html/,
+ );
+});
+
+test('maybeAttachPublishedHtmlLink rewrites tkmind domain variants to the canonical published url', async () => {
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-rewrite-domain-');
+ const htmlPath = `${workspaceRoot}/public/codex-prod-check-v6.html`;
+ fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true });
+ fs.writeFileSync(htmlPath, 'Prod Check');
+
+ const text = await maybeAttachPublishedHtmlLink(
+ {
+ text: '唯一可访问链接:https://mindspace.tkmind.cn/a70ff537-8908-486e-9b6c-042e07cc25db/codex-prod-check-v6.html',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'toolRequest',
+ toolCall: {
+ value: {
+ name: 'sandbox-fs__write_file',
+ arguments: {
+ path: 'public/codex-prod-check-v6.html',
+ content: 'Prod Check',
+ },
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ workingDir: workspaceRoot,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ },
+ );
+
+ assert.doesNotMatch(text, /https:\/\/mindspace\.tkmind\.cn\/.+\/codex-prod-check-v6\.html/);
+ assert.match(
+ text,
+ /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/codex-prod-check-v6\.html/,
+ );
+});
+
+test('maybeAttachPublishedHtmlLink can attach a verified existing public html link from provided artifacts', async () => {
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-existing-public-');
+ const htmlPath = `${workspaceRoot}/public/thailand-guide.html`;
+ fs.mkdirSync(`${workspaceRoot}/public`, { recursive: true });
+ fs.writeFileSync(htmlPath, 'Thailand');
+
+ const text = await maybeAttachPublishedHtmlLink(
+ {
+ text: '页面已生成!点击下方链接查看:',
+ messages: [],
+ },
+ {
+ workingDir: workspaceRoot,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ artifacts: [
+ {
+ localPath: htmlPath,
+ relativePath: 'public/thailand-guide.html',
+ url: `https://m.tkmind.cn/MindSpace/${path.basename(workspaceRoot)}/public/thailand-guide.html`,
+ },
+ ],
+ },
+ );
+
+ assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
+});
+
test('wechat mp service splits long agent replies into multiple customer messages', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -388,6 +614,10 @@ test('wechat mp service strips markdown emphasis around outbound links', async (
}
throw new Error(`unexpected wechat url: ${url}`);
},
+ linkExists: async (urlText) => {
+ if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
+ return fs.existsSync(htmlPath);
+ },
});
const originalRandomUuid = crypto.randomUUID;
@@ -452,6 +682,10 @@ test('wechat mp service does not send stale page links from unscoped conversatio
}
throw new Error(`unexpected wechat url: ${url}`);
},
+ linkExists: async (urlText) => {
+ if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
+ return fs.existsSync(htmlPath);
+ },
});
const originalRandomUuid = crypto.randomUUID;
@@ -647,7 +881,8 @@ test('wechat mp service routes text to dedicated session and sends customer serv
}
if (pathname === '/sessions/session-1/reply') {
const body = JSON.parse(init.body);
- assert.equal(body.user_message.content[0].text, '【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。\n若用户只是在测试连通性,请一句话确认收到即可,不要展开旧任务。\n\n用户消息:帮我做个页面');
+ assert.match(body.user_message.content[0].text, /用户消息:帮我做个页面/);
+ assert.match(body.user_message.content[0].text, /static-page-publish/);
body.request_id && (apiCalls[apiCalls.length - 1][2] = body.request_id);
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
@@ -696,6 +931,10 @@ test('wechat mp service routes text to dedicated session and sends customer serv
}
throw new Error(`unexpected wechat url: ${url}`);
},
+ linkExists: async (urlText) => {
+ if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
+ return fs.existsSync(htmlPath);
+ },
});
const originalRandomUuid = crypto.randomUUID;
@@ -808,6 +1047,10 @@ test('wechat mp service rewrites internal wx username mentions that appear mid-m
}
throw new Error(`unexpected wechat url: ${url}`);
},
+ linkExists: async (urlText) => {
+ if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
+ return fs.existsSync(htmlPath);
+ },
});
const originalRandomUuid = crypto.randomUUID;
@@ -895,6 +1138,90 @@ test('wechat mp service applies provider before forwarding to dedicated session'
assert.deepEqual(appliedSessions, ['session-1']);
});
+test('wechat mp service injects static page publish instructions for html generation requests', async () => {
+ const token = 'token';
+ const timestamp = '1710000000';
+ const nonce = 'nonce';
+ const service = createBoundWechatService({
+ token,
+ userAuth: {
+ async findWechatUserByOpenid() {
+ return { userId: 'user-1', status: 'active', nickname: '毕升' };
+ },
+ async recordWechatMpMessage() {
+ return { inserted: true };
+ },
+ async insertWechatMpMessageDetail() {},
+ async finishWechatMpMessage() {},
+ async resolveWorkingDir() {
+ return '/tmp/user-1';
+ },
+ async getAgentSessionPolicy() {
+ return { enableContextMemory: false, extensionOverrides: [], unrestricted: true };
+ },
+ async getUserPublishLayout() {
+ return { displayName: 'John', username: 'john', slug: 'john', constraints: null };
+ },
+ async registerAgentSession() {},
+ async upsertWechatAgentRoute() {},
+ async billSessionUsage() {},
+ },
+ sessionApiFetch: async (sessionId, pathname, init = {}) => {
+ assert.equal(sessionId, 'session-1');
+ if (pathname === '/sessions/session-1/events') {
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-page","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"收到,我去生成页面。"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-page","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ if (pathname === '/sessions/session-1/reply') {
+ const body = JSON.parse(init.body);
+ assert.match(body.user_message.content[0].text, /static-page-publish/);
+ assert.match(body.user_message.content[0].text, /必须先调用 `load_skill`/);
+ assert.match(body.user_message.content[0].text, /写入 `public\/\*\.html`/);
+ assert.match(body.user_message.content[0].text, /唯一正确链接/);
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ throw new Error(`unexpected api path: ${pathname}`);
+ },
+ wechatFetch: async (url) => {
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ },
+ });
+
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = () => 'req-page';
+ try {
+ const result = await service.handleInboundMessage(inboundXml({ content: '请生成一个 html 页面,文件名 public/hello-world.html' }), {
+ timestamp,
+ nonce,
+ signature: signatureFor(token, timestamp, nonce),
+ });
+ assert.equal(result.status, 200);
+ await result.task;
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ }
+});
+
test('wechat mp service injects schedule skill instructions for reminder-like requests', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -1259,6 +1586,7 @@ test('wechat mp service recreates poisoned dedicated session after bare completi
}
return new Response(
[
+ 'data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-skill","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"load_skill","arguments":{"name":"static-page-publish"}}}}]}}\n\n',
`data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"developer","arguments":{"action":"write","path":"${htmlPath.replace(/\\/g, '\\\\')}","content":"Hello"}}}}]}}\n\n`,
'data: {"type":"Message","request_id":"req-poisoned-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-poisoned-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
@@ -1284,6 +1612,10 @@ test('wechat mp service recreates poisoned dedicated session after bare completi
}
throw new Error(`unexpected wechat url: ${url}`);
},
+ linkExists: async (urlText) => {
+ if (!String(urlText).includes('/summer-breeze-journal.html')) return false;
+ return fs.existsSync(htmlPath);
+ },
});
const originalRandomUuid = crypto.randomUUID;
@@ -1314,6 +1646,677 @@ test('wechat mp service recreates poisoned dedicated session after bare completi
assert.match(payload.text.content, /页面生成未完成|hello\.html/);
});
+test('wechat mp service recreates dedicated session when html was written before loading static-page-publish skill', async () => {
+ const token = 'token';
+ const timestamp = '1710000000';
+ const nonce = 'nonce';
+ const wechatCalls = [];
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-missing-skill-');
+ const htmlPath = path.join(workspaceRoot, 'public', 'hello-world.html');
+ let routeCleared = false;
+ let started = false;
+
+ const service = createWechatMpService({
+ config: {
+ enabled: true,
+ appId: 'wx123',
+ appSecret: 'secret',
+ token,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ bindPath: '/auth/wechat/authorize?intent=login',
+ ackText: 'ack',
+ unsupportedText: 'unsupported',
+ unboundTextPrefix: '请先绑定',
+ progressDelayMs: 0,
+ },
+ userAuth: {
+ async findWechatUserByOpenid() {
+ return { userId: 'user-1', status: 'active', nickname: '毕升' };
+ },
+ async getWechatAgentRoute() {
+ return routeCleared ? null : { agentSessionId: 'session-1' };
+ },
+ async clearWechatAgentRoute() {
+ routeCleared = true;
+ },
+ async canUseChat() {
+ return { ok: true };
+ },
+ async resolveWorkingDir() {
+ return workspaceRoot;
+ },
+ async getAgentSessionPolicy() {
+ return {
+ enableContextMemory: false,
+ extensionOverrides: [
+ {
+ type: 'platform',
+ name: 'developer',
+ available_tools: ['write'],
+ },
+ ],
+ unrestricted: false,
+ };
+ },
+ async getUserPublishLayout() {
+ return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
+ },
+ async registerAgentSession() {},
+ async upsertWechatAgentRoute({ agentSessionId }) {
+ assert.equal(agentSessionId, 'session-2');
+ },
+ async billSessionUsage() {},
+ async recordWechatMpMessage() {
+ return { inserted: true };
+ },
+ async finishWechatMpMessage() {},
+ async insertWechatMpMessageDetail() {},
+ },
+ apiFetch: async (pathname, init = {}) => {
+ if (pathname === '/agent/start') {
+ started = true;
+ const body = JSON.parse(init.body);
+ assert.equal(body.working_dir, workspaceRoot);
+ return new Response(JSON.stringify({ id: 'session-2' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected api path: ${pathname}`);
+ },
+ sessionApiFetch: async (sessionId, pathname) => {
+ if (pathname === `/sessions/${sessionId}/extensions`) {
+ return new Response(JSON.stringify({ extensions: [{ name: 'developer', available_tools: ['write'] }] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (pathname === '/agent/update_working_dir' || pathname === '/agent/update_session' || pathname === '/agent/restart') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/add_extension') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === `/sessions/${sessionId}`) {
+ return new Response(JSON.stringify({ working_dir: workspaceRoot }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (pathname === `/sessions/${sessionId}/reply`) {
+ if (sessionId === 'session-2') {
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
+ fs.writeFileSync(htmlPath, 'Hello');
+ }
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === `/sessions/${sessionId}/events`) {
+ if (sessionId === 'session-1') {
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-skill-miss","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"sandbox-fs__write_file","arguments":{"path":"public/hello-world.html","content":"Hello"}}}}]}}\n\n',
+ 'data: {"type":"Message","request_id":"req-skill-miss","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成:https://m.tkmind.cn/MindSpace/john/public/hello-world.html"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-skill-miss","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-skill-retry","message":{"id":"assistant-skill","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"load_skill","arguments":{"name":"static-page-publish"}}}}]}}\n\n',
+ 'data: {"type":"Message","request_id":"req-skill-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"sandbox-fs__write_file","arguments":{"path":"public/hello-world.html","content":"Hello"}}}}]}}\n\n',
+ 'data: {"type":"Message","request_id":"req-skill-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"[Hello World](https://m.tkmind.cn/MindSpace/user-1/public/hello-world.html)"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-skill-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
+ },
+ wechatFetch: async (url, init = {}) => {
+ wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ },
+ });
+
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = (() => {
+ const ids = ['req-skill-miss', 'req-skill-retry'];
+ return () => ids.shift() ?? 'req-skill-retry';
+ })();
+ try {
+ const result = await service.handleInboundMessage(inboundXml({ content: '请生成一个页面 public/hello-world.html' }), {
+ timestamp,
+ nonce,
+ signature: signatureFor(token, timestamp, nonce),
+ });
+ assert.equal(result.status, 200);
+ await result.task;
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ }
+
+ assert.equal(routeCleared, true);
+ assert.equal(started, true);
+ const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
+ const payload = JSON.parse(sendCall[2]);
+ assert.match(payload.text.content, /Hello World/);
+ assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello-world\.html/);
+});
+
+test('wechat mp service trusts an existing requested public html file when the assistant only returns a title', async () => {
+ const token = 'token';
+ const timestamp = '1710000000';
+ const nonce = 'nonce';
+ const wechatCalls = [];
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-existing-requested-');
+ const htmlPath = path.join(workspaceRoot, 'public', 'thailand-guide.html');
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
+ fs.writeFileSync(htmlPath, 'Thailand');
+
+ const service = createWechatMpService({
+ config: {
+ enabled: true,
+ appId: 'wx123',
+ appSecret: 'secret',
+ token,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ bindPath: '/auth/wechat/authorize?intent=login',
+ ackText: 'ack',
+ unsupportedText: 'unsupported',
+ unboundTextPrefix: '请先绑定',
+ progressDelayMs: 0,
+ },
+ userAuth: {
+ async findWechatUserByOpenid() {
+ return { userId: 'user-1', status: 'active', nickname: '毕升' };
+ },
+ async getWechatAgentRoute() {
+ return { agentSessionId: 'session-1' };
+ },
+ async clearWechatAgentRoute() {},
+ async canUseChat() {
+ return { ok: true };
+ },
+ async resolveWorkingDir() {
+ return workspaceRoot;
+ },
+ async getAgentSessionPolicy() {
+ return { enableContextMemory: false, extensionOverrides: [], unrestricted: true };
+ },
+ async getUserPublishLayout() {
+ return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
+ },
+ async billSessionUsage() {},
+ async recordWechatMpMessage() {
+ return { inserted: true };
+ },
+ async finishWechatMpMessage() {},
+ async insertWechatMpMessageDetail() {},
+ },
+ sessionApiFetch: async (sessionId, pathname) => {
+ if (pathname === `/sessions/${sessionId}/events`) {
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-existing","message":{"id":"assistant-skill","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"load_skill","arguments":{"name":"static-page-publish"}}}}]}}\n\n',
+ 'data: {"type":"Message","request_id":"req-existing","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成!点击下方链接查看:\\n\\n🌴 泰国旅行攻略 · 说走就走"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-existing","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ if (pathname === `/sessions/${sessionId}/reply`) {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
+ },
+ wechatFetch: async (url, init = {}) => {
+ wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ },
+ });
+
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = () => 'req-existing';
+ try {
+ const result = await service.handleInboundMessage(inboundXml({ content: '请生成 thailand-guide.html 页面' }), {
+ timestamp,
+ nonce,
+ signature: signatureFor(token, timestamp, nonce),
+ });
+ assert.equal(result.status, 200);
+ await result.task;
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ }
+
+ const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
+ const payload = JSON.parse(sendCall[2]);
+ assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
+ assert.doesNotMatch(payload.text.content, /页面生成未完成/);
+});
+
+test('wechat mp service attaches the newly generated page link even when no html tool call is streamed', async () => {
+ const token = 'token';
+ const timestamp = '1710000000';
+ const nonce = 'nonce';
+ const wechatCalls = [];
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-recent-public-');
+ const htmlPath = path.join(workspaceRoot, 'public', 'summer-breeze-journal.html');
+
+ const service = createWechatMpService({
+ config: {
+ enabled: true,
+ appId: 'wx123',
+ appSecret: 'secret',
+ token,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ bindPath: '/auth/wechat/authorize?intent=login',
+ ackText: 'ack',
+ unsupportedText: 'unsupported',
+ unboundTextPrefix: '请先绑定',
+ progressDelayMs: 0,
+ },
+ userAuth: {
+ async findWechatUserByOpenid() {
+ return { userId: 'user-1', status: 'active', nickname: '毕升' };
+ },
+ async getWechatAgentRoute() {
+ return { agentSessionId: 'session-1' };
+ },
+ async clearWechatAgentRoute() {},
+ async canUseChat() {
+ return { ok: true };
+ },
+ async resolveWorkingDir() {
+ return workspaceRoot;
+ },
+ async getAgentSessionPolicy() {
+ return { enableContextMemory: false, extensionOverrides: [], unrestricted: true };
+ },
+ async getUserPublishLayout() {
+ return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
+ },
+ async billSessionUsage() {},
+ async recordWechatMpMessage() {
+ return { inserted: true };
+ },
+ async finishWechatMpMessage() {},
+ async insertWechatMpMessageDetail() {},
+ },
+ sessionApiFetch: async (sessionId, pathname) => {
+ if (pathname === `/sessions/${sessionId}/events`) {
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-recent","message":{"id":"assistant-skill","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"load_skill","arguments":{"name":"static-page-publish"}}}}]}}\n\n',
+ 'data: {"type":"Message","request_id":"req-recent","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成!点击下方链接查看:\\n\\n🌴 夏日主题页面"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-recent","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ if (pathname === `/sessions/${sessionId}/reply`) {
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
+ fs.writeFileSync(htmlPath, 'Summer');
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
+ },
+ wechatFetch: async (url, init = {}) => {
+ wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ },
+ });
+
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = () => 'req-recent';
+ try {
+ const result = await service.handleInboundMessage(inboundXml({ content: '帮我生成一个夏日页面' }), {
+ timestamp,
+ nonce,
+ signature: signatureFor(token, timestamp, nonce),
+ });
+ assert.equal(result.status, 200);
+ await result.task;
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ }
+
+ const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
+ const payload = JSON.parse(sendCall[2]);
+ assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/summer-breeze-journal\.html/);
+ assert.doesNotMatch(payload.text.content, /页面生成未完成|旧指令污染|稍后重试/);
+});
+
+test('wechat mp service creates a simple fallback page when html generation reply produced no file', async () => {
+ const token = 'token';
+ const timestamp = '1710000000';
+ const nonce = 'nonce';
+ const wechatCalls = [];
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-fallback-page-');
+
+ const service = createWechatMpService({
+ config: {
+ enabled: true,
+ appId: 'wx123',
+ appSecret: 'secret',
+ token,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ bindPath: '/auth/wechat/authorize?intent=login',
+ ackText: 'ack',
+ unsupportedText: 'unsupported',
+ unboundTextPrefix: '请先绑定',
+ progressDelayMs: 0,
+ },
+ userAuth: {
+ async findWechatUserByOpenid() {
+ return { userId: 'user-1', status: 'active', nickname: '毕升' };
+ },
+ async getWechatAgentRoute() {
+ return { agentSessionId: 'session-1' };
+ },
+ async clearWechatAgentRoute() {},
+ async canUseChat() {
+ return { ok: true };
+ },
+ async resolveWorkingDir() {
+ return workspaceRoot;
+ },
+ async getAgentSessionPolicy() {
+ return { enableContextMemory: false, extensionOverrides: [], unrestricted: true };
+ },
+ async getUserPublishLayout() {
+ return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
+ },
+ async billSessionUsage() {},
+ async recordWechatMpMessage() {
+ return { inserted: true };
+ },
+ async finishWechatMpMessage() {},
+ async insertWechatMpMessageDetail() {},
+ },
+ sessionApiFetch: async (sessionId, pathname) => {
+ if (pathname === `/sessions/${sessionId}/events`) {
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-fallback","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"🌴 泰国简易攻略"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-fallback","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ if (pathname === `/sessions/${sessionId}/reply`) {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
+ },
+ wechatFetch: async (url, init = {}) => {
+ wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ },
+ });
+
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = () => 'req-fallback';
+ try {
+ const result = await service.handleInboundMessage(inboundXml({ content: '帮我做一个泰国攻略页面,稍微简单一点的' }), {
+ timestamp,
+ nonce,
+ signature: signatureFor(token, timestamp, nonce),
+ });
+ assert.equal(result.status, 200);
+ await result.task;
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ }
+
+ const htmlPath = path.join(workspaceRoot, 'public', 'thailand-guide.html');
+ assert.equal(fs.existsSync(htmlPath), true);
+ const html = fs.readFileSync(htmlPath, 'utf8');
+ assert.match(html, /泰国简易攻略/);
+ assert.match(html, /推荐路线/);
+ const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
+ const payload = JSON.parse(sendCall[2]);
+ assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/);
+ assert.doesNotMatch(payload.text.content, /页面生成未完成|稍后重试/);
+});
+
+test('wechat mp service can deliver a fallback page without rebuilding the session on publish-success claims', async () => {
+ const token = 'token';
+ const timestamp = '1710000000';
+ const nonce = 'nonce';
+ const wechatCalls = [];
+ let routeCleared = false;
+ let started = false;
+ const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-publish-claim-');
+ const htmlPath = path.join(workspaceRoot, 'public', 'summer-breeze-journal.html');
+
+ const service = createWechatMpService({
+ config: {
+ enabled: true,
+ appId: 'wx123',
+ appSecret: 'secret',
+ token,
+ publicBaseUrl: 'https://m.tkmind.cn',
+ bindPath: '/auth/wechat/authorize?intent=login',
+ ackText: 'ack',
+ unsupportedText: 'unsupported',
+ unboundTextPrefix: '请先绑定',
+ progressDelayMs: 0,
+ },
+ userAuth: {
+ async findWechatUserByOpenid() {
+ return { userId: 'user-1', status: 'active', nickname: '毕升' };
+ },
+ async getWechatAgentRoute() {
+ return routeCleared ? null : { agentSessionId: 'session-1' };
+ },
+ async clearWechatAgentRoute() {
+ routeCleared = true;
+ },
+ async canUseChat() {
+ return { ok: true };
+ },
+ async resolveWorkingDir() {
+ return workspaceRoot;
+ },
+ async getAgentSessionPolicy() {
+ return {
+ enableContextMemory: false,
+ extensionOverrides: [
+ {
+ type: 'platform',
+ name: 'developer',
+ available_tools: ['write'],
+ },
+ ],
+ unrestricted: false,
+ };
+ },
+ async getUserPublishLayout() {
+ return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null };
+ },
+ async registerAgentSession() {},
+ async upsertWechatAgentRoute({ agentSessionId }) {
+ assert.equal(agentSessionId, 'session-2');
+ },
+ async billSessionUsage() {},
+ async recordWechatMpMessage() {
+ return { inserted: true };
+ },
+ async finishWechatMpMessage() {},
+ async insertWechatMpMessageDetail() {},
+ },
+ apiFetch: async (pathname, init = {}) => {
+ if (pathname === '/agent/start') {
+ started = true;
+ const body = JSON.parse(init.body);
+ assert.equal(body.working_dir, workspaceRoot);
+ return new Response(JSON.stringify({ id: 'session-2' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected api path: ${pathname}`);
+ },
+ sessionApiFetch: async (sessionId, pathname) => {
+ if (pathname === `/sessions/${sessionId}/extensions`) {
+ return new Response(JSON.stringify({ extensions: [{ name: 'developer', available_tools: ['write'] }] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (pathname === '/agent/update_working_dir' || pathname === '/agent/update_session' || pathname === '/agent/restart') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/add_extension') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === `/sessions/${sessionId}`) {
+ return new Response(JSON.stringify({ working_dir: workspaceRoot }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (pathname === `/sessions/${sessionId}/reply`) {
+ if (sessionId === 'session-2') {
+ fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
+ fs.writeFileSync(htmlPath, 'Summer');
+ }
+ return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
+ }
+ if (pathname === `/sessions/${sessionId}/events`) {
+ if (sessionId === 'session-1') {
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-claim","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"好消息!m.tkmind.cn 其实是可以访问的,页面都已经成功发布了!以下是您的夏日主题页面:\\n\\n🌴 夏日主题页面已发布\\n\\nhttps://m.tkmind.cn/MindSpace/john/public/summer-breeze-journal.html"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-claim","token_state":{"inputTokens":1,"outputTokens":1}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ return new Response(
+ [
+ 'data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-skill","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"load_skill","arguments":{"name":"static-page-publish"}}}}]}}\n\n',
+ `data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"developer","arguments":{"action":"write","path":"${htmlPath.replace(/\\/g, '\\\\')}","content":"Summer"}}}}]}}\n\n`,
+ 'data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n',
+ 'data: {"type":"Finish","request_id":"req-claim-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n',
+ ].join(''),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ throw new Error(`unexpected session api path: ${sessionId} ${pathname}`);
+ },
+ wechatFetch: async (url, init = {}) => {
+ wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]);
+ if (String(url).includes('/cgi-bin/stable_token')) {
+ return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ if (String(url).includes('/cgi-bin/message/custom/send')) {
+ return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+ }
+ throw new Error(`unexpected wechat url: ${url}`);
+ },
+ });
+
+ const originalRandomUuid = crypto.randomUUID;
+ crypto.randomUUID = (() => {
+ const ids = ['req-claim', 'req-claim-retry'];
+ return () => ids.shift() ?? 'req-claim-retry';
+ })();
+ try {
+ const result = await service.handleInboundMessage(
+ inboundXml({ content: '帮我生成一个夏日页面' }),
+ {
+ timestamp,
+ nonce,
+ signature: signatureFor(token, timestamp, nonce),
+ },
+ );
+ assert.equal(result.status, 200);
+ await result.task;
+ } finally {
+ crypto.randomUUID = originalRandomUuid;
+ }
+
+ assert.equal(routeCleared, false);
+ assert.equal(started, false);
+ const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send'));
+ const payload = JSON.parse(sendCall[2]);
+ assert.doesNotMatch(payload.text.content, /页面都已经成功发布了|主题页面已发布/);
+ assert.match(payload.text.content, /summer-breeze-journal\.html/);
+ assert.match(payload.text.content, /可直接打开的简版页面|查看页面/);
+});
+
test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -2433,6 +3436,6 @@ test('wechat mp service exposes route status and can recreate route for bound us
assert.equal(recreated.ok, true);
assert.equal(recreated.route.agentSessionId, 'session-new');
assert.equal(calls.includes('cleared'), true);
- assert.equal(calls.some((item) => item === 'register:session-new'), false);
+ assert.equal(calls.some((item) => item === 'register:session-new'), true);
assert.equal(calls.includes('upserted'), true);
});