From 550969bed2770dc5abe1883c87e1d01f02c8e655 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 24 Jul 2026 17:57:39 +0800 Subject: [PATCH] fix: preserve delivered page links when auto images fail --- agent-run-gateway.test.mjs | 65 +++++++++++++++++++ chat-intent-router.mjs | 6 +- chat-intent-router.test.mjs | 9 +++ .../mindspace-publish-and-chat-finish.md | 27 ++++++++ tkmind-proxy.mjs | 29 +++++++-- tkmind-proxy.test.mjs | 21 ++++++ 6 files changed, 152 insertions(+), 5 deletions(-) diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 958fcde..9ec4e78 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -743,6 +743,71 @@ test('static page run succeeds when workspace fallback reports the current HTML assert.ok(Number(observedRunStartedAtMs) > 0); }); +test('auto image generation failure does not fail a delivered static page', async () => { + const pool = createFakePool({ + sessionDeliverables: { + 'user-1:session-poem-page': [{ + page_id: 'page-poem', + title: '山居秋夜', + workspace_relative_path: 'public/shan-ju-qiu-ye.html', + }], + }, + }); + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + chatIntentRouter: { + isEnabled() { + return true; + }, + async classify() { + return { + route: 'agent_orchestration', + confidence: 0.95, + reason: '内容页生成意图', + suggestedSkill: 'static-page-publish', + imageGeneration: { mode: 'auto', source: 'intent' }, + }; + }, + applyAgentOrchestration(message) { + return message; + }, + }, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-poem-page' }; + }, + async submitSessionReplyAndAwaitFinishForUser() { + return { + ok: true, + finishEvent: { type: 'Finish' }, + toolEvidence: { + calls: ['sandbox-fs__generate_image', 'sandbox-fs__write_file'], + generateImage: { called: true, succeeded: false }, + }, + }; + }, + }, + syncUserPagesOnSuccess: async () => ({ pageDataBind: { errors: [] } }), + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-poem-page', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我写一首诗词,做个页面吧' }], + metadata: { + displayText: '帮我写一首诗词,做个页面吧', + memindRun: { imageGenerationMode: 'auto' }, + }, + }, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).error_message, null); +}); + test('agent run uses direct chat service for eligible chat messages', async () => { const pool = createFakePool(); const directRuns = []; diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 0d8da5f..003fbd1 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -134,7 +134,11 @@ export function resolveImageGenerationDecision({ text, requestedMode = IMAGE_GEN isPageGenerationIntent(normalizedText) && VISUAL_PAGE_IMAGE_HINT_PATTERNS.some((pattern) => pattern.test(normalizedText)) ) { - return { mode: IMAGE_GENERATION_MODE.REQUIRED, source: 'intent', reason: '视觉类页面需要真实主图和缩略图源图' }; + return { + mode: IMAGE_GENERATION_MODE.AUTO, + source: 'intent', + reason: '视觉类页面建议生成主图,但用户未明确要求时不阻断页面交付', + }; } return { mode: IMAGE_GENERATION_MODE.AUTO, source: 'default', reason: '未检测到必须生成或禁止生成图片的要求' }; } diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index 1402e10..d4fac3f 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -48,6 +48,15 @@ test('image generation user override can force or disable image generation', () }).mode, IMAGE_GENERATION_MODE.DISABLED); }); +test('visual page hints keep image generation best-effort unless the user explicitly requests an image', () => { + const decision = resolveImageGenerationDecision({ + text: '帮我写一首诗词,做个页面吧', + requestedMode: 'auto', + }); + assert.equal(decision.mode, IMAGE_GENERATION_MODE.AUTO); + assert.equal(decision.source, 'intent'); +}); + test('router injects a fail-closed generate_image contract for required visual pages', async () => { const router = createChatIntentRouter(); const text = '帮我生成一个精美唐诗页面,背景是大唐盛景图片'; diff --git a/docs/regression-guards/mindspace-publish-and-chat-finish.md b/docs/regression-guards/mindspace-publish-and-chat-finish.md index 6dadc94..894843c 100644 --- a/docs/regression-guards/mindspace-publish-and-chat-finish.md +++ b/docs/regression-guards/mindspace-publish-and-chat-finish.md @@ -75,6 +75,33 @@ --- +## 4. 页面已落盘:自动配图失败或多工作区不得吞掉链接 + +### 症状 + +- 用户要求「帮我写一首诗词,做个页面吧」 +- Agent 已调用 `write_file`,`public/*.html` 也已真实落盘 +- 自动生图失败后整个 Agent Run 仍被标记失败,前端显示红条 +- Portal 代码目录与 session 工作目录不同时,链接过滤器只检查 `process.cwd()/MindSpace`, + 将另一个共享工作区中真实存在的链接改成「页面生成未完成」 + +### 必须保留 + +- 输入区为 `auto` 且用户没有明确要求图片时,视觉页面配图只能是 best effort;生图失败不得拖垮已成功的 HTML 主交付 +- 用户明确要求图片,或输入区切到 `required` 时,仍保留位图证据 fail closed +- 页面链接存在性检查必须同时覆盖: + - 当前 Portal 工作目录下的 `MindSpace/` + - `H5_USERS_ROOT` 同级的共享 `MindSpace/` + - 显式 `MEMIND_SHARED_PUBLISH_ROOT` / `GOOSED_SANDBOX_PUBLISH_ROOT` +- 不得因为 Portal 运行在临时 worktree,而误删共享 session 工作区中的有效页面链接 + +### 守卫 + +- 单测:`chat-intent-router.test.mjs`、`agent-run-gateway.test.mjs`、`tkmind-proxy.test.mjs` +- 综合验证:`npm run verify:h5-session-patches` + +--- + ## 发版 / CI 必跑命令 ```bash diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index bf88283..395ec9b 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -503,6 +503,25 @@ function buildMissingPublicHtmlNotice(filename) { return `(页面生成未完成,已阻止显示失效链接:${filename || '页面'}。)`; } +function publicHtmlRootsForUser(owner) { + const roots = [ + path.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner), + ]; + const sharedPublishRoots = [ + process.env.GOOSED_SANDBOX_PUBLISH_ROOT, + process.env.MEMIND_SHARED_PUBLISH_ROOT, + ]; + for (const configuredRoot of sharedPublishRoots) { + const value = String(configuredRoot ?? '').trim(); + if (value) roots.push(path.resolve(value, owner)); + } + const usersRoot = String(process.env.H5_USERS_ROOT ?? '').trim(); + if (usersRoot) { + roots.push(path.resolve(path.dirname(usersRoot), PUBLISH_ROOT_DIR, owner)); + } + return [...new Set(roots)]; +} + function publicHtmlExistsForUser(owner, relativePath, currentUser) { const normalizedOwner = String(owner ?? '').trim().toLowerCase(); const normalizedUserId = String(currentUser?.id ?? '').trim().toLowerCase(); @@ -511,10 +530,12 @@ function publicHtmlExistsForUser(owner, relativePath, currentUser) { if (normalizedOwner !== normalizedUserId && normalizedOwner !== normalizedUsername) return true; const normalizedRelativePath = normalizeStaticHtmlRelativePath(relativePath); if (!normalizedRelativePath || !normalizedRelativePath.toLowerCase().endsWith('.html')) return false; - const root = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner); - const target = path.resolve(root, normalizedRelativePath); - if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; - return fs.existsSync(target) && fs.statSync(target).isFile(); + for (const root of publicHtmlRootsForUser(normalizedOwner)) { + const target = path.resolve(root, normalizedRelativePath); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) continue; + if (fs.existsSync(target) && fs.statSync(target).isFile()) return true; + } + return false; } function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser) { diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 15afbdb..1e6ce01 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -198,6 +198,27 @@ test('sanitizePublicHtmlLinksInText keeps existing own public html links', () => } }); +test('sanitizePublicHtmlLinksInText finds pages in the workspace beside H5_USERS_ROOT', () => { + const owner = `test-user-${Date.now()}-shared-workspace`; + const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'memind-shared-workspace-')); + const previousUsersRoot = process.env.H5_USERS_ROOT; + const htmlPath = path.join(runtimeRoot, 'MindSpace', owner, 'public', 'poem.html'); + process.env.H5_USERS_ROOT = path.join(runtimeRoot, 'users'); + try { + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, 'Poem'); + const text = + `[诗词页面](https://m.tkmind.cn/MindSpace/${owner}/public/poem.html)`; + const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' }); + assert.match(next, new RegExp(`/MindSpace/${owner}/public/poem\\.html`)); + assert.doesNotMatch(next, /页面生成未完成/); + } finally { + if (previousUsersRoot == null) delete process.env.H5_USERS_ROOT; + else process.env.H5_USERS_ROOT = previousUsersRoot; + fs.rmSync(runtimeRoot, { recursive: true, force: true }); + } +}); + test('sanitizePublicHtmlLinksInText canonicalizes wrong MindSpace public hosts', () => { const owner = `test-user-${Date.now()}-canonical-host`; const previousBase = process.env.H5_PUBLIC_BASE_URL;