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