fix: preserve delivered page links when auto images fail
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
@@ -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: '未检测到必须生成或禁止生成图片的要求' };
|
||||
}
|
||||
|
||||
@@ -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 = '帮我生成一个精美唐诗页面,背景是大唐盛景图片';
|
||||
|
||||
@@ -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/<userId>`
|
||||
- `H5_USERS_ROOT` 同级的共享 `MindSpace/<userId>`
|
||||
- 显式 `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
|
||||
|
||||
+25
-4
@@ -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) {
|
||||
|
||||
@@ -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, '<!doctype html><title>Poem</title>');
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user