feat: add wechat publish recovery and conversation memory
This commit is contained in:
+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;
|
||||
|
||||
Reference in New Issue
Block a user