feat: enhance canvas agent UI customization
This commit is contained in:
parent
ccb62d6f7d
commit
3101f5966d
@ -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>
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
62
app/api/canvas/ui-screen/review/route.ts
Normal file
62
app/api/canvas/ui-screen/review/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
44
components/canvas/CanvasToolbarIcon.tsx
Normal file
44
components/canvas/CanvasToolbarIcon.tsx
Normal 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} />;
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
409
components/canvas/nodes/UiScreenNode.tsx
Normal file
409
components/canvas/nodes/UiScreenNode.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
362
components/canvas/ui/UiScreenRenderer.tsx
Normal file
362
components/canvas/ui/UiScreenRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
152
lib/ai/detail-agent-kimi-profile.ts
Normal file
152
lib/ai/detail-agent-kimi-profile.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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}`
|
||||
|
||||
752
lib/canvas-toolbar-config.ts
Normal file
752
lib/canvas-toolbar-config.ts
Normal 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];
|
||||
}
|
||||
231
lib/detail-ui-design-skill.ts
Normal file
231
lib/detail-ui-design-skill.ts
Normal 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");
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
492
lib/detail-ui-style-board.ts
Normal file
492
lib/detail-ui-style-board.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
515
lib/ui-screen/detail-adapter.ts
Normal file
515
lib/ui-screen/detail-adapter.ts
Normal 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
274
lib/ui-screen/patch.ts
Normal 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
308
lib/ui-screen/schema.ts
Normal 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
469
lib/ui-screen/validation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user