feat: sync portal runtime fixes for release
This commit is contained in:
+187
-10
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user