feat: enhance canvas agent UI customization
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-23 13:25:05 +08:00
parent ccb62d6f7d
commit 3101f5966d
31 changed files with 6449 additions and 693 deletions

View File

@ -30,6 +30,7 @@ import {
} from "@/components/ui/select";
import {
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
@ -84,6 +85,16 @@ import {
Settings,
Wrench,
Bot,
Box,
Brain,
CheckCircle2,
Copy,
Crosshair,
Download,
Eraser,
ExternalLink,
FolderOpen,
Globe2,
HardDrive,
Plus,
Edit,
@ -92,29 +103,57 @@ import {
Menu,
Key,
MessageSquare,
MousePointer,
Bell,
Image as ImageIcon,
ImagePlus,
BarChart3,
Search,
Scan,
Calendar,
TrendingUp,
Users,
Clock,
Sparkles,
RefreshCw,
ArrowLeft,
ArrowUp,
ArrowDown,
Scissors,
Expand,
Maximize2,
MessageSquarePlus,
Move3D,
MoreHorizontal,
MoreVertical,
Hand,
GitBranch,
Grid3X3,
Layers,
Paperclip,
PenTool,
Redo2,
Tag,
Type,
Undo2,
Upload,
Video,
Pause,
Play,
Square,
StickyNote,
Loader2,
Check,
Paintbrush,
Wand2,
Waypoints,
Volume2,
VolumeX,
X,
ZoomIn,
ZoomOut,
Database,
type LucideIcon,
} from "lucide-react";
import {
Dialog,
@ -155,6 +194,14 @@ import {
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
import {
DEFAULT_CANVAS_TOOLBAR_ITEMS,
defaultCanvasToolbarConfig,
getDefaultCanvasToolbarItem,
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import {
defaultAgentWorkflowUiConfig,
normalizeAgentWorkflowUiConfig,
@ -733,6 +780,121 @@ type RealtimeCanvasModeIconConfig = {
type RealtimeCanvasModeIconKey = "realtimeIconUrl" | "enhanceIconUrl";
const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> = {
selectTool: MousePointer,
panTool: Hand,
saveCanvas: Save,
cloudFiles: FolderOpen,
markerPanel: Tag,
workflowConnection: GitBranch,
styleMode: Wand2,
batchProcessing: Layers,
addImage: ImagePlus,
addImageInput: ImagePlus,
addText: Type,
addScriptNode: Type,
addVideoInput: Video,
workflowNodeLibrary: Grid3X3,
realtimeCanvas: PenTool,
enhanceCanvas: Sparkles,
model3d: Box,
exportVideoCanvas: Download,
importVideoCanvas: Upload,
newCanvas: Plus,
newVideoCanvas: Plus,
undo: Undo2,
redo: Redo2,
zoomOut: ZoomOut,
resetZoom: RefreshCw,
zoomIn: ZoomIn,
downloadFolder: Download,
imageNodeMore: MoreVertical,
imageAddToChat: MessageSquarePlus,
imagePickObject: Crosshair,
imageEnhance: Sparkles,
imageRemoveBackground: Scissors,
imageBoxCutout: Scan,
imageRemoveWatermark: Eraser,
imageResize: Maximize2,
imageTextEdit: Type,
imageMultiAngle: Box,
imageGenerate3dModel: Box,
imageGenerate3dWorld: Globe2,
imageLayerSplit: Layers,
imageExtend: Expand,
imagePromptRegenerate: RefreshCw,
imageNote: StickyNote,
imageRename: Edit,
imageDownload: Download,
imageDelete: Trash2,
imageCompare: Scan,
imageConfirmCutout: CheckCircle2,
imageCancelCutout: X,
textDelete: Trash2,
uiScreenReview: Wand2,
uiScreenDelete: Trash2,
uiScreenSaveText: Check,
videoUpload: Video,
videoPlay: Play,
videoPause: Pause,
videoMute: Volume2,
videoUnmute: VolumeX,
videoMore: MoreVertical,
videoDownload: Download,
videoDelete: Trash2,
workflowDelete: Trash2,
model3dOpen3d: ExternalLink,
model3dOpenWorld: Globe2,
model3dMore: MoreVertical,
model3dOpenMesh: Box,
model3dOpenImage: ImageIcon,
model3dDownload: Download,
model3dCopy: Copy,
model3dDelete: Trash2,
world3dViewSplat: Sparkles,
world3dViewImage: ImageIcon,
world3dViewMesh: Box,
world3dFirstPerson: Waypoints,
world3dHotspot: Crosshair,
world3dMore: MoreHorizontal,
world3dOpenMesh: Box,
world3dOpenImage: ImageIcon,
world3dDownload: Download,
world3dCopy: Copy,
world3dDelete: Trash2,
realtimeReturnCanvas: ArrowLeft,
realtimeModeRealtime: Paintbrush,
realtimeModeEnhance: Wand2,
realtimeLayerPanel: Layers,
realtimeLayerSplitImport: Layers,
realtimeAddImageLayer: Upload,
realtimeDownloadPreview: Download,
realtimeSelectTool: MousePointer,
realtimeBrushTool: Paintbrush,
realtimeEraserTool: Eraser,
realtimeShapeTool: Square,
realtimeSaveProject: Save,
realtimeProjectList: FolderOpen,
realtimeNewBlank: Plus,
realtimeGenerate: Sparkles,
realtimeSendToCanvas: ArrowUp,
chatModeAgentActive: Sparkles,
chatModePresetAgent: Bot,
chatModeAgentMenu: Sparkles,
chatModeDetailActive: Grid3X3,
chatModeDetailMenu: Grid3X3,
chatModeImageActive: ImagePlus,
chatModeImageMenu: ImagePlus,
chatModeVideoActive: Video,
chatModeVideoMenu: Video,
chatAttach: Paperclip,
chatDeepThinking: Brain,
chatModelSelector: Bot,
chatPoints: Sparkles,
chatSend: ArrowUp,
chatStartGeneration: Sparkles,
};
type SiteSettings = {
siteName: string;
siteLogo: string | null;
@ -774,6 +936,7 @@ type SiteSettings = {
agentWorkflowUiConfig: AgentWorkflowUiConfig;
realtimeCanvasEnabled: boolean;
realtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig;
canvasToolbarConfig: CanvasToolbarConfig;
aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
@ -1344,6 +1507,7 @@ export function AdminSettingsClient() {
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
realtimeCanvasEnabled: true,
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
canvasToolbarConfig: defaultCanvasToolbarConfig,
aiDetailGenerationEnabled: false,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
@ -1488,6 +1652,9 @@ export function AdminSettingsClient() {
realtimeCanvasModeIconConfig: normalizeRealtimeCanvasModeIconConfig(
(siteSettingsData as any).realtimeCanvasModeIconConfig
),
canvasToolbarConfig: normalizeCanvasToolbarConfig(
(siteSettingsData as any).canvasToolbarConfig
),
aiDetailGenerationEnabled:
siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
@ -1827,7 +1994,7 @@ export function AdminSettingsClient() {
baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
apiKey: "",
modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
displayName: "官方 Kimi K2.5",
displayName: DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
isEnabled: true,
});
const [thirdPartySqlServerConfig, setThirdPartySqlServerConfig] = useState({
@ -2490,6 +2657,8 @@ export function AdminSettingsClient() {
>(null);
const [realtimeModeIconUploading, setRealtimeModeIconUploading] =
useState<RealtimeCanvasModeIconKey | null>(null);
const [canvasToolbarIconUploading, setCanvasToolbarIconUploading] =
useState<CanvasToolbarItemKey | null>(null);
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
enabled?: boolean;
title?: string;
@ -2670,6 +2839,65 @@ export function AdminSettingsClient() {
}
};
const updateCanvasToolbarItem = (
key: CanvasToolbarItemKey,
updates: Partial<{ label: string; iconUrl: string }>
) => {
setSiteSettings((prev) => {
const normalized = normalizeCanvasToolbarConfig(prev.canvasToolbarConfig);
return {
...prev,
canvasToolbarConfig: {
items: {
...normalized.items,
[key]: {
...normalized.items[key],
...updates,
},
},
},
};
});
};
const handleCanvasToolbarIconUpload = async (
key: CanvasToolbarItemKey,
file: File
) => {
const isSvg =
file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
if (!isSvg) {
toast.error("请上传 SVG 图标文件");
return;
}
if (file.size > 512 * 1024) {
toast.error("SVG 图标不能超过 512KB");
return;
}
try {
setCanvasToolbarIconUploading(key);
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "admin-svg-icon");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
updateCanvasToolbarItem(key, { iconUrl: result.url });
toast.success("工具栏图标上传成功,请保存配置后生效");
} catch (error: any) {
toast.error(error?.message || "工具栏图标上传失败");
} finally {
setCanvasToolbarIconUploading(null);
}
};
const handleAddCanvasSkillTag = () => {
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
};
@ -5471,7 +5699,8 @@ export function AdminSettingsClient() {
moonshotDetailAgentConfig.modelId.trim() ||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
const trimmedDisplayName =
moonshotDetailAgentConfig.displayName.trim() || "官方 Kimi K2.5";
moonshotDetailAgentConfig.displayName.trim() ||
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME;
const trimmedProviderName =
moonshotDetailAgentConfig.providerName.trim() ||
"Moonshot AI (Official)";
@ -5484,7 +5713,7 @@ export function AdminSettingsClient() {
}
if (!trimmedModelId) {
toast.error("请填写官方 Kimi K2.5 的 API 模型 ID");
toast.error("请填写官方 Kimi K2.6 的 API 模型 ID");
return;
}
@ -5544,7 +5773,7 @@ export function AdminSettingsClient() {
mutateMoonshotDetailAgentSetting();
mutateSiteSettings();
toast.success("Moonshot 官方 Kimi K2.5 已绑定为 AI详情 Agent 专用模型");
toast.success("Moonshot 官方 Kimi K2.6 已绑定为 AI详情 Agent 专用模型");
} catch (error) {
console.error("[Moonshot Detail Agent Config] Error:", error);
toast.error(
@ -10490,6 +10719,200 @@ export function AdminSettingsClient() {
</div>
)}
<div className="space-y-4 rounded-lg border p-3">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">
无限画布工具栏名称与 SVG 图标
</div>
<div className="text-xs text-muted-foreground">
统一管理画布底部主工具栏、画布快捷工具条、节点工具栏、实时画布工具栏、对话框底部工具栏的前端显示名称和按钮图标。上传 SVG 后会按按钮状态自动切换黑白色。
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
setSiteSettings((prev) => ({
...prev,
canvasToolbarConfig: defaultCanvasToolbarConfig,
}))
}
>
恢复全部默认
</Button>
</div>
{Array.from(
new Set(
DEFAULT_CANVAS_TOOLBAR_ITEMS.map(
(item) => item.group
)
)
).map((group) => {
const normalizedToolbarConfig =
normalizeCanvasToolbarConfig(
siteSettings.canvasToolbarConfig
);
const groupItems = DEFAULT_CANVAS_TOOLBAR_ITEMS.filter(
(item) => item.group === group
);
return (
<div className="space-y-2" key={group}>
<div className="text-xs font-semibold text-muted-foreground">
{group}
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{groupItems.map((item) => {
const itemConfig =
normalizedToolbarConfig.items[item.key];
const defaultItem =
getDefaultCanvasToolbarItem(item.key);
const FallbackIcon =
CANVAS_TOOLBAR_FALLBACK_ICONS[item.key];
const uploadId = `canvas-toolbar-icon-${item.key}`;
const isUploading =
canvasToolbarIconUploading === item.key;
return (
<div
key={item.key}
className="space-y-3 rounded-xl border bg-muted/10 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border bg-background">
{itemConfig.iconUrl ? (
<img
alt={`${itemConfig.label}图标预览`}
className="h-4 w-4 object-contain brightness-0 opacity-75 dark:invert"
src={itemConfig.iconUrl}
/>
) : (
<FallbackIcon className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 space-y-0.5">
<div className="truncate text-sm font-medium">
{itemConfig.label}
</div>
<div className="line-clamp-2 text-xs text-muted-foreground">
{item.description}
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground"
onClick={() =>
updateCanvasToolbarItem(item.key, {
label: defaultItem.label,
iconUrl: "",
})
}
>
默认
</Button>
</div>
<div className="space-y-1.5">
<Label
className="text-xs text-muted-foreground"
htmlFor={`${uploadId}-label`}
>
功能显示名称
</Label>
<Input
id={`${uploadId}-label`}
maxLength={40}
onChange={(event) =>
updateCanvasToolbarItem(item.key, {
label: event.target.value.slice(
0,
40
),
})
}
placeholder={defaultItem.label}
value={itemConfig.label}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
className="min-w-[180px] flex-1"
onChange={(event) =>
updateCanvasToolbarItem(item.key, {
iconUrl: event.target.value,
})
}
placeholder="SVG 图标 URL(可选)"
value={itemConfig.iconUrl}
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-9"
disabled={isUploading}
onClick={() =>
document
.getElementById(uploadId)
?.click()
}
>
{isUploading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
上传中
</>
) : (
"上传 SVG"
)}
</Button>
{itemConfig.iconUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 px-2 text-muted-foreground"
onClick={() =>
updateCanvasToolbarItem(item.key, {
iconUrl: "",
})
}
>
清除
</Button>
) : null}
<input
id={uploadId}
type="file"
accept=".svg,image/svg+xml"
className="hidden"
onChange={async (event) => {
const file =
event.target.files?.[0];
if (file) {
await handleCanvasToolbarIconUpload(
item.key,
file
);
}
event.currentTarget.value = "";
}}
/>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="videoCanvasEnabled">
@ -10621,7 +11044,7 @@ export function AdminSettingsClient() {
</Select>
<div className="text-xs text-muted-foreground">
用于 AI详情生成 的分镜规划与排版复审。未设置时沿用
Agent 对话默认模型;仅影响 AI详情生成,不影响普通无限画布对话框的 Agent 对话。建议选择官方 Kimi K2.5 多模态模型。
Agent 对话默认模型;仅影响 AI详情生成,不影响普通无限画布对话框的 Agent 对话。建议选择官方 Kimi K2.6 多模态模型。
</div>
</div>
@ -14767,10 +15190,10 @@ export function AdminSettingsClient() {
<CardHeader className="flex flex-col gap-4 space-y-0 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="text-xl">
Moonshot 官方 Kimi K2.5 配置
Moonshot 官方 Kimi K2.6 配置
</CardTitle>
<CardDescription>
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.5
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
</CardDescription>
</div>
@ -14845,7 +15268,7 @@ export function AdminSettingsClient() {
</div>
<div className="space-y-2">
<Label htmlFor="moonshotModelId">Kimi K2.5 API 模型 ID *</Label>
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
<Input
id="moonshotModelId"
value={moonshotDetailAgentConfig.modelId}
@ -14859,7 +15282,7 @@ export function AdminSettingsClient() {
/>
<div className="text-xs text-muted-foreground">
已按你提供的 Moonshot 官方文档默认填写
`kimi-k2.5`;保存后系统会自动创建/更新对应模型记录。
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
</div>
</div>
@ -14874,7 +15297,7 @@ export function AdminSettingsClient() {
displayName: e.target.value,
}))
}
placeholder="官方 Kimi K2.5"
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME}
/>
</div>
@ -14917,7 +15340,7 @@ export function AdminSettingsClient() {
<div className="flex justify-end">
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
<Save className="mr-2 h-4 w-4" />
保存 Moonshot Kimi K2.5 配置
保存 Moonshot Kimi K2.6 配置
</Button>
</div>
</CardContent>

View File

@ -13,7 +13,12 @@ import {
normalizeRealtimeCanvasConfigForStorage,
resolveRealtimeProviders,
} from "@/lib/realtime/config";
import { DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY } from "@/lib/ai/detail-agent-model-config";
import {
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
} from "@/lib/ai/detail-agent-model-config";
import {
normalizeProductPlanningApiConfig,
PRODUCT_PLANNING_API_CONFIG_KEY,
@ -57,8 +62,7 @@ const moonshotDetailAgentConfigSchema = z.object({
isEnabled: z.boolean().optional(),
});
const MOONSHOT_OFFICIAL_BASE_URL = "https://api.moonshot.cn/v1";
const MOONSHOT_KIMI_K25_MODEL_ID = "kimi-k2.5";
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
function isMoonshotOfficialBaseUrl(value: string) {
return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL;
@ -114,8 +118,16 @@ export async function POST(request: Request) {
: {};
const config = moonshotDetailAgentConfigSchema.parse(value || {});
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL;
const normalizedModelId = String(config.modelId || "").trim() || MOONSHOT_KIMI_K25_MODEL_ID;
const normalizedDisplayName = String(config.displayName || "").trim() || "官方 Kimi K2.5";
const rawModelId = String(config.modelId || "").trim();
const rawDisplayName = String(config.displayName || "").trim();
const normalizedModelId =
!rawModelId || rawModelId === "kimi-k2.5"
? DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID
: rawModelId;
const normalizedDisplayName =
!rawDisplayName || rawDisplayName === "官方 Kimi K2.5"
? DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME
: rawDisplayName;
const normalizedProviderName =
String(config.providerName || "").trim() || "Moonshot AI (Official)";
const normalizedApiKey =

View File

@ -12,6 +12,10 @@ import {
} from "@/lib/db/ai-model-flags";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
import {
buildDetailKimiGenerateOptions,
type DetailKimiGenerateOptions,
} from "@/lib/ai/detail-agent-kimi-profile";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
import {
@ -23,8 +27,18 @@ import {
normalizeDetailUiDslV2Document,
type DetailUiDslV2Document,
} from "@/lib/detail-ui-dsl-v2";
import {
buildDetailUiDesignSkillFlowStep,
buildDetailUiDesignSkillPrompt,
buildDetailUiDesignSkillSpec,
summarizeDetailUiDesignSkill,
type DetailUiDesignSkillSpec,
type DetailUiDesignSkillSummary,
} from "@/lib/detail-ui-design-skill";
import { buildDetailBrandStyleBoard } from "@/lib/detail-ui-style-board";
import { validateDetailUiProtocol } from "@/lib/detail-ui-validator";
import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol";
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
export const runtime = "nodejs";
export const maxDuration = 180;
@ -227,6 +241,7 @@ type DetailPlanResponse = {
version: string;
steps: string[];
};
designSkill?: DetailUiDesignSkillSummary;
qualityChecks: {
passed: boolean;
issues: string[];
@ -246,6 +261,7 @@ type DetailPlanResponse = {
uiProtocol?: DetailUiProtocol;
uiSurface?: UiSurfaceSnapshot;
uiDslV2Screens?: DetailUiDslV2Document[];
brandStyleBoard?: UiScreenDocument;
model?: {
id: string;
displayName: string;
@ -800,6 +816,17 @@ function buildDetailSkillFlow(language: "zh" | "en") {
};
}
function applyDesignSkillToFlow(
flow: DetailPlanResponse["skillFlow"],
designSkill: DetailUiDesignSkillSpec
): DetailPlanResponse["skillFlow"] {
return {
...flow,
version: "1.2",
steps: [...flow.steps, buildDetailUiDesignSkillFlowStep(designSkill)],
};
}
function clampCount(value: unknown): number {
const num = Number(value);
if (!Number.isFinite(num)) return 14;
@ -1187,6 +1214,7 @@ function buildFallbackPlan(params: {
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
designSkill: DetailUiDesignSkillSpec;
referenceImageNotes: string[];
hasModelImage: boolean;
hasBundleImages: boolean;
@ -1194,7 +1222,8 @@ function buildFallbackPlan(params: {
}): DetailPlanResponse {
const { count, language } = params;
const globalStyle = buildDefaultGlobalStyle(language);
const skillFlow = buildDetailSkillFlow(language);
const skillFlow = applyDesignSkillToFlow(buildDetailSkillFlow(language), params.designSkill);
const designSkillSummary = summarizeDetailUiDesignSkill(params.designSkill);
const fallbackScreens = buildFallbackScreens({
...params,
globalStyle,
@ -1237,6 +1266,7 @@ function buildFallbackPlan(params: {
},
globalStyle,
skillFlow,
designSkill: designSkillSummary,
qualityChecks: {
passed: qualityChecks.passed && uiValidation.passed,
issues: mergedIssues,
@ -1268,6 +1298,13 @@ function buildFallbackPlan(params: {
},
})
: undefined,
brandStyleBoard: buildDetailBrandStyleBoard({
language,
prompt: params.prompt,
brief: params.brief,
globalStyle,
designSkill: designSkillSummary,
}),
};
}
@ -1575,9 +1612,12 @@ function evaluatePlanQuality(params: {
async function runDualDirectorPlanPreflight(params: {
model: any;
isLocalMoonshot?: boolean;
kimiOptions?: DetailKimiGenerateOptions;
timeoutMs?: number;
language: "zh" | "en";
brief: DetailBrief;
coreFocus: DetailCoreFocusContext;
designSkillPrompt: string;
globalStyle: DetailPlanResponse["globalStyle"];
screens: DetailPlanScreen[];
hasModelImage: boolean;
@ -1633,6 +1673,8 @@ async function runDualDirectorPlanPreflight(params: {
params.hasBundleImages
? "Bundle refs provided: keep exactly one bundle showcase frame."
: "No bundle refs: bundle showcase must be false in all frames.",
"Apply this UI design skill as a hard visual preflight contract:",
params.designSkillPrompt,
"If not pass, rewrite globalStyle and all screens directly to a production-ready version.",
"Return strict JSON only:",
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
@ -1657,6 +1699,8 @@ async function runDualDirectorPlanPreflight(params: {
params.hasBundleImages
? "已提供搭配图:必须且仅保留1屏搭配展示页。"
: "未提供搭配图:所有分镜 bundleShowcase=false。",
"必须把以下 UI 设计技能作为硬性视觉预审契约:",
params.designSkillPrompt,
"若不通过,直接重写 globalStyle 与全量 screens 到可生产状态。",
"只返回严格JSON:",
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
@ -1680,7 +1724,8 @@ async function runDualDirectorPlanPreflight(params: {
: "你同时扮演高转化电商详情页的平面设计总监与营销总监。仅输出JSON。",
temperature: params.isLocalMoonshot ? 1 : 0.1,
maxTokens: 2000,
}, DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS);
...(params.kimiOptions || {}),
}, params.timeoutMs || DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS);
const parsed = extractJsonBlock<{
pass?: unknown;
@ -1795,6 +1840,17 @@ export async function POST(request: Request) {
);
}
const coreFocus = buildCoreFocusContext(brief, language);
const designSkill = buildDetailUiDesignSkillSpec({
language,
generationMode,
screenCount: count,
hasReferenceImages: referenceImages.length > 0,
hasModelImage,
hasBundleImages,
productName: brief.productName,
brandTone: brief.brandTone,
});
const designSkillPrompt = buildDetailUiDesignSkillPrompt(designSkill);
const fallback = buildFallbackPlan({
prompt,
count,
@ -1802,6 +1858,7 @@ export async function POST(request: Request) {
language,
brief,
coreFocus,
designSkill,
referenceImageNotes: effectiveReferenceImageNotes,
hasModelImage,
hasBundleImages,
@ -1863,6 +1920,20 @@ export async function POST(request: Request) {
type: plannerConfig!.provider.type,
baseUrl: plannerConfig!.provider.baseUrl,
};
const plannerKimiOptions = buildDetailKimiGenerateOptions({
purpose: "plan",
generationMode,
modelId: selectedModelRef.modelId,
providerBaseUrl: selectedProviderRef.baseUrl,
isLocalMoonshot: Boolean(localDetailAgentResolved),
});
const preflightKimiOptions = buildDetailKimiGenerateOptions({
purpose: "preflight",
generationMode,
modelId: selectedModelRef.modelId,
providerBaseUrl: selectedProviderRef.baseUrl,
isLocalMoonshot: Boolean(localDetailAgentResolved),
});
console.log("[Canvas Detail Plan] Planner model selected:", {
id: selectedModelRef.id,
@ -1887,6 +1958,12 @@ export async function POST(request: Request) {
defaultParams: selectedModelRef.defaultParams,
}),
hasReferenceImages: referenceImages.length > 0,
kimiDetailProfile: plannerKimiOptions.headers
? {
thinking: plannerKimiOptions.headers["x-detail-agent-kimi-thinking"],
maxTokens: plannerKimiOptions.maxTokens,
}
: null,
});
const codingModelId = localDetailAgentResolved
@ -1910,6 +1987,7 @@ export async function POST(request: Request) {
`输出语言:${language === "en" ? "English" : "中文"}`,
`生成路线:${generationMode === "agent_layout" ? "AGENT排版(画面层+程序文字层)" : "传统生图(整页出图)"}`,
"任务类型:电商详情页“AI详情生成”",
`详情 UI 设计技能:\n${designSkillPrompt}`,
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
@ -2015,12 +2093,14 @@ export async function POST(request: Request) {
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
(generationMode === "agent_layout"
? `15) 当前为 AGENT 详情模式:除 screens 外,还必须返回 dslV2Screens。每个 dslV2Screens[i] 都是可直接渲染的 detail-ui-dsl/v2 文档,必须包含 version、screenIndex、layoutVariant、region、nodes。nodes 仅允许这些 kind:panel、stack、row、badge、heading、body、feature-card、spec-table、compare、proof、cta。\n`
+ `15.1) dslV2Screens 必须执行“详情 UI 设计技能”:region 覆盖面积控制在画布45%以内;避开商品主体/Logo/人脸/动作;不得卡片套卡片;每屏最多1个主面板+1个辅助网格/证据条;所有 id 语义化且文字短句化。\n`
: "") +
`输出格式:\n` +
`{"summary":"简述","globalStyle":{"designLanguage":"一句话风格方向","typography":{"fontFamily":"字体家族","title":"主标题规范","subtitle":"副标题规范","body":"正文规范"},"layout":{"grid":"栅格","safeArea":"安全区","rhythm":"留白节奏"},"palette":"配色规范"},"screens":[{"index":1,"screenType":"B","objective":"本屏目标","title":"首屏示例:甩甩杆,自拍不手抖(6-20字)","subtitle":"一句副标题","copy":"正文文案","layoutNote":"排版说明","useModel":false,"modelReason":"是否需要人物及原因","bundleShowcase":false,"bundleReason":"是否为搭配展示页及原因","fabe":{"b":"利益","f":"特征","a":"优势","e":"证据"},"visualPrompt":"用于生图的完整提示词,必须包含790x1300与统一字体排版"}],"dslV2Screens":[{"version":"detail-ui-dsl/v2","screenIndex":1,"layoutVariant":"hero-left-panel","region":{"x":0.06,"y":0.05,"width":0.88,"height":0.46},"nodes":[{"kind":"panel","id":"hero-panel","variant":"glass","tone":"light","direction":"column","gap":18,"children":[{"kind":"badge","id":"screen-badge","icon":"sparkle","text":"核心升级","tone":"primary"},{"kind":"heading","id":"hero-title","text":"护腰椅,久坐不腰酸","level":1,"emphasis":"hero"},{"kind":"body","id":"hero-subtitle","text":"在家里加班,也能护腰/舒适","emphasis":"strong"}]}]}]}`,
temperature: 0.25,
...(localDetailAgentResolved ? { temperature: 1 } : {}),
maxTokens: 1800,
...plannerKimiOptions,
});
let text = "";
@ -2028,7 +2108,7 @@ export async function POST(request: Request) {
const runPlanner = async (visionInputs: Array<{ type: "image"; image: string }>) => {
return await generateTextWithTimeout(
buildPlannerRequest(visionInputs),
DETAIL_PLAN_MAIN_TIMEOUT_MS
plannerKimiOptions.headers ? 120_000 : DETAIL_PLAN_MAIN_TIMEOUT_MS
);
};
@ -2135,9 +2215,12 @@ export async function POST(request: Request) {
const preflight = await runDualDirectorPlanPreflight({
model,
isLocalMoonshot: Boolean(localDetailAgentResolved),
kimiOptions: preflightKimiOptions,
timeoutMs: preflightKimiOptions.headers ? 30_000 : DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS,
language,
brief,
coreFocus,
designSkillPrompt,
globalStyle: globalStyleInitial,
screens: coreFocusedScreens,
hasModelImage,
@ -2228,8 +2311,10 @@ export async function POST(request: Request) {
width: 790,
height: 1300,
},
designStyle: item!.designStyle || globalStyle,
}))
: undefined;
const designSkillSummary = summarizeDetailUiDesignSkill(designSkill);
const response: DetailPlanResponse = {
summary,
@ -2241,6 +2326,7 @@ export async function POST(request: Request) {
},
globalStyle,
skillFlow: fallback.skillFlow,
designSkill: designSkillSummary,
qualityChecks: qualityChecks.passed
? {
passed: qualityChecks.passed && uiValidation.passed,
@ -2282,6 +2368,13 @@ export async function POST(request: Request) {
})
: undefined,
uiDslV2Screens,
brandStyleBoard: buildDetailBrandStyleBoard({
language,
prompt,
brief,
globalStyle,
designSkill: designSkillSummary,
}),
model: {
id: selectedModelRef.id,
displayName: selectedModelRef.displayName,

View File

@ -12,6 +12,7 @@ import {
} from "@/lib/db/ai-model-flags";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
import { buildDetailKimiGenerateOptions } from "@/lib/ai/detail-agent-kimi-profile";
import { inlineVisionImages } from "@/lib/ai/vision-inline";
import {
buildDetailUiProtocol,
@ -287,6 +288,13 @@ export async function POST(request: Request) {
type: reviewerConfig!.provider.type,
baseUrl: reviewerConfig!.provider.baseUrl,
};
const reviewKimiOptions = buildDetailKimiGenerateOptions({
purpose: "review",
generationMode,
modelId: selectedModelRef.modelId,
providerBaseUrl: selectedProviderRef.baseUrl,
isLocalMoonshot: Boolean(localDetailAgentResolved),
});
const codingModelId = localDetailAgentResolved
? null
: getAiModelAgentCodingModelId(reviewerConfig!.model);
@ -312,6 +320,12 @@ export async function POST(request: Request) {
: fallbackAgentConfig?.model.id === reviewerConfig?.model.id
? "agent-default"
: "vision-fallback",
kimiDetailProfile: reviewKimiOptions.headers
? {
thinking: reviewKimiOptions.headers["x-detail-agent-kimi-thinking"],
maxTokens: reviewKimiOptions.maxTokens,
}
: null,
});
if (!model) {
return NextResponse.json({
@ -376,19 +390,32 @@ export async function POST(request: Request) {
const reviewImageCandidates = [imageUrl, ...referenceImages].slice(0, 6);
let effectiveReviewImages = reviewImageCandidates;
if (
!localDetailAgentResolved &&
const mustInlineForKimi = Boolean(reviewKimiOptions.headers);
const shouldInlineReviewImages =
mustInlineForKimi ||
(!localDetailAgentResolved &&
reviewerConfig &&
!isVolcanoResponsesReviewer(reviewerConfig)
) {
const { inlineDataUrls } = await inlineVisionImages(reviewImageCandidates, {
!isVolcanoResponsesReviewer(reviewerConfig));
if (shouldInlineReviewImages) {
const { inlineDataUrls, skipped } = await inlineVisionImages(reviewImageCandidates, {
maxImages: 6,
maxSide: 900,
timeoutMs: 12000,
retries: 2,
loggerPrefix: "[Canvas Detail Review]",
});
effectiveReviewImages = inlineDataUrls.length > 0 ? inlineDataUrls : reviewImageCandidates;
effectiveReviewImages =
inlineDataUrls.length > 0
? inlineDataUrls
: mustInlineForKimi
? []
: reviewImageCandidates;
if (mustInlineForKimi && skipped.length > 0 && inlineDataUrls.length === 0) {
console.warn(
"[Canvas Detail Review] Kimi K2.6 image inputs skipped after inline failed:",
skipped
);
}
}
const content: Array<{ type: "text"; text: string } | { type: "image"; image: string }> = [
@ -412,6 +439,7 @@ export async function POST(request: Request) {
: "你同时是高转化电商详情页的平面设计总监与营销总监。按严格专业标准评估,仅输出JSON。",
temperature: localDetailAgentResolved ? 1 : 0.1,
maxTokens: 800,
...reviewKimiOptions,
} as any);
text = String(result?.text || "");
} catch (error) {

View File

@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import {
applyUiScreenPatch,
buildAutoUiScreenPatch,
type UiScreenPatch,
} from "@/lib/ui-screen/patch";
import { normalizeUiScreenDocument } from "@/lib/ui-screen/schema";
import { validateUiScreenDocument } from "@/lib/ui-screen/validation";
export const runtime = "nodejs";
export const maxDuration = 60;
type UiScreenReviewRequest = {
document?: unknown;
applyAutoPatch?: boolean;
};
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = (await request.json()) as UiScreenReviewRequest;
const document = normalizeUiScreenDocument(body.document);
if (!document) {
return NextResponse.json(
{ success: false, error: "缺少有效的 ui-screen/v1 文档" },
{ status: 400 }
);
}
const validation = validateUiScreenDocument(document);
let patch: UiScreenPatch | undefined;
let patchedDocument = document;
if (body.applyAutoPatch) {
patch = buildAutoUiScreenPatch({ document, validation });
patchedDocument = applyUiScreenPatch(document, patch);
}
return NextResponse.json({
success: true,
data: {
validation,
patch,
patchedDocument: body.applyAutoPatch ? patchedDocument : undefined,
},
});
} catch (error) {
console.error("[UiScreenReview] Failed:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "UI Screen 质检失败",
},
{ status: 500 }
);
}
}

View File

@ -82,6 +82,11 @@ import {
defaultAgentPresetLibraryConfig,
normalizeAgentPresetLibraryConfig,
} from "@/lib/agents/preset-library-config";
import {
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
defaultCanvasToolbarConfig,
normalizeCanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import {
defaultOssImageMigrationConfig,
normalizeOssImageMigrationConfig,
@ -452,6 +457,7 @@ export async function GET() {
dulupayConfigSetting,
realtimeCanvasConfigSetting,
realtimeCanvasModeIconConfigSetting,
canvasToolbarConfigSetting,
layerSplitConfigSetting,
canvasAgentModeEnabledSetting,
videoCanvasEnabledSetting,
@ -562,6 +568,12 @@ export async function GET() {
.where(eq(systemSettings.key, REALTIME_CANVAS_MODE_ICON_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, CANVAS_TOOLBAR_CONFIG_SETTING_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
@ -697,6 +709,9 @@ export async function GET() {
const realtimeCanvasModeIconConfig = normalizeRealtimeCanvasModeIconConfig(
realtimeCanvasModeIconConfigSetting?.value
);
const canvasToolbarConfig = normalizeCanvasToolbarConfig(
canvasToolbarConfigSetting?.value
);
const layerSplitEnabled = normalizeLayerSplitEnabled(
layerSplitConfigSetting?.value
);
@ -805,6 +820,7 @@ export async function GET() {
defaultAgentPsdTemplateCategoryTagIndex,
realtimeCanvasEnabled: true,
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
canvasToolbarConfig: defaultCanvasToolbarConfig,
realtimeCanvasServiceEnabled: true,
layerSplitEnabled,
storageFailoverConfig,
@ -866,6 +882,7 @@ export async function GET() {
dulupayConfig,
realtimeCanvasServiceEnabled,
realtimeCanvasModeIconConfig,
canvasToolbarConfig,
layerSplitEnabled,
canvasAgentModeEnabled,
videoCanvasEnabled,
@ -1222,6 +1239,18 @@ const updateSchema = z.object({
enhanceLabel: z.string().trim().max(32).optional(),
})
.optional(),
canvasToolbarConfig: z
.object({
items: z
.record(
z.object({
label: z.string().trim().max(40).optional(),
iconUrl: z.string().trim().max(2000).optional(),
})
)
.optional(),
})
.optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
@ -1349,6 +1378,7 @@ export async function PUT(request: NextRequest) {
storageFailoverConfig,
ossImageMigrationConfig,
realtimeCanvasModeIconConfig,
canvasToolbarConfig,
canvasAgentModeEnabled,
videoCanvasEnabled,
videoCanvasSidebarEnabled,
@ -2018,6 +2048,13 @@ export async function PUT(request: NextRequest) {
);
}
if (canvasToolbarConfig !== undefined) {
await persistSystemSetting(
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
normalizeCanvasToolbarConfig(canvasToolbarConfig)
);
}
await persistHomeAnimationConfig();
return NextResponse.json({
@ -2122,6 +2159,10 @@ export async function PUT(request: NextRequest) {
realtimeCanvasModeIconConfig !== undefined
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
: defaultRealtimeCanvasModeIconConfig,
canvasToolbarConfig:
canvasToolbarConfig !== undefined
? normalizeCanvasToolbarConfig(canvasToolbarConfig)
: defaultCanvasToolbarConfig,
canvasAgentModeEnabled:
canvasAgentModeEnabled !== undefined
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)
@ -2625,6 +2666,13 @@ export async function PUT(request: NextRequest) {
);
}
if (canvasToolbarConfig !== undefined) {
await persistSystemSetting(
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
normalizeCanvasToolbarConfig(canvasToolbarConfig)
);
}
if (videoCanvasEnabled !== undefined) {
const normalized = normalizeVideoCanvasEnabled(videoCanvasEnabled);
await db
@ -2747,6 +2795,10 @@ export async function PUT(request: NextRequest) {
realtimeCanvasModeIconConfig !== undefined
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
: defaultRealtimeCanvasModeIconConfig,
canvasToolbarConfig:
canvasToolbarConfig !== undefined
? normalizeCanvasToolbarConfig(canvasToolbarConfig)
: defaultCanvasToolbarConfig,
canvasAgentModeEnabled:
canvasAgentModeEnabled !== undefined
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)

View File

@ -88,6 +88,7 @@ import {
} from "@/components/agents/AgentWorkflowPanelNew";
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { Badge } from "@/components/ui/badge";
@ -165,6 +166,14 @@ import {
type VideoCanvasMarkerValue,
type VideoCanvasSymbolMarkerId,
} from "@/lib/canvas/video-canvas-markers";
import {
getCanvasToolbarItem,
getDefaultCanvasToolbarItem,
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
type CanvasToolbarItemConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
import {
buildAgentWorkflowComposerContext,
getAgentWorkflowProgress,
@ -510,30 +519,21 @@ function normalizeRealtimeCanvasModeDisplayConfig(
};
}
function RealtimeCanvasToolbarIcon({
iconUrl,
fallback: FallbackIcon,
className,
}: {
iconUrl?: string;
fallback: LucideIcon;
className?: string;
}) {
if (iconUrl) {
return (
<img
alt=""
aria-hidden="true"
className={cn(
"object-contain brightness-0 opacity-80 transition dark:invert",
className
)}
src={iconUrl}
/>
);
}
function resolveCanvasToolbarItemWithFallback(
config: CanvasToolbarConfig,
key: CanvasToolbarItemKey,
fallback?: Partial<CanvasToolbarItemConfig>
): CanvasToolbarItemConfig {
const item = getCanvasToolbarItem(config, key);
const defaultItem = getDefaultCanvasToolbarItem(key);
return <FallbackIcon className={className} />;
return {
label:
item.label !== defaultItem.label
? item.label
: fallback?.label || item.label,
iconUrl: item.iconUrl || fallback?.iconUrl || "",
};
}
const VIDEO_CANVAS_SIDEBAR_GROUPS: VideoCanvasSidebarGroup[] = [
@ -1613,6 +1613,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
realtimeCanvasEnabled?: boolean;
realtimeCanvasServiceEnabled?: boolean;
realtimeCanvasModeIconConfig?: Partial<RealtimeCanvasModeIconConfig>;
canvasToolbarConfig?: CanvasToolbarConfig;
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
agentWorkflowBrandModelConfig?: unknown;
agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex;
@ -1671,6 +1672,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
),
[siteSettingsData?.realtimeCanvasModeIconConfig]
);
const canvasToolbarConfig = useMemo(
() => normalizeCanvasToolbarConfig(siteSettingsData?.canvasToolbarConfig),
[siteSettingsData?.canvasToolbarConfig]
);
const isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode;
const hasLoadedCurrentAgentProject =
!requestedAgentMode ||
@ -8734,6 +8739,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
>
<CanvasWorkspace
batchProcessingEnabled={isBatchProcessingEnabled}
canvasToolbarConfig={canvasToolbarConfig}
emptyStateDescription={
isVideoMode
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
@ -9470,6 +9476,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
<CanvasChatPanel
agentEnabled={isCanvasAgentModeEnabled}
canvasToolbarConfig={canvasToolbarConfig}
chatId={`canvas-${id}`}
chatScope="canvas"
onClose={() => setIsPanelOpen(false)}
@ -9570,10 +9577,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant={tool === "select" ? "secondary" : "ghost"}
>
<MousePointer className="h-4 w-4" />
<CanvasToolbarIcon
active={tool === "select"}
className="h-4 w-4"
fallback={MousePointer}
item={getCanvasToolbarItem(canvasToolbarConfig, "selectTool")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">选择 (V)</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label} (V)
</TooltipContent>
</Tooltip>
<Tooltip>
@ -9592,10 +9606,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant={tool === "pan" ? "secondary" : "ghost"}
>
<Hand className="h-4 w-4" />
<CanvasToolbarIcon
active={tool === "pan"}
className="h-4 w-4"
fallback={Hand}
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">平移 (H)</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "panTool").label} (H)
</TooltipContent>
</Tooltip>
{isVideoMode ? (
@ -9611,11 +9632,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Save}
item={getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas")}
/>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">保存到云端</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9631,10 +9658,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<FolderOpen className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={FolderOpen}
item={getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">云文件列表</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9654,10 +9687,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Tag className="h-4 w-4" />
<CanvasToolbarIcon
active={isVideoCanvasMarkerPanelOpen}
className="h-4 w-4"
fallback={Tag}
item={getCanvasToolbarItem(canvasToolbarConfig, "markerPanel")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">标记</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "markerPanel").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9678,10 +9718,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant={tool === "draw" ? "secondary" : "ghost"}
>
<GitBranch className="h-4 w-4" />
<CanvasToolbarIcon
active={tool === "draw"}
className="h-4 w-4"
fallback={GitBranch}
item={getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">工作流连线 (L)</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection").label} (L)
</TooltipContent>
</Tooltip>
) : null}
@ -9702,10 +9749,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant={isAgentMode ? "secondary" : "ghost"}
>
<Wand2 className="h-4 w-4" />
<CanvasToolbarIcon
active={isAgentMode}
className="h-4 w-4"
fallback={Wand2}
item={getCanvasToolbarItem(canvasToolbarConfig, "styleMode")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">风格化模式</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "styleMode").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9722,10 +9776,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Layers className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers}
item={getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">批量处理</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9741,11 +9801,23 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<ImagePlus className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addImageInput" : "addImage"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isVideoMode ? "添加图片输入" : "添加图片"}
{
getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addImageInput" : "addImage"
).label
}
</TooltipContent>
</Tooltip>
@ -9761,11 +9833,23 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Type className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Type}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addScriptNode" : "addText"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isVideoMode ? "添加脚本节点" : "添加文本"}
{
getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addScriptNode" : "addText"
).label
}
</TooltipContent>
</Tooltip>
@ -9778,10 +9862,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Video className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Video}
item={getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">添加视频输入</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9791,9 +9881,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
<Button
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
size="icon"
title={
getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary").label
}
variant="ghost"
>
<Grid3X3 className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Grid3X3}
item={getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary")}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -9881,15 +9978,33 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<RealtimeCanvasToolbarIcon
<CanvasToolbarIcon
className="h-4 w-4"
fallback={PenTool}
iconUrl={realtimeCanvasModeDisplayConfig.realtimeIconUrl}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"realtimeCanvas",
{
iconUrl:
realtimeCanvasModeDisplayConfig.realtimeIconUrl,
label: realtimeCanvasModeDisplayConfig.realtimeLabel,
}
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{realtimeCanvasModeDisplayConfig.realtimeLabel}
{
resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"realtimeCanvas",
{
iconUrl:
realtimeCanvasModeDisplayConfig.realtimeIconUrl,
label: realtimeCanvasModeDisplayConfig.realtimeLabel,
}
).label
}
</TooltipContent>
</Tooltip>
) : null}
@ -9910,17 +10025,44 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<RealtimeCanvasToolbarIcon
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
iconUrl={realtimeCanvasModeDisplayConfig.enhanceIconUrl}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"enhanceCanvas",
{
iconUrl:
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
label: realtimeCanvasModeDisplayConfig.enhanceLabel,
}
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{selectedImageNode
? realtimeCanvasModeDisplayConfig.enhanceLabel
: `进入${realtimeCanvasModeDisplayConfig.enhanceLabel}后上传图片`}
? resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"enhanceCanvas",
{
iconUrl:
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
label: realtimeCanvasModeDisplayConfig.enhanceLabel,
}
).label
: `进入${
resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"enhanceCanvas",
{
iconUrl:
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
label:
realtimeCanvasModeDisplayConfig.enhanceLabel,
}
).label
}后上传图片`}
</TooltipContent>
</Tooltip>
) : null}
@ -9937,10 +10079,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Box className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Box}
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">3D生成</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "model3d").label}
</TooltipContent>
</Tooltip>
{isVideoMode ? (
@ -9952,10 +10100,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Download className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Download}
item={getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">导出视频画布</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9972,11 +10126,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
{isImportingVideoCanvas ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Upload}
item={getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas")}
/>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">导入视频画布</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas").label}
</TooltipContent>
</Tooltip>
) : null}
@ -9992,10 +10152,24 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Plus className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Plus}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "newVideoCanvas" : "newCanvas"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">新建{workspaceLabel}</TooltipContent>
<TooltipContent side="top">
{
getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "newVideoCanvas" : "newCanvas"
).label
}
</TooltipContent>
</Tooltip>
</div>
</div>
@ -10034,10 +10208,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Undo2 className="h-3 w-3" />
<CanvasToolbarIcon
className="h-3 w-3"
fallback={Undo2}
item={getCanvasToolbarItem(canvasToolbarConfig, "undo")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">撤销</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "undo").label}
</TooltipContent>
</Tooltip>
<Tooltip>
@ -10052,10 +10232,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
size="icon"
variant="ghost"
>
<Redo2 className="h-3 w-3" />
<CanvasToolbarIcon
className="h-3 w-3"
fallback={Redo2}
item={getCanvasToolbarItem(canvasToolbarConfig, "redo")}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">重做</TooltipContent>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "redo").label}
</TooltipContent>
</Tooltip>
<div className="mx-1 h-4 w-px bg-border/70" />
@ -10067,11 +10253,19 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
)}
onClick={zoomOut}
size="icon"
title={getCanvasToolbarItem(canvasToolbarConfig, "zoomOut").label}
variant="ghost"
>
<ZoomOut className="h-3.5 w-3.5" />
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={ZoomOut}
item={getCanvasToolbarItem(canvasToolbarConfig, "zoomOut")}
/>
</Button>
<span className="w-10 text-center font-medium text-[10px] text-muted-foreground tabular-nums">
<span
className="w-10 text-center font-medium text-[10px] text-muted-foreground tabular-nums"
title={getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label}
>
{Math.round(viewport.zoom * 100)}%
</span>
<Button
@ -10081,9 +10275,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
)}
onClick={zoomIn}
size="icon"
title={getCanvasToolbarItem(canvasToolbarConfig, "zoomIn").label}
variant="ghost"
>
<ZoomIn className="h-3.5 w-3.5" />
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={ZoomIn}
item={getCanvasToolbarItem(canvasToolbarConfig, "zoomIn")}
/>
</Button>
</div>
</div>

View File

@ -9,19 +9,22 @@ import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
import {
buildDetailTextLayerNodes,
buildDetailTextLayerNodesFromReviewPatch,
buildDetailTextLayerNodesFromSurfaceScreen,
collectManagedDetailTextLayerNodeIds,
} from "@/lib/detail-ui-renderer";
import { buildUiScreenDocumentFromDetailUiDsl } from "@/lib/ui-screen/detail-adapter";
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
import {
applyUiSurfacePatch,
type UiSurfacePatch,
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard } from "lucide-react";
import { Send, Loader2, ImagePlus, Paperclip, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, Box, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
@ -87,6 +90,13 @@ import { ImageGenerationLoading } from "@/components/image-generation-loading";
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel";
import { DetailSurfaceInspectorPanel } from "@/components/canvas/DetailSurfaceInspectorPanel";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import {
getCanvasToolbarItem,
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
type CanvasToolbarItemKey,
} from "@/lib/canvas-toolbar-config";
// 标签悬停预览信息
interface TagPreviewInfo {
@ -187,6 +197,7 @@ interface CanvasChatPanelProps {
presentation?: "panel" | "workspace";
agentEnabled?: boolean;
workspaceVariant?: "canvas" | "video";
canvasToolbarConfig?: CanvasToolbarConfig;
}
type VideoStoryboardStreamData =
@ -1345,6 +1356,13 @@ type DetailPlanResponse = {
version?: string;
steps?: string[];
};
designSkill?: {
version?: string;
name?: string;
source?: string;
visualDirection?: string[];
reviewChecklist?: string[];
};
qualityChecks?: {
passed?: boolean;
issues?: string[];
@ -1368,11 +1386,15 @@ type DetailPlanResponse = {
uiProtocol?: DetailUiProtocol;
uiSurface?: UiSurfaceSnapshot;
uiDslV2Screens?: DetailUiDslV2Document[];
brandStyleBoard?: UiScreenDocument;
};
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
const DETAIL_TARGET_WIDTH = 790;
const DETAIL_TARGET_HEIGHT = 1300;
const DETAIL_STYLE_BOARD_WIDTH = 790;
const DETAIL_STYLE_BOARD_HEIGHT = 1080;
const DETAIL_STYLE_BOARD_SPACING = 36;
const DETAIL_REVIEW_COUNT = 3;
const DETAIL_REVIEW_REGEN_COUNT = 2;
const DETAIL_REVIEW_IMAGE_COUNT = 3;
@ -1750,6 +1772,13 @@ function buildDetailResultMarkdown(params: {
version?: string;
steps?: string[];
};
designSkill?: {
version?: string;
name?: string;
source?: string;
visualDirection?: string[];
reviewChecklist?: string[];
};
qualityChecks?: {
passed?: boolean;
issues?: string[];
@ -1758,6 +1787,7 @@ function buildDetailResultMarkdown(params: {
passed?: boolean;
issues?: string[];
};
brandStyleBoard?: UiScreenDocument | null;
globalStyle?: {
designLanguage?: string;
typography?: {
@ -1803,6 +1833,9 @@ function buildDetailResultMarkdown(params: {
if (params.summary) {
lines.push(params.summary);
}
if (params.brandStyleBoard) {
lines.push(`品牌 UI 配色板:已先生成「${params.brandStyleBoard.title || "品牌 UI 配色板"}」,后续详情页将沿用这套视觉基准。`);
}
if (params.skillFlow?.steps?.length) {
lines.push(
`技能流程:${params.skillFlow.name || "AI详情生成"} ${params.skillFlow.version ? `(v${params.skillFlow.version})` : ""}`
@ -1811,6 +1844,14 @@ function buildDetailResultMarkdown(params: {
lines.push(`${index + 1}. ${step}`);
});
}
if (params.designSkill?.name) {
lines.push(
`设计技能:${params.designSkill.name}${params.designSkill.version ? ` (${params.designSkill.version})` : ""}`
);
params.designSkill.reviewChecklist?.slice(0, 3).forEach((item) => {
lines.push(`- ${item}`);
});
}
if (params.qualityChecks) {
if (params.qualityChecks.passed) {
lines.push("质检结果:通过");
@ -1937,9 +1978,14 @@ export function CanvasChatPanel({
presentation = "panel",
agentEnabled = true,
workspaceVariant = "canvas",
canvasToolbarConfig: rawCanvasToolbarConfig,
}: CanvasChatPanelProps) {
const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video";
const propCanvasToolbarConfig = useMemo(
() => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig),
[rawCanvasToolbarConfig]
);
const effectiveAgentEnabled = agentEnabled && !isVideoWorkspace;
const { data: session } = useSession();
const [input, setInput] = useState("");
@ -2963,6 +3009,7 @@ export function CanvasChatPanel({
canvasDefaultModelId?: string | null;
canvasAutoModeDefaultModelId?: string | null;
canvasDetailDefaultModelId?: string | null;
canvasToolbarConfig?: CanvasToolbarConfig;
}>("/api/site-settings", fetcher);
const { data: canvasSkillTagsData } = useSWR<{
enabled?: boolean;
@ -2991,6 +3038,17 @@ export function CanvasChatPanel({
if (!response.ok) return null;
return response.json();
});
const canvasToolbarConfig = useMemo(
() =>
rawCanvasToolbarConfig
? propCanvasToolbarConfig
: normalizeCanvasToolbarConfig(siteSettings?.canvasToolbarConfig),
[propCanvasToolbarConfig, rawCanvasToolbarConfig, siteSettings?.canvasToolbarConfig]
);
const getToolbarItem = useCallback(
(key: CanvasToolbarItemKey) => getCanvasToolbarItem(canvasToolbarConfig, key),
[canvasToolbarConfig]
);
const { data: userImageStats } = useSWR<{ totalImages?: number } | null>(
"/api/user/image-stats",
async (url: string) => {
@ -3698,14 +3756,14 @@ export function CanvasChatPanel({
);
const activeModeLabel =
activeComposerMode === "video"
? "视频"
? getToolbarItem("chatModeVideoActive").label
: activeComposerMode === "detail"
? "AI详情"
? getToolbarItem("chatModeDetailActive").label
: activeComposerMode === "image"
? "生图"
? getToolbarItem("chatModeImageActive").label
: isPresetAgentSelection
? "预设Agent"
: "Auto Agent";
? getToolbarItem("chatModePresetAgent").label
: getToolbarItem("chatModeAgentActive").label;
const shouldMaskAgentRuntimeModelName =
activeComposerMode === "agent" || selectedModelId === "auto";
const getAgentSafeModelName = useCallback(
@ -6355,7 +6413,9 @@ export function CanvasChatPanel({
const detailPlaceholderItemWidth = DETAIL_TARGET_WIDTH;
const detailPlaceholderItemHeight = DETAIL_TARGET_HEIGHT;
const detailPlaceholderStartX = 120;
const detailPlaceholderStartY = 120;
const detailStyleBoardStartY = 120;
const detailPlaceholderStartY =
detailStyleBoardStartY + DETAIL_STYLE_BOARD_HEIGHT + DETAIL_STYLE_BOARD_SPACING;
const getDetailPlaceholderPosition = (index: number) => ({
x: detailPlaceholderStartX,
y:
@ -6547,6 +6607,41 @@ export function CanvasChatPanel({
planData.generationMode || detailGenerationMode
);
currentDetailSurfaceSnapshot = planData.uiSurface || null;
if (planData.brandStyleBoard) {
addNode({
type: "ui-screen",
position: {
x: detailPlaceholderStartX,
y: detailStyleBoardStartY,
},
size: {
width: DETAIL_STYLE_BOARD_WIDTH,
height: DETAIL_STYLE_BOARD_HEIGHT,
},
data: {
status: "completed",
document: planData.brandStyleBoard,
source: {
prompt: detailPrompt,
model: "detail-agent-brand-style-board",
},
evaluation: {
score: planData.preflight?.designDirectorScore,
issues: Array.isArray(planData.qualityChecks?.issues)
? planData.qualityChecks.issues.filter(Boolean)
: undefined,
},
metadata: {
detailBrandStyleBoard: true,
detailPlanSummary: planData.summary || "",
detailGlobalStyle: planData.globalStyle || null,
detailDesignSkill: planData.designSkill || null,
detailGenerationMode: activeDetailGenerationMode,
},
},
connections: [],
});
}
if (activeDetailGenerationMode !== detailGenerationMode) {
setDetailGenerationMode(activeDetailGenerationMode);
}
@ -6682,6 +6777,13 @@ export function CanvasChatPanel({
}
const imageNode = storeState.getNode(params.nodeId);
if (!imageNode) return;
if (imageNode.type === "ui-screen") {
replaceDetailTextLayers({
nodeId: params.nodeId,
drafts: [],
});
return;
}
const x = Number(imageNode.position?.x || 0);
const y = Number(imageNode.position?.y || 0);
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
@ -7172,18 +7274,21 @@ export function CanvasChatPanel({
const storeState = useCanvasStore.getState();
const currentNode = storeState.getNode(targetNodeId);
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
updateNode(targetNodeId, {
size: {
width: DETAIL_TARGET_WIDTH,
height: DETAIL_TARGET_HEIGHT,
},
data: {
...currentData,
src: finalGenerated.originalUrl || finalGenerated.thumbnailUrl,
status: "completed",
prompt: promptText,
title: `详情图-${index + 1}`,
metadata: {
const finalImageUrl = finalGenerated.originalUrl || finalGenerated.thumbnailUrl;
const detailUiScreenOverlay = buildDetailUiScreenOverlaySnapshot({
surface: currentDetailSurfaceSnapshot,
screenIndex: index + 1,
});
const detailUiDslDocument =
Array.isArray(planData.uiDslV2Screens) && planData.uiDslV2Screens[index]
? planData.uiDslV2Screens[index]
: detailUiScreenOverlay
? buildDetailUiDslV2({
overlay: detailUiScreenOverlay,
subjectAnalysis: detailSubjectAnalysis,
})
: null;
const completedDetailMetadata = {
...(currentData.metadata || {}),
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
@ -7201,22 +7306,66 @@ export function CanvasChatPanel({
detailBundleShowcase: bundleShowcase,
detailBundleReason: bundleReason,
detailGlobalStyle: planData.globalStyle || null,
detailDesignSkill: planData.designSkill || null,
detailGenerationMode: activeDetailGenerationMode,
detailScreenReview: screenReviewResult || null,
detailUiSurfaceId: currentDetailSurfaceSnapshot?.surfaceId || null,
detailUiSurfaceType: currentDetailSurfaceSnapshot?.surfaceType || null,
detailUiScreenOverlay: buildDetailUiScreenOverlaySnapshot({
surface: currentDetailSurfaceSnapshot,
screenIndex: index + 1,
}),
detailUiDslDocument:
Array.isArray(planData.uiDslV2Screens) && planData.uiDslV2Screens[index]
? planData.uiDslV2Screens[index]
: null,
detailUiScreenOverlay,
detailUiDslDocument,
detailSubjectAnalysis,
detailGeneratingPlaceholder: false,
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
};
const uiScreenDocument =
isAgentLayoutMode && detailUiDslDocument
? buildUiScreenDocumentFromDetailUiDsl({
dsl: detailUiDslDocument,
background: {
src: finalImageUrl,
thumbnailUrl: finalGenerated.thumbnailUrl,
alt: screen?.title || `详情图-${index + 1}`,
},
title: screen?.title || `详情图-${index + 1}`,
metadata: completedDetailMetadata,
})
: null;
updateNode(targetNodeId, {
type: uiScreenDocument ? "ui-screen" : currentNode?.type || "image",
size: {
width: DETAIL_TARGET_WIDTH,
height: DETAIL_TARGET_HEIGHT,
},
data: uiScreenDocument
? {
status: "completed",
document: uiScreenDocument,
renderArtifacts: {
screenshotUrl: finalGenerated.originalUrl,
thumbnailUrl: finalGenerated.thumbnailUrl,
},
source: {
prompt: finalGenerationPrompt,
model: modelForGeneration,
},
evaluation: {
score:
typeof screenReviewResult?.score === "number"
? screenReviewResult.score
: undefined,
issues: Array.isArray(screenReviewResult?.issues)
? screenReviewResult.issues.filter(Boolean)
: undefined,
},
metadata: completedDetailMetadata,
}
: {
...currentData,
src: finalImageUrl,
status: "completed",
prompt: promptText,
title: `详情图-${index + 1}`,
metadata: completedDetailMetadata,
},
});
createAgentLayoutTextLayers({
@ -7237,7 +7386,7 @@ export function CanvasChatPanel({
const reviewGroups: DetailReviewGroup[] = [];
const reviewFailures: string[] = [];
const detailStartX = 120;
const detailStartY = 120;
const detailStartY = detailPlaceholderStartY;
const detailSpacing = 24;
const reviewStartY =
detailStartY + prompts.length * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
@ -7356,8 +7505,10 @@ export function CanvasChatPanel({
images: generatedImages,
prompts,
skillFlow: planData.skillFlow,
designSkill: planData.designSkill,
qualityChecks: planData.qualityChecks,
uiValidation: planData.uiValidation,
brandStyleBoard: planData.brandStyleBoard,
globalStyle: planData.globalStyle,
screens: screenSpecs,
reviewGroups,
@ -8797,11 +8948,14 @@ export function CanvasChatPanel({
activeComposerMode === "agent" &&
!isPresetAgentSelection &&
Boolean(activeAgentTargetLock);
const isAgentTargetClarificationActive =
shouldShowAgentTargetLockCard && activeAgentTargetLock?.stage === "clarify";
const shouldShowAgentProgressCard =
!isDetailGenerationMode &&
activeComposerMode === "agent" &&
!isPresetAgentSelection &&
Boolean(activeAgentProgress) &&
!(activeAgentProgress?.stage === "clarify" && isAgentTargetClarificationActive) &&
(isLoading ||
activeAgentProgress?.stage === "clarify" ||
activeAgentProgress?.stage === "failed");
@ -8818,6 +8972,21 @@ export function CanvasChatPanel({
const agentTargetResolutionLabel = getAgentTargetResolutionLabel(
activeAgentTargetLock?.resolutionMode
);
const agentTargetLockPrimaryMessage =
activeAgentTargetLock?.stage === "clarify"
? activeAgentTargetLock.clarificationMessage ||
activeAgentTargetLock.reason ||
"我需要先确认目标图或执行范围,避免误处理。"
: activeAgentTargetLock?.reason ||
activeAgentTargetLock?.clarificationMessage ||
"系统正在根据当前上下文锁定目标图。";
const agentTargetLockSecondaryMessage =
activeAgentTargetLock?.stage === "clarify" &&
activeAgentTargetLock.reason &&
activeAgentTargetLock.clarificationMessage &&
activeAgentTargetLock.reason !== activeAgentTargetLock.clarificationMessage
? activeAgentTargetLock.reason
: "";
const agentProgressTitle =
activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress);
const agentProgressMessage =
@ -10958,13 +11127,19 @@ export function CanvasChatPanel({
</span>
) : null}
</div>
<div className="mt-1 text-xs leading-5 text-[#5b6472]">
{activeAgentTargetLock.reason || activeAgentTargetLock.clarificationMessage || "系统正在根据当前上下文锁定目标图。"}
<div
className={cn(
"mt-1 whitespace-pre-wrap text-xs leading-5",
activeAgentTargetLock.stage === "clarify"
? "text-[#8a5a14]"
: "text-[#5b6472]"
)}
>
{agentTargetLockPrimaryMessage}
</div>
{activeAgentTargetLock.stage === "clarify" &&
activeAgentTargetLock.clarificationMessage ? (
<div className="mt-1 text-[11px] leading-5 text-[#8a5a14]">
{activeAgentTargetLock.clarificationMessage}
{agentTargetLockSecondaryMessage ? (
<div className="mt-1 text-[11px] leading-5 text-[#6b7280]">
系统依据:{agentTargetLockSecondaryMessage}
</div>
) : null}
</div>
@ -11281,13 +11456,33 @@ export function CanvasChatPanel({
>
<span className="flex items-center">
{activeComposerMode === "video" ? (
<Video className="mr-1 h-4 w-4" />
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
fallback={Video}
item={getToolbarItem("chatModeVideoActive")}
/>
) : activeComposerMode === "detail" ? (
<LayoutGrid className="mr-1 h-4 w-4" />
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
fallback={LayoutGrid}
item={getToolbarItem("chatModeDetailActive")}
/>
) : activeComposerMode === "image" ? (
<ImagePlus className="mr-1 h-4 w-4" />
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
fallback={ImagePlus}
item={getToolbarItem("chatModeImageActive")}
/>
) : (
<Sparkles className="mr-1 h-4 w-4" />
<CanvasToolbarIcon
className="mr-1 h-4 w-4"
fallback={Sparkles}
item={getToolbarItem(
isPresetAgentSelection
? "chatModePresetAgent"
: "chatModeAgentActive"
)}
/>
)}
{activeModeLabel}
</span>
@ -11321,8 +11516,14 @@ export function CanvasChatPanel({
: "hover:bg-white/70"
)}
>
<Sparkles className="h-4 w-4" />
<span className="flex-1">Agent对话</span>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
item={getToolbarItem("chatModeAgentMenu")}
/>
<span className="flex-1">
{getToolbarItem("chatModeAgentMenu").label}
</span>
{activeComposerMode === "agent" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
) : null}
@ -11345,8 +11546,14 @@ export function CanvasChatPanel({
: "hover:bg-white/70"
)}
>
<LayoutGrid className="h-4 w-4" />
<span className="flex-1">AI详情生成</span>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={LayoutGrid}
item={getToolbarItem("chatModeDetailMenu")}
/>
<span className="flex-1">
{getToolbarItem("chatModeDetailMenu").label}
</span>
{activeComposerMode === "detail" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
) : null}
@ -11368,8 +11575,14 @@ export function CanvasChatPanel({
: "hover:bg-white/70"
)}
>
<ImagePlus className="h-4 w-4" />
<span className="flex-1">图像生成</span>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
item={getToolbarItem("chatModeImageMenu")}
/>
<span className="flex-1">
{getToolbarItem("chatModeImageMenu").label}
</span>
{activeComposerMode === "image" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
) : null}
@ -11390,8 +11603,14 @@ export function CanvasChatPanel({
: "hover:bg-white/70"
)}
>
<Video className="h-4 w-4" />
<span className="flex-1">视频生成</span>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Video}
item={getToolbarItem("chatModeVideoMenu")}
/>
<span className="flex-1">
{getToolbarItem("chatModeVideoMenu").label}
</span>
{activeComposerMode === "video" ? (
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
) : null}
@ -11412,8 +11631,13 @@ export function CanvasChatPanel({
)}
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={getToolbarItem("chatAttach").label}
>
<Paperclip className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Paperclip}
item={getToolbarItem("chatAttach")}
/>
</Button>
)}
@ -11451,7 +11675,7 @@ export function CanvasChatPanel({
<Button
variant="ghost"
size="icon"
title={`深度思考:${deepThinkingEnabled ? "开" : "关"}`}
title={`${getToolbarItem("chatDeepThinking").label}:${deepThinkingEnabled ? "开" : "关"}`}
className={cn(
"h-8 w-8 shrink-0 rounded-full border-0 bg-transparent p-0 shadow-none transition-colors",
isVideoWorkspace
@ -11465,7 +11689,11 @@ export function CanvasChatPanel({
onClick={() => setDeepThinkingEnabled((prev) => !prev)}
disabled={isSubmitting}
>
<Brain className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Brain}
item={getToolbarItem("chatDeepThinking")}
/>
</Button>
) : null}
@ -11497,6 +11725,7 @@ export function CanvasChatPanel({
? "border border-border/60 bg-background"
: "border border-[#cfd4dc] bg-white"
)}
title={getToolbarItem("chatModelSelector").label}
>
<span
className={cn(
@ -11504,7 +11733,13 @@ export function CanvasChatPanel({
isVideoWorkspace ? "bg-background" : "bg-white"
)}
>
{selectDisplayModel?.avatarUrl ? (
{getToolbarItem("chatModelSelector").iconUrl ? (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Bot}
item={getToolbarItem("chatModelSelector")}
/>
) : selectDisplayModel?.avatarUrl ? (
<img
src={buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64)}
alt={selectDisplayModel.displayName || "模型头像"}
@ -11543,11 +11778,15 @@ export function CanvasChatPanel({
)}
title={estimatedPointsCost.title}
>
<Sparkles className="h-3.5 w-3.5" />
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Sparkles}
item={getToolbarItem("chatPoints")}
/>
<span className="font-medium text-foreground">
{estimatedPointsCost.cost}
</span>
<span>积分</span>
<span>{getToolbarItem("chatPoints").label}</span>
</div>
) : null}
@ -11571,6 +11810,11 @@ export function CanvasChatPanel({
)}
onClick={handleSend}
disabled={isSubmitting || !canSend}
title={
isWorkspacePresentation || isDetailGenerationMode
? getToolbarItem("chatStartGeneration").label
: getToolbarItem("chatSend").label
}
>
{isSubmitting ? (
<>
@ -11579,13 +11823,33 @@ export function CanvasChatPanel({
</>
) : isDetailGenerationMode ? (
<>
<Sparkles className="h-4 w-4" />
{isWorkspacePresentation ? <span className="ml-2">开始生成</span> : null}
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
item={getToolbarItem("chatStartGeneration")}
/>
{isWorkspacePresentation ? (
<span className="ml-2">
{getToolbarItem("chatStartGeneration").label}
</span>
) : null}
</>
) : (
<>
<ArrowUp className="h-4 w-4" />
{isWorkspacePresentation ? <span className="ml-2">开始生成</span> : null}
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ArrowUp}
item={getToolbarItem(
isWorkspacePresentation
? "chatStartGeneration"
: "chatSend"
)}
/>
{isWorkspacePresentation ? (
<span className="ml-2">
{getToolbarItem("chatStartGeneration").label}
</span>
) : null}
</>
)}
</Button>

View File

@ -0,0 +1,44 @@
"use client";
import type { CSSProperties } from "react";
import type { LucideIcon } from "lucide-react";
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils";
type CanvasToolbarIconProps = {
item?: CanvasToolbarItemConfig | null;
iconUrl?: string | null;
fallback: LucideIcon;
className?: string;
style?: CSSProperties;
active?: boolean;
};
export function CanvasToolbarIcon({
item,
iconUrl,
fallback: FallbackIcon,
className,
style,
active = false,
}: CanvasToolbarIconProps) {
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
if (resolvedIconUrl) {
return (
<img
alt=""
aria-hidden="true"
className={cn(
"object-contain",
active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert",
className
)}
src={resolvedIconUrl}
style={style}
/>
);
}
return <FallbackIcon className={className} style={style} />;
}

View File

@ -8,9 +8,11 @@ import type { CanvasEdge, CanvasNode, Position } from "@/types/canvas";
import { ImageNode } from "./nodes/ImageNode";
import { Model3DNode } from "./nodes/Model3DNode";
import { TextNode } from "./nodes/TextNode";
import { UiScreenNode } from "./nodes/UiScreenNode";
import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode";
import { World3DNode } from "./nodes/World3DNode";
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
@ -41,6 +43,11 @@ import {
VIDEO_CANVAS_MARKER_METADATA_KEY,
VIDEO_CANVAS_PRIORITY_MARKERS,
} from "@/lib/canvas/video-canvas-markers";
import {
getCanvasToolbarItem,
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
idea: Lightbulb,
@ -182,6 +189,7 @@ interface CanvasWorkspaceProps {
workspaceLabel?: string;
emptyStateDescription?: string;
workspaceVariant?: "canvas" | "video";
canvasToolbarConfig?: CanvasToolbarConfig;
layerSplitEnabled?: boolean;
batchProcessingEnabled?: boolean;
showEmptyState?: boolean;
@ -197,12 +205,17 @@ export function CanvasWorkspace({
workspaceLabel = "画布",
emptyStateDescription = '点击顶部"添加"按钮开始创作',
workspaceVariant = "canvas",
canvasToolbarConfig: rawCanvasToolbarConfig,
layerSplitEnabled = false,
batchProcessingEnabled = true,
showEmptyState = true,
showZoomControls = true,
onBlankCanvasDoubleClick,
}: CanvasWorkspaceProps) {
const canvasToolbarConfig = useMemo(
() => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig),
[rawCanvasToolbarConfig]
);
const SNAP_THRESHOLD = 10;
const SNAP_GAP = 12;
const SELECTION_COLOR = "#27a8ff";
@ -1496,25 +1509,53 @@ export function CanvasWorkspace({
{node.type === 'image' && (
<ImageNode
batchProcessingEnabled={batchProcessingEnabled}
canvasToolbarConfig={canvasToolbarConfig}
isLayerSplitEnabled={layerSplitEnabled}
isSelected={isSelected}
node={node}
/>
)}
{node.type === 'text' && (
<TextNode node={node} isSelected={isSelected} />
<TextNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'ui-screen' && (
<UiScreenNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'video' && (
<VideoNode node={node} isSelected={isSelected} />
<VideoNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'workflow' && (
<WorkflowNode node={node} isSelected={isSelected} />
<WorkflowNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'model3d' && (
<Model3DNode node={node} isSelected={isSelected} />
<Model3DNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'world3d' && (
<World3DNode node={node} isSelected={isSelected} />
<World3DNode
canvasToolbarConfig={canvasToolbarConfig}
node={node}
isSelected={isSelected}
/>
)}
{node.type === 'message' && (
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
@ -1828,10 +1869,16 @@ export function CanvasWorkspace({
)}
onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting}
title="下载为文件夹压缩包"
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
>
<Download className="h-3.5 w-3.5" />
{isDownloadSubmitting ? "打包中..." : `下载文件夹(${selectedImageNodes.length})`}
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Download}
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
/>
{isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
</Button>
</div>
)}
@ -1854,9 +1901,13 @@ export function CanvasWorkspace({
const newZoom = Math.max(0.1, viewport.zoom - 0.1);
setViewport({ zoom: newZoom });
}}
title="缩小"
title={getCanvasToolbarItem(canvasToolbarConfig, "zoomOut").label}
>
<Minus className="h-3.5 w-3.5" />
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Minus}
item={getCanvasToolbarItem(canvasToolbarConfig, "zoomOut")}
/>
</Button>
<Button
variant="ghost"
@ -1866,7 +1917,7 @@ export function CanvasWorkspace({
isWorkflowCanvas && "rounded-full px-3"
)}
onClick={() => setViewport({ zoom: 1, x: 0, y: 0 })}
title="重置缩放"
title={getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label}
>
{Math.round(viewport.zoom * 100)}%
</Button>
@ -1878,9 +1929,13 @@ export function CanvasWorkspace({
const newZoom = Math.min(4, viewport.zoom + 0.1);
setViewport({ zoom: newZoom });
}}
title="放大"
title={getCanvasToolbarItem(canvasToolbarConfig, "zoomIn").label}
>
<Plus className="h-3.5 w-3.5" />
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Plus}
item={getCanvasToolbarItem(canvasToolbarConfig, "zoomIn")}
/>
</Button>
</div>
) : null}

View File

@ -3,6 +3,7 @@
import {
ArrowLeft,
Circle as CircleIcon,
Download,
Eraser,
FolderOpen,
Layers3,
@ -31,6 +32,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { EnhanceProWorkspace } from "@/components/canvas/EnhanceProWorkspace";
import { Button } from "@/components/ui/button";
import {
@ -55,6 +57,13 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
defaultCanvasToolbarConfig,
getCanvasToolbarItem,
getDefaultCanvasToolbarItem,
normalizeCanvasToolbarConfig,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils";
import { imageGenerationService } from "@/services/canvas";
@ -867,6 +876,8 @@ export function RealtimeCanvasDialog({
realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL,
enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL,
});
const [canvasToolbarConfig, setCanvasToolbarConfig] =
useState<CanvasToolbarConfig>(defaultCanvasToolbarConfig);
const statusBadge = useMemo(() => {
if (status === "live")
@ -911,6 +922,89 @@ export function RealtimeCanvasDialog({
previewUrl,
selectedImageLayer?.src,
]);
const resolveRealtimeModeItem = useCallback(
(
key: "realtimeModeRealtime" | "realtimeModeEnhance",
fallback: { label: string; iconUrl: string }
) => {
const item = getCanvasToolbarItem(canvasToolbarConfig, key);
const defaultItem = getDefaultCanvasToolbarItem(key);
return {
label: item.label === defaultItem.label ? fallback.label : item.label,
iconUrl: item.iconUrl || fallback.iconUrl,
};
},
[canvasToolbarConfig]
);
const realtimeModeRealtimeItem = resolveRealtimeModeItem(
"realtimeModeRealtime",
{
label: modeIconConfig.realtimeLabel,
iconUrl: modeIconConfig.realtimeIconUrl,
}
);
const realtimeModeEnhanceItem = resolveRealtimeModeItem("realtimeModeEnhance", {
label: modeIconConfig.enhanceLabel,
iconUrl: modeIconConfig.enhanceIconUrl,
});
const realtimeReturnCanvasItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeReturnCanvas"
);
const realtimeLayerPanelItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeLayerPanel"
);
const realtimeLayerSplitImportItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeLayerSplitImport"
);
const realtimeAddImageLayerItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeAddImageLayer"
);
const realtimeDownloadPreviewItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeDownloadPreview"
);
const realtimeSelectToolItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeSelectTool"
);
const realtimeBrushToolItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeBrushTool"
);
const realtimeEraserToolItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeEraserTool"
);
const realtimeShapeToolItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeShapeTool"
);
const realtimeSaveProjectItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeSaveProject"
);
const realtimeProjectListItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeProjectList"
);
const realtimeNewBlankItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeNewBlank"
);
const realtimeGenerateItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeGenerate"
);
const realtimeSendToCanvasItem = getCanvasToolbarItem(
canvasToolbarConfig,
"realtimeSendToCanvas"
);
const realtimeUndoItem = getCanvasToolbarItem(canvasToolbarConfig, "undo");
const realtimeRedoItem = getCanvasToolbarItem(canvasToolbarConfig, "redo");
useEffect(() => {
if (!open) return;
@ -926,6 +1020,7 @@ export function RealtimeCanvasDialog({
.then((data) => {
if (!data || cancelled) return;
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
setCanvasToolbarConfig(normalizeCanvasToolbarConfig(data.canvasToolbarConfig));
const iconConfig =
data.realtimeCanvasModeIconConfig &&
typeof data.realtimeCanvasModeIconConfig === "object"
@ -3174,14 +3269,18 @@ export function RealtimeCanvasDialog({
<button
className="inline-flex h-12 shrink-0 items-center gap-3 rounded-[22px] border border-white/90 bg-white/80 px-4 text-left text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)] transition hover:bg-white"
onClick={handleCloseRealtime}
title={standalone ? "返回无限画布" : "关闭并返回画布"}
title={realtimeReturnCanvasItem.label}
type="button"
>
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white shadow-[0_8px_18px_rgba(0,0,0,0.06)]">
<ArrowLeft className="h-4 w-4 text-[#111111]" />
<CanvasToolbarIcon
className="h-4 w-4 text-[#111111]"
fallback={ArrowLeft}
item={realtimeReturnCanvasItem}
/>
</span>
<span className="font-semibold text-[14px] tracking-[-0.02em]">
{standalone ? "返回无限画布" : "返回画布"}
{realtimeReturnCanvasItem.label}
</span>
</button>
@ -3196,23 +3295,14 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("realtime")}
type="button"
>
{modeIconConfig.realtimeIconUrl ? (
<img
alt=""
aria-hidden="true"
className={cn(
"h-4 w-4 object-contain transition",
workspaceMode === "realtime"
? "brightness-0 invert"
: "brightness-0 opacity-70 group-hover:opacity-100"
)}
src={modeIconConfig.realtimeIconUrl}
<CanvasToolbarIcon
active={workspaceMode === "realtime"}
className="h-4 w-4 transition group-hover:opacity-100"
fallback={Paintbrush}
item={realtimeModeRealtimeItem}
/>
) : (
<Paintbrush className="h-4 w-4" />
)}
<span className="max-w-[9rem] truncate">
{modeIconConfig.realtimeLabel}
{realtimeModeRealtimeItem.label}
</span>
</button>
<button
@ -3225,23 +3315,14 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("enhance-pro")}
type="button"
>
{modeIconConfig.enhanceIconUrl ? (
<img
alt=""
aria-hidden="true"
className={cn(
"h-4 w-4 object-contain transition",
workspaceMode === "enhance-pro"
? "brightness-0 invert"
: "brightness-0 opacity-70 group-hover:opacity-100"
)}
src={modeIconConfig.enhanceIconUrl}
<CanvasToolbarIcon
active={workspaceMode === "enhance-pro"}
className="h-4 w-4 transition group-hover:opacity-100"
fallback={Wand2}
item={realtimeModeEnhanceItem}
/>
) : (
<Wand2 className="h-4 w-4" />
)}
<span className="max-w-[9rem] truncate">
{modeIconConfig.enhanceLabel}
{realtimeModeEnhanceItem.label}
</span>
</button>
</div>
@ -3545,8 +3626,15 @@ export function RealtimeCanvasDialog({
)}
>
<div className="flex items-center justify-center border-[#e5e5e5] border-b pb-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#111111]">
<Layers3 className="h-4 w-4" />
<div
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#111111]"
title={realtimeLayerPanelItem.label}
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers3}
item={realtimeLayerPanelItem}
/>
</div>
</div>
<div className="mt-2 flex flex-1 flex-col items-center space-y-2 overflow-y-auto py-1">
@ -3609,14 +3697,18 @@ export function RealtimeCanvasDialog({
)}
disabled={isLayerSplitImporting}
onClick={() => splitUploadInputRef.current?.click()}
title="图层拆分导入"
title={realtimeLayerSplitImportItem.label}
type="button"
variant="outline"
>
{isLayerSplitImporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Layers3 className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers3}
item={realtimeLayerSplitImportItem}
/>
)}
</Button>
) : null}
@ -3627,11 +3719,15 @@ export function RealtimeCanvasDialog({
)}
disabled={isLayerSplitImporting}
onClick={() => uploadInputRef.current?.click()}
title="添加图片图层"
title={realtimeAddImageLayerItem.label}
type="button"
variant="outline"
>
<Upload className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Upload}
item={realtimeAddImageLayerItem}
/>
</Button>
</div>
</div>
@ -3671,7 +3767,13 @@ export function RealtimeCanvasDialog({
size="sm"
type="button"
>
下载图片
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Download}
item={realtimeDownloadPreviewItem}
/>
{realtimeDownloadPreviewItem.label}
</Button>
</div>
) : null}
@ -3696,14 +3798,22 @@ export function RealtimeCanvasDialog({
)}
onClick={activateSelectMode}
size="icon"
title={realtimeSelectToolItem.label}
type="button"
variant="ghost"
>
<MousePointer2 className="h-4.5 w-4.5" />
<CanvasToolbarIcon
active={brushMode === "select"}
className="h-4.5 w-4.5"
fallback={MousePointer2}
item={realtimeSelectToolItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">鼠标</div>
<div className="font-medium text-sm">
{realtimeSelectToolItem.label}
</div>
<div className="text-[#666666] text-xs">
选择图层并拖动位置
</div>
@ -3724,10 +3834,16 @@ export function RealtimeCanvasDialog({
)}
onClick={activateBrushMode}
size="icon"
title={realtimeBrushToolItem.label}
type="button"
variant="ghost"
>
<Paintbrush className="h-4.5 w-4.5" />
<CanvasToolbarIcon
active={brushMode === "brush"}
className="h-4.5 w-4.5"
fallback={Paintbrush}
item={realtimeBrushToolItem}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
@ -3790,7 +3906,9 @@ export function RealtimeCanvasDialog({
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">画笔</div>
<div className="font-medium text-sm">
{realtimeBrushToolItem.label}
</div>
<div className="text-[#666666] text-xs">
点击后直接调整颜色和粗细
</div>
@ -3811,10 +3929,16 @@ export function RealtimeCanvasDialog({
)}
onClick={activateEraserMode}
size="icon"
title={realtimeEraserToolItem.label}
type="button"
variant="ghost"
>
<Eraser className="h-4.5 w-4.5" />
<CanvasToolbarIcon
active={brushMode === "eraser"}
className="h-4.5 w-4.5"
fallback={Eraser}
item={realtimeEraserToolItem}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
@ -3846,7 +3970,9 @@ export function RealtimeCanvasDialog({
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">橡皮</div>
<div className="font-medium text-sm">
{realtimeEraserToolItem.label}
</div>
<div className="text-[#666666] text-xs">
点击后直接调整橡皮粗细
</div>
@ -3863,10 +3989,16 @@ export function RealtimeCanvasDialog({
compactToolButtonActiveClass
)}
size="icon"
title={realtimeShapeToolItem.label}
type="button"
variant="ghost"
>
<Square className="h-4.5 w-4.5" />
<CanvasToolbarIcon
active={isShapeMode(brushMode)}
className="h-4.5 w-4.5"
fallback={Square}
item={realtimeShapeToolItem}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
@ -3923,7 +4055,9 @@ export function RealtimeCanvasDialog({
</PopoverContent>
</Popover>
<TooltipContent>
<div className="font-medium text-sm">形状</div>
<div className="font-medium text-sm">
{realtimeShapeToolItem.label}
</div>
<div className="text-[#666666] text-xs">
矩形、圆形与直线
</div>
@ -3936,14 +4070,21 @@ export function RealtimeCanvasDialog({
className={compactToolButtonClass}
onClick={() => void saveCurrentProject()}
size="icon"
title={realtimeSaveProjectItem.label}
type="button"
variant="ghost"
>
<Save className="h-4.5 w-4.5" />
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Save}
item={realtimeSaveProjectItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">保存项目</div>
<div className="font-medium text-sm">
{realtimeSaveProjectItem.label}
</div>
<div className="text-[#666666] text-xs">
保存当前画布状态
</div>
@ -3958,14 +4099,21 @@ export function RealtimeCanvasDialog({
setProjectPanelOpen((openState) => !openState);
}}
size="icon"
title={realtimeProjectListItem.label}
type="button"
variant="ghost"
>
<FolderOpen className="h-4.5 w-4.5" />
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={FolderOpen}
item={realtimeProjectListItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">项目列表</div>
<div className="font-medium text-sm">
{realtimeProjectListItem.label}
</div>
<div className="text-[#666666] text-xs">
打开已保存项目
</div>
@ -3977,14 +4125,21 @@ export function RealtimeCanvasDialog({
className={compactToolButtonClass}
onClick={startBlankProject}
size="icon"
title={realtimeNewBlankItem.label}
type="button"
variant="ghost"
>
<Plus className="h-4.5 w-4.5" />
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Plus}
item={realtimeNewBlankItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">新建空白</div>
<div className="font-medium text-sm">
{realtimeNewBlankItem.label}
</div>
<div className="text-[#666666] text-xs">
清空并开始新项目
</div>
@ -3998,14 +4153,21 @@ export function RealtimeCanvasDialog({
disabled={!canUndo}
onClick={undo}
size="icon"
title={realtimeUndoItem.label}
type="button"
variant="ghost"
>
<Undo2 className="h-4.5 w-4.5" />
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Undo2}
item={realtimeUndoItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">撤销</div>
<div className="font-medium text-sm">
{realtimeUndoItem.label}
</div>
<div className="text-[#666666] text-xs">
回退上一步编辑
</div>
@ -4018,14 +4180,21 @@ export function RealtimeCanvasDialog({
disabled={!canRedo}
onClick={redo}
size="icon"
title={realtimeRedoItem.label}
type="button"
variant="ghost"
>
<Redo2 className="h-4.5 w-4.5" />
<CanvasToolbarIcon
className="h-4.5 w-4.5"
fallback={Redo2}
item={realtimeRedoItem}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium text-sm">重做</div>
<div className="font-medium text-sm">
{realtimeRedoItem.label}
</div>
<div className="text-[#666666] text-xs">
恢复已撤销操作
</div>
@ -4087,11 +4256,20 @@ export function RealtimeCanvasDialog({
disabled={status === "connecting"}
onClick={triggerGenerate}
size="sm"
title={realtimeGenerateItem.label}
>
{status === "connecting" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"生成"
<>
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
item={realtimeGenerateItem}
/>
{realtimeGenerateItem.label}
</>
)}
</Button>
<Button
@ -4102,10 +4280,16 @@ export function RealtimeCanvasDialog({
disabled={!previewUrl}
onClick={handleApplyToCanvas}
size="sm"
title={realtimeSendToCanvasItem.label}
variant="outline"
>
<Send className="mr-1.5 h-4 w-4" />
发送到画布
<CanvasToolbarIcon
active
className="mr-1.5 h-4 w-4"
fallback={Send}
item={realtimeSendToCanvasItem}
/>
{realtimeSendToCanvasItem.label}
</Button>
</div>
</div>

View File

@ -34,6 +34,7 @@ import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecution
import { AngleEditDialog } from "@/components/angle-edit-dialog";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { ImageAnnotator } from "@/components/image-annotator";
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
import { Button } from "@/components/ui/button";
@ -79,6 +80,10 @@ import {
getNodeContentScale,
getScaledNodeContentStyle,
} from "@/lib/canvas/node-content-scale";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils";
import type {
CanvasTextEditPayloadItem,
@ -398,6 +403,7 @@ interface ImageNodeProps {
isSelected: boolean;
isLayerSplitEnabled?: boolean;
batchProcessingEnabled?: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
export function ImageNode({
@ -405,8 +411,52 @@ export function ImageNode({
isSelected,
isLayerSplitEnabled = false,
batchProcessingEnabled = true,
canvasToolbarConfig,
}: ImageNodeProps) {
const data = node.data as ImageNodeData;
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
const imageEnhanceItem = getCanvasToolbarItem(canvasToolbarConfig, "imageEnhance");
const imageRemoveBackgroundItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveBackground"
);
const imageBoxCutoutItem = getCanvasToolbarItem(canvasToolbarConfig, "imageBoxCutout");
const imageRemoveWatermarkItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageRemoveWatermark"
);
const imageResizeItem = getCanvasToolbarItem(canvasToolbarConfig, "imageResize");
const imageTextEditItem = getCanvasToolbarItem(canvasToolbarConfig, "imageTextEdit");
const imageMultiAngleItem = getCanvasToolbarItem(canvasToolbarConfig, "imageMultiAngle");
const imageGenerate3dModelItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageGenerate3dModel"
);
const imageGenerate3dWorldItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageGenerate3dWorld"
);
const imageLayerSplitItem = getCanvasToolbarItem(canvasToolbarConfig, "imageLayerSplit");
const imageExtendItem = getCanvasToolbarItem(canvasToolbarConfig, "imageExtend");
const imagePromptRegenerateItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imagePromptRegenerate"
);
const imageNoteItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNote");
const imageRenameItem = getCanvasToolbarItem(canvasToolbarConfig, "imageRename");
const imageDownloadItem = getCanvasToolbarItem(canvasToolbarConfig, "imageDownload");
const imageDeleteItem = getCanvasToolbarItem(canvasToolbarConfig, "imageDelete");
const imageCompareItem = getCanvasToolbarItem(canvasToolbarConfig, "imageCompare");
const imageConfirmCutoutItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageConfirmCutout"
);
const imageCancelCutoutItem = getCanvasToolbarItem(
canvasToolbarConfig,
"imageCancelCutout"
);
const contentScale = useMemo(
() =>
getNodeContentScale(node.size, {
@ -4053,7 +4103,7 @@ export function ImageNode({
isSelected &&
!isMultiSelected && (
<button
aria-label={isCompareMode ? "对比已开启" : "开启对比"}
aria-label={imageCompareItem.label}
className="-translate-y-1/2 pointer-events-auto absolute top-1/2 z-[220] inline-flex items-center justify-center rounded-full text-white shadow-[0_8px_16px_rgba(0,0,0,0.22)] transition-all"
data-canvas-no-drag="true"
onClick={(event) => {
@ -4069,7 +4119,7 @@ export function ImageNode({
height: `${compareButtonHitSize}px`,
right: `${compareButtonInset}px`,
}}
title={isCompareMode ? "对比已开启" : "开启对比"}
title={imageCompareItem.label}
type="button"
>
<span
@ -4079,28 +4129,14 @@ export function ImageNode({
height: `${compareButtonVisualSize}px`,
}}
>
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
<CanvasToolbarIcon
fallback={Scan}
item={imageCompareItem}
style={{
width: `${compareButtonIconSize}px`,
height: `${compareButtonIconSize}px`,
}}
viewBox="0 0 24 24"
>
<rect height="16" rx="4.5" width="18" x="3" y="4" />
<path d="M12 4v16" />
<circle
cx="12"
cy="12"
fill="currentColor"
r="2.8"
stroke="none"
/>
</svg>
</span>
</button>
)}
@ -4177,55 +4213,100 @@ export function ImageNode({
<Button
className="h-8 w-8 rounded-full border border-[#dbe2ee] bg-white text-[#5d687b] shadow-[0_0_0_1px_rgba(219,226,238,0.3)] hover:bg-[#f7f9ff] hover:text-[#16181d]"
size="icon"
title={imageNodeMoreItem.label}
variant="secondary"
>
<MoreVertical className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={imageNodeMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleAction("rmbg")}>
<Scissors className="mr-2 h-4 w-4" />
抠图
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Scissors}
item={imageRemoveBackgroundItem}
/>
{imageRemoveBackgroundItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction("watermark")}>
<Eraser className="mr-2 h-4 w-4" />
去印
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction("enhance")}>
<Sparkles className="mr-2 h-4 w-4" />
增强
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Sparkles}
item={imageEnhanceItem}
/>
{imageEnhanceItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction("note")}>
<StickyNote className="mr-2 h-4 w-4" />
备注
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</DropdownMenuItem>
{isLayerSplitEnabled ? (
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
<Layers className="mr-2 h-4 w-4" />
图层拆分
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Layers}
item={imageLayerSplitItem}
/>
{imageLayerSplitItem.label}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onClick={() => handleAction("resize")}>
<Maximize2 className="mr-2 h-4 w-4" />
调尺
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Maximize2}
item={imageResizeItem}
/>
{imageResizeItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction("angle")}>
<Box className="mr-2 h-4 w-4" />
视角
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
{imageMultiAngleItem.label}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleAction("prompt-regenerate")}
>
<RefreshCw className="mr-2 h-4 w-4" />
重绘
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
<Type className="mr-2 h-4 w-4" />
改字
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Type}
item={imageTextEditItem}
/>
{imageTextEditItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction("extend")}>
<Expand className="mr-2 h-4 w-4" />
扩图
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
/>
{imageExtendItem.label}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
@ -4233,20 +4314,32 @@ export function ImageNode({
setIsEditingTitle(true);
}}
>
<Pencil className="mr-2 h-4 w-4" />
改名
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Pencil}
item={imageRenameItem}
/>
{imageRenameItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
下载
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
{imageDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={imageDeleteItem}
/>
{imageDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -4263,10 +4356,14 @@ export function ImageNode({
{data.note && !isSelected && (
<div
className="pointer-events-none absolute top-2 left-2 inline-flex items-center gap-1 rounded-full border border-[#f5e0a7] bg-white px-2 py-1 text-[11px] text-[#c48917] shadow-[0_0_0_1px_rgba(245,224,167,0.22)]"
title="该图片含有备注"
title={imageNoteItem.label}
>
<StickyNote className="h-3 w-3" />
备注
<CanvasToolbarIcon
className="h-3 w-3"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</div>
)}
{shortAgentExecutionSnapshotId ? (
@ -4395,14 +4492,20 @@ export function ImageNode({
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
disabled={isProcessing || isAddingToChat}
onClick={handleAddToChat}
title={imageAddToChatItem.label}
variant="ghost"
>
{isAddingToChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageSquarePlus className="h-4 w-4" />
<CanvasToolbarIcon
active
className="h-4 w-4 dark:invert-0"
fallback={MessageSquarePlus}
item={imageAddToChatItem}
/>
)}
<span className="whitespace-nowrap">添加对话</span>
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
</Button>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
@ -4411,102 +4514,150 @@ export function ImageNode({
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isPickerLoading || isThisNodePicking}
onClick={handleStartPicking}
title={imagePickObjectItem.label}
variant="ghost"
>
<Crosshair className="mr-1.5 h-4 w-4" />
识物
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Crosshair}
item={imagePickObjectItem}
/>
{imagePickObjectItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("enhance")}
title={imageEnhanceItem.label}
variant="ghost"
>
<span className="mr-1.5 rounded-md border border-slate-300 px-1.5 text-[11px] text-slate-600 leading-5 dark:border-slate-500 dark:text-slate-100">
HD
</span>
高清
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
item={imageEnhanceItem}
/>
{imageEnhanceItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("rmbg")}
title={imageRemoveBackgroundItem.label}
variant="ghost"
>
<Scissors className="mr-1.5 h-4 w-4" />
抠图
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Scissors}
item={imageRemoveBackgroundItem}
/>
{imageRemoveBackgroundItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("box-cutout")}
title={imageBoxCutoutItem.label}
variant="ghost"
>
<Scan className="mr-1.5 h-4 w-4" />
框选抠图
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Scan}
item={imageBoxCutoutItem}
/>
{imageBoxCutoutItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("watermark")}
title={imageRemoveWatermarkItem.label}
variant="ghost"
>
<Eraser className="mr-1.5 h-4 w-4" />
去水印
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
/>
{imageRemoveWatermarkItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("resize")}
title={imageResizeItem.label}
variant="ghost"
>
<Maximize2 className="mr-1.5 h-4 w-4" />
尺寸
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Maximize2}
item={imageResizeItem}
/>
{imageResizeItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
onClick={() => handleAction("text-edit")}
title={imageTextEditItem.label}
variant="ghost"
>
<Type className="mr-1.5 h-4 w-4" />
文字
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Type}
item={imageTextEditItem}
/>
{imageTextEditItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("angle")}
title={imageMultiAngleItem.label}
variant="ghost"
>
<Box className="mr-1.5 h-4 w-4" />
多视角
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
/>
{imageMultiAngleItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-model")}
title={imageGenerate3dModelItem.label}
variant="ghost"
>
<Box className="mr-1.5 h-4 w-4" />
3D模型
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Box}
item={imageGenerate3dModelItem}
/>
{imageGenerate3dModelItem.label}
</Button>
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-world")}
title={imageGenerate3dWorldItem.label}
variant="ghost"
>
<Globe2 className="mr-1.5 h-4 w-4" />
3D世界
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Globe2}
item={imageGenerate3dWorldItem}
/>
{imageGenerate3dWorldItem.label}
</Button>
{isLayerSplitEnabled ? (
@ -4514,10 +4665,15 @@ export function ImageNode({
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("layer-split")}
title={imageLayerSplitItem.label}
variant="ghost"
>
<Layers className="mr-1.5 h-4 w-4" />
图层拆分
<CanvasToolbarIcon
className="mr-1.5 h-4 w-4"
fallback={Layers}
item={imageLayerSplitItem}
/>
{imageLayerSplitItem.label}
</Button>
) : null}
@ -4529,9 +4685,14 @@ export function ImageNode({
className="h-10 w-10 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
size="icon"
title={imageNodeMoreItem.label}
variant="ghost"
>
<MoreHorizontal className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreHorizontal}
item={imageNodeMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -4540,26 +4701,42 @@ export function ImageNode({
side="bottom"
>
<DropdownMenuItem onClick={() => handleAction("extend")}>
<Expand className="mr-2 h-4 w-4" />
扩图
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
/>
{imageExtendItem.label}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleAction("prompt-regenerate")}
>
<RefreshCw className="mr-2 h-4 w-4" />
重绘
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={RefreshCw}
item={imagePromptRegenerateItem}
/>
{imagePromptRegenerateItem.label}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAction("note")}>
<StickyNote className="mr-2 h-4 w-4" />
备注
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
/>
{imageNoteItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:text-red-700"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
删除
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={imageDeleteItem}
/>
{imageDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -4569,9 +4746,14 @@ export function ImageNode({
disabled={isProcessing}
onClick={handleDownload}
size="icon"
title={imageDownloadItem.label}
variant="ghost"
>
<Download className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Download}
item={imageDownloadItem}
/>
</Button>
</div>
</TooltipProvider>,
@ -4605,7 +4787,11 @@ export function ImageNode({
}}
>
<div className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 rounded-xl bg-slate-100 px-3 font-medium text-[13px] text-slate-700 dark:bg-zinc-800 dark:text-slate-100">
<Scan className="h-4 w-4 text-[#27a8ff]" />
<CanvasToolbarIcon
className="h-4 w-4 text-[#27a8ff]"
fallback={Scan}
item={imageBoxCutoutItem}
/>
<span className="whitespace-nowrap">{boxCutoutHintText}</span>
</div>
@ -4615,23 +4801,36 @@ export function ImageNode({
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white disabled:bg-slate-300 disabled:text-slate-500 dark:bg-slate-100 dark:text-slate-900 dark:disabled:bg-slate-700 dark:disabled:text-slate-400 dark:hover:bg-slate-200"
disabled={isProcessing || !boxCutoutSelection}
onClick={() => void handleConfirmBoxCutout()}
title={imageConfirmCutoutItem.label}
variant="ghost"
>
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
<CanvasToolbarIcon
active
className="h-4 w-4"
fallback={Check}
item={imageConfirmCutoutItem}
/>
)}
<span className="whitespace-nowrap">确认抠图</span>
<span className="whitespace-nowrap">
{imageConfirmCutoutItem.label}
</span>
</Button>
<Button
className="h-10 w-10 shrink-0 rounded-xl text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
onClick={() => resetBoxCutoutState()}
size="icon"
title={imageCancelCutoutItem.label}
variant="ghost"
>
<X className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={X}
item={imageCancelCutoutItem}
/>
</Button>
</div>,
document.body

View File

@ -2,7 +2,12 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Trash2 } from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { useCanvasStore } from "@/store/canvas";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import type { CanvasNode, TextNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
@ -11,6 +16,7 @@ import { toast } from "sonner";
interface TextNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
const DEFAULT_TEXT_NODE_COLOR = "#111827";
@ -61,8 +67,13 @@ function getPastedImageDimensions(file: File): Promise<{
});
}
export function TextNode({ node, isSelected }: TextNodeProps) {
export function TextNode({
node,
isSelected,
canvasToolbarConfig,
}: TextNodeProps) {
const data = node.data as TextNodeData;
const textDeleteItem = getCanvasToolbarItem(canvasToolbarConfig, "textDelete");
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(data.content);
const [isUploadingPastedImage, setIsUploadingPastedImage] = useState(false);
@ -288,10 +299,14 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
? "border-slate-200 bg-white/95 shadow-sm hover:border-destructive/30 hover:bg-red-50"
: "border-slate-200 bg-white hover:border-destructive/30 hover:bg-red-50"
)}
aria-label="删除文本模块"
title="删除文本模块"
aria-label={textDeleteItem.label}
title={textDeleteItem.label}
>
<Trash2 className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Trash2}
item={textDeleteItem}
/>
</button>
<div className="absolute bottom-1 right-2 text-[10px] text-muted-foreground">

View File

@ -0,0 +1,409 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
Check,
CheckCircle2,
Layers3,
Loader2,
MonitorSmartphone,
Sparkles,
Trash2,
Wand2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { UiScreenRenderer } from "@/components/canvas/ui/UiScreenRenderer";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { normalizeUiScreenDocument, type UiScreenLayer } from "@/lib/ui-screen/schema";
import type { UiScreenPatch } from "@/lib/ui-screen/patch";
import type { UiScreenValidationResult } from "@/lib/ui-screen/validation";
import { cn } from "@/lib/utils";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, UiScreenNodeData } from "@/types/canvas";
interface UiScreenNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
const STATUS_LABELS = {
pending: "待生成",
generating: "渲染中",
completed: "可编辑 UI",
failed: "失败",
} as const;
const STATUS_CLASS_MAP = {
pending: "border-slate-200 bg-slate-50 text-slate-600",
generating: "border-blue-200 bg-blue-50 text-blue-700",
completed: "border-emerald-200 bg-emerald-50 text-emerald-700",
failed: "border-rose-200 bg-rose-50 text-rose-700",
} as const;
type ValidationSummary = {
score: number;
issueCount: number;
patchedCount: number;
};
type UiScreenReviewResponse = {
success?: boolean;
error?: string;
data?: {
validation?: UiScreenValidationResult;
patch?: UiScreenPatch;
patchedDocument?: unknown;
};
};
function findLayerById(layers: UiScreenLayer[], id: string | null): UiScreenLayer | null {
if (!id) return null;
for (const layer of layers) {
if (layer.id === id) return layer;
const child = layer.children ? findLayerById(layer.children, id) : null;
if (child) return child;
}
return null;
}
function getEditableLayerText(layer: UiScreenLayer | null) {
if (!layer) return null;
if (layer.kind === "text") return layer.text || "";
if (layer.kind === "button" || layer.kind === "badge") return layer.label || layer.text || "";
return null;
}
function updateLayerText(
layers: UiScreenLayer[],
targetLayerId: string,
nextText: string
): UiScreenLayer[] {
return layers.map((layer) => {
const children = layer.children
? updateLayerText(layer.children, targetLayerId, nextText)
: undefined;
if (layer.id !== targetLayerId) {
return children ? { ...layer, children } : layer;
}
if (layer.kind === "text") {
return { ...layer, children, text: nextText };
}
if (layer.kind === "button" || layer.kind === "badge") {
return { ...layer, children, label: nextText };
}
return children ? { ...layer, children } : layer;
});
}
export function UiScreenNode({
node,
isSelected,
canvasToolbarConfig,
}: UiScreenNodeProps) {
const data = node.data as UiScreenNodeData;
const uiScreenReviewItem = getCanvasToolbarItem(
canvasToolbarConfig,
"uiScreenReview"
);
const uiScreenDeleteItem = getCanvasToolbarItem(
canvasToolbarConfig,
"uiScreenDelete"
);
const uiScreenSaveTextItem = getCanvasToolbarItem(
canvasToolbarConfig,
"uiScreenSaveText"
);
const deleteNode = useCanvasStore((state) => state.deleteNode);
const updateNode = useCanvasStore((state) => state.updateNode);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [draftText, setDraftText] = useState("");
const [isReviewing, setIsReviewing] = useState(false);
const [validationSummary, setValidationSummary] = useState<ValidationSummary | null>(null);
const document = useMemo(
() => normalizeUiScreenDocument(data.document),
[data.document]
);
const selectedLayer = useMemo(
() => (document ? findLayerById(document.layers, selectedLayerId) : null),
[document, selectedLayerId]
);
const editableText = getEditableLayerText(selectedLayer);
useEffect(() => {
setDraftText(editableText ?? "");
}, [editableText, selectedLayerId]);
const status = data.status || "completed";
const statusLabel = STATUS_LABELS[status] || STATUS_LABELS.completed;
const statusClassName = STATUS_CLASS_MAP[status] || STATUS_CLASS_MAP.completed;
const currentScore = validationSummary?.score ?? data.evaluation?.score;
const currentIssueCount =
validationSummary?.issueCount ?? data.evaluation?.issues?.length ?? null;
const scoreClassName =
typeof currentScore === "number" && currentScore >= 82
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700";
const handleSaveLayerText = () => {
if (!selectedLayer || editableText === null) return;
const nextDocument = {
...data.document,
layers: updateLayerText(data.document.layers, selectedLayer.id, draftText),
};
updateNode(node.id, {
data: {
...data,
document: nextDocument,
},
});
};
const handleReviewAndPatch = async () => {
if (!document || isReviewing) return;
setIsReviewing(true);
try {
const response = await fetch("/api/canvas/ui-screen/review", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
document,
applyAutoPatch: true,
}),
});
const payload = (await response.json()) as UiScreenReviewResponse;
if (!response.ok || !payload.success || !payload.data?.validation) {
throw new Error(payload.error || "UI Screen 质检失败");
}
const validation = payload.data.validation;
const patch = payload.data.patch;
const patchedDocument =
normalizeUiScreenDocument(payload.data.patchedDocument) || document;
const patchedCount =
patch?.operations.filter((operation) => operation.op !== "set_validation").length || 0;
updateNode(node.id, {
data: {
...data,
document: patchedDocument,
evaluation: {
score: validation.score,
issues: validation.issues.map((issue) => issue.message).slice(0, 12),
reviewedAt: new Date().toISOString(),
},
metadata: {
...(data.metadata || {}),
uiScreenValidation: validation,
uiScreenPatch: patch,
},
},
});
setValidationSummary({
score: validation.score,
issueCount: validation.issues.length,
patchedCount,
});
if (patchedCount > 0) {
toast.success(`已自动修复 ${patchedCount} 项 UI 问题`);
} else if (validation.passed) {
toast.success("UI Screen 质检通过");
} else {
toast.message(`发现 ${validation.issues.length} 项需要人工确认的问题`);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "UI Screen 质检失败");
} finally {
setIsReviewing(false);
}
};
return (
<div
className={cn(
"relative h-full w-full overflow-visible rounded-[14px] border bg-white shadow-[0_18px_48px_rgba(15,23,42,0.16)]",
isSelected ? "border-blue-400 ring-2 ring-blue-500/20" : "border-slate-200"
)}
>
{document ? (
<UiScreenRenderer
document={document}
interactive={isSelected}
selectedLayerId={selectedLayerId}
onSelectLayer={setSelectedLayerId}
className="rounded-[13px]"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 rounded-[13px] bg-slate-50 p-6 text-center text-slate-500">
<AlertTriangle className="h-6 w-6 text-amber-500" />
<div>
<div className="text-sm font-semibold text-slate-700">UI 文档不可渲染</div>
<div className="mt-1 text-xs">缺少有效的 ui-screen/v1 layers</div>
</div>
</div>
)}
{isSelected ? (
<>
<div
className="absolute left-3 top-3 z-30 flex items-center gap-2"
data-canvas-no-drag="true"
onPointerDown={(event) => event.stopPropagation()}
>
<Badge className={cn("gap-1.5 border px-2 py-1 text-[11px]", statusClassName)}>
<MonitorSmartphone className="h-3 w-3" />
{statusLabel}
</Badge>
{document?.title ? (
<Badge variant="secondary" className="max-w-[220px] truncate px-2 py-1 text-[11px]">
{document.title}
</Badge>
) : null}
{typeof currentScore === "number" ? (
<Badge className={cn("gap-1.5 border px-2 py-1 text-[11px]", scoreClassName)}>
{currentScore >= 82 ? (
<CheckCircle2 className="h-3 w-3" />
) : (
<Sparkles className="h-3 w-3" />
)}
UI {currentScore}
{currentIssueCount ? ` · ${currentIssueCount}` : ""}
</Badge>
) : null}
</div>
<div
className="absolute right-3 top-3 z-30 flex items-center gap-2"
data-canvas-no-drag="true"
onPointerDown={(event) => event.stopPropagation()}
>
<Button
aria-label={uiScreenReviewItem.label}
className="h-8 w-8 rounded-full border-slate-200 bg-white text-slate-600 shadow-sm hover:border-blue-200 hover:bg-blue-50 hover:text-blue-700"
disabled={!document || isReviewing}
onClick={(event) => {
event.stopPropagation();
void handleReviewAndPatch();
}}
size="icon"
title={uiScreenReviewItem.label}
type="button"
variant="outline"
>
{isReviewing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Wand2}
item={uiScreenReviewItem}
/>
)}
</Button>
<Button
aria-label={uiScreenDeleteItem.label}
className="h-8 w-8 rounded-full border-slate-200 bg-white text-slate-500 shadow-sm hover:border-rose-200 hover:bg-rose-50 hover:text-rose-600"
onClick={(event) => {
event.stopPropagation();
deleteNode(node.id);
}}
size="icon"
title={uiScreenDeleteItem.label}
type="button"
variant="outline"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Trash2}
item={uiScreenDeleteItem}
/>
</Button>
</div>
{selectedLayer ? (
<div
className="absolute bottom-3 left-3 z-30 max-w-[70%] rounded-xl border border-slate-200 bg-white/95 px-3 py-2 text-xs text-slate-600 shadow-[0_12px_28px_rgba(15,23,42,0.14)] backdrop-blur"
data-canvas-no-drag="true"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-2 font-semibold text-slate-900">
<Layers3 className="h-3.5 w-3.5 text-blue-600" />
<span className="truncate">{selectedLayer.name || selectedLayer.id}</span>
</div>
<div className="mt-1 text-[11px] text-slate-500">
{selectedLayer.kind}
{selectedLayer.bounds
? ` · ${Math.round(selectedLayer.bounds.width)}x${Math.round(selectedLayer.bounds.height)}`
: ""}
</div>
{editableText !== null ? (
<div className="mt-2 flex items-center gap-2">
<input
className="h-8 min-w-0 flex-1 rounded-lg border border-slate-200 bg-white px-2 text-[12px] text-slate-800 outline-none transition focus:border-blue-300 focus:ring-2 focus:ring-blue-100"
data-canvas-no-drag="true"
onChange={(event) => setDraftText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleSaveLayerText();
}
}}
value={draftText}
/>
<Button
className="h-8 rounded-lg px-2 text-[12px]"
onClick={(event) => {
event.stopPropagation();
handleSaveLayerText();
}}
size="sm"
title={uiScreenSaveTextItem.label}
type="button"
>
<CanvasToolbarIcon
className="mr-1 h-3.5 w-3.5"
fallback={Check}
item={uiScreenSaveTextItem}
/>
{uiScreenSaveTextItem.label}
</Button>
</div>
) : null}
{validationSummary ? (
<div className="mt-2 rounded-lg bg-slate-50 px-2 py-1.5 text-[11px] text-slate-500">
质检 {validationSummary.score} · 问题 {validationSummary.issueCount} · 自动修复{" "}
{validationSummary.patchedCount}
</div>
) : null}
</div>
) : (
<div
className="absolute bottom-3 left-3 z-30 rounded-full border border-slate-200 bg-white/95 px-3 py-1.5 text-[11px] font-medium text-slate-500 shadow-sm backdrop-blur"
data-canvas-no-drag="true"
onPointerDown={(event) => event.stopPropagation()}
>
点击内部图层可选中
</div>
)}
</>
) : null}
</div>
);
}

View File

@ -2,6 +2,7 @@
import React, { useMemo, useRef, useState } from "react";
import { Video, Loader2, AlertCircle, MoreVertical, Download, Trash2, Play, Pause, Volume2, VolumeX } from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -17,6 +18,10 @@ import {
getNodeContentScale,
getScaledNodeContentStyle,
} from "@/lib/canvas/node-content-scale";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { useCanvasStore } from "@/store/canvas";
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
import { cn } from "@/lib/utils";
@ -24,10 +29,23 @@ import { cn } from "@/lib/utils";
interface VideoNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
export function VideoNode({ node, isSelected }: VideoNodeProps) {
export function VideoNode({
node,
isSelected,
canvasToolbarConfig,
}: VideoNodeProps) {
const data = node.data as VideoNodeData;
const videoUploadItem = getCanvasToolbarItem(canvasToolbarConfig, "videoUpload");
const videoPlayItem = getCanvasToolbarItem(canvasToolbarConfig, "videoPlay");
const videoPauseItem = getCanvasToolbarItem(canvasToolbarConfig, "videoPause");
const videoMuteItem = getCanvasToolbarItem(canvasToolbarConfig, "videoMute");
const videoUnmuteItem = getCanvasToolbarItem(canvasToolbarConfig, "videoUnmute");
const videoMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "videoMore");
const videoDownloadItem = getCanvasToolbarItem(canvasToolbarConfig, "videoDownload");
const videoDeleteItem = getCanvasToolbarItem(canvasToolbarConfig, "videoDelete");
const contentScale = useMemo(
() =>
getNodeContentScale(node.size, {
@ -51,6 +69,8 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
const [isHovered, setIsHovered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const videoPlaybackItem = isPlaying ? videoPauseItem : videoPlayItem;
const videoAudioItem = isMuted ? videoUnmuteItem : videoMuteItem;
const [progress, setProgress] = useState(0);
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
@ -185,11 +205,20 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
size="icon"
className="absolute left-1/2 top-1/2 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-white text-[#16181d] shadow-[0_12px_26px_rgba(0,0,0,0.28)] hover:bg-white"
onClick={togglePlay}
title={videoPlaybackItem.label}
>
{isPlaying ? (
<Pause className="h-6 w-6" />
<CanvasToolbarIcon
className="h-6 w-6"
fallback={Pause}
item={videoPauseItem}
/>
) : (
<Play className="h-6 w-6 ml-1" />
<CanvasToolbarIcon
className="ml-1 h-6 w-6"
fallback={Play}
item={videoPlayItem}
/>
)}
</Button>
@ -212,11 +241,20 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
size="icon"
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
onClick={togglePlay}
title={videoPlaybackItem.label}
>
{isPlaying ? (
<Pause className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Pause}
item={videoPauseItem}
/>
) : (
<Play className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Play}
item={videoPlayItem}
/>
)}
</Button>
<Button
@ -224,11 +262,20 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
size="icon"
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
onClick={toggleMute}
title={videoAudioItem.label}
>
{isMuted ? (
<VolumeX className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={VolumeX}
item={videoUnmuteItem}
/>
) : (
<Volume2 className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Volume2}
item={videoMuteItem}
/>
)}
</Button>
</div>
@ -257,9 +304,15 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
) : (
<label className="flex h-full w-full cursor-pointer flex-col items-center justify-center bg-[#f7f9fc] px-6 text-center transition-colors select-none hover:bg-[#f2f5fb] dark:bg-zinc-900 dark:hover:bg-zinc-800">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#dde5f4] bg-white text-[#5b76fe] shadow-[0_0_0_1px_rgba(219,226,238,0.22)]">
<Video className="h-5 w-5" />
<CanvasToolbarIcon
className="h-5 w-5"
fallback={Video}
item={videoUploadItem}
/>
</span>
<span className="text-sm font-medium text-[#3f4b63] select-none">
{videoUploadItem.label}
</span>
<span className="text-sm font-medium text-[#3f4b63] select-none">上传视频</span>
<span className="mt-1 text-xs text-[#7a8395] select-none">添加视频素材、参考视频或结果片段</span>
<input
type="file"
@ -279,22 +332,35 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
variant="secondary"
size="icon"
className="h-8 w-8 rounded-full border border-[#dbe2ee] bg-white text-[#5d687b] shadow-[0_0_0_1px_rgba(219,226,238,0.3)] hover:bg-[#f7f9ff] hover:text-[#16181d]"
title={videoMoreItem.label}
>
<MoreVertical className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={MoreVertical}
item={videoMoreItem}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
下载
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Download}
item={videoDownloadItem}
/>
{videoDownloadItem.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => deleteNode(node.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
删除
<CanvasToolbarIcon
className="mr-2 h-4 w-4"
fallback={Trash2}
item={videoDeleteItem}
/>
{videoDeleteItem.label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -11,7 +11,12 @@ import {
Trash2,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { useCanvasStore } from "@/store/canvas";
import {
getCanvasToolbarItem,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils";
import {
getNodeContentScale,
@ -26,6 +31,7 @@ import type { CanvasNode, WorkflowNodeData } from "@/types/canvas";
interface WorkflowNodeProps {
node: CanvasNode;
isSelected: boolean;
canvasToolbarConfig?: CanvasToolbarConfig;
}
function toAlphaColor(color: string | undefined, alpha: number) {
@ -131,9 +137,17 @@ const STATUS_CLASS_MAP = {
"border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/40 dark:bg-rose-950/40 dark:text-rose-200",
} as const;
export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
export function WorkflowNode({
node,
isSelected,
canvasToolbarConfig,
}: WorkflowNodeProps) {
const deleteNode = useCanvasStore((state) => state.deleteNode);
const data = node.data as WorkflowNodeData;
const workflowDeleteItem = getCanvasToolbarItem(
canvasToolbarConfig,
"workflowDelete"
);
const definition = getVideoWorkflowNodeDefinition(data.templateType);
const contentScale = getNodeContentScale(
node.size,
@ -315,10 +329,14 @@ export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
deleteNode(node.id);
}}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/75 bg-white/92 text-[#555a6a] transition-colors hover:border-red-200 hover:bg-red-50 hover:text-destructive dark:border-zinc-800 dark:bg-zinc-950 dark:hover:border-destructive/40 dark:hover:bg-red-950/30"
aria-label="删除模块"
title="删除模块"
aria-label={workflowDeleteItem.label}
title={workflowDeleteItem.label}
>
<Trash2 className="h-4 w-4" />
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Trash2}
item={workflowDeleteItem}
/>
</button>
) : null}
</div>

View File

@ -0,0 +1,362 @@
"use client";
import type { CSSProperties, PointerEvent } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRight, CheckCircle2, ShieldCheck, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
UiScreenAsset,
UiScreenDocument,
UiScreenLayer,
UiScreenLayerStyle,
UiScreenTokens,
} from "@/lib/ui-screen/schema";
interface UiScreenRendererProps {
document: UiScreenDocument;
className?: string;
interactive?: boolean;
selectedLayerId?: string | null;
onSelectLayer?: (layerId: string) => void;
}
const TONE_CLASS_MAP = {
primary: "border-blue-200 bg-blue-50 text-blue-700",
secondary: "border-violet-200 bg-violet-50 text-violet-700",
neutral: "border-slate-200 bg-slate-50 text-slate-700",
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
danger: "border-rose-200 bg-rose-50 text-rose-700",
} as const;
function resolveTokenValue(
value: string | undefined,
tokenGroup: Record<string, string | number> | undefined
) {
if (!value) return undefined;
if (!tokenGroup) return value;
const tokenName = value.startsWith("token:") ? value.slice("token:".length) : value;
const tokenValue = tokenGroup[tokenName];
return tokenValue === undefined ? value : tokenValue;
}
function toFlexAlign(value: string | undefined) {
if (value === "center") return "center";
if (value === "end") return "flex-end";
if (value === "stretch") return "stretch";
return "flex-start";
}
function toFlexJustify(value: string | undefined) {
if (value === "center") return "center";
if (value === "end") return "flex-end";
if (value === "between") return "space-between";
return "flex-start";
}
function iconFor(name: string | undefined, size = 14) {
const style = { width: size, height: size };
if (name === "shield") return <ShieldCheck style={style} />;
if (name === "arrow") return <ArrowRight style={style} />;
if (name === "check") return <CheckCircle2 style={style} />;
return <Sparkles style={style} />;
}
function getStyle(
layer: UiScreenLayer,
tokens: UiScreenTokens | undefined,
assetMap: Map<string, UiScreenAsset>
): CSSProperties {
const layerStyle = layer.style || {};
const style: CSSProperties = {
opacity: layer.opacity,
};
if (layer.bounds) {
style.position = "absolute";
style.left = layer.bounds.x;
style.top = layer.bounds.y;
style.width = layer.bounds.width;
style.height = layer.bounds.height;
} else {
style.position = "relative";
}
const background = resolveTokenValue(layerStyle.background, tokens?.colors);
const color = resolveTokenValue(layerStyle.color, tokens?.colors);
const borderColor = resolveTokenValue(layerStyle.borderColor, tokens?.colors);
const boxShadow = resolveTokenValue(layerStyle.boxShadow, tokens?.shadows);
if (background) style.background = String(background);
if (color) style.color = String(color);
if (borderColor) style.borderColor = String(borderColor);
if (boxShadow) style.boxShadow = String(boxShadow);
if (typeof layerStyle.borderWidth === "number" && layerStyle.borderWidth > 0) {
style.borderWidth = layerStyle.borderWidth;
style.borderStyle = "solid";
}
if (typeof layerStyle.borderRadius === "number") style.borderRadius = layerStyle.borderRadius;
if (typeof layerStyle.padding === "number") style.padding = layerStyle.padding;
if (typeof layerStyle.paddingX === "number") {
style.paddingLeft = layerStyle.paddingX;
style.paddingRight = layerStyle.paddingX;
}
if (typeof layerStyle.paddingY === "number") {
style.paddingTop = layerStyle.paddingY;
style.paddingBottom = layerStyle.paddingY;
}
if (typeof layerStyle.blur === "number" && layerStyle.blur > 0) {
style.backdropFilter = `blur(${layerStyle.blur}px)`;
style.WebkitBackdropFilter = `blur(${layerStyle.blur}px)`;
}
applyTextStyle(style, layerStyle);
if (layer.kind === "stack") {
style.display = "flex";
style.flexDirection = layer.direction || "column";
style.alignItems = toFlexAlign(layer.align);
style.justifyContent = toFlexJustify(layer.justify);
style.gap = layerStyle.gap ?? 14;
}
if (layer.kind === "grid") {
style.display = "grid";
style.gridTemplateColumns = `repeat(${Math.max(1, layer.columns || 2)}, minmax(0, 1fr))`;
style.gap = layerStyle.gap ?? 14;
}
if (layer.kind === "card" || layer.kind === "frame") {
style.display = "flex";
style.flexDirection = layer.direction || "column";
style.alignItems = toFlexAlign(layer.align);
style.justifyContent = toFlexJustify(layer.justify);
style.gap = layerStyle.gap ?? 12;
}
if (layer.kind === "image") {
const asset = layer.assetId ? assetMap.get(layer.assetId) : null;
const src = layer.src || asset?.src;
if (!src) {
style.background =
style.background || "linear-gradient(135deg, rgba(226,232,240,0.72), rgba(248,250,252,0.95))";
}
}
return style;
}
function applyTextStyle(style: CSSProperties, layerStyle: UiScreenLayerStyle) {
if (layerStyle.fontFamily) style.fontFamily = layerStyle.fontFamily;
if (typeof layerStyle.fontSize === "number") style.fontSize = layerStyle.fontSize;
if (layerStyle.fontWeight !== undefined) style.fontWeight = layerStyle.fontWeight;
if (typeof layerStyle.lineHeight === "number") style.lineHeight = layerStyle.lineHeight;
if (typeof layerStyle.letterSpacing === "number") style.letterSpacing = layerStyle.letterSpacing;
if (layerStyle.textAlign) style.textAlign = layerStyle.textAlign;
if (layerStyle.textTransform) style.textTransform = layerStyle.textTransform;
}
function renderLayer(params: {
layer: UiScreenLayer;
tokens?: UiScreenTokens;
assetMap: Map<string, UiScreenAsset>;
interactive: boolean;
selectedLayerId?: string | null;
onSelectLayer?: (layerId: string) => void;
}) {
const { layer, tokens, assetMap, interactive, selectedLayerId, onSelectLayer } = params;
if (layer.visible === false) return null;
const isSelected = selectedLayerId === layer.id;
const baseStyle = getStyle(layer, tokens, assetMap);
const children = layer.children?.map((child) =>
renderLayer({
layer: child,
tokens,
assetMap,
interactive,
selectedLayerId,
onSelectLayer,
})
);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!interactive) return;
event.preventDefault();
event.stopPropagation();
onSelectLayer?.(layer.id);
};
const commonProps = {
key: layer.id,
"data-ui-layer-id": layer.id,
"data-canvas-no-drag": interactive ? "true" : undefined,
onPointerDown: handlePointerDown,
};
if (layer.kind === "image") {
const asset = layer.assetId ? assetMap.get(layer.assetId) : null;
const src = layer.src || asset?.src;
return (
<div
{...commonProps}
className={cn("overflow-hidden", isSelected && "ring-2 ring-blue-500 ring-offset-2")}
style={baseStyle}
>
{src ? (
<img
alt={layer.alt || asset?.alt || layer.name || ""}
className="h-full w-full"
draggable={false}
src={src}
style={{
objectFit:
layer.fit === "contain" ? "contain" : layer.fit === "fill" ? "fill" : "cover",
}}
/>
) : null}
</div>
);
}
if (layer.kind === "text") {
return (
<div
{...commonProps}
className={cn(
"whitespace-pre-wrap break-words",
isSelected && "rounded-[6px] ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
{layer.text}
</div>
);
}
if (layer.kind === "button") {
return (
<div
{...commonProps}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-full border font-bold",
TONE_CLASS_MAP[layer.tone || "primary"],
isSelected && "ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
<span>{layer.label || layer.text}</span>
{iconFor("arrow", 16)}
</div>
);
}
if (layer.kind === "badge") {
return (
<div
{...commonProps}
className={cn(
"inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-bold",
TONE_CLASS_MAP[layer.tone || "neutral"],
isSelected && "ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
{iconFor("sparkle", 13)}
<span>{layer.label || layer.text}</span>
</div>
);
}
if (layer.kind === "divider") {
return (
<div
{...commonProps}
className={cn("bg-slate-200", isSelected && "ring-2 ring-blue-500 ring-offset-2")}
style={baseStyle}
/>
);
}
return (
<div
{...commonProps}
className={cn(
"overflow-hidden",
layer.kind === "card" && "rounded-2xl border border-slate-200 bg-white shadow-sm",
isSelected && "ring-2 ring-blue-500 ring-offset-2"
)}
style={baseStyle}
>
{children}
</div>
);
}
export function UiScreenRenderer({
document,
className,
interactive = false,
selectedLayerId,
onSelectLayer,
}: UiScreenRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ width: document.width, height: document.height });
useEffect(() => {
const element = containerRef.current;
if (!element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
setSize({
width: Math.max(1, entry.contentRect.width),
height: Math.max(1, entry.contentRect.height),
});
});
observer.observe(element);
return () => observer.disconnect();
}, []);
const assetMap = useMemo(() => {
const map = new Map<string, UiScreenAsset>();
for (const asset of document.assets || []) {
map.set(asset.id, asset);
}
return map;
}, [document.assets]);
const scale = Math.min(size.width / document.width, size.height / document.height);
const offsetX = (size.width - document.width * scale) / 2;
const offsetY = (size.height - document.height * scale) / 2;
return (
<div
className={cn("relative h-full w-full overflow-hidden bg-white text-slate-950", className)}
ref={containerRef}
>
<div
className="absolute left-0 top-0 overflow-hidden"
style={{
width: document.width,
height: document.height,
transform: `translate(${offsetX}px, ${offsetY}px) scale(${scale})`,
transformOrigin: "top left",
}}
>
{document.layers.map((layer) =>
renderLayer({
layer,
tokens: document.tokens,
assetMap,
interactive,
selectedLayerId,
onSelectLayer,
})
)}
</div>
</div>
);
}

View File

@ -15,6 +15,10 @@ const DEICTIC_TARGET_REGEX =
const SELECTION_REGEX = /(选中的|当前这张|我选的|这几张|这些图|选中图)/i;
const ACTIONABLE_EDIT_REGEX =
/(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格|亮一点|暗一点|白底|透明底)/i;
const ACTIONABLE_GENERATION_REGEX =
/(生图|生成|画一张|出图|来一张|再来一张|做一张|做图|做视频|视频生成|生成视频|动起来)/i;
const IMPLICIT_HISTORY_TARGET_REGEX =
/(继续|接着|沿用|按刚才|照刚才|上次|上一轮|上一版|刚才|刚生成|最新生成|结果图|生成图|这张|这个|当前|原图|上传图|选中)/i;
function normalizeStringArray(values?: string[] | null): string[] {
if (!Array.isArray(values)) return [];
@ -378,6 +382,16 @@ export function resolveTargetAssets(
}
const workingStateResolvedUrls = normalizeStringArray(workingState?.lastResolvedTargetImageUrls);
const allowsImplicitHistoryTarget =
IMPLICIT_HISTORY_TARGET_REGEX.test(text) &&
(ACTIONABLE_EDIT_REGEX.test(text) ||
ACTIONABLE_GENERATION_REGEX.test(text) ||
LATEST_RESULT_REGEX.test(text) ||
ORIGINAL_IMAGE_REGEX.test(text) ||
DEICTIC_TARGET_REGEX.test(text) ||
SELECTION_REGEX.test(text));
if (allowsImplicitHistoryTarget) {
if (workingStateResolvedUrls.length > 0) {
const refs = toWorkingMemoryRefs(workingStateResolvedUrls);
return buildResolvedTargetAssets({
@ -386,7 +400,7 @@ export function resolveTargetAssets(
resolutionMode: "working_memory",
confidence: 82,
needsClarification: false,
reason: "使用会话工作记忆中的上一轮已解析目标图",
reason: "用户表达了连续处理意图,使用会话工作记忆中的上一轮已解析目标图",
fromHistory: true,
});
}
@ -399,7 +413,7 @@ export function resolveTargetAssets(
resolutionMode: "working_memory",
confidence: 78,
needsClarification: false,
reason: "使用会话工作记忆中的最近生成结果",
reason: "用户表达了连续处理意图,使用会话工作记忆中的最近生成结果",
fromHistory: true,
});
}
@ -411,7 +425,7 @@ export function resolveTargetAssets(
resolutionMode: "latest_result",
confidence: 74,
needsClarification: false,
reason: "未显式指定时,默认续接最近一轮生成结果",
reason: "用户表达了连续处理意图,续接最近一轮生成结果",
fromHistory: true,
});
}
@ -423,10 +437,11 @@ export function resolveTargetAssets(
resolutionMode: "working_memory",
confidence: 68,
needsClarification: false,
reason: "回退使用最近一次用户上传图片",
reason: "用户表达了连续处理意图,回退使用最近一次用户上传图片",
fromHistory: true,
});
}
}
if (ACTIONABLE_EDIT_REGEX.test(text)) {
return buildResolvedTargetAssets({
@ -435,7 +450,7 @@ export function resolveTargetAssets(
resolutionMode: "clarification",
confidence: 0,
needsClarification: true,
reason: "用户表达了编辑意图,但当前没有可用图片目标",
reason: "用户表达了编辑意图,但没有明确选中或文本定位目标图;不会自动关联历史图片",
fromHistory: false,
});
}

View File

@ -0,0 +1,152 @@
export const DETAIL_KIMI_THINKING_HEADER = "x-detail-agent-kimi-thinking";
export const DETAIL_KIMI_PROFILE_HEADER = "x-detail-agent-kimi-profile";
export type DetailKimiThinkingMode = "enabled" | "disabled";
export type DetailKimiPurpose = "plan" | "preflight" | "review" | "repair";
export type DetailKimiGenerateOptions = {
temperature?: number;
topP?: number;
maxTokens?: number;
headers?: Record<string, string>;
};
const KIMI_K26_PATTERN = /^kimi[-_.]?k2\.6(?:\b|[-_.])/i;
const MOONSHOT_BASE_URL_PATTERN = /moonshot\.(?:cn|ai)|kimi\.com/i;
export function isKimiK26ModelId(value: unknown) {
return KIMI_K26_PATTERN.test(String(value || "").trim());
}
export function isMoonshotOfficialBaseUrl(value: unknown) {
return MOONSHOT_BASE_URL_PATTERN.test(String(value || "").trim());
}
function normalizeThinkingMode(value: unknown): DetailKimiThinkingMode | null {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "enabled" || normalized === "on" || normalized === "true") {
return "enabled";
}
if (normalized === "disabled" || normalized === "off" || normalized === "false") {
return "disabled";
}
return null;
}
function getPurposeThinkingMode(purpose: DetailKimiPurpose): DetailKimiThinkingMode {
if (purpose === "plan" || purpose === "repair") return "enabled";
return "disabled";
}
function getPurposeMaxTokens(params: {
purpose: DetailKimiPurpose;
generationMode?: string | null;
}) {
if (params.purpose === "plan") {
return params.generationMode === "agent_layout" ? 16_000 : 10_000;
}
if (params.purpose === "repair") return 8_000;
if (params.purpose === "preflight") return 4_096;
return 3_000;
}
export function shouldUseKimiDetailProfile(params: {
modelId?: string | null;
providerBaseUrl?: string | null;
isLocalMoonshot?: boolean;
}) {
if (!isKimiK26ModelId(params.modelId)) return false;
return Boolean(params.isLocalMoonshot) || isMoonshotOfficialBaseUrl(params.providerBaseUrl);
}
export function buildDetailKimiGenerateOptions(params: {
purpose: DetailKimiPurpose;
generationMode?: string | null;
modelId?: string | null;
providerBaseUrl?: string | null;
isLocalMoonshot?: boolean;
}): DetailKimiGenerateOptions {
if (!shouldUseKimiDetailProfile(params)) return {};
const thinking = getPurposeThinkingMode(params.purpose);
return {
temperature: thinking === "enabled" ? 1 : 0.6,
topP: 0.95,
maxTokens: getPurposeMaxTokens(params),
headers: {
[DETAIL_KIMI_THINKING_HEADER]: thinking,
[DETAIL_KIMI_PROFILE_HEADER]: `detail-ui-${params.purpose}`,
},
};
}
function toHeaders(headers: HeadersInit | undefined) {
const next = new Headers();
if (!headers) return next;
if (headers instanceof Headers) {
headers.forEach((value, key) => next.set(key, value));
return next;
}
if (Array.isArray(headers)) {
headers.forEach(([key, value]) => {
if (value !== undefined && value !== null) next.set(key, String(value));
});
return next;
}
Object.entries(headers).forEach(([key, value]) => {
if (value !== undefined && value !== null) next.set(key, String(value));
});
return next;
}
function bodyToString(body: BodyInit | null | undefined) {
if (!body) return null;
if (typeof body === "string") return body;
if (body instanceof Uint8Array) {
return new TextDecoder().decode(body);
}
return null;
}
function applyKimiThinkingToBody(params: {
rawBody: string;
thinking: DetailKimiThinkingMode | null;
}) {
if (!params.thinking) return null;
try {
const body = JSON.parse(params.rawBody) as Record<string, unknown>;
if (!isKimiK26ModelId(body.model)) return null;
return JSON.stringify({
...body,
thinking: { type: params.thinking },
});
} catch {
return null;
}
}
export function createMoonshotKimiFetch(baseFetch: typeof fetch = fetch): typeof fetch {
return async (input, init) => {
const headers = toHeaders(
init?.headers || (input instanceof Request ? input.headers : undefined)
);
const thinking = normalizeThinkingMode(headers.get(DETAIL_KIMI_THINKING_HEADER));
headers.delete(DETAIL_KIMI_THINKING_HEADER);
headers.delete(DETAIL_KIMI_PROFILE_HEADER);
const rawBody = bodyToString(init?.body);
const patchedBody = rawBody ? applyKimiThinkingToBody({ rawBody, thinking }) : null;
return baseFetch(input, {
...init,
headers,
body: patchedBody || init?.body,
});
};
}

View File

@ -1,10 +1,12 @@
export const DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY =
"canvas_detail_agent_moonshot_config";
// Stable option id: keep the historical value so existing site settings remain valid.
export const DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID =
"__moonshot_kimi_k25_local__";
export const DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL =
"https://api.moonshot.cn/v1";
export const DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID = "kimi-k2.5";
export const DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID = "kimi-k2.6";
export const DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME = "官方 Kimi K2.6";
export type DetailAgentLocalMoonshotConfig = {
enabled: boolean;
@ -19,6 +21,22 @@ export function isDetailAgentLocalMoonshotOption(value: unknown) {
return String(value || "").trim() === DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID;
}
function normalizeMoonshotModelId(value: unknown) {
const modelId = String(value || "").trim();
if (!modelId || modelId === "kimi-k2.5") {
return DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
}
return modelId;
}
function normalizeMoonshotDisplayName(value: unknown) {
const displayName = String(value || "").trim();
if (!displayName || displayName === "官方 Kimi K2.5") {
return DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME;
}
return displayName;
}
export function normalizeDetailAgentLocalMoonshotConfig(
value: unknown,
): DetailAgentLocalMoonshotConfig {
@ -34,8 +52,7 @@ export function normalizeDetailAgentLocalMoonshotConfig(
baseUrl:
String(raw.baseUrl || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
apiKey: String(raw.apiKey || "").trim(),
modelId:
String(raw.modelId || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
displayName: String(raw.displayName || "").trim() || "官方 Kimi K2.5",
modelId: normalizeMoonshotModelId(raw.modelId),
displayName: normalizeMoonshotDisplayName(raw.displayName),
};
}

View File

@ -10,6 +10,7 @@ import {
isDetailAgentLocalMoonshotOption,
normalizeDetailAgentLocalMoonshotConfig,
} from "@/lib/ai/detail-agent-model-config";
import { createMoonshotKimiFetch } from "@/lib/ai/detail-agent-kimi-profile";
export async function resolveDetailAgentLocalMoonshotModel(params: {
siteSettingModelId?: string | null;
@ -30,6 +31,7 @@ export async function resolveDetailAgentLocalMoonshotModel(params: {
const provider = createOpenAI({
apiKey: config.apiKey,
baseURL: config.baseUrl || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
fetch: createMoonshotKimiFetch(),
});
const model = provider.chat(config.modelId);

View File

@ -29,6 +29,10 @@ import { createRunway } from "./providers/runway";
import { createKrea } from "./providers/krea";
import { createVolcanoMultimodal } from "./providers/volcano-multimodal";
import { getProviderDefaultBaseUrl } from "./provider-defaults";
import {
createMoonshotKimiFetch,
isMoonshotOfficialBaseUrl,
} from "./detail-agent-kimi-profile";
// 缓存已创建的 provider 实例
const providerCache = new Map<string, any>();
@ -38,6 +42,10 @@ function isVolcanoMultimodalBaseUrl(baseUrl?: string | null) {
return /ark\.cn-beijing\.volces\.com/i.test(baseUrl);
}
function getOpenAICompatibleFetch(baseUrl?: string | null) {
return isMoonshotOfficialBaseUrl(baseUrl) ? createMoonshotKimiFetch() : undefined;
}
/**
* 根据提供商类型创建对应的 AI Provider 实例
* 不同供应商使用OpenAI兼容的API格式,但可能有细微差异
@ -67,6 +75,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
providerInstance = createOpenAI({
apiKey: provider.apiKey,
baseURL: resolvedBaseUrl,
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
});
console.log(`[Provider] Created Alibaba DashScope provider: ${resolvedBaseUrl}`);
break;
@ -78,6 +87,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
providerInstance = createOpenAI({
apiKey: provider.apiKey,
baseURL: resolvedBaseUrl || "https://api.openai.com/v1",
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
});
console.log(`[Provider] Created OpenAI provider: ${resolvedBaseUrl || "https://api.openai.com/v1"}`);
break;
@ -87,6 +97,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
providerInstance = createOpenAI({
apiKey: provider.apiKey,
baseURL: resolvedBaseUrl || "https://open.bigmodel.cn/api/paas/v4",
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
});
console.log(
`[Provider] Created Zhipu Open Platform provider: ${resolvedBaseUrl || "https://open.bigmodel.cn/api/paas/v4"}`
@ -143,6 +154,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
providerInstance = createOpenAI({
apiKey: provider.apiKey,
baseURL: resolvedBaseUrl || "https://ark.cn-beijing.volces.com/api/v3",
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
});
console.log(
`[Provider] Created Doubao video provider: ${resolvedBaseUrl || "https://ark.cn-beijing.volces.com/api/v3"}`
@ -275,6 +287,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
providerInstance = createOpenAI({
apiKey: provider.apiKey,
baseURL: resolvedBaseUrl || provider.baseUrl,
fetch: getOpenAICompatibleFetch(resolvedBaseUrl || provider.baseUrl),
});
console.log(
`[Provider] Created custom OpenAI-compatible provider: ${resolvedBaseUrl || provider.baseUrl}`

View File

@ -0,0 +1,752 @@
export const CANVAS_TOOLBAR_CONFIG_SETTING_KEY = "canvas_toolbar_config";
export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
{
key: "selectTool",
group: "画布主工具栏",
label: "选择",
description: "选择和移动画布节点",
},
{
key: "panTool",
group: "画布主工具栏",
label: "平移",
description: "拖动画布视图",
},
{
key: "saveCanvas",
group: "画布主工具栏",
label: "保存到云端",
description: "保存当前视频画布",
},
{
key: "cloudFiles",
group: "画布主工具栏",
label: "云文件列表",
description: "打开当前画布文件列表",
},
{
key: "markerPanel",
group: "画布主工具栏",
label: "标记",
description: "给视频画布节点添加标记",
},
{
key: "workflowConnection",
group: "画布主工具栏",
label: "工作流连线",
description: "连接视频工作流节点",
},
{
key: "styleMode",
group: "画布主工具栏",
label: "风格化模式",
description: "进入无限画布风格化工作区",
},
{
key: "batchProcessing",
group: "画布主工具栏",
label: "批量处理",
description: "打开批量处理入口",
},
{
key: "addImage",
group: "画布主工具栏",
label: "添加图片",
description: "在普通画布添加图片节点",
},
{
key: "addImageInput",
group: "画布主工具栏",
label: "添加图片输入",
description: "在视频画布添加图片输入节点",
},
{
key: "addText",
group: "画布主工具栏",
label: "添加文本",
description: "在普通画布添加文本节点",
},
{
key: "addScriptNode",
group: "画布主工具栏",
label: "添加脚本节点",
description: "在视频画布添加脚本节点",
},
{
key: "addVideoInput",
group: "画布主工具栏",
label: "添加视频输入",
description: "在视频画布添加视频输入节点",
},
{
key: "workflowNodeLibrary",
group: "画布主工具栏",
label: "视频工作流节点库",
description: "打开视频工作流节点库",
},
{
key: "realtimeCanvas",
group: "画布主工具栏",
label: "实时画布模式",
description: "进入实时画布模式",
},
{
key: "enhanceCanvas",
group: "画布主工具栏",
label: "图像增强模式",
description: "进入图像增强模式",
},
{
key: "model3d",
group: "画布主工具栏",
label: "3D生成",
description: "进入 3D 生成工作区",
},
{
key: "exportVideoCanvas",
group: "画布主工具栏",
label: "导出视频画布",
description: "导出视频画布 JSON",
},
{
key: "importVideoCanvas",
group: "画布主工具栏",
label: "导入视频画布",
description: "导入视频画布 JSON",
},
{
key: "newCanvas",
group: "画布主工具栏",
label: "新建画布",
description: "新建普通画布",
},
{
key: "newVideoCanvas",
group: "画布主工具栏",
label: "新建视频画布",
description: "新建视频画布",
},
{
key: "undo",
group: "画布快捷工具条",
label: "撤销",
description: "撤销上一步操作",
},
{
key: "redo",
group: "画布快捷工具条",
label: "重做",
description: "重做上一步操作",
},
{
key: "zoomOut",
group: "画布快捷工具条",
label: "缩小",
description: "缩小画布视图",
},
{
key: "resetZoom",
group: "画布快捷工具条",
label: "重置缩放",
description: "恢复 100% 缩放",
},
{
key: "zoomIn",
group: "画布快捷工具条",
label: "放大",
description: "放大画布视图",
},
{
key: "downloadFolder",
group: "画布快捷工具条",
label: "下载文件夹",
description: "打包下载选中的图片节点",
},
{
key: "imageNodeMore",
group: "节点工具栏",
label: "更多操作",
description: "图片节点悬浮更多菜单",
},
{
key: "imageAddToChat",
group: "节点工具栏",
label: "添加对话",
description: "把图片添加到画布对话框上下文",
},
{
key: "imagePickObject",
group: "节点工具栏",
label: "识物",
description: "识别图片中的局部物品",
},
{
key: "imageEnhance",
group: "节点工具栏",
label: "高清",
description: "图片增强或高清处理",
},
{
key: "imageRemoveBackground",
group: "节点工具栏",
label: "抠图",
description: "移除图片背景",
},
{
key: "imageBoxCutout",
group: "节点工具栏",
label: "框选抠图",
description: "框选区域后抠图",
},
{
key: "imageRemoveWatermark",
group: "节点工具栏",
label: "去水印",
description: "清理图片水印或瑕疵",
},
{
key: "imageResize",
group: "节点工具栏",
label: "尺寸",
description: "调整图片尺寸",
},
{
key: "imageTextEdit",
group: "节点工具栏",
label: "文字",
description: "编辑图片中的文字",
},
{
key: "imageMultiAngle",
group: "节点工具栏",
label: "多视角",
description: "生成图片多视角版本",
},
{
key: "imageGenerate3dModel",
group: "节点工具栏",
label: "3D模型",
description: "从图片生成 3D 模型",
},
{
key: "imageGenerate3dWorld",
group: "节点工具栏",
label: "3D世界",
description: "从图片生成 3D 世界",
},
{
key: "imageLayerSplit",
group: "节点工具栏",
label: "图层拆分",
description: "拆分图片图层",
},
{
key: "imageExtend",
group: "节点工具栏",
label: "扩图",
description: "扩展图片画幅",
},
{
key: "imagePromptRegenerate",
group: "节点工具栏",
label: "重绘",
description: "基于新提示词重绘图片",
},
{
key: "imageNote",
group: "节点工具栏",
label: "备注",
description: "打开或显示图片备注",
},
{
key: "imageRename",
group: "节点工具栏",
label: "改名",
description: "重命名图片节点",
},
{
key: "imageDownload",
group: "节点工具栏",
label: "下载",
description: "下载图片节点资源",
},
{
key: "imageDelete",
group: "节点工具栏",
label: "删除",
description: "删除图片节点",
},
{
key: "imageCompare",
group: "节点工具栏",
label: "开启对比",
description: "打开图片前后对比视图",
},
{
key: "imageConfirmCutout",
group: "节点工具栏",
label: "确认抠图",
description: "确认框选抠图结果",
},
{
key: "imageCancelCutout",
group: "节点工具栏",
label: "取消抠图",
description: "退出框选抠图状态",
},
{
key: "textDelete",
group: "节点工具栏",
label: "删除文本模块",
description: "删除文本节点",
},
{
key: "uiScreenReview",
group: "节点工具栏",
label: "检查并修复 UI",
description: "检查并自动修复 UI Screen 节点",
},
{
key: "uiScreenDelete",
group: "节点工具栏",
label: "删除 UI Screen",
description: "删除 UI Screen 节点",
},
{
key: "uiScreenSaveText",
group: "节点工具栏",
label: "保存",
description: "保存 UI Screen 选中图层文字",
},
{
key: "videoUpload",
group: "节点工具栏",
label: "上传视频",
description: "视频节点上传入口",
},
{
key: "videoPlay",
group: "节点工具栏",
label: "播放",
description: "视频节点播放按钮",
},
{
key: "videoPause",
group: "节点工具栏",
label: "暂停",
description: "视频节点暂停按钮",
},
{
key: "videoMute",
group: "节点工具栏",
label: "静音",
description: "视频节点静音按钮",
},
{
key: "videoUnmute",
group: "节点工具栏",
label: "取消静音",
description: "视频节点取消静音按钮",
},
{
key: "videoMore",
group: "节点工具栏",
label: "更多操作",
description: "视频节点更多菜单",
},
{
key: "videoDownload",
group: "节点工具栏",
label: "下载",
description: "下载视频节点资源",
},
{
key: "videoDelete",
group: "节点工具栏",
label: "删除",
description: "删除视频节点",
},
{
key: "workflowDelete",
group: "节点工具栏",
label: "删除模块",
description: "删除工作流节点",
},
{
key: "model3dOpen3d",
group: "节点工具栏",
label: "打开 3D",
description: "打开 3D 模型地址",
},
{
key: "model3dOpenWorld",
group: "节点工具栏",
label: "打开 World",
description: "打开 3D World 地址",
},
{
key: "model3dMore",
group: "节点工具栏",
label: "更多操作",
description: "3D 模型节点更多菜单",
},
{
key: "model3dOpenMesh",
group: "节点工具栏",
label: "打开 Mesh",
description: "打开 3D Mesh 文件",
},
{
key: "model3dOpenImage",
group: "节点工具栏",
label: "打开图像",
description: "打开 3D 预览图像",
},
{
key: "model3dDownload",
group: "节点工具栏",
label: "下载",
description: "下载 3D 模型资源",
},
{
key: "model3dCopy",
group: "节点工具栏",
label: "复制链接",
description: "复制 3D 模型或 World 链接",
},
{
key: "model3dDelete",
group: "节点工具栏",
label: "删除",
description: "删除 3D 模型节点",
},
{
key: "world3dViewSplat",
group: "节点工具栏",
label: "World 视图",
description: "切换 3D 世界 Splat 视图",
},
{
key: "world3dViewImage",
group: "节点工具栏",
label: "图像视图",
description: "切换 3D 世界图像视图",
},
{
key: "world3dViewMesh",
group: "节点工具栏",
label: "Mesh 视图",
description: "切换 3D 世界 Mesh 视图",
},
{
key: "world3dFirstPerson",
group: "节点工具栏",
label: "进入漫游",
description: "切换 3D 世界漫游模式",
},
{
key: "world3dHotspot",
group: "节点工具栏",
label: "添加热点",
description: "切换 3D 世界热点标注",
},
{
key: "world3dMore",
group: "节点工具栏",
label: "更多操作",
description: "3D 世界节点更多菜单",
},
{
key: "world3dOpenMesh",
group: "节点工具栏",
label: "打开 Mesh",
description: "打开 3D 世界 Mesh 资源",
},
{
key: "world3dOpenImage",
group: "节点工具栏",
label: "打开图像",
description: "打开 3D 世界图像资源",
},
{
key: "world3dDownload",
group: "节点工具栏",
label: "下载当前资产",
description: "下载 3D 世界当前资产",
},
{
key: "world3dCopy",
group: "节点工具栏",
label: "复制链接",
description: "复制 3D 世界资源链接",
},
{
key: "world3dDelete",
group: "节点工具栏",
label: "删除",
description: "删除 3D 世界节点",
},
{
key: "realtimeReturnCanvas",
group: "实时画布工具栏",
label: "返回画布",
description: "实时画布返回无限画布按钮",
},
{
key: "realtimeModeRealtime",
group: "实时画布工具栏",
label: "实时画布模式",
description: "实时画布内部的实时模式切换",
},
{
key: "realtimeModeEnhance",
group: "实时画布工具栏",
label: "图像增强模式",
description: "实时画布内部的图像增强模式切换",
},
{
key: "realtimeLayerPanel",
group: "实时画布工具栏",
label: "图层",
description: "实时画布图层栏入口",
},
{
key: "realtimeLayerSplitImport",
group: "实时画布工具栏",
label: "图层拆分导入",
description: "实时画布图层拆分导入按钮",
},
{
key: "realtimeAddImageLayer",
group: "实时画布工具栏",
label: "添加图片图层",
description: "实时画布添加图片图层按钮",
},
{
key: "realtimeDownloadPreview",
group: "实时画布工具栏",
label: "下载图片",
description: "下载实时画布预览图",
},
{
key: "realtimeSelectTool",
group: "实时画布工具栏",
label: "鼠标",
description: "实时画布选择工具",
},
{
key: "realtimeBrushTool",
group: "实时画布工具栏",
label: "画笔",
description: "实时画布画笔工具",
},
{
key: "realtimeEraserTool",
group: "实时画布工具栏",
label: "橡皮",
description: "实时画布橡皮工具",
},
{
key: "realtimeShapeTool",
group: "实时画布工具栏",
label: "形状",
description: "实时画布形状工具",
},
{
key: "realtimeSaveProject",
group: "实时画布工具栏",
label: "保存项目",
description: "保存实时画布项目",
},
{
key: "realtimeProjectList",
group: "实时画布工具栏",
label: "项目列表",
description: "打开实时画布项目列表",
},
{
key: "realtimeNewBlank",
group: "实时画布工具栏",
label: "新建空白",
description: "新建实时画布空白项目",
},
{
key: "realtimeGenerate",
group: "实时画布工具栏",
label: "生成",
description: "实时画布生成按钮",
},
{
key: "realtimeSendToCanvas",
group: "实时画布工具栏",
label: "发送到画布",
description: "把实时画布结果发送回无限画布",
},
{
key: "chatModeAgentActive",
group: "对话框工具栏",
label: "Auto Agent",
description: "对话框当前 Agent 模式名称",
},
{
key: "chatModePresetAgent",
group: "对话框工具栏",
label: "预设Agent",
description: "对话框当前预设 Agent 模式名称",
},
{
key: "chatModeAgentMenu",
group: "对话框工具栏",
label: "Agent对话",
description: "模式菜单里的 Agent 入口",
},
{
key: "chatModeDetailActive",
group: "对话框工具栏",
label: "AI详情",
description: "对话框当前 AI 详情模式名称",
},
{
key: "chatModeDetailMenu",
group: "对话框工具栏",
label: "AI详情生成",
description: "模式菜单里的 AI 详情入口",
},
{
key: "chatModeImageActive",
group: "对话框工具栏",
label: "生图",
description: "对话框当前图像生成模式名称",
},
{
key: "chatModeImageMenu",
group: "对话框工具栏",
label: "图像生成",
description: "模式菜单里的图像生成入口",
},
{
key: "chatModeVideoActive",
group: "对话框工具栏",
label: "视频",
description: "对话框当前视频生成模式名称",
},
{
key: "chatModeVideoMenu",
group: "对话框工具栏",
label: "视频生成",
description: "模式菜单里的视频生成入口",
},
{
key: "chatAttach",
group: "对话框工具栏",
label: "上传图片",
description: "对话框附件上传按钮",
},
{
key: "chatDeepThinking",
group: "对话框工具栏",
label: "深度思考",
description: "图像生成深度思考开关",
},
{
key: "chatModelSelector",
group: "对话框工具栏",
label: "选择模型",
description: "对话框模型选择按钮",
},
{
key: "chatPoints",
group: "对话框工具栏",
label: "积分",
description: "对话框预计积分展示",
},
{
key: "chatSend",
group: "对话框工具栏",
label: "发送",
description: "对话框发送按钮",
},
{
key: "chatStartGeneration",
group: "对话框工具栏",
label: "开始生成",
description: "工作区模式下的生成按钮名称",
},
] as const;
export type CanvasToolbarItemKey =
(typeof DEFAULT_CANVAS_TOOLBAR_ITEMS)[number]["key"];
export type CanvasToolbarItemConfig = {
label: string;
iconUrl: string;
};
export type CanvasToolbarConfig = {
items: Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
};
const DEFAULT_ITEM_BY_KEY = DEFAULT_CANVAS_TOOLBAR_ITEMS.reduce(
(acc, item) => {
acc[item.key] = {
label: item.label,
iconUrl: "",
};
return acc;
},
{} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>
);
export const defaultCanvasToolbarConfig: CanvasToolbarConfig = {
items: DEFAULT_ITEM_BY_KEY,
};
function normalizeString(value: unknown, maxLength: number) {
return String(value || "").trim().slice(0, maxLength);
}
export function normalizeCanvasToolbarConfig(
value: unknown
): CanvasToolbarConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const rawItems =
raw.items && typeof raw.items === "object" && !Array.isArray(raw.items)
? (raw.items as Record<string, unknown>)
: raw;
const items = {} as Record<CanvasToolbarItemKey, CanvasToolbarItemConfig>;
DEFAULT_CANVAS_TOOLBAR_ITEMS.forEach((defaultItem) => {
const rawItem =
rawItems[defaultItem.key] &&
typeof rawItems[defaultItem.key] === "object" &&
!Array.isArray(rawItems[defaultItem.key])
? (rawItems[defaultItem.key] as Record<string, unknown>)
: {};
const label = normalizeString(rawItem.label, 40) || defaultItem.label;
const iconUrl = normalizeString(rawItem.iconUrl, 2000);
items[defaultItem.key] = {
label,
iconUrl,
};
});
return { items };
}
export function getDefaultCanvasToolbarItem(
key: CanvasToolbarItemKey
): CanvasToolbarItemConfig {
return DEFAULT_ITEM_BY_KEY[key];
}
export function getCanvasToolbarItem(
config: CanvasToolbarConfig | null | undefined,
key: CanvasToolbarItemKey
): CanvasToolbarItemConfig {
return config?.items?.[key] || DEFAULT_ITEM_BY_KEY[key];
}

View File

@ -0,0 +1,231 @@
import type { DetailGenerationMode } from "@/lib/detail-ui-protocol";
export const DETAIL_UI_DESIGN_SKILL_VERSION = "detail-ui-design-skill/v1";
export interface DetailUiDesignSkillSpec {
version: typeof DETAIL_UI_DESIGN_SKILL_VERSION;
name: string;
source: string;
language: "zh" | "en";
generationMode: DetailGenerationMode;
canvas: {
width: number;
height: number;
grid: string;
maxOverlayCoverage: string;
};
visualDirection: string[];
typography: string[];
layout: string[];
imageFusion: string[];
dslContract: string[];
antiPatterns: string[];
reviewChecklist: string[];
metadata: {
screenCount: number;
hasReferenceImages: boolean;
hasModelImage: boolean;
hasBundleImages: boolean;
productName?: string;
brandTone?: string;
};
}
export interface DetailUiDesignSkillSummary {
version: typeof DETAIL_UI_DESIGN_SKILL_VERSION;
name: string;
source: string;
visualDirection: string[];
reviewChecklist: string[];
}
function normalizeText(value: unknown) {
return String(value || "").replace(/\s+/g, " ").trim();
}
export function buildDetailUiDesignSkillSpec(params: {
language: "zh" | "en";
generationMode: DetailGenerationMode;
screenCount: number;
hasReferenceImages: boolean;
hasModelImage: boolean;
hasBundleImages: boolean;
productName?: string;
brandTone?: string;
}): DetailUiDesignSkillSpec {
const isZh = params.language === "zh";
return {
version: DETAIL_UI_DESIGN_SKILL_VERSION,
name: isZh ? "详情 UI 设计技能" : "Detail UI Design Skill",
source: "Google Stitch DESIGN.md pattern + Apple product-showcase restraint + Linear precision + Figma editable-layer semantics",
language: params.language,
generationMode: params.generationMode,
canvas: {
width: 790,
height: 1300,
grid: isZh ? "8px 基础栅格,左右安全边距 56px,主内容按 12 栅格对齐" : "8px base grid, 56px side safe margins, main content aligned to a 12-column rhythm",
maxOverlayCoverage: isZh ? "可编辑 UI 覆盖面积建议 18%-38%,最高不超过 45%" : "Editable UI overlay should cover 18%-38% of the canvas, never above 45%",
},
visualDirection: isZh
? [
"产品/素材主体优先,主体轮廓不能被大面积 UI 卡片遮挡。",
"一屏只建立一个主视觉焦点,文字层服务焦点,不把画面做成模板拼贴说明页。",
"整体气质克制、精密、可上线:少装饰,靠比例、留白、对比和真实素材质感建立高级感。",
"强调色只用于 CTA、重点徽章或关键对比,不能把整屏染成单一蓝紫渐变。",
]
: [
"Prioritize the product or reference asset; never bury the silhouette under large UI cards.",
"Create one dominant focal point per screen. Text supports the focal point instead of turning the frame into a collage.",
"Use restrained production-grade polish: proportion, whitespace, contrast and material realism over decoration.",
"Reserve accent color for CTA, proof badge, or key comparison only; avoid one-note blue/purple gradients.",
],
typography: isZh
? [
"主标题 52-64px、字重 650-750、行高 1.06-1.15,禁止负字距,最多 2 行。",
"副标题 26-34px、字重 550-650,必须是一句结果句,不写成长段说明。",
"正文 18-24px、字重 450-600、行高 1.34-1.5,单块最多 4 行。",
"同一套详情页只能使用一个字体家族和三个清晰层级:标题/副标题/正文。",
]
: [
"Hero title 52-64px, weight 650-750, line-height 1.06-1.15, no negative tracking, max 2 lines.",
"Subtitle 26-34px, weight 550-650, one result sentence only.",
"Body 18-24px, weight 450-600, line-height 1.34-1.5, max 4 lines per block.",
"Use one font family and exactly three readable levels: title, subtitle, body.",
],
layout: isZh
? [
"首屏必须是封面海报/KV:单主体、强焦点、清晰标题安全区,禁止多卡片堆叠。",
"卡片只用于承载真实信息模块;卡片圆角 8-12px,阴影轻,边框低透明。",
"不要把卡片套卡片;一屏最多 1 个主面板 + 1 个辅助网格/证据条。",
"UI 区域必须避开商品脸部、Logo、关键结构和手持/使用动作。",
]
: [
"First screen must feel like a cover/KV poster: single subject, strong focus, clean headline safe zone.",
"Use cards only for real information modules; radius 8-12px, light shadow, low-opacity border.",
"No card nesting. Max one main panel plus one supporting grid/proof strip per screen.",
"UI regions must avoid product face, logo, critical structure, and usage action.",
],
imageFusion: isZh
? [
params.hasReferenceImages
? "已给参考图:生成画面必须复用参考素材的结构、颜色、材质、比例和关键识别特征。"
: "未给参考图:画面主体仍需清晰、完整、可识别,不用抽象氛围图代替产品。",
params.hasModelImage
? "已给模特图:涉及人物时保持同一人物身份,不替换脸型、发型、体型和服装风格。"
: "未给模特图:不要依赖人物脸部作为主卖点。",
params.hasBundleImages
? "搭配图只服务一屏关联销售,其余屏不要把搭配品抢成主角。"
: "未给搭配图:不要凭空制造搭配商品主叙事。",
"AGENT排版模式下,生图提示词只生成干净画面层,不生成任何可见文字、假按钮、假 UI。",
]
: [
params.hasReferenceImages
? "Reference images are provided: preserve structure, color, material, proportion, and key identity cues."
: "No reference image: keep the product subject clear and inspectable; avoid abstract atmosphere as a substitute.",
params.hasModelImage
? "Model image is provided: preserve the same identity when humans appear."
: "No model image: do not rely on a human face as the main selling point.",
params.hasBundleImages
? "Bundle products serve exactly one cross-sell screen and must not steal the main subject elsewhere."
: "No bundle refs: do not invent a bundle-product narrative.",
"In agent-layout mode, image prompts generate clean background only: no visible text, fake buttons, or fake UI.",
],
dslContract: isZh
? [
"dslV2Screens 必须语义化命名 id,例如 hero-title、proof-strip、feature-grid。",
"region 使用 0-1 归一化坐标;宽高和位置必须像设计工具的 frame,而不是随意漂浮。",
"nodes 只使用允许的 kind,优先 panel/stack/row/heading/body/badge/proof/cta 的少量组合。",
"每个文本节点必须可编辑、短句化、层级明确,不把长说明塞进 heading。",
]
: [
"dslV2Screens must use semantic ids such as hero-title, proof-strip, feature-grid.",
"Use normalized 0-1 regions like design-tool frames, not arbitrary floating boxes.",
"Use only allowed node kinds, with a sparse combination of panel/stack/row/heading/body/badge/proof/cta.",
"Every text node must be editable, concise, and hierarchy-aware; no long explanations in headings.",
],
antiPatterns: isZh
? [
"禁止大面积玻璃拟态卡片盖住主体。",
"禁止蓝紫渐变、发光球、装饰块堆满画面。",
"禁止主标题写“核心价值/场景痛点/第X屏/本屏展示”等规划话术。",
"禁止同屏 4 个以上信息模块或超过 2 种强调色。",
"禁止可见文字由生图模型生成,文字必须走可编辑 UI 层。",
]
: [
"No large glassmorphism cards covering the subject.",
"No blue/purple gradient wash, glowing blobs, or decorative block clutter.",
"No planning phrases in visible copy such as Screen X or This frame shows.",
"No more than 4 information modules or 2 accent colors in one screen.",
"No image-model rendered visible text; text must live in editable UI layers.",
],
reviewChecklist: isZh
? [
"主体是否完整、清晰、没有被 UI 遮挡?",
"首屏是否像封面海报,而不是拼贴说明页?",
"字体层级是否只有标题/副标题/正文三层,且统一?",
"UI 覆盖面积是否控制在 45% 内,并避开关键素材?",
"是否没有负字距、超大圆角、重阴影、卡片套卡片?",
"visualPrompt 是否完全禁止生成可见文字?",
]
: [
"Is the subject complete, clear, and unobstructed by UI?",
"Does the first screen feel like a cover poster rather than a collage?",
"Are there only title/subtitle/body levels, consistently applied?",
"Is UI coverage under 45% and away from critical asset details?",
"Are negative tracking, huge radii, heavy shadows, and nested cards avoided?",
"Does visualPrompt fully forbid visible text generation?",
],
metadata: {
screenCount: Math.max(1, Math.round(params.screenCount || 1)),
hasReferenceImages: params.hasReferenceImages,
hasModelImage: params.hasModelImage,
hasBundleImages: params.hasBundleImages,
productName: normalizeText(params.productName) || undefined,
brandTone: normalizeText(params.brandTone) || undefined,
},
};
}
export function summarizeDetailUiDesignSkill(
spec: DetailUiDesignSkillSpec
): DetailUiDesignSkillSummary {
return {
version: spec.version,
name: spec.name,
source: spec.source,
visualDirection: spec.visualDirection.slice(0, 4),
reviewChecklist: spec.reviewChecklist.slice(0, 6),
};
}
export function buildDetailUiDesignSkillFlowStep(
spec: DetailUiDesignSkillSpec
): string {
return spec.language === "zh"
? "详情 UI Design Skill:按 DESIGN.md 方式锁定视觉语言、素材融合、安全区、可编辑 DSL 与反俗套自检。"
: "Detail UI Design Skill: lock visual language, asset fusion, safe zones, editable DSL, and anti-slop review using DESIGN.md-style rules.";
}
export function buildDetailUiDesignSkillPrompt(
spec: DetailUiDesignSkillSpec
): string {
const section = (title: string, items: string[]) =>
`${title}\n${items.map((item) => `- ${item}`).join("\n")}`;
const title = spec.language === "zh"
? `【${spec.name} ${spec.version}】`
: `[${spec.name} ${spec.version}]`;
return [
title,
`${spec.language === "zh" ? "来源" : "Source"}: ${spec.source}`,
`${spec.language === "zh" ? "画布" : "Canvas"}: ${spec.canvas.width}x${spec.canvas.height}; ${spec.canvas.grid}; ${spec.canvas.maxOverlayCoverage}`,
section(spec.language === "zh" ? "视觉方向" : "Visual Direction", spec.visualDirection),
section(spec.language === "zh" ? "字体层级" : "Typography", spec.typography),
section(spec.language === "zh" ? "布局规则" : "Layout Rules", spec.layout),
section(spec.language === "zh" ? "图片素材融合" : "Image Fusion", spec.imageFusion),
section(spec.language === "zh" ? "DSL 输出契约" : "DSL Contract", spec.dslContract),
section(spec.language === "zh" ? "禁止项" : "Anti-patterns", spec.antiPatterns),
section(spec.language === "zh" ? "生成前自检" : "Preflight Checklist", spec.reviewChecklist),
].join("\n");
}

View File

@ -94,14 +94,14 @@ export interface DetailUiDesignTokens {
const BASE_TOKENS: DetailUiDesignTokens = {
canvas: {
sectionGap: 16,
sectionRadius: 28,
sectionRadius: 12,
},
typography: {
title: {
fontSize: 52,
fontWeight: "800",
fontWeight: "700",
lineHeight: 1.08,
letterSpacing: -0.8,
letterSpacing: 0,
color: "#0f172a",
},
subtitle: {

View File

@ -0,0 +1,492 @@
import type { DetailUiGlobalStyle } from "@/lib/detail-ui-protocol";
import type { DetailUiDesignSkillSummary } from "@/lib/detail-ui-design-skill";
import type { UiScreenDocument, UiScreenLayer } from "@/lib/ui-screen/schema";
export const DETAIL_BRAND_STYLE_BOARD_VERSION = "detail-brand-style-board/v1";
type DetailStyleBoardBrief = {
productName?: string;
brandTone?: string;
coreNeed?: string;
usageScenario?: string;
coreSellingPoint?: string;
};
type PaletteSwatch = {
id: string;
name: string;
role: string;
hex: string;
};
function normalizeText(value: unknown, fallback = "") {
return String(value || "").replace(/\s+/g, " ").trim() || fallback;
}
function extractHexColors(value: unknown) {
const matched = normalizeText(value).match(/#[0-9a-fA-F]{6}\b/g) || [];
return Array.from(new Set(matched.map((item) => item.toLowerCase()))).slice(0, 6);
}
function hashText(value: string) {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash;
}
function chooseBasePalette(params: {
language: "zh" | "en";
brief?: DetailStyleBoardBrief;
globalStyle?: DetailUiGlobalStyle;
}): PaletteSwatch[] {
const extracted = extractHexColors(params.globalStyle?.palette);
if (extracted.length >= 3) {
const fallbacks = ["#f8fafc", "#0f172a", "#0071e3", "#14b8a6", "#f59e0b"];
const colors = [...extracted, ...fallbacks].slice(0, 5);
return [
{ id: "primary", name: "Primary", role: "主行动/重点卖点", hex: colors[0]! },
{ id: "ink", name: "Ink", role: "标题/强文字", hex: colors[1]! },
{ id: "surface", name: "Surface", role: "面板/留白", hex: colors[2]! },
{ id: "accent", name: "Accent", role: "证据/对比", hex: colors[3]! },
{ id: "warm", name: "Warm", role: "价格/提醒", hex: colors[4]! },
];
}
const tone = normalizeText(params.brief?.brandTone).toLowerCase();
const seed = `${params.brief?.productName || ""} ${params.brief?.coreSellingPoint || ""} ${tone}`;
const presetIndex = hashText(seed) % 5;
const reliable = /专业|可靠|信任|稳|medical|pro|trust|reliable|clinical/.test(tone);
const premium = /高级|高端|轻奢|premium|luxury|minimal|质感/.test(tone);
const friendly = /亲和|温暖|活泼|friendly|warm|soft|生活/.test(tone);
const tech = /科技|智能|未来|tech|smart|digital|ai/.test(tone);
if (premium) {
return [
{ id: "primary", name: "Obsidian", role: "主标题/高级底色", hex: "#111827" },
{ id: "ink", name: "Graphite", role: "正文文字", hex: "#1f2937" },
{ id: "surface", name: "Porcelain", role: "大面积背景", hex: "#f8fafc" },
{ id: "accent", name: "Champagne", role: "重点证明/质感线", hex: "#d6a756" },
{ id: "support", name: "Mist Blue", role: "辅助信息", hex: "#dbeafe" },
];
}
if (friendly) {
return [
{ id: "primary", name: "Coral", role: "主 CTA/亲和重点", hex: "#e85d75" },
{ id: "ink", name: "Cocoa Ink", role: "标题文字", hex: "#312e2b" },
{ id: "surface", name: "Milk", role: "干净背景", hex: "#fff7ed" },
{ id: "accent", name: "Fresh Mint", role: "舒适/安心", hex: "#2dd4bf" },
{ id: "support", name: "Sky", role: "辅助信息", hex: "#dbeafe" },
];
}
if (tech) {
return [
{ id: "primary", name: "Electric Blue", role: "主行动/科技重点", hex: "#2563eb" },
{ id: "ink", name: "Deep Navy", role: "标题文字", hex: "#0f172a" },
{ id: "surface", name: "Ice", role: "背景/面板", hex: "#eff6ff" },
{ id: "accent", name: "Signal Green", role: "性能/通过", hex: "#22c55e" },
{ id: "support", name: "Violet", role: "辅助强调", hex: "#8b5cf6" },
];
}
if (reliable || presetIndex <= 2) {
return [
{ id: "primary", name: "Apple Blue", role: "主 CTA/核心卖点", hex: "#0071e3" },
{ id: "ink", name: "Slate Ink", role: "标题/正文", hex: "#0f172a" },
{ id: "surface", name: "Cloud", role: "大面积留白", hex: "#f5f5f7" },
{ id: "accent", name: "Teal Proof", role: "证据/安心", hex: "#14b8a6" },
{ id: "support", name: "Amber", role: "提醒/价格", hex: "#f59e0b" },
];
}
return [
{ id: "primary", name: "Blue", role: "主 CTA/核心卖点", hex: "#2563eb" },
{ id: "ink", name: "Ink", role: "标题/正文", hex: "#111827" },
{ id: "surface", name: "Surface", role: "背景/卡片", hex: "#f8fafc" },
{ id: "accent", name: "Emerald", role: "证明/通过", hex: "#10b981" },
{ id: "support", name: "Rose", role: "提醒/差异", hex: "#f43f5e" },
];
}
function hexToRgb(hex: string) {
const value = hex.replace("#", "");
return {
r: Number.parseInt(value.slice(0, 2), 16),
g: Number.parseInt(value.slice(2, 4), 16),
b: Number.parseInt(value.slice(4, 6), 16),
};
}
function contrastText(hex: string) {
const { r, g, b } = hexToRgb(hex);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.62 ? "#0f172a" : "#ffffff";
}
function textLayer(params: {
id: string;
text: string;
fontSize: number;
fontWeight?: number | string;
color?: string;
lineHeight?: number;
textAlign?: "left" | "center" | "right";
}): UiScreenLayer {
return {
id: params.id,
kind: "text",
text: params.text,
style: {
fontSize: params.fontSize,
fontWeight: params.fontWeight || 500,
lineHeight: params.lineHeight || 1.25,
color: params.color || "#0f172a",
textAlign: params.textAlign,
letterSpacing: 0,
},
};
}
function badgeLayer(params: {
id: string;
label: string;
tone?: "primary" | "secondary" | "neutral" | "success";
}): UiScreenLayer {
return {
id: params.id,
kind: "badge",
label: params.label,
tone: params.tone || "neutral",
style: {
fontSize: 14,
fontWeight: 700,
paddingX: 12,
paddingY: 7,
},
};
}
function colorSwatchLayer(swatch: PaletteSwatch): UiScreenLayer {
const textColor = contrastText(swatch.hex);
return {
id: `swatch-${swatch.id}`,
kind: "card",
name: swatch.name,
style: {
background: swatch.hex,
color: textColor,
borderRadius: 10,
padding: 16,
gap: 8,
boxShadow: "0 10px 26px rgba(15,23,42,0.08)",
},
children: [
textLayer({
id: `swatch-${swatch.id}-name`,
text: swatch.name,
fontSize: 18,
fontWeight: 700,
color: textColor,
}),
textLayer({
id: `swatch-${swatch.id}-hex`,
text: swatch.hex.toUpperCase(),
fontSize: 15,
fontWeight: 650,
color: textColor,
}),
textLayer({
id: `swatch-${swatch.id}-role`,
text: swatch.role,
fontSize: 13,
fontWeight: 500,
color: textColor,
lineHeight: 1.35,
}),
],
};
}
function sectionFrame(params: {
id: string;
bounds: { x: number; y: number; width: number; height: number };
children: UiScreenLayer[];
gap?: number;
background?: string;
}): UiScreenLayer {
return {
id: params.id,
kind: "stack",
bounds: params.bounds,
direction: "column",
align: "stretch",
justify: "start",
style: {
background: params.background || "rgba(255,255,255,0.82)",
borderColor: "rgba(15,23,42,0.08)",
borderWidth: 1,
borderRadius: 12,
padding: 22,
gap: params.gap || 14,
boxShadow: "0 12px 30px rgba(15,23,42,0.08)",
},
children: params.children,
};
}
export function buildDetailBrandStyleBoard(params: {
language: "zh" | "en";
prompt: string;
brief?: DetailStyleBoardBrief;
globalStyle?: DetailUiGlobalStyle;
designSkill?: DetailUiDesignSkillSummary;
}): UiScreenDocument {
const isZh = params.language === "zh";
const palette = chooseBasePalette({
language: params.language,
brief: params.brief,
globalStyle: params.globalStyle,
});
const primary = palette[0]?.hex || "#0071e3";
const ink = palette[1]?.hex || "#0f172a";
const surface = palette[2]?.hex || "#f5f5f7";
const accent = palette[3]?.hex || "#14b8a6";
const productName = normalizeText(params.brief?.productName, isZh ? "详情页品牌" : "Detail Brand");
const coreNeed = normalizeText(params.brief?.coreNeed, isZh ? "核心购买理由" : "Core buying reason");
const brandTone = normalizeText(params.brief?.brandTone, isZh ? "克制、专业、可上线" : "restrained, professional, production-ready");
const fontFamily =
normalizeText(params.globalStyle?.typography?.fontFamily) ||
"Noto Sans SC, PingFang SC, Microsoft YaHei, Inter, Arial, sans-serif";
const checklist = params.designSkill?.reviewChecklist?.length
? params.designSkill.reviewChecklist.slice(0, 4)
: isZh
? ["主体完整不被遮挡", "UI 覆盖小于45%", "三层字体层级统一", "不生成假文字/假按钮"]
: ["Subject unobstructed", "UI coverage under 45%", "Three consistent type levels", "No fake image text/UI"];
return {
version: "ui-screen/v1",
id: `detail-brand-style-board-${Date.now()}`,
title: isZh ? "品牌 UI 配色板" : "Brand UI Style Board",
width: 790,
height: 1080,
tokens: {
colors: {
primary,
ink,
surface,
accent,
},
typography: {
title: { fontFamily, fontSize: 54, fontWeight: 700, lineHeight: 1.08 },
subtitle: { fontFamily, fontSize: 28, fontWeight: 600, lineHeight: 1.2 },
body: { fontFamily, fontSize: 18, fontWeight: 500, lineHeight: 1.42 },
},
radii: {
panel: 12,
card: 10,
pill: 999,
},
shadows: {
soft: "0 12px 30px rgba(15,23,42,0.08)",
},
},
layers: [
{
id: "board-background",
kind: "frame",
bounds: { x: 0, y: 0, width: 790, height: 1080 },
style: {
background: `linear-gradient(180deg, ${surface} 0%, #ffffff 52%, rgba(248,250,252,0.96) 100%)`,
},
},
{
id: "board-header",
kind: "stack",
bounds: { x: 48, y: 44, width: 694, height: 154 },
direction: "column",
align: "start",
justify: "start",
style: { gap: 12, fontFamily },
children: [
badgeLayer({
id: "board-badge",
label: isZh ? "品牌 UI 基准 / 先锁定再生成" : "Brand UI Baseline / Lock Before Generate",
tone: "primary",
}),
textLayer({
id: "board-title",
text: isZh ? `${productName} 视觉系统` : `${productName} Visual System`,
fontSize: 50,
fontWeight: 700,
lineHeight: 1.08,
color: ink,
}),
textLayer({
id: "board-subtitle",
text: isZh ? `${brandTone};围绕「${coreNeed}」建立统一详情页语言。` : `${brandTone}; build one detail-page language around "${coreNeed}".`,
fontSize: 21,
fontWeight: 500,
lineHeight: 1.34,
color: "#475569",
}),
],
},
{
id: "palette-section",
kind: "grid",
bounds: { x: 48, y: 234, width: 694, height: 208 },
columns: 5,
style: { gap: 12 },
children: palette.map(colorSwatchLayer),
},
sectionFrame({
id: "type-section",
bounds: { x: 48, y: 480, width: 694, height: 202 },
children: [
textLayer({
id: "type-section-title",
text: isZh ? "字体层级" : "Typography",
fontSize: 22,
fontWeight: 700,
color: ink,
}),
textLayer({
id: "type-hero",
text: isZh ? "主标题:一眼说清为什么值得买" : "Hero: Say why it is worth buying",
fontSize: 40,
fontWeight: 700,
lineHeight: 1.08,
color: ink,
}),
textLayer({
id: "type-subtitle",
text: isZh ? "副标题:一句结果句,承接核心需求" : "Subtitle: one result sentence tied to the need",
fontSize: 25,
fontWeight: 600,
color: "#334155",
}),
textLayer({
id: "type-body",
text: isZh ? "正文控制在 2-4 行,解释场景、证据和转化理由,不堆满信息。" : "Body stays within 2-4 lines: scene, proof, and conversion reason without clutter.",
fontSize: 18,
fontWeight: 500,
lineHeight: 1.42,
color: "#64748b",
}),
],
}),
sectionFrame({
id: "component-section",
bounds: { x: 48, y: 714, width: 694, height: 142 },
children: [
textLayer({
id: "component-title",
text: isZh ? "组件基调" : "Component Tone",
fontSize: 22,
fontWeight: 700,
color: ink,
}),
{
id: "component-row",
kind: "grid",
columns: 3,
style: { gap: 12 },
children: [
{
id: "component-cta",
kind: "button",
label: isZh ? "主行动按钮" : "Primary CTA",
tone: "primary",
style: {
background: primary,
color: contrastText(primary),
borderRadius: 999,
paddingX: 18,
paddingY: 12,
fontSize: 15,
fontWeight: 700,
},
},
{
id: "component-proof",
kind: "badge",
label: isZh ? "证据标签" : "Proof Badge",
tone: "success",
style: {
background: accent,
color: contrastText(accent),
borderRadius: 999,
paddingX: 14,
paddingY: 9,
fontSize: 14,
fontWeight: 700,
},
},
{
id: "component-panel",
kind: "card",
style: {
background: "#ffffff",
borderColor: "rgba(15,23,42,0.10)",
borderWidth: 1,
borderRadius: 10,
padding: 14,
boxShadow: "0 10px 24px rgba(15,23,42,0.08)",
},
children: [
textLayer({
id: "component-panel-label",
text: isZh ? "轻面板 / 不挡主体" : "Light panel / keep subject clear",
fontSize: 14,
fontWeight: 650,
color: ink,
}),
],
},
],
},
],
}),
sectionFrame({
id: "rules-section",
bounds: { x: 48, y: 888, width: 694, height: 140 },
background: "rgba(15,23,42,0.92)",
children: [
textLayer({
id: "rules-title",
text: isZh ? "后续详情页必须遵守" : "Rules For The Following Screens",
fontSize: 21,
fontWeight: 700,
color: "#f8fafc",
}),
{
id: "rules-grid",
kind: "grid",
columns: 2,
style: { gap: 10 },
children: checklist.map((item, index) =>
textLayer({
id: `rule-${index + 1}`,
text: `0${index + 1}. ${item}`,
fontSize: 15,
fontWeight: 550,
lineHeight: 1.34,
color: "#e2e8f0",
})
),
},
],
}),
],
metadata: {
source: "detail-brand-style-board",
detailBrandStyleBoardVersion: DETAIL_BRAND_STYLE_BOARD_VERSION,
prompt: normalizeText(params.prompt).slice(0, 800),
globalStyle: params.globalStyle || null,
designSkill: params.designSkill || null,
palette,
},
};
}

View File

@ -0,0 +1,515 @@
import { normalizeDetailUiDslV2Document, type DetailUiDslNode, type DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
import type { UiScreenDocument, UiScreenLayer } from "@/lib/ui-screen/schema";
type DetailUiScreenBackground = {
src: string;
thumbnailUrl?: string;
alt?: string;
};
function normalizedRegionToBounds(
region: DetailUiDslV2Document["region"],
width: number,
height: number
) {
return {
x: Math.round(region.x * width),
y: Math.round(region.y * height),
width: Math.round(region.width * width),
height: Math.round(region.height * height),
};
}
function typographySize(node: DetailUiDslNode) {
if (node.kind === "heading") {
return node.emphasis === "hero" ? 54 : 34;
}
if (node.kind === "body") {
return node.emphasis === "strong" ? 22 : node.emphasis === "muted" ? 17 : 19;
}
return 18;
}
function panelStyle(node: Extract<DetailUiDslNode, { kind: "panel" }>) {
if (node.variant === "glass" && node.tone === "dark") {
return {
background: "rgba(15, 23, 42, 0.76)",
color: "#ffffff",
borderColor: "rgba(255,255,255,0.14)",
borderWidth: 1,
borderRadius: 12,
padding: 24,
gap: node.gap || 18,
blur: 14,
boxShadow: "0 18px 48px rgba(15, 23, 42, 0.24)",
};
}
if (node.variant === "glass" && node.tone === "accent") {
return {
background: "rgba(37, 99, 235, 0.88)",
color: "#ffffff",
borderColor: "rgba(147,197,253,0.34)",
borderWidth: 1,
borderRadius: 12,
padding: 24,
gap: node.gap || 18,
blur: 12,
boxShadow: "0 18px 44px rgba(37, 99, 235, 0.20)",
};
}
return {
background: node.variant === "ghost" ? "transparent" : "rgba(255, 255, 255, 0.88)",
color: "#0f172a",
borderColor: node.variant === "ghost" ? "transparent" : "rgba(255,255,255,0.6)",
borderWidth: node.variant === "ghost" ? 0 : 1,
borderRadius: node.variant === "ghost" ? 0 : 12,
padding: node.variant === "ghost" ? 0 : 24,
gap: node.gap || 18,
blur: node.variant === "ghost" ? 0 : 12,
boxShadow: node.variant === "ghost" ? undefined : "0 18px 48px rgba(15, 23, 42, 0.12)",
};
}
function convertDetailNode(node: DetailUiDslNode, path: string): UiScreenLayer {
const id = `${path}-${node.id}`.replace(/[^a-zA-Z0-9_-]/g, "-");
if (node.kind === "panel") {
return {
id,
kind: "stack",
name: node.id,
direction: node.direction || "column",
align: "stretch",
justify: "start",
style: panelStyle(node),
children: node.children.map((child) => convertDetailNode(child, id)),
};
}
if (node.kind === "stack") {
return {
id,
kind: "stack",
name: node.id,
direction: "column",
align: "stretch",
justify: "start",
style: {
gap: node.gap || 14,
},
children: node.children.map((child) => convertDetailNode(child, id)),
};
}
if (node.kind === "row") {
return {
id,
kind: "grid",
name: node.id,
columns: Math.max(1, node.columns || node.children.length || 1),
style: {
gap: node.gap || 14,
},
children: node.children.map((child) => convertDetailNode(child, id)),
};
}
if (node.kind === "badge") {
return {
id,
kind: "badge",
name: node.id,
label: node.text,
tone: node.tone || "primary",
style: {
fontSize: 15,
fontWeight: 700,
},
};
}
if (node.kind === "heading") {
return {
id,
kind: "text",
name: node.id,
text: node.text,
style: {
fontSize: typographySize(node),
fontWeight: 700,
lineHeight: 1.06,
letterSpacing: 0,
},
};
}
if (node.kind === "body") {
return {
id,
kind: "text",
name: node.id,
text: node.text,
style: {
fontSize: typographySize(node),
fontWeight: node.emphasis === "muted" ? 500 : 650,
lineHeight: 1.35,
color: node.emphasis === "muted" ? "rgba(71,85,105,0.82)" : undefined,
},
};
}
if (node.kind === "feature-card") {
return {
id,
kind: "card",
name: node.id,
style: {
background: "#ffffff",
color: "#0f172a",
borderColor:
node.tone === "secondary"
? "rgba(196,181,253,0.72)"
: node.tone === "neutral"
? "rgba(226,232,240,0.9)"
: "rgba(191,219,254,0.86)",
borderWidth: 1,
borderRadius: 10,
padding: 16,
boxShadow: "0 12px 30px rgba(15, 23, 42, 0.08)",
},
children: [
...(node.tag
? [
{
id: `${id}-tag`,
kind: "badge" as const,
label: node.tag,
tone: node.tone || "primary",
style: {
fontSize: 13,
fontWeight: 700,
},
},
]
: []),
{
id: `${id}-title`,
kind: "text",
text: node.title,
style: {
fontSize: 19,
fontWeight: 700,
lineHeight: 1.18,
color: "#0f172a",
},
},
...(node.body
? [
{
id: `${id}-body`,
kind: "text" as const,
text: node.body,
style: {
fontSize: 15,
fontWeight: 500,
lineHeight: 1.35,
color: "#64748b",
},
},
]
: []),
],
};
}
if (node.kind === "spec-table") {
return {
id,
kind: "stack",
name: node.id,
direction: "column",
style: {
background: "rgba(255,255,255,0.96)",
borderColor: "rgba(191,219,254,0.72)",
borderWidth: 1,
borderRadius: 10,
padding: 14,
gap: 8,
boxShadow: "0 12px 30px rgba(15, 23, 42, 0.08)",
},
children: node.rows.map((row, index) => ({
id: `${id}-row-${index + 1}`,
kind: "grid",
columns: 2,
style: { gap: 8 },
children: [
{
id: `${id}-row-${index + 1}-label`,
kind: "text",
text: row.label,
style: {
background: "#f1f5f9",
borderRadius: 12,
paddingX: 12,
paddingY: 9,
fontSize: 14,
fontWeight: 700,
textAlign: "center",
color: "#475569",
},
},
{
id: `${id}-row-${index + 1}-value`,
kind: "text",
text: row.value,
style: {
background: "#ffffff",
borderRadius: 12,
paddingX: 12,
paddingY: 9,
fontSize: 14,
fontWeight: 500,
color: "#0f172a",
},
},
],
})),
};
}
if (node.kind === "compare") {
return {
id,
kind: "grid",
name: node.id,
columns: 2,
style: { gap: 14 },
children: [
{
id: `${id}-before`,
kind: "card",
style: {
background: "rgba(255,255,255,0.95)",
borderColor: "rgba(226,232,240,0.9)",
borderWidth: 1,
borderRadius: 10,
padding: 16,
boxShadow: "0 10px 26px rgba(15,23,42,0.07)",
},
children: [
{
id: `${id}-before-title`,
kind: "text",
text: node.before.title,
style: { fontSize: 13, fontWeight: 700, color: "#64748b", textTransform: "uppercase" },
},
{
id: `${id}-before-body`,
kind: "text",
text: node.before.body,
style: { fontSize: 16, fontWeight: 500, lineHeight: 1.35, color: "#475569" },
},
],
},
{
id: `${id}-after`,
kind: "card",
style: {
background: "rgba(37,99,235,0.94)",
color: "#ffffff",
borderColor: "rgba(147,197,253,0.8)",
borderWidth: 1,
borderRadius: 10,
padding: 16,
boxShadow: "0 14px 34px rgba(37,99,235,0.18)",
},
children: [
{
id: `${id}-after-title`,
kind: "text",
text: node.after.title,
style: { fontSize: 13, fontWeight: 700, color: "#dbeafe", textTransform: "uppercase" },
},
{
id: `${id}-after-body`,
kind: "text",
text: node.after.body,
style: { fontSize: 16, fontWeight: 650, lineHeight: 1.35, color: "#ffffff" },
},
],
},
],
};
}
if (node.kind === "proof") {
return {
id,
kind: "badge",
name: node.id,
label: node.text,
tone: "neutral",
style: {
background: "rgba(255,255,255,0.94)",
color: "#334155",
borderColor: "rgba(226,232,240,0.9)",
borderWidth: 1,
borderRadius: 999,
paddingX: 16,
paddingY: 11,
fontSize: 15,
fontWeight: 700,
boxShadow: "0 8px 22px rgba(15,23,42,0.07)",
},
};
}
return {
id,
kind: "card",
name: node.id,
style: {
background: "rgba(15,23,42,0.82)",
color: "#ffffff",
borderColor: "rgba(255,255,255,0.16)",
borderWidth: 1,
borderRadius: 12,
padding: 20,
boxShadow: "0 16px 38px rgba(15,23,42,0.20)",
},
children: [
...(node.headline
? [
{
id: `${id}-headline`,
kind: "text" as const,
text: node.headline,
style: {
fontSize: 20,
fontWeight: 700,
lineHeight: 1.1,
},
},
]
: []),
{
id: `${id}-button`,
kind: "button",
label: node.buttonText,
tone: "primary",
style: {
fontSize: 15,
fontWeight: 700,
paddingX: 20,
paddingY: 14,
},
},
...(node.supportText
? [
{
id: `${id}-support`,
kind: "text" as const,
text: node.supportText,
style: {
fontSize: 14,
fontWeight: 500,
lineHeight: 1.3,
color: "#cbd5e1",
},
},
]
: []),
],
};
}
export function buildUiScreenDocumentFromDetailUiDsl(params: {
dsl: unknown;
background: DetailUiScreenBackground;
title?: string;
metadata?: Record<string, unknown>;
}): UiScreenDocument | null {
const dsl = normalizeDetailUiDslV2Document(params.dsl);
const backgroundSrc = String(params.background.src || "").trim();
if (!dsl || !backgroundSrc) return null;
const width = dsl.screenSize.width;
const height = dsl.screenSize.height;
const regionBounds = normalizedRegionToBounds(dsl.region, width, height);
const contentLayer: UiScreenLayer = {
id: `detail-screen-${dsl.screenIndex}-content`,
kind: "stack",
name: "可编辑详情排版",
bounds: regionBounds,
direction: "column",
align: "stretch",
justify:
dsl.layoutVariant === "floating-split" || dsl.layoutVariant === "cta-focus"
? "between"
: "start",
style: {
gap: 18,
fontFamily: dsl.designStyle?.typography?.fontFamily || "Inter, Arial, sans-serif",
},
children: dsl.nodes.map((node) => convertDetailNode(node, `detail-${dsl.screenIndex}`)),
};
return {
version: "ui-screen/v1",
id: `detail-ui-screen-${dsl.surfaceId}-${dsl.screenIndex}`,
title: params.title || `详情图-${dsl.screenIndex}`,
width,
height,
tokens: {
colors: {
primary: "#0071e3",
text: "#0f172a",
muted: "#64748b",
surface: "#ffffff",
},
shadows: {
soft: "0 12px 30px rgba(15,23,42,0.10)",
strong: "0 18px 48px rgba(15,23,42,0.18)",
},
},
assets: [
{
id: "background",
type: "image",
src: backgroundSrc,
alt: params.background.alt || params.title || `详情图-${dsl.screenIndex}`,
},
],
layers: [
{
id: `detail-screen-${dsl.screenIndex}-background`,
kind: "image",
name: "AI画面层",
assetId: "background",
fit: "cover",
bounds: {
x: 0,
y: 0,
width,
height,
},
},
contentLayer,
],
metadata: {
...(params.metadata || {}),
source: "detail-agent-layout",
detailDesignSkillVersion:
(params.metadata?.detailDesignSkill as { version?: unknown } | undefined)?.version ||
"detail-ui-design-skill/v1",
detailDslVersion: dsl.version,
detailSurfaceId: dsl.surfaceId,
detailScreenIndex: dsl.screenIndex,
detailLayoutVariant: dsl.layoutVariant,
backgroundThumbnailUrl: params.background.thumbnailUrl,
},
};
}

274
lib/ui-screen/patch.ts Normal file
View File

@ -0,0 +1,274 @@
import type {
UiScreenBounds,
UiScreenDocument,
UiScreenLayer,
UiScreenLayerStyle,
} from "@/lib/ui-screen/schema";
import type {
UiScreenValidationIssue,
UiScreenValidationResult,
} from "@/lib/ui-screen/validation";
export type UiScreenPatchOperation =
| { op: "set_text"; layerId: string; text: string }
| { op: "set_style"; layerId: string; style: Partial<UiScreenLayerStyle> }
| { op: "set_bounds"; layerId: string; bounds: Partial<UiScreenBounds> }
| { op: "set_validation"; validation: UiScreenValidationResult };
export interface UiScreenPatch {
version: "ui-screen-patch/v1";
documentId?: string;
summary?: string;
operations: UiScreenPatchOperation[];
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function clampBoundsToDocument(
bounds: UiScreenBounds,
document: UiScreenDocument
): UiScreenBounds {
const width = clamp(Math.round(bounds.width), 1, document.width);
const height = clamp(Math.round(bounds.height), 1, document.height);
return {
x: clamp(Math.round(bounds.x), 0, Math.max(0, document.width - width)),
y: clamp(Math.round(bounds.y), 0, Math.max(0, document.height - height)),
width,
height,
};
}
function clampBoundsToParent(
bounds: UiScreenBounds,
parentWidth: number,
parentHeight: number
): UiScreenBounds {
const width = clamp(Math.round(bounds.width), 1, Math.max(1, parentWidth));
const height = clamp(Math.round(bounds.height), 1, Math.max(1, parentHeight));
return {
x: clamp(Math.round(bounds.x), 0, Math.max(0, parentWidth - width)),
y: clamp(Math.round(bounds.y), 0, Math.max(0, parentHeight - height)),
width,
height,
};
}
function buildBoundsFix(params: {
issue: UiScreenValidationIssue;
layer: UiScreenLayer;
document: UiScreenDocument;
}): UiScreenBounds | null {
if (!params.layer.bounds) return null;
if (params.issue.code === "overflow_region") {
const parentWidth = Number(params.issue.details?.parentWidth);
const parentHeight = Number(params.issue.details?.parentHeight);
if (Number.isFinite(parentWidth) && Number.isFinite(parentHeight)) {
return clampBoundsToParent(params.layer.bounds, parentWidth, parentHeight);
}
}
const absoluteBounds = {
x: Number(params.issue.details?.x),
y: Number(params.issue.details?.y),
width: Number(params.issue.details?.width),
height: Number(params.issue.details?.height),
};
if (
Number.isFinite(absoluteBounds.x) &&
Number.isFinite(absoluteBounds.y) &&
Number.isFinite(absoluteBounds.width) &&
Number.isFinite(absoluteBounds.height)
) {
const clampedAbsolute = clampBoundsToDocument(absoluteBounds, params.document);
return {
x: Math.round(params.layer.bounds.x + clampedAbsolute.x - absoluteBounds.x),
y: Math.round(params.layer.bounds.y + clampedAbsolute.y - absoluteBounds.y),
width: clampedAbsolute.width,
height: clampedAbsolute.height,
};
}
return clampBoundsToDocument(params.layer.bounds, params.document);
}
function mapLayers(
layers: UiScreenLayer[],
layerId: string,
updater: (layer: UiScreenLayer) => UiScreenLayer
): UiScreenLayer[] {
return layers.map((layer) => {
const children = layer.children
? mapLayers(layer.children, layerId, updater)
: undefined;
const nextLayer = children ? { ...layer, children } : layer;
return nextLayer.id === layerId ? updater(nextLayer) : nextLayer;
});
}
function findLayer(layers: UiScreenLayer[], layerId: string): UiScreenLayer | null {
for (const layer of layers) {
if (layer.id === layerId) return layer;
const child = layer.children ? findLayer(layer.children, layerId) : null;
if (child) return child;
}
return null;
}
function shouldAutoFixBounds(issue: UiScreenValidationIssue) {
return issue.code === "out_of_bounds" || issue.code === "overflow_region";
}
export function applyUiScreenPatch(
document: UiScreenDocument,
patch: UiScreenPatch
): UiScreenDocument {
let nextDocument: UiScreenDocument = {
...document,
layers: document.layers,
metadata: {
...(document.metadata || {}),
},
};
for (const operation of patch.operations) {
if (operation.op === "set_text") {
nextDocument = {
...nextDocument,
layers: mapLayers(nextDocument.layers, operation.layerId, (layer) => {
if (layer.kind === "text") {
return { ...layer, text: operation.text };
}
if (layer.kind === "button" || layer.kind === "badge") {
return { ...layer, label: operation.text };
}
return layer;
}),
};
continue;
}
if (operation.op === "set_style") {
nextDocument = {
...nextDocument,
layers: mapLayers(nextDocument.layers, operation.layerId, (layer) => ({
...layer,
style: {
...(layer.style || {}),
...operation.style,
},
})),
};
continue;
}
if (operation.op === "set_bounds") {
nextDocument = {
...nextDocument,
layers: mapLayers(nextDocument.layers, operation.layerId, (layer) => ({
...layer,
bounds: {
...(layer.bounds || { x: 0, y: 0, width: 1, height: 1 }),
...operation.bounds,
},
})),
};
continue;
}
if (operation.op === "set_validation") {
nextDocument = {
...nextDocument,
metadata: {
...(nextDocument.metadata || {}),
uiScreenValidation: operation.validation,
},
};
}
}
return nextDocument;
}
export function buildAutoUiScreenPatch(params: {
document: UiScreenDocument;
validation: UiScreenValidationResult;
}): UiScreenPatch {
const styleUpdates = new Map<string, Partial<UiScreenLayerStyle>>();
const boundsUpdates = new Map<string, UiScreenBounds>();
const operations: UiScreenPatchOperation[] = [
{ op: "set_validation", validation: params.validation },
];
for (const issue of params.validation.issues) {
if (!issue.layerId) continue;
const layer = findLayer(params.document.layers, issue.layerId);
if (!layer) continue;
if (shouldAutoFixBounds(issue) && layer.bounds) {
const boundsFix = buildBoundsFix({ issue, layer, document: params.document });
if (boundsFix) {
boundsUpdates.set(layer.id, boundsFix);
}
}
if (issue.code === "tiny_text") {
styleUpdates.set(layer.id, {
...(styleUpdates.get(layer.id) || {}),
fontSize: Math.max(12, layer.style?.fontSize || 12),
lineHeight: Math.max(1.22, layer.style?.lineHeight || 1.22),
});
}
if (issue.code === "dense_text") {
const currentSize = layer.style?.fontSize || 16;
styleUpdates.set(layer.id, {
...(styleUpdates.get(layer.id) || {}),
fontSize: Math.max(12, Math.round(currentSize * 0.94)),
lineHeight: Math.max(1.32, layer.style?.lineHeight || 1.32),
});
}
if (issue.code === "negative_letter_spacing") {
styleUpdates.set(layer.id, {
...(styleUpdates.get(layer.id) || {}),
letterSpacing: 0,
});
}
if (issue.code === "excessive_radius") {
styleUpdates.set(layer.id, {
...(styleUpdates.get(layer.id) || {}),
borderRadius: Math.min(12, layer.style?.borderRadius || 12),
});
}
if (issue.code === "heavy_shadow") {
styleUpdates.set(layer.id, {
...(styleUpdates.get(layer.id) || {}),
boxShadow: "0 12px 30px rgba(15,23,42,0.10)",
});
}
}
for (const [layerId, bounds] of boundsUpdates) {
operations.push({ op: "set_bounds", layerId, bounds });
}
for (const [layerId, style] of styleUpdates) {
operations.push({ op: "set_style", layerId, style });
}
return {
version: "ui-screen-patch/v1",
documentId: params.document.id,
summary:
operations.length > 1
? `自动修复 ${operations.length - 1} 项 ui-screen 布局问题`
: "记录 ui-screen 质检结果",
operations,
};
}

308
lib/ui-screen/schema.ts Normal file
View File

@ -0,0 +1,308 @@
export type UiScreenLayerKind =
| "frame"
| "stack"
| "grid"
| "card"
| "image"
| "text"
| "button"
| "badge"
| "divider";
export type UiScreenFit = "cover" | "contain" | "fill";
export type UiScreenDirection = "row" | "column";
export type UiScreenAlign = "start" | "center" | "end" | "stretch";
export type UiScreenJustify = "start" | "center" | "end" | "between";
export interface UiScreenBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface UiScreenAsset {
id: string;
type: "image";
src: string;
alt?: string;
width?: number;
height?: number;
}
export interface UiScreenLayerStyle {
background?: string;
color?: string;
borderColor?: string;
borderWidth?: number;
borderRadius?: number;
boxShadow?: string;
blur?: number;
padding?: number;
paddingX?: number;
paddingY?: number;
gap?: number;
fontFamily?: string;
fontSize?: number;
fontWeight?: number | string;
lineHeight?: number;
letterSpacing?: number;
textAlign?: "left" | "center" | "right";
textTransform?: "none" | "uppercase" | "lowercase" | "capitalize";
}
export interface UiScreenLayer {
id: string;
kind: UiScreenLayerKind;
name?: string;
visible?: boolean;
opacity?: number;
bounds?: UiScreenBounds;
style?: UiScreenLayerStyle;
children?: UiScreenLayer[];
text?: string;
label?: string;
assetId?: string;
src?: string;
alt?: string;
fit?: UiScreenFit;
direction?: UiScreenDirection;
align?: UiScreenAlign;
justify?: UiScreenJustify;
columns?: number;
tone?: "primary" | "secondary" | "neutral" | "success" | "danger";
}
export interface UiScreenTokens {
colors?: Record<string, string>;
typography?: Record<
string,
{
fontFamily?: string;
fontSize?: number;
fontWeight?: number | string;
lineHeight?: number;
}
>;
spacing?: Record<string, number>;
radii?: Record<string, number>;
shadows?: Record<string, string>;
}
export interface UiScreenDocument {
version: "ui-screen/v1";
id: string;
title?: string;
width: number;
height: number;
tokens?: UiScreenTokens;
assets?: UiScreenAsset[];
layers: UiScreenLayer[];
metadata?: Record<string, unknown>;
}
function normalizeString(value: unknown, fallback = "") {
return String(value || "").trim() || fallback;
}
function normalizeNumber(value: unknown, fallback: number, min = -Infinity, max = Infinity) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, parsed));
}
function normalizeBounds(value: unknown): UiScreenBounds | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const bounds = {
x: normalizeNumber(raw.x, 0),
y: normalizeNumber(raw.y, 0),
width: normalizeNumber(raw.width, 0, 0),
height: normalizeNumber(raw.height, 0, 0),
};
return bounds.width > 0 && bounds.height > 0 ? bounds : undefined;
}
function normalizeStyle(value: unknown): UiScreenLayerStyle | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const style: UiScreenLayerStyle = {};
const stringKeys = [
"background",
"color",
"borderColor",
"boxShadow",
"fontFamily",
"fontWeight",
"textAlign",
"textTransform",
] as const;
stringKeys.forEach((key) => {
const normalized = normalizeString(raw[key]);
if (normalized) {
(style as Record<string, unknown>)[key] = normalized;
}
});
const numberKeys = [
"borderWidth",
"borderRadius",
"blur",
"padding",
"paddingX",
"paddingY",
"gap",
"fontSize",
"lineHeight",
"letterSpacing",
] as const;
numberKeys.forEach((key) => {
if (raw[key] === undefined || raw[key] === null) return;
(style as Record<string, unknown>)[key] = normalizeNumber(raw[key], 0);
});
if (
style.textAlign !== undefined &&
style.textAlign !== "left" &&
style.textAlign !== "center" &&
style.textAlign !== "right"
) {
delete style.textAlign;
}
if (
style.textTransform !== undefined &&
style.textTransform !== "none" &&
style.textTransform !== "uppercase" &&
style.textTransform !== "lowercase" &&
style.textTransform !== "capitalize"
) {
delete style.textTransform;
}
return Object.keys(style).length > 0 ? style : undefined;
}
function normalizeLayer(value: unknown): UiScreenLayer | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const id = normalizeString(raw.id);
const kind = normalizeString(raw.kind) as UiScreenLayerKind;
if (!id) return null;
if (
kind !== "frame" &&
kind !== "stack" &&
kind !== "grid" &&
kind !== "card" &&
kind !== "image" &&
kind !== "text" &&
kind !== "button" &&
kind !== "badge" &&
kind !== "divider"
) {
return null;
}
const children = Array.isArray(raw.children)
? raw.children.map(normalizeLayer).filter(Boolean)
: undefined;
const layer: UiScreenLayer = {
id,
kind,
name: normalizeString(raw.name) || undefined,
visible: raw.visible === false ? false : undefined,
opacity:
raw.opacity === undefined || raw.opacity === null
? undefined
: normalizeNumber(raw.opacity, 1, 0, 1),
bounds: normalizeBounds(raw.bounds),
style: normalizeStyle(raw.style),
children: children && children.length > 0 ? (children as UiScreenLayer[]) : undefined,
text: normalizeString(raw.text) || undefined,
label: normalizeString(raw.label) || undefined,
assetId: normalizeString(raw.assetId) || undefined,
src: normalizeString(raw.src) || undefined,
alt: normalizeString(raw.alt) || undefined,
fit:
raw.fit === "contain" || raw.fit === "fill" || raw.fit === "cover"
? raw.fit
: undefined,
direction: raw.direction === "row" ? "row" : raw.direction === "column" ? "column" : undefined,
align:
raw.align === "center" || raw.align === "end" || raw.align === "stretch"
? raw.align
: raw.align === "start"
? "start"
: undefined,
justify:
raw.justify === "center" ||
raw.justify === "end" ||
raw.justify === "between" ||
raw.justify === "start"
? raw.justify
: undefined,
columns: raw.columns === undefined ? undefined : Math.max(1, Math.round(normalizeNumber(raw.columns, 1))),
tone:
raw.tone === "secondary" ||
raw.tone === "neutral" ||
raw.tone === "success" ||
raw.tone === "danger"
? raw.tone
: raw.tone === "primary"
? "primary"
: undefined,
};
return layer;
}
export function normalizeUiScreenDocument(value: unknown): UiScreenDocument | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const raw = value as Record<string, unknown>;
const layers = Array.isArray(raw.layers)
? raw.layers.map(normalizeLayer).filter(Boolean)
: [];
if (layers.length === 0) return null;
return {
version: "ui-screen/v1",
id: normalizeString(raw.id, "ui-screen"),
title: normalizeString(raw.title) || undefined,
width: normalizeNumber(raw.width, 790, 240, 4096),
height: normalizeNumber(raw.height, 1300, 240, 4096),
tokens:
raw.tokens && typeof raw.tokens === "object" && !Array.isArray(raw.tokens)
? (raw.tokens as UiScreenTokens)
: undefined,
assets: Array.isArray(raw.assets)
? raw.assets
.map((asset) => {
if (!asset || typeof asset !== "object" || Array.isArray(asset)) return null;
const next = asset as Record<string, unknown>;
const id = normalizeString(next.id);
const src = normalizeString(next.src);
if (!id || !src) return null;
return {
id,
type: "image" as const,
src,
alt: normalizeString(next.alt) || undefined,
width: next.width === undefined ? undefined : normalizeNumber(next.width, 0, 0),
height: next.height === undefined ? undefined : normalizeNumber(next.height, 0, 0),
};
})
.filter(Boolean) as UiScreenAsset[]
: undefined,
layers: layers as UiScreenLayer[],
metadata:
raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata)
? (raw.metadata as Record<string, unknown>)
: undefined,
};
}

469
lib/ui-screen/validation.ts Normal file
View File

@ -0,0 +1,469 @@
import type {
UiScreenBounds,
UiScreenDocument,
UiScreenLayer,
} from "@/lib/ui-screen/schema";
export type UiScreenValidationSeverity = "info" | "warning" | "error";
export type UiScreenValidationCode =
| "out_of_bounds"
| "missing_image_source"
| "empty_text"
| "tiny_text"
| "dense_text"
| "overlap"
| "overflow_region"
| "excessive_radius"
| "heavy_shadow"
| "negative_letter_spacing"
| "overlay_too_large"
| "nested_card"
| "missing_design_skill_metadata";
export interface UiScreenValidationIssue {
id: string;
layerId?: string;
severity: UiScreenValidationSeverity;
code: UiScreenValidationCode;
message: string;
suggestion?: string;
details?: Record<string, number | string | boolean | undefined>;
}
export interface UiScreenValidationResult {
passed: boolean;
score: number;
issues: UiScreenValidationIssue[];
}
type FlatLayer = {
layer: UiScreenLayer;
path: string[];
ancestorKinds: UiScreenLayer["kind"][];
parentId?: string;
absoluteBounds?: UiScreenBounds;
parentBounds?: UiScreenBounds;
};
const DOCUMENT_EDGE_TOLERANCE = 2;
const MIN_READABLE_FONT_SIZE = 11;
function clampScore(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
function rectArea(bounds: UiScreenBounds) {
return Math.max(0, bounds.width) * Math.max(0, bounds.height);
}
function intersectionArea(left: UiScreenBounds, right: UiScreenBounds) {
const x = Math.max(left.x, right.x);
const y = Math.max(left.y, right.y);
const width = Math.min(left.x + left.width, right.x + right.width) - x;
const height = Math.min(left.y + left.height, right.y + right.height) - y;
return width > 0 && height > 0 ? width * height : 0;
}
function offsetBounds(bounds: UiScreenBounds, parentBounds?: UiScreenBounds): UiScreenBounds {
return {
x: Math.round((parentBounds?.x || 0) + bounds.x),
y: Math.round((parentBounds?.y || 0) + bounds.y),
width: Math.round(bounds.width),
height: Math.round(bounds.height),
};
}
function flattenLayers(params: {
layers: UiScreenLayer[];
parentId?: string;
parentBounds?: UiScreenBounds;
path?: string[];
ancestorKinds?: UiScreenLayer["kind"][];
}): FlatLayer[] {
const flattened: FlatLayer[] = [];
for (const layer of params.layers) {
if (layer.visible === false) continue;
const absoluteBounds = layer.bounds
? offsetBounds(layer.bounds, params.parentBounds)
: undefined;
const item: FlatLayer = {
layer,
path: [...(params.path || []), layer.id],
ancestorKinds: params.ancestorKinds || [],
parentId: params.parentId,
parentBounds: params.parentBounds,
absoluteBounds,
};
flattened.push(item);
if (layer.children?.length) {
flattened.push(
...flattenLayers({
layers: layer.children,
parentId: layer.id,
parentBounds: absoluteBounds || params.parentBounds,
path: item.path,
ancestorKinds: [...item.ancestorKinds, layer.kind],
})
);
}
}
return flattened;
}
function textValue(layer: UiScreenLayer) {
if (layer.kind === "text") return layer.text || "";
if (layer.kind === "button" || layer.kind === "badge") {
return layer.label || layer.text || "";
}
return "";
}
function isTextLikeLayer(layer: UiScreenLayer) {
return layer.kind === "text" || layer.kind === "button" || layer.kind === "badge";
}
function isOverlapCandidate(layer: UiScreenLayer) {
return (
layer.kind === "text" ||
layer.kind === "button" ||
layer.kind === "badge" ||
layer.kind === "card"
);
}
function textWeight(text: string) {
let weight = 0;
for (const char of text) {
if (/\s/.test(char)) {
weight += 0.25;
} else if (/[\u3400-\u9fff]/.test(char)) {
weight += 1;
} else {
weight += 0.58;
}
}
return weight;
}
function hasAsset(document: UiScreenDocument, assetId: string | undefined) {
if (!assetId) return false;
return Boolean(document.assets?.some((asset) => asset.id === assetId && asset.src));
}
function isAncestor(left: FlatLayer, right: FlatLayer) {
return (
left.path.includes(right.layer.id) ||
right.path.includes(left.layer.id)
);
}
function issueId(layerId: string | undefined, code: UiScreenValidationCode, suffix = "") {
return [code, layerId || "document", suffix].filter(Boolean).join(":");
}
function hasVisibleSurface(layer: UiScreenLayer) {
return Boolean(layer.style?.background || layer.style?.borderColor || layer.style?.boxShadow);
}
function maxShadowPixelValue(boxShadow: string | undefined) {
if (!boxShadow) return 0;
const matches = boxShadow.match(/-?\d+(?:\.\d+)?px/g) || [];
return matches.reduce((max, value) => Math.max(max, Math.abs(Number.parseFloat(value))), 0);
}
function isDetailAgentDocument(document: UiScreenDocument) {
return document.metadata?.source === "detail-agent-layout";
}
export function validateUiScreenDocument(
document: UiScreenDocument
): UiScreenValidationResult {
const issues: UiScreenValidationIssue[] = [];
const flattened = flattenLayers({ layers: document.layers });
const documentArea = Math.max(1, document.width * document.height);
if (isDetailAgentDocument(document) && !document.metadata?.detailDesignSkillVersion) {
issues.push({
id: issueId(undefined, "missing_design_skill_metadata"),
severity: "info",
code: "missing_design_skill_metadata",
message: "当前详情 UI 文档缺少设计技能版本标记",
suggestion: "用新版 AI详情 AGENT排版重新生成,或在 metadata 中补齐 detailDesignSkillVersion。",
});
}
for (const item of flattened) {
const { layer, absoluteBounds } = item;
if (absoluteBounds) {
const overflowLeft = absoluteBounds.x < -DOCUMENT_EDGE_TOLERANCE;
const overflowTop = absoluteBounds.y < -DOCUMENT_EDGE_TOLERANCE;
const overflowRight =
absoluteBounds.x + absoluteBounds.width > document.width + DOCUMENT_EDGE_TOLERANCE;
const overflowBottom =
absoluteBounds.y + absoluteBounds.height > document.height + DOCUMENT_EDGE_TOLERANCE;
if (overflowLeft || overflowTop || overflowRight || overflowBottom) {
issues.push({
id: issueId(layer.id, "out_of_bounds"),
layerId: layer.id,
severity: "error",
code: "out_of_bounds",
message: `图层「${layer.name || layer.id}」超出画布边界`,
suggestion: "将图层 bounds 限制在 ui-screen 文档宽高范围内。",
details: {
x: absoluteBounds.x,
y: absoluteBounds.y,
width: absoluteBounds.width,
height: absoluteBounds.height,
documentWidth: document.width,
documentHeight: document.height,
},
});
}
}
if (layer.bounds && item.parentBounds) {
const childOverflowsParent =
layer.bounds.x < -DOCUMENT_EDGE_TOLERANCE ||
layer.bounds.y < -DOCUMENT_EDGE_TOLERANCE ||
layer.bounds.x + layer.bounds.width >
item.parentBounds.width + DOCUMENT_EDGE_TOLERANCE ||
layer.bounds.y + layer.bounds.height >
item.parentBounds.height + DOCUMENT_EDGE_TOLERANCE;
if (childOverflowsParent) {
issues.push({
id: issueId(layer.id, "overflow_region"),
layerId: layer.id,
severity: "warning",
code: "overflow_region",
message: `图层「${layer.name || layer.id}」溢出父级区域`,
suggestion: "收窄图层 bounds,或扩大父级容器的有效排版区域。",
details: {
parentId: item.parentId,
x: layer.bounds.x,
y: layer.bounds.y,
width: layer.bounds.width,
height: layer.bounds.height,
parentWidth: item.parentBounds.width,
parentHeight: item.parentBounds.height,
},
});
}
}
if (layer.kind === "image" && !layer.src && !hasAsset(document, layer.assetId)) {
issues.push({
id: issueId(layer.id, "missing_image_source"),
layerId: layer.id,
severity: "error",
code: "missing_image_source",
message: `图片图层「${layer.name || layer.id}」缺少可渲染素材`,
suggestion: "为图层设置 src,或补齐匹配 assetId 的 assets 条目。",
});
}
if (
isDetailAgentDocument(document) &&
absoluteBounds &&
!item.parentId &&
layer.kind !== "image"
) {
const coverage = rectArea(absoluteBounds) / documentArea;
if (coverage > 0.45) {
issues.push({
id: issueId(layer.id, "overlay_too_large"),
layerId: layer.id,
severity: "warning",
code: "overlay_too_large",
message: `可编辑 UI 区域「${layer.name || layer.id}」覆盖画面过大`,
suggestion: "将 region 控制在画布 45% 内,让产品/素材主体成为第一视觉。",
details: {
coverage: Number(coverage.toFixed(2)),
maxCoverage: 0.45,
},
});
}
}
if (layer.kind === "card" && item.ancestorKinds.includes("card")) {
issues.push({
id: issueId(layer.id, "nested_card"),
layerId: layer.id,
severity: "warning",
code: "nested_card",
message: `卡片「${layer.name || layer.id}」存在卡片套卡片`,
suggestion: "扁平化信息结构,改用 stack/grid 分组或减少外层卡片。",
});
}
if (
layer.style?.borderRadius !== undefined &&
layer.kind !== "button" &&
layer.kind !== "badge" &&
hasVisibleSurface(layer) &&
layer.style.borderRadius > 16
) {
issues.push({
id: issueId(layer.id, "excessive_radius"),
layerId: layer.id,
severity: "warning",
code: "excessive_radius",
message: `图层「${layer.name || layer.id}」圆角过大`,
suggestion: "详情 UI 面板/卡片圆角建议控制在 8-12px,避免廉价模板感。",
details: {
borderRadius: layer.style.borderRadius,
maxRadius: 16,
},
});
}
const shadowMax = maxShadowPixelValue(layer.style?.boxShadow);
if (shadowMax > 56) {
issues.push({
id: issueId(layer.id, "heavy_shadow"),
layerId: layer.id,
severity: "warning",
code: "heavy_shadow",
message: `图层「${layer.name || layer.id}」阴影过重`,
suggestion: "降低阴影模糊和扩散,使用更轻的层级表达。",
details: {
maxShadowPx: shadowMax,
recommendedMaxPx: 56,
},
});
}
if (isTextLikeLayer(layer)) {
const text = textValue(layer).trim();
const fontSize = layer.style?.fontSize;
if (typeof layer.style?.letterSpacing === "number" && layer.style.letterSpacing < 0) {
issues.push({
id: issueId(layer.id, "negative_letter_spacing"),
layerId: layer.id,
severity: "warning",
code: "negative_letter_spacing",
message: `文字图层「${layer.name || layer.id}」使用了负字距`,
suggestion: "将 letterSpacing 调整为 0,减少跨字体渲染风险。",
details: {
letterSpacing: layer.style.letterSpacing,
},
});
}
if (!text) {
issues.push({
id: issueId(layer.id, "empty_text"),
layerId: layer.id,
severity: "warning",
code: "empty_text",
message: `文字图层「${layer.name || layer.id}」为空`,
suggestion: "补齐真实文案,或隐藏该图层。",
});
}
if (typeof fontSize === "number" && fontSize > 0 && fontSize < MIN_READABLE_FONT_SIZE) {
issues.push({
id: issueId(layer.id, "tiny_text"),
layerId: layer.id,
severity: "warning",
code: "tiny_text",
message: `文字图层「${layer.name || layer.id}」字号过小`,
suggestion: `将字号提升到至少 ${MIN_READABLE_FONT_SIZE}px。`,
details: {
fontSize,
minFontSize: MIN_READABLE_FONT_SIZE,
},
});
}
if (text && absoluteBounds) {
const readableFontSize =
typeof fontSize === "number" && fontSize > 0 ? fontSize : 16;
const lineHeight =
typeof layer.style?.lineHeight === "number" && layer.style.lineHeight > 0
? layer.style.lineHeight
: 1.28;
const horizontalCapacity = Math.max(1, absoluteBounds.width / (readableFontSize * 0.64));
const verticalCapacity = Math.max(
1,
absoluteBounds.height / (readableFontSize * lineHeight)
);
const estimatedCapacity = horizontalCapacity * verticalCapacity;
if (textWeight(text) > estimatedCapacity * 1.24) {
issues.push({
id: issueId(layer.id, "dense_text"),
layerId: layer.id,
severity: "warning",
code: "dense_text",
message: `文字图层「${layer.name || layer.id}」内容密度偏高`,
suggestion: "减少文案长度、放大文本区域,或降低字号并提高行高。",
details: {
textWeight: Math.round(textWeight(text)),
estimatedCapacity: Math.round(estimatedCapacity),
width: absoluteBounds.width,
height: absoluteBounds.height,
fontSize: readableFontSize,
},
});
}
}
}
}
const overlapCandidates = flattened.filter(
(item) => item.absoluteBounds && isOverlapCandidate(item.layer)
);
for (let leftIndex = 0; leftIndex < overlapCandidates.length; leftIndex += 1) {
for (
let rightIndex = leftIndex + 1;
rightIndex < overlapCandidates.length;
rightIndex += 1
) {
const left = overlapCandidates[leftIndex];
const right = overlapCandidates[rightIndex];
if (!left?.absoluteBounds || !right?.absoluteBounds || isAncestor(left, right)) {
continue;
}
const intersection = intersectionArea(left.absoluteBounds, right.absoluteBounds);
const smallerArea = Math.min(rectArea(left.absoluteBounds), rectArea(right.absoluteBounds));
if (smallerArea <= 0 || intersection / smallerArea < 0.42) continue;
issues.push({
id: issueId(left.layer.id, "overlap", right.layer.id),
layerId: left.layer.id,
severity: "warning",
code: "overlap",
message: `图层「${left.layer.name || left.layer.id}」与「${
right.layer.name || right.layer.id
}」重叠过多`,
suggestion: "拉开图层间距,或把其中一个图层移动到新的排版区域。",
details: {
otherLayerId: right.layer.id,
overlapRatio: Number((intersection / smallerArea).toFixed(2)),
},
});
}
}
const errorCount = issues.filter((issue) => issue.severity === "error").length;
const warningCount = issues.filter((issue) => issue.severity === "warning").length;
const infoCount = issues.filter((issue) => issue.severity === "info").length;
const score = clampScore(100 - errorCount * 18 - warningCount * 8 - infoCount * 3);
return {
passed: errorCount === 0 && score >= 82,
score,
issues,
};
}

View File

@ -1,4 +1,5 @@
import type { AgentWorkflowPersistedState } from "@/types/agent";
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
export type CanvasWorkspaceType = 'canvas' | 'agent' | 'video';
@ -9,6 +10,7 @@ export type NodeType =
| 'image'
| 'video'
| 'text'
| 'ui-screen'
| 'workflow'
| 'scene-fusion'
| 'model3d'
@ -105,6 +107,34 @@ export interface TextNodeData {
metadata?: Record<string, any>;
}
export interface UiScreenNodeData {
status: NodeStatus;
document: UiScreenDocument;
renderArtifacts?: {
htmlUrl?: string;
screenshotUrl?: string;
thumbnailUrl?: string;
layerMap?: Array<{
layerId: string;
x: number;
y: number;
width: number;
height: number;
}>;
};
source?: {
prompt?: string;
model?: string;
generationId?: string;
};
evaluation?: {
score?: number;
issues?: string[];
reviewedAt?: string;
};
metadata?: Record<string, any>;
}
// 工作流节点数据
export interface WorkflowNodeData {
templateType: string;
@ -181,6 +211,7 @@ export interface CanvasNode {
| ImageNodeData
| VideoNodeData
| TextNodeData
| UiScreenNodeData
| WorkflowNodeData
| SceneFusionNodeData
| Model3DNodeData