feat: add guarded image generation controls
This commit is contained in:
+42
-5
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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: '你好',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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/旧图后宣称图片生成完成
|
||||||
|
|
||||||
## 按需伴生下载文件
|
## 按需伴生下载文件
|
||||||
|
|
||||||
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成
|
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成
|
||||||
|
|||||||
@@ -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="勾选后,本轮消息使用深度推理完成"
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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/旧图后宣称图片生成完成
|
||||||
|
|
||||||
## 按需伴生下载文件
|
## 按需伴生下载文件
|
||||||
|
|
||||||
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成
|
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成
|
||||||
|
|||||||
Reference in New Issue
Block a user