feat: add guarded image generation controls

This commit is contained in:
john
2026-07-20 07:37:43 +08:00
parent 72ed4dde83
commit 105a72c1f1
19 changed files with 482 additions and 23 deletions
+42 -5
View File
@@ -66,6 +66,33 @@ function extractRunMessageText(row) {
.join('\n'); .join('\n');
} }
function resolveRequiredImageGeneration(row, routing) {
const message = parseDbJsonColumn(row?.user_message_json, {}) ?? {};
const metadata = message.metadata ?? {};
const runMetadata = metadata[RUN_METADATA_KEY] ?? metadata.agentRun ?? {};
const requestedMode = String(
runMetadata.imageGenerationMode ?? runMetadata.image_generation_mode ?? metadata.imageGenerationMode ?? '',
).trim().toLowerCase();
if (requestedMode === 'required') return true;
if (requestedMode === 'disabled') return false;
return routing?.imageGeneration?.mode === 'required';
}
export function assertRequiredImageGenerationCompleted(row, routing, toolEvidence) {
if (!resolveRequiredImageGeneration(row, routing)) return;
if (toolEvidence?.generateImage?.succeeded === true) return;
if (toolEvidence?.generateImage?.called !== true) {
const error = new Error('本轮明确要求生成图片,但 Agent 没有实际调用 generate_image;历史回复不能作为本轮工具证据');
error.code = 'IMAGE_GENERATION_REQUIRED_NOT_CALLED';
error.retryable = false;
throw error;
}
const error = new Error('本轮明确要求生成图片,但未获得 image_make 的有效 PNG/JPEG/WebP 产物,不能标记成功');
error.code = 'IMAGE_GENERATION_REQUIRED_MISSING';
error.retryable = false;
throw error;
}
function positiveInteger(value, fallback) { function positiveInteger(value, fallback) {
const n = Number(value); const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return fallback; if (!Number.isFinite(n) || n <= 0) return fallback;
@@ -780,6 +807,7 @@ export function createAgentRunGateway({
}); });
} }
await invalidatePortalDirectChatSnapshot(sessionId); await invalidatePortalDirectChatSnapshot(sessionId);
let toolEvidence = null;
const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true) const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true)
&& runOptions.toolMode === 'chat' && runOptions.toolMode === 'chat'
&& typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; && typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function';
@@ -799,7 +827,10 @@ export function createAgentRunGateway({
await appendEvent(runId, 'session_finished', { await appendEvent(runId, 'session_finished', {
sessionId, sessionId,
tokenState: finish.tokenState ?? null, tokenState: finish.tokenState ?? null,
toolCalls: finish.toolEvidence?.calls ?? [],
generateImage: finish.toolEvidence?.generateImage ?? null,
}); });
toolEvidence = finish.toolEvidence ?? null;
} catch (err) { } catch (err) {
if (await recoverRunFromDeliverables({ if (await recoverRunFromDeliverables({
runId, runId,
@@ -823,10 +854,16 @@ export function createAgentRunGateway({
}, },
); );
} }
return { sessionId, routing }; return { sessionId, routing, toolEvidence };
} }
async function finalizeSuccessfulRun(runId, row, sessionId, { routing = null } = {}) { async function finalizeSuccessfulRun(
runId,
row,
sessionId,
{ routing = null, toolEvidence = null } = {},
) {
assertRequiredImageGenerationCompleted(row, routing, toolEvidence);
let deliveryResult = null; let deliveryResult = null;
if (typeof syncUserPagesOnSuccess === 'function') { if (typeof syncUserPagesOnSuccess === 'function') {
deliveryResult = await syncUserPagesOnSuccess({ deliveryResult = await syncUserPagesOnSuccess({
@@ -933,12 +970,12 @@ export function createAgentRunGateway({
const stopHeartbeat = startRunHeartbeat(runId, { attempt: nextAttempt }); const stopHeartbeat = startRunHeartbeat(runId, { attempt: nextAttempt });
try { try {
const { sessionId, routing } = await runWithTimeout(runId, () => executeRun(row, runId)); const execution = await runWithTimeout(runId, () => executeRun(row, runId));
await finalizeSuccessfulRun(runId, row, sessionId, { routing }); await finalizeSuccessfulRun(runId, row, execution.sessionId, execution);
} catch (err) { } catch (err) {
const latest = await getRunById(runId); const latest = await getRunById(runId);
const recoverySessionId = latest?.agent_session_id ?? row.agent_session_id ?? null; const recoverySessionId = latest?.agent_session_id ?? row.agent_session_id ?? null;
if (await recoverRunFromDeliverables({ if (err?.code !== 'IMAGE_GENERATION_REQUIRED_MISSING' && await recoverRunFromDeliverables({
runId, runId,
userId: row.user_id, userId: row.user_id,
sessionId: recoverySessionId, sessionId: recoverySessionId,
+27 -1
View File
@@ -4,7 +4,33 @@ import fs from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { createAgentRunGateway } from './agent-run-gateway.mjs'; import {
assertRequiredImageGenerationCompleted,
createAgentRunGateway,
} from './agent-run-gateway.mjs';
test('required image generation cannot succeed without a verified raster image_make result', () => {
const row = {
user_message_json: JSON.stringify({
metadata: { memindRun: { imageGenerationMode: 'required' } },
}),
};
assert.throws(
() => assertRequiredImageGenerationCompleted(row, null, {
generateImage: { called: false, succeeded: false },
}),
(error) => error?.code === 'IMAGE_GENERATION_REQUIRED_NOT_CALLED',
);
assert.throws(
() => assertRequiredImageGenerationCompleted(row, null, {
generateImage: { called: true, succeeded: false },
}),
(error) => error?.code === 'IMAGE_GENERATION_REQUIRED_MISSING',
);
assert.doesNotThrow(() => assertRequiredImageGenerationCompleted(row, null, {
generateImage: { called: true, succeeded: true, jobId: 'job-1', mimeType: 'image/webp' },
}));
});
function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } = {}) { function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } = {}) {
const runs = new Map(); const runs = new Map();
+16 -5
View File
@@ -32,10 +32,10 @@ export const ASSET_PLUGIN_CATALOG = [
]; ];
export const IMAGE_MAKE_PURPOSE_CATALOG = [ export const IMAGE_MAKE_PURPOSE_CATALOG = [
{ id: 'inline_image', label: '页面正文图片', presetId: 'memind_square_illustration' }, { id: 'inline_image', label: '页面正文图片', presetId: 'memind_square_illustration', strategy: 'generate', sourcePurpose: null },
{ id: 'hero', label: '页面头图', presetId: 'memind_dark_hero' }, { id: 'hero', label: '页面头图', presetId: 'memind_dark_hero', strategy: 'generate', sourcePurpose: null },
{ id: 'card_cover', label: '卡片封面', presetId: 'memind_card_cover' }, { id: 'card_cover', label: '卡片封面', presetId: 'memind_card_cover', strategy: 'derive', sourcePurpose: 'hero' },
{ id: 'feed_cover', label: '信息流缩略图源图', presetId: 'memind_feed_cover_source' }, { id: 'feed_cover', label: '信息流缩略图', presetId: 'memind_feed_cover_source', strategy: 'derive', sourcePurpose: 'hero' },
]; ];
const imageMakePurposeIds = new Set(IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => purpose.id)); const imageMakePurposeIds = new Set(IMAGE_MAKE_PURPOSE_CATALOG.map((purpose) => purpose.id));
@@ -281,10 +281,21 @@ export function createAssetGatewayConfigService(pool, { llmProviderService = nul
if (!resolved.plugin.purposes?.[purpose]) { if (!resolved.plugin.purposes?.[purpose]) {
return { ok: false, code: 'purpose_disabled', message: '该图片用途未启用,可安全降级到原有流程' }; return { ok: false, code: 'purpose_disabled', message: '该图片用途未启用,可安全降级到原有流程' };
} }
const purposeConfig = IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose);
if (purposeConfig?.strategy === 'derive' && resolved.plugin.purposes?.hero) {
return {
ok: false,
code: 'reuse_hero_required',
message: '页面头图已启用:请只生成一次 hero,并从该主图派生卡片封面和信息流缩略图',
sourcePurpose: 'hero',
};
}
return { return {
ok: true, ok: true,
purpose, purpose,
presetId: IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose)?.presetId, presetId: purposeConfig?.presetId,
strategy: purposeConfig?.strategy ?? 'generate',
sourcePurpose: purposeConfig?.sourcePurpose ?? null,
plugin: resolved.plugin, plugin: resolved.plugin,
}; };
}, },
+22
View File
@@ -120,6 +120,12 @@ test('image_make purposes are disabled by default and resolved independently', a
feed_cover: true, feed_cover: true,
}); });
assert.equal((await service.resolveImageGenerationPurpose('hero')).ok, true); assert.equal((await service.resolveImageGenerationPurpose('hero')).ok, true);
assert.deepEqual(await service.resolveImageGenerationPurpose('feed_cover'), {
ok: false,
code: 'reuse_hero_required',
message: '页面头图已启用:请只生成一次 hero,并从该主图派生卡片封面和信息流缩略图',
sourcePurpose: 'hero',
});
assert.equal(plugin.llmProviderKeyId, null); assert.equal(plugin.llmProviderKeyId, null);
assert.equal(plugin.llmModel, null); assert.equal(plugin.llmModel, null);
assert.deepEqual(await service.resolveImageGenerationPurpose('card_cover'), { assert.deepEqual(await service.resolveImageGenerationPurpose('card_cover'), {
@@ -128,3 +134,19 @@ test('image_make purposes are disabled by default and resolved independently', a
message: '该图片用途未启用,可安全降级到原有流程', message: '该图片用途未启用,可安全降级到原有流程',
}); });
}); });
test('derived cover purpose can generate one source when hero output is disabled', async () => {
const service = createAssetGatewayConfigService(createPool(), { llmProviderService });
await service.updateGlobalConfig({ enabled: true }, { updatedBy: 'admin-1' });
await service.updatePluginConfig('asset-generate', {
enabled: true,
provider: 'image_make',
purposes: { hero: false, card_cover: true },
}, { updatedBy: 'admin-1' });
const resolved = await service.resolveImageGenerationPurpose('card_cover');
assert.equal(resolved.ok, true);
assert.equal(resolved.strategy, 'derive');
assert.equal(resolved.sourcePurpose, 'hero');
assert.equal(resolved.presetId, 'memind_card_cover');
});
+87 -5
View File
@@ -84,6 +84,83 @@ const OBVIOUS_DIRECT_CHAT_PATTERNS = [
/(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u, /(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u,
]; ];
export const IMAGE_GENERATION_MODE = {
AUTO: 'auto',
REQUIRED: 'required',
DISABLED: 'disabled',
};
const IMAGE_GENERATION_REQUIRED_PATTERNS = [
/(?:生成|制作|创建|画|绘制|配|添加|使用).{0,16}(?:AI\s*)?(?:图片|图像|背景图|封面图|插画|照片|主图|配图)/iu,
/(?:图片|图像|背景图|封面图|插画|照片|主图|配图).{0,16}(?:生成|制作|创建|画|绘制|作为|用作)/iu,
/(?:全屏|页面|网页|卡片|信息流).{0,12}(?:背景|封面|主图).{0,12}(?:图片|图像|照片)/iu,
/(?:AI配图|AI图片|真图|真实图片|生图)/iu,
];
const IMAGE_GENERATION_DISABLED_PATTERNS = [
/(?:不要|无需|不需要|禁止|关闭|取消).{0,12}(?:AI\s*)?(?:生图|图片生成|生成图片|配图)/iu,
/(?:纯文字|只要文字|不要图片|不要配图|不用图片|不用配图)/iu,
];
const VISUAL_PAGE_IMAGE_HINT_PATTERNS = [
/(?:精美|沉浸式|视觉化|视觉效果|全屏背景|大唐盛景|海报风|摄影风)/u,
/(?:旅行|美食|活动|品牌|产品|促销|诗词|唐诗).{0,16}(?:页面|网页|H5|h5|封面|背景)/u,
];
export function normalizeImageGenerationMode(value) {
const normalized = String(value ?? '').trim().toLowerCase();
if (normalized === IMAGE_GENERATION_MODE.REQUIRED) return IMAGE_GENERATION_MODE.REQUIRED;
if (normalized === IMAGE_GENERATION_MODE.DISABLED) return IMAGE_GENERATION_MODE.DISABLED;
return IMAGE_GENERATION_MODE.AUTO;
}
export function resolveImageGenerationDecision({ text, requestedMode = IMAGE_GENERATION_MODE.AUTO } = {}) {
const normalizedText = String(text ?? '').trim();
const normalizedMode = normalizeImageGenerationMode(requestedMode);
if (normalizedMode === IMAGE_GENERATION_MODE.REQUIRED) {
return { mode: IMAGE_GENERATION_MODE.REQUIRED, source: 'user_override', reason: '用户在输入区强制开启 AI 配图' };
}
if (normalizedMode === IMAGE_GENERATION_MODE.DISABLED) {
return { mode: IMAGE_GENERATION_MODE.DISABLED, source: 'user_override', reason: '用户在输入区关闭 AI 配图' };
}
if (IMAGE_GENERATION_DISABLED_PATTERNS.some((pattern) => pattern.test(normalizedText))) {
return { mode: IMAGE_GENERATION_MODE.DISABLED, source: 'intent', reason: '用户明确要求不生成图片' };
}
if (IMAGE_GENERATION_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalizedText))) {
return { mode: IMAGE_GENERATION_MODE.REQUIRED, source: 'intent', reason: '检测到明确的图片生成需求' };
}
if (
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: 'default', reason: '未检测到必须生成或禁止生成图片的要求' };
}
function requestedImageGenerationMode(userMessage) {
const metadata = userMessage?.metadata ?? {};
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
return normalizeImageGenerationMode(
runMetadata.imageGenerationMode ?? runMetadata.image_generation_mode ?? metadata.imageGenerationMode,
);
}
function buildImageGenerationInstruction(decision) {
if (decision?.mode === IMAGE_GENERATION_MODE.REQUIRED) {
return [
'图片策略:强制生成。必须先调用 sandbox-fs__generate_image,并使用返回的 workspaceRelativePath/publicUrl 作为页面真实主图或背景图,再生成 HTML 与缩略图。',
'性能规则:页面头图、卡片封面和信息流缩略图只能共享一张主图。优先只调用一次 purpose=hero;hero 成功后必须复用该资产并裁剪派生,禁止再调用 card_cover 或 feed_cover。只有 hero 用途被后台关闭、且确实只需要对应封面时,才允许改用一个 card_cover 或 feed_cover 请求。正文插图 inline_image 仅在后台开启且正文确有需要时单独生成。',
'本轮证据要求:必须在当前这一次执行中发起新的 generate_image 工具请求;历史对话中的成功或失败都不能作为本轮结果。禁止在没有新 toolRequest/toolResponse 的情况下复述“已尝试”“Provider 未启用”或其他旧结论。',
'完成条件:返回结果必须 ok=true,包含 jobId,且产物为 PNG/JPEG/WebP;不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生图。生图失败时必须明确报告失败,不得静默降级后宣称完成。',
].join('\n');
}
if (decision?.mode === IMAGE_GENERATION_MODE.DISABLED) {
return '图片策略:关闭。本轮禁止调用 generate_image;如需页面视觉,只能使用现有合法资源或纯 CSS,并如实说明未生成新图片。';
}
return '图片策略:自动。仅在用户需求确实需要新主图、背景图、正文配图或封面时调用 generate_image;不要为了装饰无条件生图。';
}
/** Realtime / web-search prompts — fast-path to agent even when LLM router is enabled. */ /** Realtime / web-search prompts — fast-path to agent even when LLM router is enabled. */
const REALTIME_INFO_AGENT_PATTERNS = [ const REALTIME_INFO_AGENT_PATTERNS = [
/(?:世界杯|欧冠|NBA|英超|西甲|意甲|德甲|法甲|欧洲杯|亚洲杯|奥运会).{0,16}(?:赛况|赛程|比分|战况|结果|积分|排名|进展|情况)/u, /(?:世界杯|欧冠|NBA|英超|西甲|意甲|德甲|法甲|欧洲杯|亚洲杯|奥运会).{0,16}(?:赛况|赛程|比分|战况|结果|积分|排名|进展|情况)/u,
@@ -719,6 +796,7 @@ export function buildAgentOrchestrationAgentText({
`路由判定:${classification.reason}`, `路由判定:${classification.reason}`,
classification.agentBrief ? `执行要点:${classification.agentBrief}` : '', classification.agentBrief ? `执行要点:${classification.agentBrief}` : '',
classification.suggestedSkill ? `建议 skill${classification.suggestedSkill}` : '', classification.suggestedSkill ? `建议 skill${classification.suggestedSkill}` : '',
buildImageGenerationInstruction(classification.imageGeneration),
skillPrompt, skillPrompt,
memoryLines.length memoryLines.length
? [ ? [
@@ -1114,8 +1192,7 @@ export function createChatIntentRouter(options = {}) {
if (!classification) return classification; if (!classification) return classification;
const base = { ...classification }; const base = { ...classification };
delete base.decision; delete base.decision;
return finalizeRouterClassification( const coerced = coercePageGenerationSkill(
coercePageGenerationSkill(
coercePageDataSkill( coercePageDataSkill(
coerceRealtimeWebSkill(base, text, { grantedSkills }), coerceRealtimeWebSkill(base, text, { grantedSkills }),
text, text,
@@ -1123,9 +1200,14 @@ export function createChatIntentRouter(options = {}) {
), ),
text, text,
{ grantedSkills }, { grantedSkills },
), );
decisionContext, return finalizeRouterClassification({
); ...coerced,
imageGeneration: resolveImageGenerationDecision({
text,
requestedMode: requestedImageGenerationMode(userMessage),
}),
}, decisionContext);
}; };
const ruleResult = classifyWithRules({ const ruleResult = classifyWithRules({
text, text,
+48
View File
@@ -23,10 +23,58 @@ import {
isRealtimeInfoQuestion, isRealtimeInfoQuestion,
isRichChannelPageIntent, isRichChannelPageIntent,
isRichPublishableContentIntent, isRichPublishableContentIntent,
IMAGE_GENERATION_MODE,
resolveImageGenerationDecision,
coerceRealtimeWebSkill, coerceRealtimeWebSkill,
resolveChatIntentRouterPolicy, resolveChatIntentRouterPolicy,
} from './chat-intent-router.mjs'; } from './chat-intent-router.mjs';
test('image generation intent requires a real image for explicit background requests', () => {
const decision = resolveImageGenerationDecision({
text: '帮我生成一首唐诗页面,背景是大唐盛景图片,页面要精美',
});
assert.equal(decision.mode, IMAGE_GENERATION_MODE.REQUIRED);
assert.equal(decision.source, 'intent');
});
test('image generation user override can force or disable image generation', () => {
assert.equal(resolveImageGenerationDecision({
text: '做一个纯文字页面',
requestedMode: 'required',
}).mode, IMAGE_GENERATION_MODE.REQUIRED);
assert.equal(resolveImageGenerationDecision({
text: '生成一个带背景图的页面',
requestedMode: 'disabled',
}).mode, IMAGE_GENERATION_MODE.DISABLED);
});
test('router injects a fail-closed generate_image contract for required visual pages', async () => {
const router = createChatIntentRouter();
const text = '帮我生成一个精美唐诗页面,背景是大唐盛景图片';
const userMessage = {
role: 'user',
content: [{ type: 'text', text }],
metadata: {
displayText: text,
memindRun: { imageGenerationMode: 'auto' },
},
};
const classification = await router.classify({
userMessage,
grantedSkills: ['static-page-publish'],
});
assert.equal(classification.imageGeneration.mode, IMAGE_GENERATION_MODE.REQUIRED);
const enriched = router.applyAgentOrchestration(userMessage, classification, {
grantedSkills: ['static-page-publish'],
});
assert.match(enriched.content[0].text, /sandbox-fs__generate_image/);
assert.match(enriched.content[0].text, /当前这一次执行中发起新的 generate_image/);
assert.match(enriched.content[0].text, /历史对话中的成功或失败都不能作为本轮结果/);
assert.match(enriched.content[0].text, /hero 成功后必须复用该资产/);
assert.match(enriched.content[0].text, /禁止再调用 card_cover 或 feed_cover/);
assert.match(enriched.content[0].text, /不得用 SVG/);
});
test('classifyWithRules routes greetings to direct chat', () => { test('classifyWithRules routes greetings to direct chat', () => {
const result = classifyWithRules({ const result = classifyWithRules({
text: '你好', text: '你好',
+2 -2
View File
@@ -52,7 +52,7 @@ export function createImageMakeClient({
token, token,
fetchImpl = globalThis.fetch, fetchImpl = globalThis.fetch,
requestTimeoutMs = 15_000, requestTimeoutMs = 15_000,
generationTimeoutMs = 10 * 60_000, generationTimeoutMs = 15 * 60_000,
pollIntervalMs = 1_000, pollIntervalMs = 1_000,
maxResultBytes = DEFAULT_MAX_RESULT_BYTES, maxResultBytes = DEFAULT_MAX_RESULT_BYTES,
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -62,7 +62,7 @@ export function createImageMakeClient({
const bearerToken = String(token ?? '').trim(); const bearerToken = String(token ?? '').trim();
if (!bearerToken) throw new Error('image_make token is required'); if (!bearerToken) throw new Error('image_make token is required');
const perRequestTimeout = positiveInteger(requestTimeoutMs, 15_000); const perRequestTimeout = positiveInteger(requestTimeoutMs, 15_000);
const overallTimeout = positiveInteger(generationTimeoutMs, 10 * 60_000); const overallTimeout = positiveInteger(generationTimeoutMs, 15 * 60_000);
const pollingDelay = positiveInteger(pollIntervalMs, 1_000); const pollingDelay = positiveInteger(pollIntervalMs, 1_000);
const resultLimit = positiveInteger(maxResultBytes, DEFAULT_MAX_RESULT_BYTES); const resultLimit = positiveInteger(maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
+2 -2
View File
@@ -142,7 +142,7 @@ async function generateMindSpaceImage({ purpose, prompt, negativePrompt, idempot
prompt, prompt,
negative_prompt: negativePrompt, negative_prompt: negativePrompt,
}), }),
signal: AbortSignal.timeout(Number(process.env.IMAGE_MAKE_GENERATION_TIMEOUT_MS ?? 600_000)), signal: AbortSignal.timeout(Number(process.env.IMAGE_MAKE_GENERATION_TIMEOUT_MS ?? 900_000)),
}); });
let body = null; let body = null;
try { try {
@@ -233,7 +233,7 @@ const ALL_TOOLS = [
{ {
name: 'generate_image', name: 'generate_image',
description: description:
'通过平台 image_make 服务生成页面图片并直接写入当前用户 MindSpace。仅在后台开关开启时生效;失败时继续原页面流程。返回 canonical publicUrl 与 workspaceRelativePath。', '通过平台 image_make 服务生成页面图片并直接写入当前用户 MindSpace。页面头图、卡片封面和信息流缩略图必须共享一次生成结果:优先只调用一次 hero,再从该图派生其他尺寸;禁止在 hero 成功后继续调用 card_cover/feed_cover。正文插图 inline_image 仅按需单独生成。仅在后台对应开关开启时生效返回 canonical publicUrl 与 workspaceRelativePath。',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
+79
View File
@@ -24,6 +24,82 @@ export function eventMatchesRequest(event, requestId) {
return !routingId || routingId === requestId; return !routingId || routingId === requestId;
} }
function messageContentItems(event) {
const candidates = [
event?.message?.content,
event?.data?.message?.content,
event?.content,
];
return candidates.find((content) => Array.isArray(content)) ?? [];
}
function parseGenerateImageResult(toolResult) {
const content = toolResult?.value?.content;
if (!Array.isArray(content)) return null;
for (const item of content) {
if (item?.type !== 'text' || !String(item.text ?? '').trim()) continue;
try {
const result = JSON.parse(String(item.text));
const mimeType = String(result?.source?.mimeType ?? result?.asset?.mimeType ?? '').toLowerCase();
if (
result?.ok === true
&& String(result?.jobId ?? '').trim()
&& /^image\/(?:png|jpeg|webp)$/.test(mimeType)
) {
return {
jobId: String(result.jobId),
mimeType,
assetId: String(result?.asset?.id ?? '').trim() || null,
publicUrl: String(result?.asset?.publicUrl ?? '').trim() || null,
workspaceRelativePath: String(result?.asset?.workspaceRelativePath ?? '').trim() || null,
};
}
} catch {
// Ignore non-JSON tool text; it cannot prove a successful image_make delivery.
}
}
return null;
}
function createToolEvidenceCollector() {
const requestNames = new Map();
const calls = new Set();
const successfulCalls = new Set();
let generateImage = { called: false, succeeded: false };
return {
observe(event) {
for (const item of messageContentItems(event)) {
if (item?.type === 'toolRequest') {
const name = String(item?.toolCall?.value?.name ?? '').trim();
if (!name) continue;
calls.add(name);
if (item.id) requestNames.set(String(item.id), name);
if (name.endsWith('generate_image')) generateImage = { called: true, succeeded: false };
continue;
}
if (item?.type !== 'toolResponse') continue;
const name = requestNames.get(String(item.id ?? '')) ?? '';
const result = item.toolResult ?? {};
const succeeded = result.status === 'success' && result?.value?.isError !== true;
if (name && succeeded) successfulCalls.add(name);
if (!name.endsWith('generate_image')) continue;
const generated = succeeded ? parseGenerateImageResult(result) : null;
generateImage = generated
? { called: true, succeeded: true, ...generated }
: { called: true, succeeded: false };
}
},
snapshot() {
return {
calls: [...calls],
successfulCalls: [...successfulCalls],
generateImage,
};
},
};
}
export async function consumeSessionEventsUntilFinish( export async function consumeSessionEventsUntilFinish(
body, body,
{ {
@@ -40,6 +116,7 @@ export async function consumeSessionEventsUntilFinish(
const reader = Readable.fromWeb(body); const reader = Readable.fromWeb(body);
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const toolEvidence = createToolEvidenceCollector();
let buffer = ''; let buffer = '';
const deadline = Date.now() + Math.max(1, Number(timeoutMs) || 1); const deadline = Date.now() + Math.max(1, Number(timeoutMs) || 1);
@@ -60,6 +137,7 @@ export async function consumeSessionEventsUntilFinish(
const event = parseSessionStreamEvent(trimmed); const event = parseSessionStreamEvent(trimmed);
if (!event) continue; if (!event) continue;
if (!eventMatchesRequest(event, requestId)) continue; if (!eventMatchesRequest(event, requestId)) continue;
toolEvidence.observe(event);
onEvent?.(event); onEvent?.(event);
if (event.type === 'Error') { if (event.type === 'Error') {
const err = new Error(String(event.error ?? 'session reply failed')); const err = new Error(String(event.error ?? 'session reply failed'));
@@ -71,6 +149,7 @@ export async function consumeSessionEventsUntilFinish(
return { return {
finishEvent: event, finishEvent: event,
tokenState: event.token_state ?? null, tokenState: event.token_state ?? null,
toolEvidence: toolEvidence.snapshot(),
}; };
} }
} }
+42
View File
@@ -33,3 +33,45 @@ test('consumeSessionEventsUntilFinish resolves on Finish', async () => {
assert.equal(result.finishEvent.type, 'Finish'); assert.equal(result.finishEvent.type, 'Finish');
assert.equal(result.tokenState.totalTokens, 12); assert.equal(result.tokenState.totalTokens, 12);
}); });
test('consumeSessionEventsUntilFinish records a successful raster generate_image result', async () => {
const toolResult = JSON.stringify({
ok: true,
jobId: 'job-1',
source: { mimeType: 'image/webp' },
asset: {
id: 'asset-1',
publicUrl: '/MindSpace/user/public/images/hero.webp',
workspaceRelativePath: 'public/images/hero.webp',
},
});
const frames = [
`data: ${JSON.stringify({
type: 'Message', request_id: 'req-1', message: {
content: [{
type: 'toolRequest', id: 'call-1',
toolCall: { value: { name: 'sandbox-fs__generate_image', arguments: { purpose: 'hero' } } },
}],
},
})}\n\n`,
`data: ${JSON.stringify({
type: 'Message', request_id: 'req-1', message: {
content: [{
type: 'toolResponse', id: 'call-1',
toolResult: { status: 'success', value: { content: [{ type: 'text', text: toolResult }], isError: false } },
}],
},
})}\n\n`,
'data: {"type":"Finish","request_id":"req-1"}\n\n',
];
const stream = new ReadableStream({
start(controller) {
for (const frame of frames) controller.enqueue(new TextEncoder().encode(frame));
controller.close();
},
});
const result = await consumeSessionEventsUntilFinish(stream, { requestId: 'req-1', timeoutMs: 5000 });
assert.equal(result.toolEvidence.generateImage.called, true);
assert.equal(result.toolEvidence.generateImage.succeeded, true);
assert.equal(result.toolEvidence.generateImage.jobId, 'job-1');
});
+11
View File
@@ -28,6 +28,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
11. **纯静态页(无 Page Data 问卷/后台)禁止调用 `private_data_bind_workspace_page`**`write_file` / `edit_file` 落盘 `public/*.html` 后,直接按下方「回复格式」交付 **MindSpace 路径**`/MindSpace/<用户ID>/public/...`),无需 bind、无需口令 11. **纯静态页(无 Page Data 问卷/后台)禁止调用 `private_data_bind_workspace_page`**`write_file` / `edit_file` 落盘 `public/*.html` 后,直接按下方「回复格式」交付 **MindSpace 路径**`/MindSpace/<用户ID>/public/...`),无需 bind、无需口令
12. **微信分享必须使用 MindSpace 路径**:该路由会在服务端注入 `og:site_name=TKMind 智趣`、封面缩略图(`*.thumbnail.png`)与微信 JS-SDK 分享桥;禁止交付 `/u/用户名/pages/...` 作为用户可见链接 12. **微信分享必须使用 MindSpace 路径**:该路由会在服务端注入 `og:site_name=TKMind 智趣`、封面缩略图(`*.thumbnail.png`)与微信 JS-SDK 分享桥;禁止交付 `/u/用户名/pages/...` 作为用户可见链接
13. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据;必须立即 `load_skill``page-data-collect`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。 13. **只要页面接收用户输入并要求以后查看、汇总、统计、修改或删除,就不是纯静态页**。禁止使用 `localStorage` / `sessionStorage` / `IndexedDB` 保存任何数据;必须立即 `load_skill``page-data-collect`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。
14. 当编排提示为“图片策略:强制生成”或用户明确要求新图片、背景图、主图、封面、插画、照片时,必须先调用 `sandbox-fs__generate_image`;不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。
详细约束以工作区内的 `.goosehints``.agents/skills/static-page-publish/SKILL.md` 为准。 详细约束以工作区内的 `.goosehints``.agents/skills/static-page-publish/SKILL.md` 为准。
@@ -41,6 +42,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
6. 若用户明确要求 Word/docx 下载,必须用 `generate_docx`sandbox-fs 工具)生成 `public/<同名>.docx`,再确认链接目标已落盘 6. 若用户明确要求 Word/docx 下载,必须用 `generate_docx`sandbox-fs 工具)生成 `public/<同名>.docx`,再确认链接目标已落盘
7. 若用户明确要求长图下载,必须用 `long-image-download` 生成 `public/<同名>.long.png`,并确认文件存在 7. 若用户明确要求长图下载,必须用 `long-image-download` 生成 `public/<同名>.long.png`,并确认文件存在
## AI 图片生成(按意图与用户选择)
- **强制生成**:页面主图优先只调用一次 `generate_image(purpose=hero)`;卡片封面和信息流缩略图必须复用这张主图裁剪派生,hero 成功后禁止再调用 `card_cover` / `feed_cover`
- 只有后台关闭 `hero`、且任务确实只需要某一种封面时,才允许改用一次 `card_cover``feed_cover`;正文配图 `inline_image` 仅在后台开启且正文确有需要时单独生成
- 成功结果必须满足 `ok=true`、包含 `jobId`,且 `source.mimeType``image/png``image/jpeg``image/webp`
- HTML 的 `<img>`、背景图及 `mindspace-cover.cover` 必须引用本次返回的 `asset.publicUrl` 或对应工作区相对路径;页面主图默认同时作为缩略图源图
- **自动**:只在用户需求确实需要新主图、背景图、正文配图或封面时调用
- **关闭**:禁止调用 `generate_image`
- 强制生成失败时必须如实报告,禁止静默改用 SVG/旧图后宣称图片生成完成
## 按需伴生下载文件 ## 按需伴生下载文件
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成 - 默认不生成伴生文件;只有用户明确要求下载附件时才生成
+31 -1
View File
@@ -1,10 +1,15 @@
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react'; import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { BrainCircuit, Database } from 'lucide-react'; import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar'; import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import { getMessageSaveActions } from '../utils/messageSave'; import { getMessageSaveActions } from '../utils/messageSave';
import { getDisplayText } from '../utils/message'; import { getDisplayText } from '../utils/message';
import {
IMAGE_GENERATION_MODE_LABELS,
nextImageGenerationMode,
type ImageGenerationMode,
} from '../utils/imageGeneration';
import { import {
downloadChatMessageDocx, downloadChatMessageDocx,
} from '../api/client'; } from '../api/client';
@@ -186,6 +191,7 @@ export function ChatPanel({
messageId?: string; messageId?: string;
forceDeepReasoning?: boolean; forceDeepReasoning?: boolean;
pgRequired?: boolean; pgRequired?: boolean;
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string; selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[]; fileAttachments?: ChatFileAttachment[];
}, },
@@ -219,6 +225,7 @@ export function ChatPanel({
const [voiceNotice, setVoiceNotice] = useState<string | null>(null); const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false); const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pgRequired, setPgRequired] = useState(false); const [pgRequired, setPgRequired] = useState(false);
const [imageGenerationMode, setImageGenerationMode] = useState<ImageGenerationMode>('auto');
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null); const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]); const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]); const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
@@ -745,6 +752,7 @@ export function ChatPanel({
messageId: outgoingMessageId, messageId: outgoingMessageId,
forceDeepReasoning, forceDeepReasoning,
pgRequired, pgRequired,
imageGenerationMode,
selectedChatSkill, selectedChatSkill,
fileAttachments: fileAttachmentsToSend, fileAttachments: fileAttachmentsToSend,
}); });
@@ -774,6 +782,7 @@ export function ChatPanel({
}, [ }, [
forceDeepReasoning, forceDeepReasoning,
pgRequired, pgRequired,
imageGenerationMode,
input, input,
onSubmit, onSubmit,
onUploadFile, onUploadFile,
@@ -1229,6 +1238,27 @@ export function ChatPanel({
<span className="chat-control-onboarding-tip" role="status">使</span> <span className="chat-control-onboarding-tip" role="status">使</span>
)} )}
</label> </label>
<button
type="button"
className={`chat-deep-reasoning-toggle chat-image-generation-toggle is-${imageGenerationMode}${imageGenerationMode === 'required' ? ' is-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={imageGenerationMode === 'auto' ? 'mixed' : imageGenerationMode === 'required'}
aria-label={`AI 配图:${IMAGE_GENERATION_MODE_LABELS[imageGenerationMode]}`}
title={`AI 配图:${IMAGE_GENERATION_MODE_LABELS[imageGenerationMode]}。点击切换为${IMAGE_GENERATION_MODE_LABELS[nextImageGenerationMode(imageGenerationMode)]}`}
onClick={() => setImageGenerationMode((current) => nextImageGenerationMode(current))}
>
{imageGenerationMode === 'required' ? (
<ImagePlus className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
) : imageGenerationMode === 'disabled' ? (
<ImageOff className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
) : (
<Image className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
)}
<span className="chat-image-generation-state" aria-hidden="true">
{imageGenerationMode === 'auto' ? 'A' : imageGenerationMode === 'required' ? '✓' : '×'}
</span>
</button>
<label <label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`} className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成" title="勾选后,本轮消息使用深度推理完成"
+1
View File
@@ -548,6 +548,7 @@ export function ChatView({
messageId: options?.messageId, messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning, forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired, pgRequired: options?.pgRequired,
imageGenerationMode: options?.imageGenerationMode,
selectedChatSkill: options?.selectedChatSkill, selectedChatSkill: options?.selectedChatSkill,
fileAttachments: options?.fileAttachments, fileAttachments: options?.fileAttachments,
}, },
+2
View File
@@ -148,6 +148,7 @@ export function SpaceChatPanel({
messageId: options?.messageId, messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning, forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired, pgRequired: options?.pgRequired,
imageGenerationMode: options?.imageGenerationMode,
fileAttachments: options?.fileAttachments, fileAttachments: options?.fileAttachments,
}, },
imageUrls, imageUrls,
@@ -160,6 +161,7 @@ export function SpaceChatPanel({
messageId: options?.messageId, messageId: options?.messageId,
forceDeepReasoning: options?.forceDeepReasoning, forceDeepReasoning: options?.forceDeepReasoning,
pgRequired: options?.pgRequired, pgRequired: options?.pgRequired,
imageGenerationMode: options?.imageGenerationMode,
fileAttachments: options?.fileAttachments, fileAttachments: options?.fileAttachments,
}, },
imageUrls, imageUrls,
+11 -1
View File
@@ -15,6 +15,7 @@ import {
} from '../api/client'; } from '../api/client';
import type { AgentRun } from '../api/client'; import type { AgentRun } from '../api/client';
import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent } from '../types'; import type { ChatState, Message, MindSpaceChatContext, PortalUser, Session, SessionEvent } from '../types';
import type { ImageGenerationMode } from '../utils/imageGeneration';
import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress'; import { buildUserAddressPrefix } from '../utils/userAddress';
import { import {
@@ -307,7 +308,7 @@ export function usePageEditSubChat({
const submit = useCallback( const submit = useCallback(
async ( async (
text: string, text: string,
context: MindSpaceChatContext & { messageId?: string }, context: MindSpaceChatContext & { messageId?: string; imageGenerationMode?: ImageGenerationMode },
imageUrls?: string[], imageUrls?: string[],
previewImageUrls?: string[], previewImageUrls?: string[],
) => { ) => {
@@ -335,6 +336,15 @@ export function usePageEditSubChat({
imageUrls: normalizedImageUrls, imageUrls: normalizedImageUrls,
previewImageUrls: normalizedPreviewImageUrls, previewImageUrls: normalizedPreviewImageUrls,
}); });
userMessage.metadata = {
...userMessage.metadata,
memindRun: {
...(userMessage.metadata?.memindRun && typeof userMessage.metadata.memindRun === 'object'
? userMessage.metadata.memindRun
: {}),
imageGenerationMode: context.imageGenerationMode ?? 'auto',
},
};
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
activeRequestId.current = requestId; activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage]; messagesRef.current = [...messagesRef.current, userMessage];
+3
View File
@@ -35,6 +35,7 @@ import {
readStoredSessionId, readStoredSessionId,
writeStoredSessionId, writeStoredSessionId,
} from '../utils/sessionStorage'; } from '../utils/sessionStorage';
import type { ImageGenerationMode } from '../utils/imageGeneration';
import type { import type {
CapabilityMap, CapabilityMap,
ChatFileAttachment, ChatFileAttachment,
@@ -1396,6 +1397,7 @@ export function useTKMindChat(
messageId?: string; messageId?: string;
forceDeepReasoning?: boolean; forceDeepReasoning?: boolean;
pgRequired?: boolean; pgRequired?: boolean;
imageGenerationMode?: ImageGenerationMode;
selectedChatSkill?: string; selectedChatSkill?: string;
fileAttachments?: ChatFileAttachment[]; fileAttachments?: ChatFileAttachment[];
}, },
@@ -1446,6 +1448,7 @@ export function useTKMindChat(
: {}), : {}),
sessionMessageCount: priorMessageCount, sessionMessageCount: priorMessageCount,
...(options?.pgRequired ? { pgRequired: true } : {}), ...(options?.pgRequired ? { pgRequired: true } : {}),
imageGenerationMode: options?.imageGenerationMode ?? 'auto',
...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}), ...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}),
}, },
}; };
+31
View File
@@ -3177,6 +3177,37 @@ body,
background: rgba(121, 183, 255, 0.14); background: rgba(121, 183, 255, 0.14);
} }
.chat-image-generation-toggle {
position: relative;
}
.chat-image-generation-toggle.is-disabled {
color: var(--color-text-tertiary);
border-color: color-mix(in srgb, var(--color-border-input) 72%, transparent);
background: color-mix(in srgb, var(--color-bg-elevated) 82%, transparent);
}
.chat-image-generation-toggle:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.chat-image-generation-state {
position: absolute;
right: 2px;
bottom: 1px;
min-width: 10px;
height: 10px;
padding: 0 1px;
border-radius: 5px;
color: currentColor;
background: var(--color-bg-elevated);
font-size: 8px;
font-weight: 800;
line-height: 10px;
text-align: center;
}
.chat-deep-reasoning-toggle:has(input:disabled) { .chat-deep-reasoning-toggle:has(input:disabled) {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.45; opacity: 0.45;
+13
View File
@@ -0,0 +1,13 @@
export type ImageGenerationMode = 'auto' | 'required' | 'disabled';
export const IMAGE_GENERATION_MODE_LABELS: Record<ImageGenerationMode, string> = {
auto: '自动识别',
required: '强制开启',
disabled: '关闭',
};
export function nextImageGenerationMode(mode: ImageGenerationMode): ImageGenerationMode {
if (mode === 'auto') return 'required';
if (mode === 'required') return 'disabled';
return 'auto';
}
+12 -1
View File
@@ -168,13 +168,14 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
1. **唯一可写目录**\`${publishDir}\` 1. **唯一可写目录**\`${publishDir}\`
2. **禁止**使用绝对路径 \`/Users/...\`\`../\` 跳出目录) 2. **禁止**使用绝对路径 \`/Users/...\`\`../\` 跳出目录)
3. **允许**在本目录内使用 \`write_file\`\`edit_file\`\`read_file\`\`list_dir\`\`generate_docx\`\`generate_long_image\`**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 OS 层拦截越界访问) 3. **允许**在本目录内使用 \`write_file\`\`edit_file\`\`read_file\`\`list_dir\`\`generate_image\`\`generate_docx\`\`generate_long_image\`**禁止**访问此目录外的路径(含 \`../\`、其它用户目录、项目根目录;系统会在 OS 层拦截越界访问)
4. **禁止** Agent扩展管理修改工作区外文件 4. **禁止** Agent扩展管理修改工作区外文件
5. 页面默认写入 \`public/\` 分区,使用相对路径如 \`public/report.html\`\`public/assets/chart.png\` 5. 页面默认写入 \`public/\` 分区,使用相对路径如 \`public/report.html\`\`public/assets/chart.png\`
6. 生成 HTML 向用户提供可访问链接格式 6. 生成 HTML 向用户提供可访问链接格式
\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/<文件名>\` \`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/<文件名>\`
写入 \`public/页面.html\` 时 URL **必须**含 \`public/\`;仅当 HTML 直接写在 workspace 根目录时才省略) 写入 \`public/页面.html\` 时 URL **必须**含 \`public/\`;仅当 HTML 直接写在 workspace 根目录时才省略)
7. **只要页面接收用户输入并要求以后查看汇总统计修改或删除就不是纯静态页**禁止使用 \`localStorage\` / \`sessionStorage\` / \`IndexedDB\` 保存任何数据;必须立即 \`load_skill\`\`page-data-collect\`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。 7. **只要页面接收用户输入并要求以后查看汇总统计修改或删除就不是纯静态页**禁止使用 \`localStorage\` / \`sessionStorage\` / \`IndexedDB\` 保存任何数据;必须立即 \`load_skill\`\`page-data-collect\`,通过 Page Data API 写入当前用户专属 PostgreSQL schema。禁止 SQLite、静态 JSON/JS 文件或内存 fallback 冒充持久化。记账、日记、打卡、清单、台账、问卷、报名及其管理页都适用。
8. 当编排提示为图片策略强制生成或用户明确要求新图片背景图主图封面插画照片时必须先调用 \`sandbox-fs__generate_image\`;不得用 SVG、CSS 绘图、旧素材或网页搜索图片冒充本次生成结果。
## 推荐工作流 ## 推荐工作流
@@ -188,6 +189,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
8. 若用户明确要求长图下载必须先 \`load_skill\`\`long-image-download\`,调用 \`generate_long_image\` 生成并确认 \`public/<页面名>.long.png\` 已存在 8. 若用户明确要求长图下载必须先 \`load_skill\`\`long-image-download\`,调用 \`generate_long_image\` 生成并确认 \`public/<页面名>.long.png\` 已存在
9. 完成后按回复格式返回可点击链接 9. 完成后按回复格式返回可点击链接
## AI 图片生成按意图与用户选择
- **强制生成**页面主图优先只调用一次 \`generate_image(purpose=hero)\`;卡片封面和信息流缩略图必须复用这张主图裁剪派生,hero 成功后禁止再调用 \`card_cover\` / \`feed_cover\`
- 只有后台关闭 \`hero\`、且任务确实只需要某一种封面时,才允许改用一次 \`card_cover\`\`feed_cover\`;正文配图 \`inline_image\` 仅在后台开启且正文确有需要时单独生成
- 成功结果必须满足 \`ok=true\`、包含 \`jobId\`,且 \`source.mimeType\`\`image/png\`\`image/jpeg\`\`image/webp\`
- HTML \`<img>\`、背景图及 \`mindspace-cover.cover\` 必须引用本次返回的 \`asset.publicUrl\` 或对应工作区相对路径;页面主图默认同时作为缩略图源图
- **自动**只在用户需求确实需要新主图背景图正文配图或封面时调用
- **关闭**禁止调用 \`generate_image\`
- 强制生成失败时必须如实报告禁止静默改用 SVG/旧图后宣称图片生成完成
## 按需伴生下载文件 ## 按需伴生下载文件
- 默认不生成伴生文件只有用户明确要求下载附件时才生成 - 默认不生成伴生文件只有用户明确要求下载附件时才生成