diff --git a/.claude/skills/memind-scenario-test/SKILL.md b/.claude/skills/memind-scenario-test/SKILL.md
index fcc9e58..c135c6d 100644
--- a/.claude/skills/memind-scenario-test/SKILL.md
+++ b/.claude/skills/memind-scenario-test/SKILL.md
@@ -67,12 +67,20 @@ node scripts/run-scenario-test.mjs --scenario suzhou-page
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
+| `upload_images` | 生成并上传测试图片到 MindSpace,供后续 `chat` 步骤引用 |
+
+`upload_images` 字段:`categoryCode`(默认 `oa`)、`images[]`
+(每项 `filename` / `background` / `shape` / `accent`,shape 支持
+`circle` `square` `triangle` `diamond`)。后续 `chat` 步骤设
+`useUploadedAssets: true` 即可把这些图作为本轮附件发出。
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
+- `forbidReplyPatterns`:回复中不允许出现的字符串
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
+- `page.requireUploadedImages` / `page.minUploadedImages`:页面必须嵌入 `upload_images` 上传的资产
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
diff --git a/.cursor/skills/memind-scenario-test/SKILL.md b/.cursor/skills/memind-scenario-test/SKILL.md
index fcc9e58..c135c6d 100644
--- a/.cursor/skills/memind-scenario-test/SKILL.md
+++ b/.cursor/skills/memind-scenario-test/SKILL.md
@@ -67,12 +67,20 @@ node scripts/run-scenario-test.mjs --scenario suzhou-page
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
+| `upload_images` | 生成并上传测试图片到 MindSpace,供后续 `chat` 步骤引用 |
+
+`upload_images` 字段:`categoryCode`(默认 `oa`)、`images[]`
+(每项 `filename` / `background` / `shape` / `accent`,shape 支持
+`circle` `square` `triangle` `diamond`)。后续 `chat` 步骤设
+`useUploadedAssets: true` 即可把这些图作为本轮附件发出。
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
+- `forbidReplyPatterns`:回复中不允许出现的字符串
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
+- `page.requireUploadedImages` / `page.minUploadedImages`:页面必须嵌入 `upload_images` 上传的资产
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
diff --git a/docs/regression-guards/vision-turn-read-image-isolation.md b/docs/regression-guards/vision-turn-read-image-isolation.md
index dbb7685..6225828 100644
--- a/docs/regression-guards/vision-turn-read-image-isolation.md
+++ b/docs/regression-guards/vision-turn-read-image-isolation.md
@@ -50,6 +50,16 @@ node --test tkmind-proxy.test.mjs chat-image-turn-scope.test.mjs wechat-mp.test.
npm run verify:h5-session-patches
```
+端到端(需本地 Portal 8081 + goosed):
+
+```bash
+node scripts/run-scenario-test.mjs --scenario image-theme-page
+```
+
+该场景上传 4 张几何图、要求做成主题页面,断言页面生成、公网 200、4 张图全部嵌入,
+并禁止回复里出现 `unknown variant`。2026-08-22 首次通过时会话内 `read_image` 调用为 0、
+持久化工具图片块为 0,工具序列是 `load_skill → write_file → read_file`。
+
## 相关代码与用例
| 位置 | 作用 |
diff --git a/scenarios/image-theme-page.json b/scenarios/image-theme-page.json
new file mode 100644
index 0000000..85300bf
--- /dev/null
+++ b/scenarios/image-theme-page.json
@@ -0,0 +1,45 @@
+{
+ "id": "image-theme-page",
+ "name": "多图主题页面(视觉链路回归)",
+ "description": "上传 4 张图 → 要求做成主题页面 → 验证页面生成、公网可访问、4 张图都嵌进页面;复现唐用户 read_image 污染文本模型会话导致页面不生成的故障",
+ "account": {
+ "username": "john2",
+ "password": "888888"
+ },
+ "steps": [
+ {
+ "action": "login",
+ "label": "登录 john2 账户"
+ },
+ {
+ "action": "upload_images",
+ "label": "上传 4 张图片",
+ "categoryCode": "oa",
+ "images": [
+ { "filename": "theme-1.jpg", "background": "#1e3a8a", "shape": "circle", "accent": "#fbbf24" },
+ { "filename": "theme-2.jpg", "background": "#7f1d1d", "shape": "square", "accent": "#fca5a5" },
+ { "filename": "theme-3.jpg", "background": "#064e3b", "shape": "triangle", "accent": "#6ee7b7" },
+ { "filename": "theme-4.jpg", "background": "#3b0764", "shape": "diamond", "accent": "#e9d5ff" }
+ ]
+ },
+ {
+ "action": "chat",
+ "label": "要求把 4 张图做成主题页面",
+ "message": "把我刚才发的这 4 张图片做成一个主题页面,每张图配一段文字",
+ "useUploadedAssets": true,
+ "expect": {
+ "assistantMinChars": 20,
+ "timeoutMs": 420000,
+ "page": {
+ "requirePublicLink": true,
+ "requireHttp200": true,
+ "requireUploadedImages": true
+ },
+ "forbidReplyPatterns": [
+ "unknown variant",
+ "没有按服务号页面技能真正生成成功"
+ ]
+ }
+ }
+ ]
+}
diff --git a/scripts/run-scenario-test.mjs b/scripts/run-scenario-test.mjs
index 7f7842c..48c93b3 100644
--- a/scripts/run-scenario-test.mjs
+++ b/scripts/run-scenario-test.mjs
@@ -9,6 +9,7 @@
*/
import { loadH5Environment } from './load-env.mjs';
import {
+ buildAssetImageUrl,
createAgentRun,
createReporter,
listScenarios,
@@ -17,6 +18,7 @@ import {
logoutViaApi,
resolvePortalBase,
snapshotPublicHtml,
+ uploadImageAssets,
verifyPageAccess,
verifySurveyDelivery,
verifyChildrenHobbyDietSurvey,
@@ -95,6 +97,7 @@ async function runScenario(scenario, port) {
let assistantCombinedLength = 0;
let publishKey = null;
let htmlBefore = [];
+ let uploadedAssetIds = [];
for (const step of scenario.steps ?? []) {
const label = step.label ?? step.action;
@@ -119,6 +122,20 @@ async function runScenario(scenario, port) {
continue;
}
+ if (step.action === 'upload_images') {
+ if (!auth) {
+ throw new Error('upload_images 步骤前必须先 login');
+ }
+ const assets = await uploadImageAssets(
+ baseUrl,
+ auth.cookie,
+ { images: step.images ?? [], categoryCode: step.categoryCode ?? 'oa' },
+ reporter,
+ );
+ uploadedAssetIds = assets.map((asset) => asset.id);
+ continue;
+ }
+
if (step.action === 'chat') {
if (!auth) {
throw new Error('chat 步骤前必须先 login');
@@ -133,6 +150,9 @@ async function runScenario(scenario, port) {
sessionId,
selectedChatSkill: step.selectedChatSkill ?? null,
selectedAssetIds: step.selectedAssetIds ?? null,
+ imageUrls: step.useUploadedAssets
+ ? uploadedAssetIds.map(buildAssetImageUrl)
+ : null,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
@@ -191,6 +211,7 @@ async function runScenario(scenario, port) {
replyText: reply.combined,
htmlBefore,
expect: step.expect.page,
+ uploadedAssetIds,
reporter,
});
}
diff --git a/scripts/scenario-test-lib.mjs b/scripts/scenario-test-lib.mjs
index ae8b221..8a51dda 100644
--- a/scripts/scenario-test-lib.mjs
+++ b/scripts/scenario-test-lib.mjs
@@ -121,11 +121,16 @@ export async function logoutViaApi(baseUrl, cookie, reporter) {
return body;
}
-function buildUserMessage(text, { selectedChatSkill = null } = {}) {
+function buildUserMessage(text, { selectedChatSkill = null, imageUrls = null } = {}) {
const metadata = { userVisible: true, displayText: text };
if (selectedChatSkill) {
metadata.memindRun = { selectedChatSkill };
}
+ // Same shape the WeChat channel attaches for recent uploads, so scenarios can
+ // exercise the vision turn without the browser upload widget.
+ if (Array.isArray(imageUrls) && imageUrls.length > 0) {
+ metadata.imageUrls = imageUrls;
+ }
return {
id: crypto.randomUUID(),
role: 'user',
@@ -134,16 +139,21 @@ function buildUserMessage(text, { selectedChatSkill = null } = {}) {
};
}
+export function buildAssetImageUrl(assetId) {
+ return `/api/mindspace/v1/assets/${assetId}/download?inline=1`;
+}
+
export async function createAgentRun(baseUrl, cookie, {
message,
sessionId = null,
selectedChatSkill = null,
selectedAssetIds = null,
+ imageUrls = null,
}) {
const requestId = crypto.randomUUID();
const body = {
request_id: requestId,
- user_message: buildUserMessage(message, { selectedChatSkill }),
+ user_message: buildUserMessage(message, { selectedChatSkill, imageUrls }),
};
if (sessionId) body.session_id = sessionId;
if (Array.isArray(selectedAssetIds) && selectedAssetIds.length > 0) {
@@ -296,6 +306,100 @@ export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, {
return null;
}
+const UPLOADABLE_CATEGORY_CODES = ['oa', 'public'];
+
+async function resolveUploadCategoryId(baseUrl, cookie, preferredCode) {
+ const response = await fetch(`${baseUrl}/api/mindspace/v1/space`, {
+ headers: { Cookie: cookie },
+ });
+ if (!response.ok) {
+ throw new Error(`读取 MindSpace 空间失败 ${response.status}`);
+ }
+ const payload = await response.json();
+ const categories = payload?.data?.categories ?? payload?.categories ?? [];
+ const uploadable = categories.filter((item) => UPLOADABLE_CATEGORY_CODES.includes(item?.code));
+ const picked = uploadable.find((item) => item.code === preferredCode) ?? uploadable[0];
+ if (!picked?.id) {
+ throw new Error(`MindSpace 没有可上传的分类(需要 ${UPLOADABLE_CATEGORY_CODES.join(' / ')})`);
+ }
+ return picked.id;
+}
+
+/**
+ * Renders a deterministic, visually distinct JPEG so a vision model can tell the
+ * uploads apart. Shapes and colors only — no text, because the rasterizer has no
+ * guaranteed CJK font.
+ */
+async function renderScenarioImage({ background = '#1e3a8a', shape = 'circle', accent = '#fbbf24' }) {
+ const { default: sharp } = await import('sharp');
+ const shapes = {
+ circle: '',
+ square: '',
+ triangle: '',
+ diamond: '',
+ };
+ const figure = (shapes[shape] ?? shapes.circle).replace('ACCENT', accent);
+ const svg = ``;
+ return sharp(Buffer.from(svg)).jpeg({ quality: 90 }).toBuffer();
+}
+
+export async function uploadImageAssets(
+ baseUrl,
+ cookie,
+ { images = [], categoryCode = 'oa' } = {},
+ reporter,
+) {
+ const categoryId = await resolveUploadCategoryId(baseUrl, cookie, categoryCode);
+ const assets = [];
+
+ for (const [index, spec] of images.entries()) {
+ const buffer = await renderScenarioImage(spec ?? {});
+ const filename = spec?.filename ?? `scenario-image-${index + 1}.jpg`;
+
+ const createResponse = await fetch(`${baseUrl}/api/mindspace/v1/uploads`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Cookie: cookie },
+ body: JSON.stringify({
+ category_id: categoryId,
+ filename,
+ size_bytes: buffer.length,
+ declared_mime_type: 'image/jpeg',
+ }),
+ });
+ if (!createResponse.ok) {
+ throw new Error(`创建上传失败 ${createResponse.status}: ${await createResponse.text()}`);
+ }
+ const upload = (await createResponse.json())?.data;
+ const uploadUrl = new URL(upload.uploadUrl, baseUrl).href;
+
+ const putResponse = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/octet-stream', Cookie: cookie },
+ body: buffer,
+ });
+ if (!putResponse.ok) {
+ throw new Error(`上传内容失败 ${putResponse.status}: ${await putResponse.text()}`);
+ }
+
+ const completeResponse = await fetch(
+ `${baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(upload.id)}/complete`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Cookie: cookie },
+ body: JSON.stringify({}),
+ },
+ );
+ if (!completeResponse.ok) {
+ throw new Error(`完成上传失败 ${completeResponse.status}: ${await completeResponse.text()}`);
+ }
+ assets.push((await completeResponse.json())?.data);
+ }
+
+ reporter?.pass('上传图片', `${assets.length} 张 → ${assets.map((a) => a.id).join(', ')}`);
+ return assets;
+}
+
export async function verifyPageAccess({
baseUrl,
cookie,
@@ -303,6 +407,7 @@ export async function verifyPageAccess({
replyText,
htmlBefore = [],
expect = {},
+ uploadedAssetIds = [],
reporter,
}) {
const keywords = expect.keywords ?? [];
@@ -354,6 +459,19 @@ export async function verifyPageAccess({
reporter.fail('页面元数据', '缺少 mindspace-cover');
return false;
}
+
+ if (expect.requireUploadedImages) {
+ const embedded = uploadedAssetIds.filter((assetId) => assetId && html.includes(assetId));
+ const required = expect.minUploadedImages ?? uploadedAssetIds.length;
+ if (embedded.length < required) {
+ reporter.fail(
+ '页面嵌入上传图片',
+ `命中 ${embedded.length}/${uploadedAssetIds.length} 张,要求至少 ${required} 张`,
+ );
+ return false;
+ }
+ reporter.pass('页面嵌入上传图片', `${embedded.length}/${uploadedAssetIds.length} 张`);
+ }
}
return true;