From 66acbc902ca0ca27ffd0a0ffb3924df35f34cde1 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 06:59:37 +0800 Subject: [PATCH] feat: add wechat publish recovery and conversation memory --- conversation-memory.mjs | 371 +++++++++++++ conversation-memory.test.mjs | 152 +++++ db.mjs | 46 ++ schema.sql | 40 ++ server.mjs | 25 +- session-reconcile.mjs | 2 + session-snapshot.mjs | 14 +- tkmind-proxy.mjs | 74 ++- user-memory-profile.mjs | 29 + user-memory-profile.test.mjs | 13 + wechat-mp.mjs | 573 ++++++++++++++++++- wechat-mp.test.mjs | 1007 +++++++++++++++++++++++++++++++++- 12 files changed, 2308 insertions(+), 38 deletions(-) create mode 100644 conversation-memory.mjs create mode 100644 conversation-memory.test.mjs 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); });