feat: sync portal runtime fixes for release

This commit is contained in:
john
2026-06-27 22:28:41 +08:00
parent 8264e71f9e
commit 4a9bc710f1
14 changed files with 795 additions and 130 deletions
+187 -10
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import { developerToolsFromPolicy } from './capabilities.mjs';
import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
@@ -23,6 +24,16 @@ const DEFAULT_UNBOUND_TEXT = '先点这里完成绑定,再继续和专属 Agen
const DEFAULT_PROGRESS_DELAY_MS = 8000;
const PUBLIC_HTML_LINK_PATTERN =
/https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi;
const SESSION_WORKSPACE_TOOL_NAMES = new Set([
'write',
'edit',
'tree',
'read_file',
'write_file',
'edit_file',
'create_dir',
'list_dir',
]);
function parseXmlField(xml, field) {
const cdataMatch = xml.match(new RegExp(`<${field}><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${field}>`));
@@ -182,6 +193,7 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
const decoder = new TextDecoder();
let buffer = '';
let messages = [];
let hasScopedAssistantUpdate = false;
while (true) {
const { value, done } = await reader.read();
@@ -209,16 +221,26 @@ async function executeSessionReply(apiFetch, sessionId, requestId, prompt, metad
if (hasActionRequired) {
throw new Error('当前回复需要人工确认,公众号通道暂不支持');
}
if (event.message.role === 'assistant') hasScopedAssistantUpdate = true;
messages = pushMessage(messages, event.message);
} else if (event.type === 'UpdateConversation') {
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
// Ignore unscoped snapshots until this request has yielded an assistant update.
// Otherwise a stale session snapshot can overwrite the current reply with a
// previous page/link from the same WeChat-dedicated session.
if (hasScopedAssistantUpdate) {
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
}
} else if (event.type === 'Error') {
throw new Error(event.error || '任务执行失败');
} else if (event.type === 'Finish') {
const assistant = [...messages].reverse().find((item) => item.role === 'assistant');
if (!hasScopedAssistantUpdate || !assistant) {
throw new Error('本轮未收到可发送的新回复,请稍后重试');
}
return {
text: messageVisibleText(assistant),
tokenState: event.token_state ?? null,
messages,
};
}
}
@@ -253,6 +275,117 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) {
export { splitWechatText, WECHAT_CUSTOMER_TEXT_MAX_BYTES };
function flattenSessionTools(extensions = []) {
return new Set(
extensions.flatMap((extension) =>
Array.isArray(extension?.available_tools) ? extension.available_tools : [],
),
);
}
function needsWorkspaceTools(sessionPolicy) {
return developerToolsFromPolicy(sessionPolicy).some((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
}
async function readSessionExtensions(fetchForSession, sessionId) {
const payload = await readJsonResponse(await fetchForSession(sessionId, `/sessions/${sessionId}/extensions`));
return payload?.extensions ?? [];
}
async function sessionHasRequiredTools(fetchForSession, sessionId, sessionPolicy) {
if (!needsWorkspaceTools(sessionPolicy)) return true;
const desired = developerToolsFromPolicy(sessionPolicy).filter((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool));
if (desired.length === 0) return true;
const available = flattenSessionTools(await readSessionExtensions(fetchForSession, sessionId));
return desired.some((tool) => available.has(tool));
}
function extractHtmlWriteTargets(messages = []) {
const targets = new Set();
for (const message of messages) {
for (const item of message?.content ?? []) {
if (item?.type !== 'toolRequest') continue;
const toolCall = item.toolCall?.value;
const name = toolCall?.name;
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';
if (!writeLikeDeveloper && !writeLikeSandbox) continue;
const candidate = String(args.path ?? '').trim();
if (candidate.toLowerCase().endsWith('.html')) targets.add(candidate);
}
}
return [...targets];
}
function looksLikeHtmlGenerationIntent(text) {
const normalized = String(text ?? '').trim().toLowerCase();
if (!normalized) return false;
return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized);
}
function isBareCompletionText(text) {
const normalized = String(text ?? '').trim();
return /^(?:已完成|完成了|完成)$/u.test(normalized);
}
function hasAnyToolRequest(messages = []) {
return messages.some((message) =>
message?.content?.some((item) => item?.type === 'toolRequest'),
);
}
function isSuspiciousBareCompletionReply(reply, intent) {
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
if (!isBareCompletionText(reply?.text)) return false;
if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false;
return !hasAnyToolRequest(reply?.messages ?? []);
}
function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) {
const owner = path.basename(path.resolve(workingDir));
const normalized = relativePath.split(path.sep).map(encodeURIComponent).join('/');
return `${String(publicBaseUrl ?? '').replace(/\/+$/, '')}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`;
}
function ensurePublicHtmlArtifact(htmlPath, workingDir) {
const workspaceRoot = path.resolve(workingDir);
const source = path.resolve(htmlPath);
if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path.sep}`)) return null;
if (!fs.existsSync(source) || !fs.statSync(source).isFile()) return null;
const publicRoot = path.join(workspaceRoot, 'public');
let publishedPath = source;
if (source !== publicRoot && !source.startsWith(`${publicRoot}${path.sep}`)) {
fs.mkdirSync(publicRoot, { recursive: true });
publishedPath = path.join(publicRoot, path.basename(source));
if (publishedPath !== source) fs.copyFileSync(source, publishedPath);
}
const relativePath = path.relative(workspaceRoot, publishedPath);
if (!relativePath || relativePath.startsWith('..')) return null;
return {
localPath: publishedPath,
relativePath,
};
}
async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl }) {
const baseText = String(reply?.text ?? '').trim();
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);
if (baseText.includes(url)) return baseText;
return baseText
? `${baseText}\n\n查看页面:\n${url}`
: `查看页面:\n${url}`;
}
return baseText;
}
function stripInternalWechatUsername(text) {
return String(text ?? '').replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、:]\s*/i, '');
}
@@ -387,6 +520,8 @@ export async function guardMissingPublicHtmlLinks(
return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value);
}
export { maybeAttachPublishedHtmlLink };
function isQuestionStatusProbe(text) {
return /^[?]+$/.test(String(text ?? '').trim());
}
@@ -1040,19 +1175,47 @@ export function createWechatMpService({
if (forceNew) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const addressName = resolveWechatAddressName(userContext);
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
if (existingRoute?.agentSessionId) {
return existingRoute.agentSessionId;
await reconcileAgentSession(
(pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init),
existingRoute.agentSessionId,
{
workingDir,
sessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userContext: publishLayout
? {
userId,
displayName: addressName || publishLayout.displayName,
username: addressName || null,
slug: null,
}
: null,
tolerateInvalidWorkingDir: true,
},
);
const routeHasTools = await sessionHasRequiredTools(
fetchForSession,
existingRoute.agentSessionId,
sessionPolicy,
).catch(() => false);
if (routeHasTools) {
return existingRoute.agentSessionId;
}
await userAuth.clearWechatAgentRoute(config.appId, openid);
} else if (existingRoute?.status === 'disabled') {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const gate = await userAuth.canUseChat(userId);
if (!gate.ok) {
throw new Error(gate.message || '当前用户无法使用聊天能力');
}
const workingDir = await userAuth.resolveWorkingDir(userId);
const sessionPolicy = await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const started = await readJsonResponse(
await apiFetch('/agent/start', {
method: 'POST',
@@ -1073,7 +1236,6 @@ export function createWechatMpService({
// `/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.
const addressName = resolveWechatAddressName(userContext);
await reconcileAgentSession(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
@@ -1175,6 +1337,7 @@ export function createWechatMpService({
userContext: user,
forceNew,
});
const publishLayout = await userAuth.getUserPublishLayout(user.userId);
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user);
let finished = false;
@@ -1220,15 +1383,22 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
throw new Error('stale_session_poisoned_completion');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId);
}
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user);
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
return { sessionId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const mayBeStaleSession =
/403|404|not found|无权访问|session/i.test(message) && sessionId;
/403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId;
if (mayBeStaleSession) {
sessionId = await ensureWechatAgentSession({
userId: user.userId,
@@ -1246,10 +1416,17 @@ export function createWechatMpService({
buildWechatAgentPrompt(intent),
buildIntentMetadata(intent),
);
if (isSuspiciousBareCompletionReply(reply, intent)) {
throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试');
}
if (reply.tokenState) {
await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId);
}
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user);
const finalizedReply = await maybeAttachPublishedHtmlLink(reply, {
workingDir: publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)),
publicBaseUrl: config.publicBaseUrl,
});
await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user);
return { sessionId };
}
throw err;