feat: add wechat publish recovery and conversation memory

This commit is contained in:
john
2026-06-29 06:59:37 +08:00
parent ea19ffb5fa
commit e80831ad4f
12 changed files with 2308 additions and 38 deletions
+543 -30
View File
@@ -35,6 +35,19 @@ const SESSION_WORKSPACE_TOOL_NAMES = new Set([
'list_dir',
]);
function escapeRegExp(value) {
return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
if (cdataMatch) return cdataMatch[1];
@@ -310,7 +323,13 @@ function extractHtmlWriteTargets(messages = []) {
const args = toolCall?.arguments ?? {};
const action = String(args.action ?? '').toLowerCase();
const writeLikeDeveloper = name === 'developer' && action === 'write';
const writeLikeSandbox = (name === 'write_file' || name === 'edit_file') && typeof args.path === 'string';
const normalizedName = String(name ?? '').trim();
const writeLikeSandbox =
(normalizedName === 'write_file' ||
normalizedName === 'edit_file' ||
normalizedName.endsWith('__write_file') ||
normalizedName.endsWith('__edit_file')) &&
typeof args.path === 'string';
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
const candidate = String(args.path ?? '').trim();
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
@@ -325,6 +344,19 @@ function looksLikeHtmlGenerationIntent(text) {
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
}
function usedStaticPagePublishSkill(messages = []) {
return messages.some((message) =>
message?.content?.some((item) => {
if (item?.type !== 'toolRequest') return false;
const toolCall = item.toolCall?.value;
const name = String(toolCall?.name ?? '').trim();
const args = toolCall?.arguments ?? {};
const skillName = String(args.name ?? '').trim();
return name === 'load_skill' && skillName === 'static-page-publish';
}),
);
}
function isBareCompletionText(text) {
const normalized = String(text ?? '').trim();
return /^(?:已完成|完成了|完成)$/u.test(normalized);
@@ -343,6 +375,39 @@ function isSuspiciousBareCompletionReply(reply, intent) {
return !hasAnyToolRequest(reply?.messages ?? []);
}
function looksLikePublishSuccessClaim(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized);
}
async function hasAnyValidPublishedHtmlLink(text, linkExists) {
const value = String(text ?? '');
for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) {
try {
if (await linkExists(match[0])) return true;
} catch {
// treat lookup failures as missing links
}
}
return false;
}
async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
const text = String(reply?.text ?? '').trim();
if (!looksLikePublishSuccessClaim(text)) return false;
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
return !(await hasAnyValidPublishedHtmlLink(text, linkExists));
}
function isMissingRequiredPublishSkill(reply, intent) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0;
if (!wroteHtml) return false;
return !usedStaticPagePublishSkill(reply?.messages ?? []);
}
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
const owner = path.basename(path.resolve(workingDir));
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
@@ -351,7 +416,9 @@ function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
const workspaceRoot = path.resolve(workingDir);
const source = path.resolve(htmlPath);
const source = path.isAbsolute(String(htmlPath ?? ''))
? path.resolve(String(htmlPath))
: path.resolve(workspaceRoot, String(htmlPath ?? ''));
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
@@ -371,13 +438,307 @@ function ensurePublicHtmlArtifact(htmlPath, workingDir) {
};
}
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
const baseText = String(reply?.text ?? '').trim();
function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) {
const artifacts = [];
const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []);
for (const target of htmlTargets) {
const artifact = ensurePublicHtmlArtifact(target, workingDir);
if (!artifact) continue;
const url = buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl);
artifacts.push({
...artifact,
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
});
}
return artifacts;
}
function extractRequestedHtmlTarget(text) {
const value = String(text ?? '');
if (!value) return '';
const explicitPublicMatch = value.match(/(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i);
if (explicitPublicMatch?.[1]) return explicitPublicMatch[1];
const bareMatch = value.match(/(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i);
if (bareMatch?.[1]) return `public/${bareMatch[1]}`;
return '';
}
function collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl }) {
const target = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
if (!target) return [];
const artifact = ensurePublicHtmlArtifact(target, workingDir);
if (!artifact) return [];
return [{
...artifact,
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
}];
}
function sanitizeFilenameSlug(value) {
const normalized = String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'simple-page';
}
function inferFallbackHtmlName(intent, reply) {
const explicit = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
if (explicit) return explicit;
const replyText = String(reply?.text ?? '');
const linkMatch = replyText.match(/([a-z0-9._-]+\.html)\b/i);
if (linkMatch?.[1]) return `public/${linkMatch[1]}`;
const source = `${String(intent?.agentText ?? '')}\n${replyText}`;
if (/泰国|thailand/i.test(source)) return 'public/thailand-guide.html';
if (/夏日|summer/i.test(source)) return 'public/summer-guide.html';
const topic = source
.replace(/https?:\/\/\S+/g, '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.trim()
.split(/\s+/)
.slice(0, 6)
.join('-');
return `public/${sanitizeFilenameSlug(topic)}.html`;
}
function extractFallbackPageTitle(intent, reply) {
const lines = String(reply?.text ?? '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !/^https?:\/\//i.test(line) && !/^\[.+\]\(https?:\/\//i.test(line));
const preferred = lines.find(
(line) =>
!/^(已完成|页面已生成|点击下方链接查看|查看页面|还在处理|明白|开始了)$/u.test(
line.replace(/[🌴✨⭐️]/gu, '').trim(),
) &&
!/成功发布|已发布|好消息|页面都已经/u.test(line),
);
if (preferred) return preferred.replace(/[🌴✨⭐️]/gu, '').trim();
if (/泰国|thailand/i.test(String(intent?.agentText ?? ''))) return '泰国简易攻略';
return '简版页面';
}
function hasAnyUrl(text) {
return /https?:\/\/\S+/i.test(String(text ?? ''));
}
function hasAnyPublicHtmlLink(text) {
return [...String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)].length > 0;
}
function buildFallbackHtmlDocument(intent, reply, title) {
const source = `${String(intent?.agentText ?? '')}\n${String(reply?.text ?? '')}`;
if (/泰国|thailand/i.test(source)) {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<meta name="description" content="泰国旅行快速上手版,适合先看一遍再出发。">
<meta name="mindspace-cover" content='{"tag":"旅行","emoji":"🇹🇭","accent":"#ff8a3d","accent2":"#185a9d","subtitle":"轻量版泰国出行建议"}'>
<style>
:root { color-scheme: light; --bg:#fff7ef; --card:#ffffff; --ink:#18212f; --muted:#5b6472; --accent:#ff8a3d; --accent2:#185a9d; }
* { box-sizing:border-box; }
body { margin:0; font-family:"PingFang SC","Noto Sans SC",sans-serif; background:linear-gradient(180deg,#fff4e8 0%,#f7fbff 100%); color:var(--ink); }
main { max-width:760px; margin:0 auto; padding:40px 20px 72px; }
.hero { background:linear-gradient(135deg,var(--accent),#ffd36e 58%,#fff 100%); border-radius:28px; padding:28px; box-shadow:0 20px 50px rgba(24,33,47,.12); }
.eyebrow { font-size:13px; letter-spacing:.08em; text-transform:uppercase; opacity:.72; }
h1 { margin:10px 0 12px; font-size:34px; line-height:1.15; }
.summary { margin:0; max-width:520px; line-height:1.7; }
section { margin-top:18px; background:var(--card); border-radius:22px; padding:22px; box-shadow:0 14px 34px rgba(24,33,47,.08); }
h2 { margin:0 0 12px; font-size:20px; }
ul { margin:0; padding-left:20px; line-height:1.8; }
.grid { display:grid; gap:18px; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); }
.tip { color:var(--muted); }
</style>
</head>
<body>
<main>
<div class="hero">
<div class="eyebrow">Thailand Quick Guide</div>
<h1>${escapeHtml(title)}</h1>
<p class="summary">适合第一次去泰国时先快速浏览:先定城市,再定节奏,预算和注意事项尽量简单清楚,出发前照着检查一遍就够了。</p>
</div>
<div class="grid">
<section>
<h2>推荐路线</h2>
<ul>
<li>曼谷 2 天:寺庙、夜市、商场和城市观景台。</li>
<li>清迈 2 到 3 天:古城慢逛、咖啡馆、夜间集市。</li>
<li>海岛 2 到 3 天:普吉、甲米或苏梅,选一个就够。</li>
</ul>
</section>
<section>
<h2>预算参考</h2>
<ul>
<li>住宿:普通酒店每晚约 200 到 500 元人民币。</li>
<li>餐饮:街边小吃和简餐通常比较友好。</li>
<li>交通:市内尽量打正规车或用常见打车软件。</li>
</ul>
</section>
</div>
<section>
<h2>出发前准备</h2>
<ul>
<li>提前确认签证或入境政策,护照有效期留足。</li>
<li>准备一点现金,热门景点与夜市会更方便。</li>
<li>防晒、驱蚊、轻便衣物尽量提前备好。</li>
</ul>
</section>
<section>
<h2>注意事项</h2>
<ul>
<li>尊重寺庙着装要求,进入室内前留意是否需要脱鞋。</li>
<li>海岛项目先问清价格和往返方式,避免临时加价。</li>
<li>如果行程只想轻松一点,城市和海岛不要排太满。</li>
</ul>
<p class="tip">这是一个简版攻略页,后续如果你要,我也可以继续扩成 5 天行程版、夜市清单版或海岛专版。</p>
</section>
</main>
</body>
</html>`;
}
const description = `根据你的要求临时补出的简版页面:${String(intent?.agentText ?? '').trim() || '已生成可访问页面'}`;
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}">
<meta name="mindspace-cover" content='{"tag":"页面","emoji":"📄","accent":"#2f6fed","accent2":"#0f172a","subtitle":"服务号自动补出简版页面"}'>
<style>
body { margin:0; font-family:"PingFang SC","Noto Sans SC",sans-serif; background:#f5f7fb; color:#172033; }
main { max-width:760px; margin:0 auto; padding:48px 20px 72px; }
article { background:#fff; border-radius:24px; padding:28px; box-shadow:0 18px 44px rgba(15,23,42,.08); }
h1 { margin:0 0 12px; font-size:32px; }
p, li { line-height:1.8; }
</style>
</head>
<body>
<main>
<article>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(description)}</p>
<ul>
<li>这是服务号兜底生成的简版页面,先确保你能直接打开和分享。</li>
<li>如果你还想加图片、配色、分栏或更完整内容,可以继续在当前话题上细化。</li>
</ul>
</article>
</main>
</body>
</html>`;
}
function createFallbackPublishedHtmlArtifact(intent, reply, { workingDir, publicBaseUrl }) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)) return null;
const relativePath = inferFallbackHtmlName(intent, reply);
const absolutePath = path.resolve(workingDir, relativePath);
const workspaceRoot = path.resolve(workingDir);
if (absolutePath !== workspaceRoot && !absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) return null;
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
const title = extractFallbackPageTitle(intent, reply);
fs.writeFileSync(absolutePath, buildFallbackHtmlDocument(intent, reply, title), 'utf8');
const artifact = ensurePublicHtmlArtifact(relativePath, workingDir);
if (!artifact) return null;
return {
...artifact,
url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl),
fallbackTitle: title,
};
}
function collectRecentPublishedHtmlArtifacts(
intent,
{ workingDir, publicBaseUrl, sinceMs = 0, limit = 20 } = {},
) {
const publicRoot = path.join(path.resolve(workingDir), 'public');
if (!fs.existsSync(publicRoot) || !fs.statSync(publicRoot).isDirectory()) return [];
const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? '');
const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, '').replace(/\\/g, '/') : '';
const artifacts = [];
const stack = [publicRoot];
while (stack.length > 0 && artifacts.length < limit) {
const current = stack.pop();
let entries = [];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(absolutePath);
continue;
}
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue;
let stat;
try {
stat = fs.statSync(absolutePath);
} catch {
continue;
}
if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue;
const relativePath = path.relative(path.resolve(workingDir), absolutePath);
if (!relativePath || relativePath.startsWith('..')) continue;
artifacts.push({
localPath: absolutePath,
relativePath,
url: buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl),
mtimeMs: Number(stat.mtimeMs ?? 0),
matchesExpected: expectedRelativePath
? relativePath.replace(/\\/g, '/') === expectedRelativePath
: false,
});
if (artifacts.length >= limit) break;
}
}
return artifacts
.sort((left, right) => {
if (left.matchesExpected !== right.matchesExpected) return left.matchesExpected ? -1 : 1;
return right.mtimeMs - left.mtimeMs;
})
.map(({ mtimeMs, matchesExpected, ...artifact }) => artifact);
}
function rewritePublishedHtmlLinks(text, artifacts = []) {
const value = String(text ?? '');
if (!value || artifacts.length === 0) return value;
let next = value;
for (const artifact of artifacts) {
const canonicalUrl = String(artifact?.url ?? '').trim();
const filename = String(artifact?.relativePath ?? '').replace(/\\/g, '/').split('/').pop();
if (!canonicalUrl || !filename) continue;
const wrongPublicLinkPattern = new RegExp(
`https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`,
'g',
);
const wrongTkmindHtmlLinkPattern = new RegExp(
`https?:\\/\\/(?:[^\\s./]+\\.)*tkmind\\.cn\\/[^\n\\s)]*${escapeRegExp(filename)}\\b`,
'gi',
);
next = next.replace(wrongPublicLinkPattern, canonicalUrl);
next = next.replace(wrongTkmindHtmlLinkPattern, canonicalUrl);
next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match) => {
if (match === canonicalUrl) return match;
const matchedFilename = String(match).split('/').pop();
return matchedFilename === filename ? canonicalUrl : match;
});
}
return next;
}
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) {
const artifacts = Array.isArray(providedArtifacts)
? providedArtifacts
: collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl });
const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? '').trim(), artifacts).trim();
for (const artifact of artifacts) {
const url = artifact.url;
if (baseText.includes(url)) return baseText;
return baseText
? `${baseText}\n\n查看页面:\n${url}`
@@ -520,6 +881,31 @@ export async function guardMissingPublicHtmlLinks(
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
}
function downgradePrematurePublishClaims(text) {
const value = String(text ?? '');
if (!value.includes('页面生成未完成')) return value;
return value
.replace(/(^|\n)([^\n]*页面都已经成功发布了[^\n]*)(?=\n|$)/g, '$1页面暂未生成完成,这次我先不发送失效链接。')
.replace(/(^|\n)([^\n]*主题页面已发布[^\n]*)(?=\n|$)/g, '$1🌴 夏日主题页面生成未完成')
.replace(/页面已创建完毕/g, '页面生成未完成')
.replace(/页面已创建/g, '页面生成未完成')
.replace(/发布成功/g, '生成未完成')
.replace(/已发布成功/g, '生成未完成');
}
function rewriteFallbackPageSuccessText(text, fallbackArtifact) {
if (!fallbackArtifact) return String(text ?? '');
const fallbackTitle = String(fallbackArtifact.fallbackTitle ?? '简版页面').trim() || '简版页面';
return [
'已为你补出一个可直接打开的简版页面。',
fallbackTitle,
'查看页面:',
fallbackArtifact.url,
]
.filter(Boolean)
.join('\n\n');
}
export { maybeAttachPublishedHtmlLink };
function isQuestionStatusProbe(text) {
@@ -676,6 +1062,15 @@ function normalizeWechatInboundIntent(inbound) {
function buildWechatAgentPrompt(intent) {
const msgType = String(intent?.msgType ?? 'text');
const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)
? [
'【页面发布技能要求】这条消息是在生成可访问 HTML 页面。',
'开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。',
'随后必须用 `write_file` / `edit_file` 写入 `public/*.html`。',
'最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。',
'',
].join('\n')
: '';
const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content)
? [
'【日程技能要求】这条消息涉及待办、提醒或日程。',
@@ -737,6 +1132,7 @@ function buildWechatAgentPrompt(intent) {
}
const content = String(intent?.agentText ?? intent?.content ?? '').trim();
const lines = [];
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
lines.push(
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
@@ -951,9 +1347,11 @@ export function createWechatMpService({
config,
userAuth,
apiFetch,
startAgentSession = null,
sessionApiFetch = null,
scheduleService = null,
applySessionLlmProvider = null,
refreshSessionSnapshot = null,
wechatFetch = undiciFetch,
linkExists = defaultPublicHtmlLinkExists,
logger = console,
@@ -1089,9 +1487,21 @@ export function createWechatMpService({
};
};
const sendCustomerServiceText = async (openid, content, user = null) => {
const sendCustomerServiceText = async (openid, content, user = null, { verifiedHtmlUrls = [] } = {}) => {
const formatted = formatWechatOutboundText(content, user);
const guarded = await guardMissingPublicHtmlLinks(formatted, { linkExists });
const verifiedUrlSet = new Set(
verifiedHtmlUrls
.map((url) => String(url ?? '').trim())
.filter(Boolean),
);
const guarded = downgradePrematurePublishClaims(
await guardMissingPublicHtmlLinks(formatted, {
linkExists: async (url) => {
if (verifiedUrlSet.has(String(url ?? '').trim())) return true;
return linkExists(url);
},
}),
);
const chunks = splitWechatText(guarded);
const accessToken = await getStableAccessToken();
for (const chunk of chunks) {
@@ -1216,26 +1626,28 @@ export function createWechatMpService({
if (!gate.ok) {
throw new Error(gate.message || '当前用户无法使用聊天能力');
}
const started = await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...(sessionPolicy.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
: {}),
}),
}),
);
const started = startAgentSession
? await startAgentSession({ userId, workingDir, sessionPolicy })
: await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
body: JSON.stringify({
working_dir: workingDir,
enable_context_memory: sessionPolicy.enableContextMemory,
...(sessionPolicy.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
: {}),
}),
}),
);
const sessionId = started?.id;
if (!sessionId) {
throw new Error('公众号专属 Agent 会话创建失败');
}
// `/agent/start` already persists the owning user and goosed node via the
// portal proxy. Re-registering here without the node can overwrite the
// correct mapping back to node 0 in multi-goosed production.
if (!startAgentSession && typeof userAuth.registerAgentSession === 'function') {
await userAuth.registerAgentSession(userId, sessionId);
}
await reconcileAgentSession(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1263,6 +1675,15 @@ export function createWechatMpService({
return sessionId;
};
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
try {
await refreshSessionSnapshot(sessionId, userId);
} catch (err) {
logger.warn?.('WeChat MP snapshot refresh failed:', err);
}
};
const rememberWechatUserContext = async (sessionId, user) => {
const addressName = resolveWechatAddressName(user);
if (!addressName) return;
@@ -1376,6 +1797,7 @@ export function createWechatMpService({
].join('\n');
};
try {
const requestStartedAt = Date.now();
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1383,17 +1805,62 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
});
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
sinceMs: requestStartedAt,
});
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists);
const shouldFallbackFromReply =
!hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply);
const fallbackArtifact =
expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
shouldFallbackFromReply &&
collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0
? createFallbackPublishedHtmlArtifact(intent, reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
})
: null;
if (
isMissingRequiredPublishSkill(reply, intent) ||
isSuspiciousBareCompletionReply(reply, intent) ||
(expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
!fallbackArtifact &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })))
) {
throw new Error('stale_session_poisoned_completion');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact);
const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts;
const fallbackArtifacts = publishedArtifacts.length > 0
? []
: (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts);
const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts;
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: verifiedArtifacts,
});
const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact);
await refreshWechatSessionSnapshot(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, {
verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url),
});
return { sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -1409,6 +1876,7 @@ export function createWechatMpService({
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user);
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1416,17 +1884,62 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId));
const expectedArtifacts = collectExpectedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
});
const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
sinceMs: retryStartedAt,
});
const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists);
const shouldFallbackFromReply =
!hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply);
const fallbackArtifact =
expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
shouldFallbackFromReply &&
collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0
? createFallbackPublishedHtmlArtifact(intent, reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
})
: null;
if (
isMissingRequiredPublishSkill(reply, intent) ||
isSuspiciousBareCompletionReply(reply, intent) ||
(expectedArtifacts.length === 0 &&
recentArtifacts.length === 0 &&
!fallbackArtifact &&
(await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists })))
) {
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
}
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
const publishedArtifacts = collectPublishedHtmlArtifacts(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact);
const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts;
const fallbackArtifacts = publishedArtifacts.length > 0
? []
: (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts);
const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts;
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir,
publicBaseUrl: config.publicBaseUrl,
artifacts: verifiedArtifacts,
});
const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact);
await refreshWechatSessionSnapshot(sessionId, user.userId);
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, {
verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url),
});
return { sessionId };
}
throw err;