feat: enhance canvas agent UI customization
This commit is contained in:
parent
ccb62d6f7d
commit
3101f5966d
@ -30,6 +30,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||||
|
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
|
||||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||||
DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
|
DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID,
|
||||||
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
|
||||||
@ -84,6 +85,16 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Wrench,
|
Wrench,
|
||||||
Bot,
|
Bot,
|
||||||
|
Box,
|
||||||
|
Brain,
|
||||||
|
CheckCircle2,
|
||||||
|
Copy,
|
||||||
|
Crosshair,
|
||||||
|
Download,
|
||||||
|
Eraser,
|
||||||
|
ExternalLink,
|
||||||
|
FolderOpen,
|
||||||
|
Globe2,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Plus,
|
Plus,
|
||||||
Edit,
|
Edit,
|
||||||
@ -92,29 +103,57 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Key,
|
Key,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
MousePointer,
|
||||||
Bell,
|
Bell,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
|
ImagePlus,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Search,
|
Search,
|
||||||
|
Scan,
|
||||||
Calendar,
|
Calendar,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
Clock,
|
Clock,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ArrowLeft,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
Scissors,
|
Scissors,
|
||||||
Expand,
|
Expand,
|
||||||
|
Maximize2,
|
||||||
|
MessageSquarePlus,
|
||||||
Move3D,
|
Move3D,
|
||||||
|
MoreHorizontal,
|
||||||
|
MoreVertical,
|
||||||
|
Hand,
|
||||||
|
GitBranch,
|
||||||
|
Grid3X3,
|
||||||
Layers,
|
Layers,
|
||||||
|
Paperclip,
|
||||||
|
PenTool,
|
||||||
|
Redo2,
|
||||||
|
Tag,
|
||||||
|
Type,
|
||||||
|
Undo2,
|
||||||
Upload,
|
Upload,
|
||||||
Video,
|
Video,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
StickyNote,
|
||||||
Loader2,
|
Loader2,
|
||||||
Check,
|
Check,
|
||||||
Paintbrush,
|
Paintbrush,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
Waypoints,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
X,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
Database,
|
Database,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -155,6 +194,14 @@ import {
|
|||||||
normalizeCanvasSkillTags,
|
normalizeCanvasSkillTags,
|
||||||
type CanvasSkillTag,
|
type CanvasSkillTag,
|
||||||
} from "@/lib/canvas-skill-tags";
|
} from "@/lib/canvas-skill-tags";
|
||||||
|
import {
|
||||||
|
DEFAULT_CANVAS_TOOLBAR_ITEMS,
|
||||||
|
defaultCanvasToolbarConfig,
|
||||||
|
getDefaultCanvasToolbarItem,
|
||||||
|
normalizeCanvasToolbarConfig,
|
||||||
|
type CanvasToolbarConfig,
|
||||||
|
type CanvasToolbarItemKey,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import {
|
import {
|
||||||
defaultAgentWorkflowUiConfig,
|
defaultAgentWorkflowUiConfig,
|
||||||
normalizeAgentWorkflowUiConfig,
|
normalizeAgentWorkflowUiConfig,
|
||||||
@ -733,6 +780,121 @@ type RealtimeCanvasModeIconConfig = {
|
|||||||
|
|
||||||
type RealtimeCanvasModeIconKey = "realtimeIconUrl" | "enhanceIconUrl";
|
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 = {
|
type SiteSettings = {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
siteLogo: string | null;
|
siteLogo: string | null;
|
||||||
@ -774,6 +936,7 @@ type SiteSettings = {
|
|||||||
agentWorkflowUiConfig: AgentWorkflowUiConfig;
|
agentWorkflowUiConfig: AgentWorkflowUiConfig;
|
||||||
realtimeCanvasEnabled: boolean;
|
realtimeCanvasEnabled: boolean;
|
||||||
realtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig;
|
realtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig;
|
||||||
|
canvasToolbarConfig: CanvasToolbarConfig;
|
||||||
aiDetailGenerationEnabled: boolean;
|
aiDetailGenerationEnabled: boolean;
|
||||||
canvasDefaultModelId: string | null;
|
canvasDefaultModelId: string | null;
|
||||||
canvasAutoModeDefaultModelId: string | null;
|
canvasAutoModeDefaultModelId: string | null;
|
||||||
@ -1344,6 +1507,7 @@ export function AdminSettingsClient() {
|
|||||||
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
|
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
|
||||||
realtimeCanvasEnabled: true,
|
realtimeCanvasEnabled: true,
|
||||||
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
|
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
|
||||||
|
canvasToolbarConfig: defaultCanvasToolbarConfig,
|
||||||
aiDetailGenerationEnabled: false,
|
aiDetailGenerationEnabled: false,
|
||||||
canvasDefaultModelId: null,
|
canvasDefaultModelId: null,
|
||||||
canvasAutoModeDefaultModelId: null,
|
canvasAutoModeDefaultModelId: null,
|
||||||
@ -1488,6 +1652,9 @@ export function AdminSettingsClient() {
|
|||||||
realtimeCanvasModeIconConfig: normalizeRealtimeCanvasModeIconConfig(
|
realtimeCanvasModeIconConfig: normalizeRealtimeCanvasModeIconConfig(
|
||||||
(siteSettingsData as any).realtimeCanvasModeIconConfig
|
(siteSettingsData as any).realtimeCanvasModeIconConfig
|
||||||
),
|
),
|
||||||
|
canvasToolbarConfig: normalizeCanvasToolbarConfig(
|
||||||
|
(siteSettingsData as any).canvasToolbarConfig
|
||||||
|
),
|
||||||
aiDetailGenerationEnabled:
|
aiDetailGenerationEnabled:
|
||||||
siteSettingsData.aiDetailGenerationEnabled ?? false,
|
siteSettingsData.aiDetailGenerationEnabled ?? false,
|
||||||
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
|
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
|
||||||
@ -1827,7 +1994,7 @@ export function AdminSettingsClient() {
|
|||||||
baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
baseUrl: DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
modelId: DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
||||||
displayName: "官方 Kimi K2.5",
|
displayName: DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
});
|
});
|
||||||
const [thirdPartySqlServerConfig, setThirdPartySqlServerConfig] = useState({
|
const [thirdPartySqlServerConfig, setThirdPartySqlServerConfig] = useState({
|
||||||
@ -2490,6 +2657,8 @@ export function AdminSettingsClient() {
|
|||||||
>(null);
|
>(null);
|
||||||
const [realtimeModeIconUploading, setRealtimeModeIconUploading] =
|
const [realtimeModeIconUploading, setRealtimeModeIconUploading] =
|
||||||
useState<RealtimeCanvasModeIconKey | null>(null);
|
useState<RealtimeCanvasModeIconKey | null>(null);
|
||||||
|
const [canvasToolbarIconUploading, setCanvasToolbarIconUploading] =
|
||||||
|
useState<CanvasToolbarItemKey | null>(null);
|
||||||
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
title?: string;
|
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 = () => {
|
const handleAddCanvasSkillTag = () => {
|
||||||
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
|
||||||
};
|
};
|
||||||
@ -5471,7 +5699,8 @@ export function AdminSettingsClient() {
|
|||||||
moonshotDetailAgentConfig.modelId.trim() ||
|
moonshotDetailAgentConfig.modelId.trim() ||
|
||||||
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
|
DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID;
|
||||||
const trimmedDisplayName =
|
const trimmedDisplayName =
|
||||||
moonshotDetailAgentConfig.displayName.trim() || "官方 Kimi K2.5";
|
moonshotDetailAgentConfig.displayName.trim() ||
|
||||||
|
DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME;
|
||||||
const trimmedProviderName =
|
const trimmedProviderName =
|
||||||
moonshotDetailAgentConfig.providerName.trim() ||
|
moonshotDetailAgentConfig.providerName.trim() ||
|
||||||
"Moonshot AI (Official)";
|
"Moonshot AI (Official)";
|
||||||
@ -5484,7 +5713,7 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!trimmedModelId) {
|
if (!trimmedModelId) {
|
||||||
toast.error("请填写官方 Kimi K2.5 的 API 模型 ID");
|
toast.error("请填写官方 Kimi K2.6 的 API 模型 ID");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5544,7 +5773,7 @@ export function AdminSettingsClient() {
|
|||||||
|
|
||||||
mutateMoonshotDetailAgentSetting();
|
mutateMoonshotDetailAgentSetting();
|
||||||
mutateSiteSettings();
|
mutateSiteSettings();
|
||||||
toast.success("Moonshot 官方 Kimi K2.5 已绑定为 AI详情 Agent 专用模型");
|
toast.success("Moonshot 官方 Kimi K2.6 已绑定为 AI详情 Agent 专用模型");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Moonshot Detail Agent Config] Error:", error);
|
console.error("[Moonshot Detail Agent Config] Error:", error);
|
||||||
toast.error(
|
toast.error(
|
||||||
@ -10490,6 +10719,200 @@ export function AdminSettingsClient() {
|
|||||||
</div>
|
</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="flex items-center justify-between rounded-lg border p-3">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="videoCanvasEnabled">
|
<Label htmlFor="videoCanvasEnabled">
|
||||||
@ -10621,7 +11044,7 @@ export function AdminSettingsClient() {
|
|||||||
</Select>
|
</Select>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
用于 AI详情生成 的分镜规划与排版复审。未设置时沿用
|
用于 AI详情生成 的分镜规划与排版复审。未设置时沿用
|
||||||
Agent 对话默认模型;仅影响 AI详情生成,不影响普通无限画布对话框的 Agent 对话。建议选择官方 Kimi K2.5 多模态模型。
|
Agent 对话默认模型;仅影响 AI详情生成,不影响普通无限画布对话框的 Agent 对话。建议选择官方 Kimi K2.6 多模态模型。
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<div className="space-y-1.5">
|
||||||
<CardTitle className="text-xl">
|
<CardTitle className="text-xl">
|
||||||
Moonshot 官方 Kimi K2.5 配置
|
Moonshot 官方 Kimi K2.6 配置
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.5
|
为 AI详情生成 单独配置 Moonshot 官方 Kimi K2.6
|
||||||
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
模型。保存后会自动绑定到“AI详情生成 - Agent专用模型”。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@ -14845,7 +15268,7 @@ export function AdminSettingsClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="moonshotModelId">Kimi K2.5 API 模型 ID *</Label>
|
<Label htmlFor="moonshotModelId">Kimi K2.6 API 模型 ID *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="moonshotModelId"
|
id="moonshotModelId"
|
||||||
value={moonshotDetailAgentConfig.modelId}
|
value={moonshotDetailAgentConfig.modelId}
|
||||||
@ -14859,7 +15282,7 @@ export function AdminSettingsClient() {
|
|||||||
/>
|
/>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
已按你提供的 Moonshot 官方文档默认填写
|
已按你提供的 Moonshot 官方文档默认填写
|
||||||
`kimi-k2.5`;保存后系统会自动创建/更新对应模型记录。
|
`kimi-k2.6`;保存后系统会自动创建/更新对应模型记录。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -14874,7 +15297,7 @@ export function AdminSettingsClient() {
|
|||||||
displayName: e.target.value,
|
displayName: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
placeholder="官方 Kimi K2.5"
|
placeholder={DETAIL_AGENT_LOCAL_MOONSHOT_DISPLAY_NAME}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -14917,7 +15340,7 @@ export function AdminSettingsClient() {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
<Button onClick={handleSaveMoonshotDetailAgentConfig}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
保存 Moonshot Kimi K2.5 配置
|
保存 Moonshot Kimi K2.6 配置
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -13,7 +13,12 @@ import {
|
|||||||
normalizeRealtimeCanvasConfigForStorage,
|
normalizeRealtimeCanvasConfigForStorage,
|
||||||
resolveRealtimeProviders,
|
resolveRealtimeProviders,
|
||||||
} from "@/lib/realtime/config";
|
} 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 {
|
import {
|
||||||
normalizeProductPlanningApiConfig,
|
normalizeProductPlanningApiConfig,
|
||||||
PRODUCT_PLANNING_API_CONFIG_KEY,
|
PRODUCT_PLANNING_API_CONFIG_KEY,
|
||||||
@ -57,8 +62,7 @@ const moonshotDetailAgentConfigSchema = z.object({
|
|||||||
isEnabled: z.boolean().optional(),
|
isEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const MOONSHOT_OFFICIAL_BASE_URL = "https://api.moonshot.cn/v1";
|
const MOONSHOT_OFFICIAL_BASE_URL = DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL;
|
||||||
const MOONSHOT_KIMI_K25_MODEL_ID = "kimi-k2.5";
|
|
||||||
|
|
||||||
function isMoonshotOfficialBaseUrl(value: string) {
|
function isMoonshotOfficialBaseUrl(value: string) {
|
||||||
return String(value || "").trim().replace(/\/+$/, "") === MOONSHOT_OFFICIAL_BASE_URL;
|
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 config = moonshotDetailAgentConfigSchema.parse(value || {});
|
||||||
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL;
|
const normalizedBaseUrl = String(config.baseUrl || "").trim() || MOONSHOT_OFFICIAL_BASE_URL;
|
||||||
const normalizedModelId = String(config.modelId || "").trim() || MOONSHOT_KIMI_K25_MODEL_ID;
|
const rawModelId = String(config.modelId || "").trim();
|
||||||
const normalizedDisplayName = String(config.displayName || "").trim() || "官方 Kimi K2.5";
|
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 =
|
const normalizedProviderName =
|
||||||
String(config.providerName || "").trim() || "Moonshot AI (Official)";
|
String(config.providerName || "").trim() || "Moonshot AI (Official)";
|
||||||
const normalizedApiKey =
|
const normalizedApiKey =
|
||||||
|
|||||||
@ -12,6 +12,10 @@ import {
|
|||||||
} from "@/lib/db/ai-model-flags";
|
} from "@/lib/db/ai-model-flags";
|
||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
|
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 { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||||
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
import { filterReachableRemoteUrls } from "@/lib/net/url-access";
|
||||||
import {
|
import {
|
||||||
@ -23,8 +27,18 @@ import {
|
|||||||
normalizeDetailUiDslV2Document,
|
normalizeDetailUiDslV2Document,
|
||||||
type DetailUiDslV2Document,
|
type DetailUiDslV2Document,
|
||||||
} from "@/lib/detail-ui-dsl-v2";
|
} 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 { validateDetailUiProtocol } from "@/lib/detail-ui-validator";
|
||||||
import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol";
|
import type { UiSurfaceSnapshot } from "@/lib/ui-surface-protocol";
|
||||||
|
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 180;
|
export const maxDuration = 180;
|
||||||
@ -227,6 +241,7 @@ type DetailPlanResponse = {
|
|||||||
version: string;
|
version: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
};
|
};
|
||||||
|
designSkill?: DetailUiDesignSkillSummary;
|
||||||
qualityChecks: {
|
qualityChecks: {
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
issues: string[];
|
issues: string[];
|
||||||
@ -246,6 +261,7 @@ type DetailPlanResponse = {
|
|||||||
uiProtocol?: DetailUiProtocol;
|
uiProtocol?: DetailUiProtocol;
|
||||||
uiSurface?: UiSurfaceSnapshot;
|
uiSurface?: UiSurfaceSnapshot;
|
||||||
uiDslV2Screens?: DetailUiDslV2Document[];
|
uiDslV2Screens?: DetailUiDslV2Document[];
|
||||||
|
brandStyleBoard?: UiScreenDocument;
|
||||||
model?: {
|
model?: {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: 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 {
|
function clampCount(value: unknown): number {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (!Number.isFinite(num)) return 14;
|
if (!Number.isFinite(num)) return 14;
|
||||||
@ -1187,6 +1214,7 @@ function buildFallbackPlan(params: {
|
|||||||
language: "zh" | "en";
|
language: "zh" | "en";
|
||||||
brief: DetailBrief;
|
brief: DetailBrief;
|
||||||
coreFocus: DetailCoreFocusContext;
|
coreFocus: DetailCoreFocusContext;
|
||||||
|
designSkill: DetailUiDesignSkillSpec;
|
||||||
referenceImageNotes: string[];
|
referenceImageNotes: string[];
|
||||||
hasModelImage: boolean;
|
hasModelImage: boolean;
|
||||||
hasBundleImages: boolean;
|
hasBundleImages: boolean;
|
||||||
@ -1194,7 +1222,8 @@ function buildFallbackPlan(params: {
|
|||||||
}): DetailPlanResponse {
|
}): DetailPlanResponse {
|
||||||
const { count, language } = params;
|
const { count, language } = params;
|
||||||
const globalStyle = buildDefaultGlobalStyle(language);
|
const globalStyle = buildDefaultGlobalStyle(language);
|
||||||
const skillFlow = buildDetailSkillFlow(language);
|
const skillFlow = applyDesignSkillToFlow(buildDetailSkillFlow(language), params.designSkill);
|
||||||
|
const designSkillSummary = summarizeDetailUiDesignSkill(params.designSkill);
|
||||||
const fallbackScreens = buildFallbackScreens({
|
const fallbackScreens = buildFallbackScreens({
|
||||||
...params,
|
...params,
|
||||||
globalStyle,
|
globalStyle,
|
||||||
@ -1237,6 +1266,7 @@ function buildFallbackPlan(params: {
|
|||||||
},
|
},
|
||||||
globalStyle,
|
globalStyle,
|
||||||
skillFlow,
|
skillFlow,
|
||||||
|
designSkill: designSkillSummary,
|
||||||
qualityChecks: {
|
qualityChecks: {
|
||||||
passed: qualityChecks.passed && uiValidation.passed,
|
passed: qualityChecks.passed && uiValidation.passed,
|
||||||
issues: mergedIssues,
|
issues: mergedIssues,
|
||||||
@ -1268,6 +1298,13 @@ function buildFallbackPlan(params: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
brandStyleBoard: buildDetailBrandStyleBoard({
|
||||||
|
language,
|
||||||
|
prompt: params.prompt,
|
||||||
|
brief: params.brief,
|
||||||
|
globalStyle,
|
||||||
|
designSkill: designSkillSummary,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1575,9 +1612,12 @@ function evaluatePlanQuality(params: {
|
|||||||
async function runDualDirectorPlanPreflight(params: {
|
async function runDualDirectorPlanPreflight(params: {
|
||||||
model: any;
|
model: any;
|
||||||
isLocalMoonshot?: boolean;
|
isLocalMoonshot?: boolean;
|
||||||
|
kimiOptions?: DetailKimiGenerateOptions;
|
||||||
|
timeoutMs?: number;
|
||||||
language: "zh" | "en";
|
language: "zh" | "en";
|
||||||
brief: DetailBrief;
|
brief: DetailBrief;
|
||||||
coreFocus: DetailCoreFocusContext;
|
coreFocus: DetailCoreFocusContext;
|
||||||
|
designSkillPrompt: string;
|
||||||
globalStyle: DetailPlanResponse["globalStyle"];
|
globalStyle: DetailPlanResponse["globalStyle"];
|
||||||
screens: DetailPlanScreen[];
|
screens: DetailPlanScreen[];
|
||||||
hasModelImage: boolean;
|
hasModelImage: boolean;
|
||||||
@ -1633,6 +1673,8 @@ async function runDualDirectorPlanPreflight(params: {
|
|||||||
params.hasBundleImages
|
params.hasBundleImages
|
||||||
? "Bundle refs provided: keep exactly one bundle showcase frame."
|
? "Bundle refs provided: keep exactly one bundle showcase frame."
|
||||||
: "No bundle refs: bundle showcase must be false in all frames.",
|
: "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.",
|
"If not pass, rewrite globalStyle and all screens directly to a production-ready version.",
|
||||||
"Return strict JSON only:",
|
"Return strict JSON only:",
|
||||||
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
|
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
|
||||||
@ -1657,6 +1699,8 @@ async function runDualDirectorPlanPreflight(params: {
|
|||||||
params.hasBundleImages
|
params.hasBundleImages
|
||||||
? "已提供搭配图:必须且仅保留1屏搭配展示页。"
|
? "已提供搭配图:必须且仅保留1屏搭配展示页。"
|
||||||
: "未提供搭配图:所有分镜 bundleShowcase=false。",
|
: "未提供搭配图:所有分镜 bundleShowcase=false。",
|
||||||
|
"必须把以下 UI 设计技能作为硬性视觉预审契约:",
|
||||||
|
params.designSkillPrompt,
|
||||||
"若不通过,直接重写 globalStyle 与全量 screens 到可生产状态。",
|
"若不通过,直接重写 globalStyle 与全量 screens 到可生产状态。",
|
||||||
"只返回严格JSON:",
|
"只返回严格JSON:",
|
||||||
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
|
"{\"pass\":boolean,\"designDirectorScore\":0-100,\"marketingDirectorScore\":0-100,\"issues\":[\"...\"],\"revisedGlobalStyle\":{...},\"revisedScreens\":[...]}",
|
||||||
@ -1680,7 +1724,8 @@ async function runDualDirectorPlanPreflight(params: {
|
|||||||
: "你同时扮演高转化电商详情页的平面设计总监与营销总监。仅输出JSON。",
|
: "你同时扮演高转化电商详情页的平面设计总监与营销总监。仅输出JSON。",
|
||||||
temperature: params.isLocalMoonshot ? 1 : 0.1,
|
temperature: params.isLocalMoonshot ? 1 : 0.1,
|
||||||
maxTokens: 2000,
|
maxTokens: 2000,
|
||||||
}, DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS);
|
...(params.kimiOptions || {}),
|
||||||
|
}, params.timeoutMs || DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS);
|
||||||
|
|
||||||
const parsed = extractJsonBlock<{
|
const parsed = extractJsonBlock<{
|
||||||
pass?: unknown;
|
pass?: unknown;
|
||||||
@ -1795,6 +1840,17 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const coreFocus = buildCoreFocusContext(brief, language);
|
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({
|
const fallback = buildFallbackPlan({
|
||||||
prompt,
|
prompt,
|
||||||
count,
|
count,
|
||||||
@ -1802,6 +1858,7 @@ export async function POST(request: Request) {
|
|||||||
language,
|
language,
|
||||||
brief,
|
brief,
|
||||||
coreFocus,
|
coreFocus,
|
||||||
|
designSkill,
|
||||||
referenceImageNotes: effectiveReferenceImageNotes,
|
referenceImageNotes: effectiveReferenceImageNotes,
|
||||||
hasModelImage,
|
hasModelImage,
|
||||||
hasBundleImages,
|
hasBundleImages,
|
||||||
@ -1863,6 +1920,20 @@ export async function POST(request: Request) {
|
|||||||
type: plannerConfig!.provider.type,
|
type: plannerConfig!.provider.type,
|
||||||
baseUrl: plannerConfig!.provider.baseUrl,
|
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:", {
|
console.log("[Canvas Detail Plan] Planner model selected:", {
|
||||||
id: selectedModelRef.id,
|
id: selectedModelRef.id,
|
||||||
@ -1887,6 +1958,12 @@ export async function POST(request: Request) {
|
|||||||
defaultParams: selectedModelRef.defaultParams,
|
defaultParams: selectedModelRef.defaultParams,
|
||||||
}),
|
}),
|
||||||
hasReferenceImages: referenceImages.length > 0,
|
hasReferenceImages: referenceImages.length > 0,
|
||||||
|
kimiDetailProfile: plannerKimiOptions.headers
|
||||||
|
? {
|
||||||
|
thinking: plannerKimiOptions.headers["x-detail-agent-kimi-thinking"],
|
||||||
|
maxTokens: plannerKimiOptions.maxTokens,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const codingModelId = localDetailAgentResolved
|
const codingModelId = localDetailAgentResolved
|
||||||
@ -1910,6 +1987,7 @@ export async function POST(request: Request) {
|
|||||||
`输出语言:${language === "en" ? "English" : "中文"}`,
|
`输出语言:${language === "en" ? "English" : "中文"}`,
|
||||||
`生成路线:${generationMode === "agent_layout" ? "AGENT排版(画面层+程序文字层)" : "传统生图(整页出图)"}`,
|
`生成路线:${generationMode === "agent_layout" ? "AGENT排版(画面层+程序文字层)" : "传统生图(整页出图)"}`,
|
||||||
"任务类型:电商详情页“AI详情生成”",
|
"任务类型:电商详情页“AI详情生成”",
|
||||||
|
`详情 UI 设计技能:\n${designSkillPrompt}`,
|
||||||
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
|
"必须遵循“详情升级文案方案”:定位卡 -> 卖点到买点转译 -> 黄金五屏 -> FABE闭环。",
|
||||||
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
|
`核心卖点锚点(整套必须围绕):${coreFocus.anchor}`,
|
||||||
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
|
`首屏标题参考(必须承接核心卖点):${coreFocus.firstScreenTitle}`,
|
||||||
@ -2015,12 +2093,14 @@ export async function POST(request: Request) {
|
|||||||
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
|
: `14) 未提供搭配产品图:所有分镜均设置 bundleShowcase=false。\n`) +
|
||||||
(generationMode === "agent_layout"
|
(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) 当前为 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` +
|
`输出格式:\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"}]}]}]}`,
|
`{"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,
|
temperature: 0.25,
|
||||||
...(localDetailAgentResolved ? { temperature: 1 } : {}),
|
...(localDetailAgentResolved ? { temperature: 1 } : {}),
|
||||||
maxTokens: 1800,
|
maxTokens: 1800,
|
||||||
|
...plannerKimiOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
@ -2028,7 +2108,7 @@ export async function POST(request: Request) {
|
|||||||
const runPlanner = async (visionInputs: Array<{ type: "image"; image: string }>) => {
|
const runPlanner = async (visionInputs: Array<{ type: "image"; image: string }>) => {
|
||||||
return await generateTextWithTimeout(
|
return await generateTextWithTimeout(
|
||||||
buildPlannerRequest(visionInputs),
|
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({
|
const preflight = await runDualDirectorPlanPreflight({
|
||||||
model,
|
model,
|
||||||
isLocalMoonshot: Boolean(localDetailAgentResolved),
|
isLocalMoonshot: Boolean(localDetailAgentResolved),
|
||||||
|
kimiOptions: preflightKimiOptions,
|
||||||
|
timeoutMs: preflightKimiOptions.headers ? 30_000 : DETAIL_PLAN_PREFLIGHT_TIMEOUT_MS,
|
||||||
language,
|
language,
|
||||||
brief,
|
brief,
|
||||||
coreFocus,
|
coreFocus,
|
||||||
|
designSkillPrompt,
|
||||||
globalStyle: globalStyleInitial,
|
globalStyle: globalStyleInitial,
|
||||||
screens: coreFocusedScreens,
|
screens: coreFocusedScreens,
|
||||||
hasModelImage,
|
hasModelImage,
|
||||||
@ -2228,8 +2311,10 @@ export async function POST(request: Request) {
|
|||||||
width: 790,
|
width: 790,
|
||||||
height: 1300,
|
height: 1300,
|
||||||
},
|
},
|
||||||
|
designStyle: item!.designStyle || globalStyle,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const designSkillSummary = summarizeDetailUiDesignSkill(designSkill);
|
||||||
|
|
||||||
const response: DetailPlanResponse = {
|
const response: DetailPlanResponse = {
|
||||||
summary,
|
summary,
|
||||||
@ -2241,6 +2326,7 @@ export async function POST(request: Request) {
|
|||||||
},
|
},
|
||||||
globalStyle,
|
globalStyle,
|
||||||
skillFlow: fallback.skillFlow,
|
skillFlow: fallback.skillFlow,
|
||||||
|
designSkill: designSkillSummary,
|
||||||
qualityChecks: qualityChecks.passed
|
qualityChecks: qualityChecks.passed
|
||||||
? {
|
? {
|
||||||
passed: qualityChecks.passed && uiValidation.passed,
|
passed: qualityChecks.passed && uiValidation.passed,
|
||||||
@ -2282,6 +2368,13 @@ export async function POST(request: Request) {
|
|||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
uiDslV2Screens,
|
uiDslV2Screens,
|
||||||
|
brandStyleBoard: buildDetailBrandStyleBoard({
|
||||||
|
language,
|
||||||
|
prompt,
|
||||||
|
brief,
|
||||||
|
globalStyle,
|
||||||
|
designSkill: designSkillSummary,
|
||||||
|
}),
|
||||||
model: {
|
model: {
|
||||||
id: selectedModelRef.id,
|
id: selectedModelRef.id,
|
||||||
displayName: selectedModelRef.displayName,
|
displayName: selectedModelRef.displayName,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/lib/db/ai-model-flags";
|
} from "@/lib/db/ai-model-flags";
|
||||||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||||||
import { resolveDetailAgentLocalMoonshotModel } from "@/lib/ai/detail-agent-model-runtime";
|
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 { inlineVisionImages } from "@/lib/ai/vision-inline";
|
||||||
import {
|
import {
|
||||||
buildDetailUiProtocol,
|
buildDetailUiProtocol,
|
||||||
@ -287,6 +288,13 @@ export async function POST(request: Request) {
|
|||||||
type: reviewerConfig!.provider.type,
|
type: reviewerConfig!.provider.type,
|
||||||
baseUrl: reviewerConfig!.provider.baseUrl,
|
baseUrl: reviewerConfig!.provider.baseUrl,
|
||||||
};
|
};
|
||||||
|
const reviewKimiOptions = buildDetailKimiGenerateOptions({
|
||||||
|
purpose: "review",
|
||||||
|
generationMode,
|
||||||
|
modelId: selectedModelRef.modelId,
|
||||||
|
providerBaseUrl: selectedProviderRef.baseUrl,
|
||||||
|
isLocalMoonshot: Boolean(localDetailAgentResolved),
|
||||||
|
});
|
||||||
const codingModelId = localDetailAgentResolved
|
const codingModelId = localDetailAgentResolved
|
||||||
? null
|
? null
|
||||||
: getAiModelAgentCodingModelId(reviewerConfig!.model);
|
: getAiModelAgentCodingModelId(reviewerConfig!.model);
|
||||||
@ -312,6 +320,12 @@ export async function POST(request: Request) {
|
|||||||
: fallbackAgentConfig?.model.id === reviewerConfig?.model.id
|
: fallbackAgentConfig?.model.id === reviewerConfig?.model.id
|
||||||
? "agent-default"
|
? "agent-default"
|
||||||
: "vision-fallback",
|
: "vision-fallback",
|
||||||
|
kimiDetailProfile: reviewKimiOptions.headers
|
||||||
|
? {
|
||||||
|
thinking: reviewKimiOptions.headers["x-detail-agent-kimi-thinking"],
|
||||||
|
maxTokens: reviewKimiOptions.maxTokens,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -376,19 +390,32 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const reviewImageCandidates = [imageUrl, ...referenceImages].slice(0, 6);
|
const reviewImageCandidates = [imageUrl, ...referenceImages].slice(0, 6);
|
||||||
let effectiveReviewImages = reviewImageCandidates;
|
let effectiveReviewImages = reviewImageCandidates;
|
||||||
if (
|
const mustInlineForKimi = Boolean(reviewKimiOptions.headers);
|
||||||
!localDetailAgentResolved &&
|
const shouldInlineReviewImages =
|
||||||
reviewerConfig &&
|
mustInlineForKimi ||
|
||||||
!isVolcanoResponsesReviewer(reviewerConfig)
|
(!localDetailAgentResolved &&
|
||||||
) {
|
reviewerConfig &&
|
||||||
const { inlineDataUrls } = await inlineVisionImages(reviewImageCandidates, {
|
!isVolcanoResponsesReviewer(reviewerConfig));
|
||||||
|
if (shouldInlineReviewImages) {
|
||||||
|
const { inlineDataUrls, skipped } = await inlineVisionImages(reviewImageCandidates, {
|
||||||
maxImages: 6,
|
maxImages: 6,
|
||||||
maxSide: 900,
|
maxSide: 900,
|
||||||
timeoutMs: 12000,
|
timeoutMs: 12000,
|
||||||
retries: 2,
|
retries: 2,
|
||||||
loggerPrefix: "[Canvas Detail Review]",
|
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 }> = [
|
const content: Array<{ type: "text"; text: string } | { type: "image"; image: string }> = [
|
||||||
@ -412,6 +439,7 @@ export async function POST(request: Request) {
|
|||||||
: "你同时是高转化电商详情页的平面设计总监与营销总监。按严格专业标准评估,仅输出JSON。",
|
: "你同时是高转化电商详情页的平面设计总监与营销总监。按严格专业标准评估,仅输出JSON。",
|
||||||
temperature: localDetailAgentResolved ? 1 : 0.1,
|
temperature: localDetailAgentResolved ? 1 : 0.1,
|
||||||
maxTokens: 800,
|
maxTokens: 800,
|
||||||
|
...reviewKimiOptions,
|
||||||
} as any);
|
} as any);
|
||||||
text = String(result?.text || "");
|
text = String(result?.text || "");
|
||||||
} catch (error) {
|
} 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,
|
defaultAgentPresetLibraryConfig,
|
||||||
normalizeAgentPresetLibraryConfig,
|
normalizeAgentPresetLibraryConfig,
|
||||||
} from "@/lib/agents/preset-library-config";
|
} from "@/lib/agents/preset-library-config";
|
||||||
|
import {
|
||||||
|
CANVAS_TOOLBAR_CONFIG_SETTING_KEY,
|
||||||
|
defaultCanvasToolbarConfig,
|
||||||
|
normalizeCanvasToolbarConfig,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import {
|
import {
|
||||||
defaultOssImageMigrationConfig,
|
defaultOssImageMigrationConfig,
|
||||||
normalizeOssImageMigrationConfig,
|
normalizeOssImageMigrationConfig,
|
||||||
@ -452,6 +457,7 @@ export async function GET() {
|
|||||||
dulupayConfigSetting,
|
dulupayConfigSetting,
|
||||||
realtimeCanvasConfigSetting,
|
realtimeCanvasConfigSetting,
|
||||||
realtimeCanvasModeIconConfigSetting,
|
realtimeCanvasModeIconConfigSetting,
|
||||||
|
canvasToolbarConfigSetting,
|
||||||
layerSplitConfigSetting,
|
layerSplitConfigSetting,
|
||||||
canvasAgentModeEnabledSetting,
|
canvasAgentModeEnabledSetting,
|
||||||
videoCanvasEnabledSetting,
|
videoCanvasEnabledSetting,
|
||||||
@ -562,6 +568,12 @@ export async function GET() {
|
|||||||
.where(eq(systemSettings.key, REALTIME_CANVAS_MODE_ICON_CONFIG_KEY))
|
.where(eq(systemSettings.key, REALTIME_CANVAS_MODE_ICON_CONFIG_KEY))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
.then(rows => rows[0]),
|
.then(rows => rows[0]),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(eq(systemSettings.key, CANVAS_TOOLBAR_CONFIG_SETTING_KEY))
|
||||||
|
.limit(1)
|
||||||
|
.then(rows => rows[0]),
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(systemSettings)
|
.from(systemSettings)
|
||||||
@ -697,6 +709,9 @@ export async function GET() {
|
|||||||
const realtimeCanvasModeIconConfig = normalizeRealtimeCanvasModeIconConfig(
|
const realtimeCanvasModeIconConfig = normalizeRealtimeCanvasModeIconConfig(
|
||||||
realtimeCanvasModeIconConfigSetting?.value
|
realtimeCanvasModeIconConfigSetting?.value
|
||||||
);
|
);
|
||||||
|
const canvasToolbarConfig = normalizeCanvasToolbarConfig(
|
||||||
|
canvasToolbarConfigSetting?.value
|
||||||
|
);
|
||||||
const layerSplitEnabled = normalizeLayerSplitEnabled(
|
const layerSplitEnabled = normalizeLayerSplitEnabled(
|
||||||
layerSplitConfigSetting?.value
|
layerSplitConfigSetting?.value
|
||||||
);
|
);
|
||||||
@ -805,6 +820,7 @@ export async function GET() {
|
|||||||
defaultAgentPsdTemplateCategoryTagIndex,
|
defaultAgentPsdTemplateCategoryTagIndex,
|
||||||
realtimeCanvasEnabled: true,
|
realtimeCanvasEnabled: true,
|
||||||
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
|
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
|
||||||
|
canvasToolbarConfig: defaultCanvasToolbarConfig,
|
||||||
realtimeCanvasServiceEnabled: true,
|
realtimeCanvasServiceEnabled: true,
|
||||||
layerSplitEnabled,
|
layerSplitEnabled,
|
||||||
storageFailoverConfig,
|
storageFailoverConfig,
|
||||||
@ -866,6 +882,7 @@ export async function GET() {
|
|||||||
dulupayConfig,
|
dulupayConfig,
|
||||||
realtimeCanvasServiceEnabled,
|
realtimeCanvasServiceEnabled,
|
||||||
realtimeCanvasModeIconConfig,
|
realtimeCanvasModeIconConfig,
|
||||||
|
canvasToolbarConfig,
|
||||||
layerSplitEnabled,
|
layerSplitEnabled,
|
||||||
canvasAgentModeEnabled,
|
canvasAgentModeEnabled,
|
||||||
videoCanvasEnabled,
|
videoCanvasEnabled,
|
||||||
@ -1222,6 +1239,18 @@ const updateSchema = z.object({
|
|||||||
enhanceLabel: z.string().trim().max(32).optional(),
|
enhanceLabel: z.string().trim().max(32).optional(),
|
||||||
})
|
})
|
||||||
.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(),
|
aiDetailGenerationEnabled: z.boolean().optional(),
|
||||||
canvasDefaultModelId: z.string().nullable().optional(),
|
canvasDefaultModelId: z.string().nullable().optional(),
|
||||||
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
|
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
|
||||||
@ -1349,6 +1378,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
storageFailoverConfig,
|
storageFailoverConfig,
|
||||||
ossImageMigrationConfig,
|
ossImageMigrationConfig,
|
||||||
realtimeCanvasModeIconConfig,
|
realtimeCanvasModeIconConfig,
|
||||||
|
canvasToolbarConfig,
|
||||||
canvasAgentModeEnabled,
|
canvasAgentModeEnabled,
|
||||||
videoCanvasEnabled,
|
videoCanvasEnabled,
|
||||||
videoCanvasSidebarEnabled,
|
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();
|
await persistHomeAnimationConfig();
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -2122,6 +2159,10 @@ export async function PUT(request: NextRequest) {
|
|||||||
realtimeCanvasModeIconConfig !== undefined
|
realtimeCanvasModeIconConfig !== undefined
|
||||||
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
||||||
: defaultRealtimeCanvasModeIconConfig,
|
: defaultRealtimeCanvasModeIconConfig,
|
||||||
|
canvasToolbarConfig:
|
||||||
|
canvasToolbarConfig !== undefined
|
||||||
|
? normalizeCanvasToolbarConfig(canvasToolbarConfig)
|
||||||
|
: defaultCanvasToolbarConfig,
|
||||||
canvasAgentModeEnabled:
|
canvasAgentModeEnabled:
|
||||||
canvasAgentModeEnabled !== undefined
|
canvasAgentModeEnabled !== undefined
|
||||||
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)
|
? 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) {
|
if (videoCanvasEnabled !== undefined) {
|
||||||
const normalized = normalizeVideoCanvasEnabled(videoCanvasEnabled);
|
const normalized = normalizeVideoCanvasEnabled(videoCanvasEnabled);
|
||||||
await db
|
await db
|
||||||
@ -2747,6 +2795,10 @@ export async function PUT(request: NextRequest) {
|
|||||||
realtimeCanvasModeIconConfig !== undefined
|
realtimeCanvasModeIconConfig !== undefined
|
||||||
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
|
||||||
: defaultRealtimeCanvasModeIconConfig,
|
: defaultRealtimeCanvasModeIconConfig,
|
||||||
|
canvasToolbarConfig:
|
||||||
|
canvasToolbarConfig !== undefined
|
||||||
|
? normalizeCanvasToolbarConfig(canvasToolbarConfig)
|
||||||
|
: defaultCanvasToolbarConfig,
|
||||||
canvasAgentModeEnabled:
|
canvasAgentModeEnabled:
|
||||||
canvasAgentModeEnabled !== undefined
|
canvasAgentModeEnabled !== undefined
|
||||||
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)
|
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)
|
||||||
|
|||||||
@ -88,6 +88,7 @@ import {
|
|||||||
} from "@/components/agents/AgentWorkflowPanelNew";
|
} from "@/components/agents/AgentWorkflowPanelNew";
|
||||||
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
import { CanvasChatPanel } from "@/components/canvas/CanvasChatPanel";
|
||||||
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
import { CanvasFileListPanel } from "@/components/canvas/CanvasFileListPanel";
|
||||||
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
|
import { ObjectPickerOverlay } from "@/components/canvas/ObjectPickerOverlay";
|
||||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -165,6 +166,14 @@ import {
|
|||||||
type VideoCanvasMarkerValue,
|
type VideoCanvasMarkerValue,
|
||||||
type VideoCanvasSymbolMarkerId,
|
type VideoCanvasSymbolMarkerId,
|
||||||
} from "@/lib/canvas/video-canvas-markers";
|
} from "@/lib/canvas/video-canvas-markers";
|
||||||
|
import {
|
||||||
|
getCanvasToolbarItem,
|
||||||
|
getDefaultCanvasToolbarItem,
|
||||||
|
normalizeCanvasToolbarConfig,
|
||||||
|
type CanvasToolbarConfig,
|
||||||
|
type CanvasToolbarItemConfig,
|
||||||
|
type CanvasToolbarItemKey,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import {
|
import {
|
||||||
buildAgentWorkflowComposerContext,
|
buildAgentWorkflowComposerContext,
|
||||||
getAgentWorkflowProgress,
|
getAgentWorkflowProgress,
|
||||||
@ -510,30 +519,21 @@ function normalizeRealtimeCanvasModeDisplayConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function RealtimeCanvasToolbarIcon({
|
function resolveCanvasToolbarItemWithFallback(
|
||||||
iconUrl,
|
config: CanvasToolbarConfig,
|
||||||
fallback: FallbackIcon,
|
key: CanvasToolbarItemKey,
|
||||||
className,
|
fallback?: Partial<CanvasToolbarItemConfig>
|
||||||
}: {
|
): CanvasToolbarItemConfig {
|
||||||
iconUrl?: string;
|
const item = getCanvasToolbarItem(config, key);
|
||||||
fallback: LucideIcon;
|
const defaultItem = getDefaultCanvasToolbarItem(key);
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
if (iconUrl) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"object-contain brightness-0 opacity-80 transition dark:invert",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
src={iconUrl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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[] = [
|
const VIDEO_CANVAS_SIDEBAR_GROUPS: VideoCanvasSidebarGroup[] = [
|
||||||
@ -1613,6 +1613,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
realtimeCanvasEnabled?: boolean;
|
realtimeCanvasEnabled?: boolean;
|
||||||
realtimeCanvasServiceEnabled?: boolean;
|
realtimeCanvasServiceEnabled?: boolean;
|
||||||
realtimeCanvasModeIconConfig?: Partial<RealtimeCanvasModeIconConfig>;
|
realtimeCanvasModeIconConfig?: Partial<RealtimeCanvasModeIconConfig>;
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
|
agentWorkflowShowcaseConfig?: AgentWorkflowShowcaseConfig;
|
||||||
agentWorkflowBrandModelConfig?: unknown;
|
agentWorkflowBrandModelConfig?: unknown;
|
||||||
agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex;
|
agentPsdTemplateCategoryTagIndex?: AgentPsdTemplateCategoryTagIndex;
|
||||||
@ -1671,6 +1672,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
),
|
),
|
||||||
[siteSettingsData?.realtimeCanvasModeIconConfig]
|
[siteSettingsData?.realtimeCanvasModeIconConfig]
|
||||||
);
|
);
|
||||||
|
const canvasToolbarConfig = useMemo(
|
||||||
|
() => normalizeCanvasToolbarConfig(siteSettingsData?.canvasToolbarConfig),
|
||||||
|
[siteSettingsData?.canvasToolbarConfig]
|
||||||
|
);
|
||||||
const isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode;
|
const isAgentMode = isAgentWorkspaceEnabled && requestedAgentMode;
|
||||||
const hasLoadedCurrentAgentProject =
|
const hasLoadedCurrentAgentProject =
|
||||||
!requestedAgentMode ||
|
!requestedAgentMode ||
|
||||||
@ -8734,6 +8739,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
>
|
>
|
||||||
<CanvasWorkspace
|
<CanvasWorkspace
|
||||||
batchProcessingEnabled={isBatchProcessingEnabled}
|
batchProcessingEnabled={isBatchProcessingEnabled}
|
||||||
|
canvasToolbarConfig={canvasToolbarConfig}
|
||||||
emptyStateDescription={
|
emptyStateDescription={
|
||||||
isVideoMode
|
isVideoMode
|
||||||
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
||||||
@ -9470,6 +9476,7 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
|
<div className="flex-1 overflow-hidden" key={`chat-${id}`}>
|
||||||
<CanvasChatPanel
|
<CanvasChatPanel
|
||||||
agentEnabled={isCanvasAgentModeEnabled}
|
agentEnabled={isCanvasAgentModeEnabled}
|
||||||
|
canvasToolbarConfig={canvasToolbarConfig}
|
||||||
chatId={`canvas-${id}`}
|
chatId={`canvas-${id}`}
|
||||||
chatScope="canvas"
|
chatScope="canvas"
|
||||||
onClose={() => setIsPanelOpen(false)}
|
onClose={() => setIsPanelOpen(false)}
|
||||||
@ -9570,10 +9577,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant={tool === "select" ? "secondary" : "ghost"}
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">选择 (V)</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "selectTool").label} (V)
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -9592,10 +9606,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant={tool === "pan" ? "secondary" : "ghost"}
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">平移 (H)</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "panTool").label} (H)
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{isVideoMode ? (
|
{isVideoMode ? (
|
||||||
@ -9611,11 +9632,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">保存到云端</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "saveCanvas").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9631,10 +9658,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={FolderOpen}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">云文件列表</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "cloudFiles").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9654,10 +9687,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Tag className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
active={isVideoCanvasMarkerPanelOpen}
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Tag}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "markerPanel")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">标记</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "markerPanel").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9678,10 +9718,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant={tool === "draw" ? "secondary" : "ghost"}
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">工作流连线 (L)</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "workflowConnection").label} (L)
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9702,10 +9749,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant={isAgentMode ? "secondary" : "ghost"}
|
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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">风格化模式</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "styleMode").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9722,10 +9776,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Layers className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Layers}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">批量处理</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "batchProcessing").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9741,11 +9801,23 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={ImagePlus}
|
||||||
|
item={getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
isVideoMode ? "addImageInput" : "addImage"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{isVideoMode ? "添加图片输入" : "添加图片"}
|
{
|
||||||
|
getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
isVideoMode ? "addImageInput" : "addImage"
|
||||||
|
).label
|
||||||
|
}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@ -9761,11 +9833,23 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Type className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Type}
|
||||||
|
item={getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
isVideoMode ? "addScriptNode" : "addText"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{isVideoMode ? "添加脚本节点" : "添加文本"}
|
{
|
||||||
|
getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
isVideoMode ? "addScriptNode" : "addText"
|
||||||
|
).label
|
||||||
|
}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@ -9778,10 +9862,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Video className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Video}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">添加视频输入</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "addVideoInput").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9791,9 +9881,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
<Button
|
<Button
|
||||||
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||||||
size="icon"
|
size="icon"
|
||||||
|
title={
|
||||||
|
getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary").label
|
||||||
|
}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Grid3X3 className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Grid3X3}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "workflowNodeLibrary")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@ -9876,20 +9973,38 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
isVideoMode
|
isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||||
: "h-9 w-9 rounded-xl"
|
: "h-9 w-9 rounded-xl"
|
||||||
)}
|
)}
|
||||||
onClick={() => router.push(`/canvas/${id}/realtime`)}
|
onClick={() => router.push(`/canvas/${id}/realtime`)}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<RealtimeCanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={PenTool}
|
fallback={PenTool}
|
||||||
iconUrl={realtimeCanvasModeDisplayConfig.realtimeIconUrl}
|
item={resolveCanvasToolbarItemWithFallback(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"realtimeCanvas",
|
||||||
|
{
|
||||||
|
iconUrl:
|
||||||
|
realtimeCanvasModeDisplayConfig.realtimeIconUrl,
|
||||||
|
label: realtimeCanvasModeDisplayConfig.realtimeLabel,
|
||||||
|
}
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{realtimeCanvasModeDisplayConfig.realtimeLabel}
|
{
|
||||||
|
resolveCanvasToolbarItemWithFallback(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"realtimeCanvas",
|
||||||
|
{
|
||||||
|
iconUrl:
|
||||||
|
realtimeCanvasModeDisplayConfig.realtimeIconUrl,
|
||||||
|
label: realtimeCanvasModeDisplayConfig.realtimeLabel,
|
||||||
|
}
|
||||||
|
).label
|
||||||
|
}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
@ -9903,24 +10018,51 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
isVideoMode
|
isVideoMode
|
||||||
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
|
||||||
: "h-9 w-9 rounded-xl"
|
: "h-9 w-9 rounded-xl"
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(`/canvas/${id}/realtime?mode=enhance-pro`)
|
router.push(`/canvas/${id}/realtime?mode=enhance-pro`)
|
||||||
}
|
}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<RealtimeCanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
iconUrl={realtimeCanvasModeDisplayConfig.enhanceIconUrl}
|
item={resolveCanvasToolbarItemWithFallback(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"enhanceCanvas",
|
||||||
|
{
|
||||||
|
iconUrl:
|
||||||
|
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
|
||||||
|
label: realtimeCanvasModeDisplayConfig.enhanceLabel,
|
||||||
|
}
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{selectedImageNode
|
{selectedImageNode
|
||||||
? realtimeCanvasModeDisplayConfig.enhanceLabel
|
? resolveCanvasToolbarItemWithFallback(
|
||||||
: `进入${realtimeCanvasModeDisplayConfig.enhanceLabel}后上传图片`}
|
canvasToolbarConfig,
|
||||||
|
"enhanceCanvas",
|
||||||
|
{
|
||||||
|
iconUrl:
|
||||||
|
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
|
||||||
|
label: realtimeCanvasModeDisplayConfig.enhanceLabel,
|
||||||
|
}
|
||||||
|
).label
|
||||||
|
: `进入${
|
||||||
|
resolveCanvasToolbarItemWithFallback(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"enhanceCanvas",
|
||||||
|
{
|
||||||
|
iconUrl:
|
||||||
|
realtimeCanvasModeDisplayConfig.enhanceIconUrl,
|
||||||
|
label:
|
||||||
|
realtimeCanvasModeDisplayConfig.enhanceLabel,
|
||||||
|
}
|
||||||
|
).label
|
||||||
|
}后上传图片`}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
@ -9937,10 +10079,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Box className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Box}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">3D生成</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "model3d").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{isVideoMode ? (
|
{isVideoMode ? (
|
||||||
@ -9952,10 +10100,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Download}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">导出视频画布</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "exportVideoCanvas").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9972,11 +10126,17 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
{isImportingVideoCanvas ? (
|
{isImportingVideoCanvas ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<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>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">导入视频画布</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "importVideoCanvas").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -9992,10 +10152,24 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Plus}
|
||||||
|
item={getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
isVideoMode ? "newVideoCanvas" : "newCanvas"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">新建{workspaceLabel}</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{
|
||||||
|
getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
isVideoMode ? "newVideoCanvas" : "newCanvas"
|
||||||
|
).label
|
||||||
|
}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -10034,10 +10208,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Undo2 className="h-3 w-3" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-3 w-3"
|
||||||
|
fallback={Undo2}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "undo")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">撤销</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "undo").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -10052,10 +10232,16 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Redo2 className="h-3 w-3" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-3 w-3"
|
||||||
|
fallback={Redo2}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "redo")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">重做</TooltipContent>
|
<TooltipContent side="top">
|
||||||
|
{getCanvasToolbarItem(canvasToolbarConfig, "redo").label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div className="mx-1 h-4 w-px bg-border/70" />
|
<div className="mx-1 h-4 w-px bg-border/70" />
|
||||||
@ -10067,11 +10253,19 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
size="icon"
|
size="icon"
|
||||||
|
title={getCanvasToolbarItem(canvasToolbarConfig, "zoomOut").label}
|
||||||
variant="ghost"
|
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>
|
</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)}%
|
{Math.round(viewport.zoom * 100)}%
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@ -10081,9 +10275,14 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
onClick={zoomIn}
|
onClick={zoomIn}
|
||||||
size="icon"
|
size="icon"
|
||||||
|
title={getCanvasToolbarItem(canvasToolbarConfig, "zoomIn").label}
|
||||||
variant="ghost"
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,19 +9,22 @@ import type { DetailUiProtocol } from "@/lib/detail-ui-protocol";
|
|||||||
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
|
import type { DetailUiScreenReviewPatch } from "@/lib/detail-ui-protocol";
|
||||||
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
import { buildDetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||||
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
|
import type { DetailUiDslV2Document } from "@/lib/detail-ui-dsl-v2";
|
||||||
|
import { buildDetailUiDslV2 } from "@/lib/detail-ui-dsl-v2-builder";
|
||||||
import {
|
import {
|
||||||
buildDetailTextLayerNodes,
|
buildDetailTextLayerNodes,
|
||||||
buildDetailTextLayerNodesFromReviewPatch,
|
buildDetailTextLayerNodesFromReviewPatch,
|
||||||
buildDetailTextLayerNodesFromSurfaceScreen,
|
buildDetailTextLayerNodesFromSurfaceScreen,
|
||||||
collectManagedDetailTextLayerNodeIds,
|
collectManagedDetailTextLayerNodeIds,
|
||||||
} from "@/lib/detail-ui-renderer";
|
} from "@/lib/detail-ui-renderer";
|
||||||
|
import { buildUiScreenDocumentFromDetailUiDsl } from "@/lib/ui-screen/detail-adapter";
|
||||||
|
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
|
||||||
import {
|
import {
|
||||||
applyUiSurfacePatch,
|
applyUiSurfacePatch,
|
||||||
type UiSurfacePatch,
|
type UiSurfacePatch,
|
||||||
type UiSurfaceSnapshot,
|
type UiSurfaceSnapshot,
|
||||||
} from "@/lib/ui-surface-protocol";
|
} from "@/lib/ui-surface-protocol";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
@ -87,6 +90,13 @@ import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
|||||||
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
|
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
|
||||||
import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel";
|
import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel";
|
||||||
import { DetailSurfaceInspectorPanel } from "@/components/canvas/DetailSurfaceInspectorPanel";
|
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 {
|
interface TagPreviewInfo {
|
||||||
@ -187,6 +197,7 @@ interface CanvasChatPanelProps {
|
|||||||
presentation?: "panel" | "workspace";
|
presentation?: "panel" | "workspace";
|
||||||
agentEnabled?: boolean;
|
agentEnabled?: boolean;
|
||||||
workspaceVariant?: "canvas" | "video";
|
workspaceVariant?: "canvas" | "video";
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
type VideoStoryboardStreamData =
|
type VideoStoryboardStreamData =
|
||||||
@ -1345,6 +1356,13 @@ type DetailPlanResponse = {
|
|||||||
version?: string;
|
version?: string;
|
||||||
steps?: string[];
|
steps?: string[];
|
||||||
};
|
};
|
||||||
|
designSkill?: {
|
||||||
|
version?: string;
|
||||||
|
name?: string;
|
||||||
|
source?: string;
|
||||||
|
visualDirection?: string[];
|
||||||
|
reviewChecklist?: string[];
|
||||||
|
};
|
||||||
qualityChecks?: {
|
qualityChecks?: {
|
||||||
passed?: boolean;
|
passed?: boolean;
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
@ -1368,11 +1386,15 @@ type DetailPlanResponse = {
|
|||||||
uiProtocol?: DetailUiProtocol;
|
uiProtocol?: DetailUiProtocol;
|
||||||
uiSurface?: UiSurfaceSnapshot;
|
uiSurface?: UiSurfaceSnapshot;
|
||||||
uiDslV2Screens?: DetailUiDslV2Document[];
|
uiDslV2Screens?: DetailUiDslV2Document[];
|
||||||
|
brandStyleBoard?: UiScreenDocument;
|
||||||
};
|
};
|
||||||
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
|
type DetailPlanScreenSpec = NonNullable<DetailPlanResponse["screens"]>[number];
|
||||||
|
|
||||||
const DETAIL_TARGET_WIDTH = 790;
|
const DETAIL_TARGET_WIDTH = 790;
|
||||||
const DETAIL_TARGET_HEIGHT = 1300;
|
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_COUNT = 3;
|
||||||
const DETAIL_REVIEW_REGEN_COUNT = 2;
|
const DETAIL_REVIEW_REGEN_COUNT = 2;
|
||||||
const DETAIL_REVIEW_IMAGE_COUNT = 3;
|
const DETAIL_REVIEW_IMAGE_COUNT = 3;
|
||||||
@ -1750,6 +1772,13 @@ function buildDetailResultMarkdown(params: {
|
|||||||
version?: string;
|
version?: string;
|
||||||
steps?: string[];
|
steps?: string[];
|
||||||
};
|
};
|
||||||
|
designSkill?: {
|
||||||
|
version?: string;
|
||||||
|
name?: string;
|
||||||
|
source?: string;
|
||||||
|
visualDirection?: string[];
|
||||||
|
reviewChecklist?: string[];
|
||||||
|
};
|
||||||
qualityChecks?: {
|
qualityChecks?: {
|
||||||
passed?: boolean;
|
passed?: boolean;
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
@ -1758,6 +1787,7 @@ function buildDetailResultMarkdown(params: {
|
|||||||
passed?: boolean;
|
passed?: boolean;
|
||||||
issues?: string[];
|
issues?: string[];
|
||||||
};
|
};
|
||||||
|
brandStyleBoard?: UiScreenDocument | null;
|
||||||
globalStyle?: {
|
globalStyle?: {
|
||||||
designLanguage?: string;
|
designLanguage?: string;
|
||||||
typography?: {
|
typography?: {
|
||||||
@ -1803,6 +1833,9 @@ function buildDetailResultMarkdown(params: {
|
|||||||
if (params.summary) {
|
if (params.summary) {
|
||||||
lines.push(params.summary);
|
lines.push(params.summary);
|
||||||
}
|
}
|
||||||
|
if (params.brandStyleBoard) {
|
||||||
|
lines.push(`品牌 UI 配色板:已先生成「${params.brandStyleBoard.title || "品牌 UI 配色板"}」,后续详情页将沿用这套视觉基准。`);
|
||||||
|
}
|
||||||
if (params.skillFlow?.steps?.length) {
|
if (params.skillFlow?.steps?.length) {
|
||||||
lines.push(
|
lines.push(
|
||||||
`技能流程:${params.skillFlow.name || "AI详情生成"} ${params.skillFlow.version ? `(v${params.skillFlow.version})` : ""}`
|
`技能流程:${params.skillFlow.name || "AI详情生成"} ${params.skillFlow.version ? `(v${params.skillFlow.version})` : ""}`
|
||||||
@ -1811,6 +1844,14 @@ function buildDetailResultMarkdown(params: {
|
|||||||
lines.push(`${index + 1}. ${step}`);
|
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) {
|
||||||
if (params.qualityChecks.passed) {
|
if (params.qualityChecks.passed) {
|
||||||
lines.push("质检结果:通过");
|
lines.push("质检结果:通过");
|
||||||
@ -1937,9 +1978,14 @@ export function CanvasChatPanel({
|
|||||||
presentation = "panel",
|
presentation = "panel",
|
||||||
agentEnabled = true,
|
agentEnabled = true,
|
||||||
workspaceVariant = "canvas",
|
workspaceVariant = "canvas",
|
||||||
|
canvasToolbarConfig: rawCanvasToolbarConfig,
|
||||||
}: CanvasChatPanelProps) {
|
}: CanvasChatPanelProps) {
|
||||||
const isWorkspacePresentation = presentation === "workspace";
|
const isWorkspacePresentation = presentation === "workspace";
|
||||||
const isVideoWorkspace = workspaceVariant === "video";
|
const isVideoWorkspace = workspaceVariant === "video";
|
||||||
|
const propCanvasToolbarConfig = useMemo(
|
||||||
|
() => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig),
|
||||||
|
[rawCanvasToolbarConfig]
|
||||||
|
);
|
||||||
const effectiveAgentEnabled = agentEnabled && !isVideoWorkspace;
|
const effectiveAgentEnabled = agentEnabled && !isVideoWorkspace;
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@ -2963,6 +3009,7 @@ export function CanvasChatPanel({
|
|||||||
canvasDefaultModelId?: string | null;
|
canvasDefaultModelId?: string | null;
|
||||||
canvasAutoModeDefaultModelId?: string | null;
|
canvasAutoModeDefaultModelId?: string | null;
|
||||||
canvasDetailDefaultModelId?: string | null;
|
canvasDetailDefaultModelId?: string | null;
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
}>("/api/site-settings", fetcher);
|
}>("/api/site-settings", fetcher);
|
||||||
const { data: canvasSkillTagsData } = useSWR<{
|
const { data: canvasSkillTagsData } = useSWR<{
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@ -2991,6 +3038,17 @@ export function CanvasChatPanel({
|
|||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
return response.json();
|
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>(
|
const { data: userImageStats } = useSWR<{ totalImages?: number } | null>(
|
||||||
"/api/user/image-stats",
|
"/api/user/image-stats",
|
||||||
async (url: string) => {
|
async (url: string) => {
|
||||||
@ -3698,14 +3756,14 @@ export function CanvasChatPanel({
|
|||||||
);
|
);
|
||||||
const activeModeLabel =
|
const activeModeLabel =
|
||||||
activeComposerMode === "video"
|
activeComposerMode === "video"
|
||||||
? "视频"
|
? getToolbarItem("chatModeVideoActive").label
|
||||||
: activeComposerMode === "detail"
|
: activeComposerMode === "detail"
|
||||||
? "AI详情"
|
? getToolbarItem("chatModeDetailActive").label
|
||||||
: activeComposerMode === "image"
|
: activeComposerMode === "image"
|
||||||
? "生图"
|
? getToolbarItem("chatModeImageActive").label
|
||||||
: isPresetAgentSelection
|
: isPresetAgentSelection
|
||||||
? "预设Agent"
|
? getToolbarItem("chatModePresetAgent").label
|
||||||
: "Auto Agent";
|
: getToolbarItem("chatModeAgentActive").label;
|
||||||
const shouldMaskAgentRuntimeModelName =
|
const shouldMaskAgentRuntimeModelName =
|
||||||
activeComposerMode === "agent" || selectedModelId === "auto";
|
activeComposerMode === "agent" || selectedModelId === "auto";
|
||||||
const getAgentSafeModelName = useCallback(
|
const getAgentSafeModelName = useCallback(
|
||||||
@ -6355,7 +6413,9 @@ export function CanvasChatPanel({
|
|||||||
const detailPlaceholderItemWidth = DETAIL_TARGET_WIDTH;
|
const detailPlaceholderItemWidth = DETAIL_TARGET_WIDTH;
|
||||||
const detailPlaceholderItemHeight = DETAIL_TARGET_HEIGHT;
|
const detailPlaceholderItemHeight = DETAIL_TARGET_HEIGHT;
|
||||||
const detailPlaceholderStartX = 120;
|
const detailPlaceholderStartX = 120;
|
||||||
const detailPlaceholderStartY = 120;
|
const detailStyleBoardStartY = 120;
|
||||||
|
const detailPlaceholderStartY =
|
||||||
|
detailStyleBoardStartY + DETAIL_STYLE_BOARD_HEIGHT + DETAIL_STYLE_BOARD_SPACING;
|
||||||
const getDetailPlaceholderPosition = (index: number) => ({
|
const getDetailPlaceholderPosition = (index: number) => ({
|
||||||
x: detailPlaceholderStartX,
|
x: detailPlaceholderStartX,
|
||||||
y:
|
y:
|
||||||
@ -6547,6 +6607,41 @@ export function CanvasChatPanel({
|
|||||||
planData.generationMode || detailGenerationMode
|
planData.generationMode || detailGenerationMode
|
||||||
);
|
);
|
||||||
currentDetailSurfaceSnapshot = planData.uiSurface || null;
|
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) {
|
if (activeDetailGenerationMode !== detailGenerationMode) {
|
||||||
setDetailGenerationMode(activeDetailGenerationMode);
|
setDetailGenerationMode(activeDetailGenerationMode);
|
||||||
}
|
}
|
||||||
@ -6682,6 +6777,13 @@ export function CanvasChatPanel({
|
|||||||
}
|
}
|
||||||
const imageNode = storeState.getNode(params.nodeId);
|
const imageNode = storeState.getNode(params.nodeId);
|
||||||
if (!imageNode) return;
|
if (!imageNode) return;
|
||||||
|
if (imageNode.type === "ui-screen") {
|
||||||
|
replaceDetailTextLayers({
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
drafts: [],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const x = Number(imageNode.position?.x || 0);
|
const x = Number(imageNode.position?.x || 0);
|
||||||
const y = Number(imageNode.position?.y || 0);
|
const y = Number(imageNode.position?.y || 0);
|
||||||
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
|
const width = Number(imageNode.size?.width || DETAIL_TARGET_WIDTH);
|
||||||
@ -7172,52 +7274,99 @@ export function CanvasChatPanel({
|
|||||||
const storeState = useCanvasStore.getState();
|
const storeState = useCanvasStore.getState();
|
||||||
const currentNode = storeState.getNode(targetNodeId);
|
const currentNode = storeState.getNode(targetNodeId);
|
||||||
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
const currentData = ((currentNode?.data as any) || {}) as Record<string, any>;
|
||||||
|
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,
|
||||||
|
generationPrompt: finalGenerationPrompt,
|
||||||
|
detailSectionIndex: index + 1,
|
||||||
|
detailPlanSummary: planData.summary || "",
|
||||||
|
detailTargetWidth: DETAIL_TARGET_WIDTH,
|
||||||
|
detailTargetHeight: DETAIL_TARGET_HEIGHT,
|
||||||
|
detailTitle: screen?.title || "",
|
||||||
|
detailSubtitle: screen?.subtitle || "",
|
||||||
|
detailScreenType: screen?.screenType || "",
|
||||||
|
detailLayoutNote: screen?.layoutNote || "",
|
||||||
|
detailUseModel: useModelForScene,
|
||||||
|
detailModelReason: screen?.modelReason || "",
|
||||||
|
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,
|
||||||
|
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, {
|
updateNode(targetNodeId, {
|
||||||
|
type: uiScreenDocument ? "ui-screen" : currentNode?.type || "image",
|
||||||
size: {
|
size: {
|
||||||
width: DETAIL_TARGET_WIDTH,
|
width: DETAIL_TARGET_WIDTH,
|
||||||
height: DETAIL_TARGET_HEIGHT,
|
height: DETAIL_TARGET_HEIGHT,
|
||||||
},
|
},
|
||||||
data: {
|
data: uiScreenDocument
|
||||||
...currentData,
|
? {
|
||||||
src: finalGenerated.originalUrl || finalGenerated.thumbnailUrl,
|
status: "completed",
|
||||||
status: "completed",
|
document: uiScreenDocument,
|
||||||
prompt: promptText,
|
renderArtifacts: {
|
||||||
title: `详情图-${index + 1}`,
|
screenshotUrl: finalGenerated.originalUrl,
|
||||||
metadata: {
|
thumbnailUrl: finalGenerated.thumbnailUrl,
|
||||||
...(currentData.metadata || {}),
|
},
|
||||||
originalWidth: dimensions.originalWidth,
|
source: {
|
||||||
originalHeight: dimensions.originalHeight,
|
prompt: finalGenerationPrompt,
|
||||||
generationPrompt: finalGenerationPrompt,
|
model: modelForGeneration,
|
||||||
detailSectionIndex: index + 1,
|
},
|
||||||
detailPlanSummary: planData.summary || "",
|
evaluation: {
|
||||||
detailTargetWidth: DETAIL_TARGET_WIDTH,
|
score:
|
||||||
detailTargetHeight: DETAIL_TARGET_HEIGHT,
|
typeof screenReviewResult?.score === "number"
|
||||||
detailTitle: screen?.title || "",
|
? screenReviewResult.score
|
||||||
detailSubtitle: screen?.subtitle || "",
|
: undefined,
|
||||||
detailScreenType: screen?.screenType || "",
|
issues: Array.isArray(screenReviewResult?.issues)
|
||||||
detailLayoutNote: screen?.layoutNote || "",
|
? screenReviewResult.issues.filter(Boolean)
|
||||||
detailUseModel: useModelForScene,
|
: undefined,
|
||||||
detailModelReason: screen?.modelReason || "",
|
},
|
||||||
detailBundleShowcase: bundleShowcase,
|
metadata: completedDetailMetadata,
|
||||||
detailBundleReason: bundleReason,
|
}
|
||||||
detailGlobalStyle: planData.globalStyle || null,
|
: {
|
||||||
detailGenerationMode: activeDetailGenerationMode,
|
...currentData,
|
||||||
detailScreenReview: screenReviewResult || null,
|
src: finalImageUrl,
|
||||||
detailUiSurfaceId: currentDetailSurfaceSnapshot?.surfaceId || null,
|
status: "completed",
|
||||||
detailUiSurfaceType: currentDetailSurfaceSnapshot?.surfaceType || null,
|
prompt: promptText,
|
||||||
detailUiScreenOverlay: buildDetailUiScreenOverlaySnapshot({
|
title: `详情图-${index + 1}`,
|
||||||
surface: currentDetailSurfaceSnapshot,
|
metadata: completedDetailMetadata,
|
||||||
screenIndex: index + 1,
|
},
|
||||||
}),
|
|
||||||
detailUiDslDocument:
|
|
||||||
Array.isArray(planData.uiDslV2Screens) && planData.uiDslV2Screens[index]
|
|
||||||
? planData.uiDslV2Screens[index]
|
|
||||||
: null,
|
|
||||||
detailSubjectAnalysis,
|
|
||||||
detailGeneratingPlaceholder: false,
|
|
||||||
detailProgressLabel: `该屏完成 ${index + 1}/${prompts.length}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
createAgentLayoutTextLayers({
|
createAgentLayoutTextLayers({
|
||||||
nodeId: targetNodeId,
|
nodeId: targetNodeId,
|
||||||
@ -7237,7 +7386,7 @@ export function CanvasChatPanel({
|
|||||||
const reviewGroups: DetailReviewGroup[] = [];
|
const reviewGroups: DetailReviewGroup[] = [];
|
||||||
const reviewFailures: string[] = [];
|
const reviewFailures: string[] = [];
|
||||||
const detailStartX = 120;
|
const detailStartX = 120;
|
||||||
const detailStartY = 120;
|
const detailStartY = detailPlaceholderStartY;
|
||||||
const detailSpacing = 24;
|
const detailSpacing = 24;
|
||||||
const reviewStartY =
|
const reviewStartY =
|
||||||
detailStartY + prompts.length * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
|
detailStartY + prompts.length * (DETAIL_TARGET_HEIGHT + detailSpacing) + 120;
|
||||||
@ -7356,8 +7505,10 @@ export function CanvasChatPanel({
|
|||||||
images: generatedImages,
|
images: generatedImages,
|
||||||
prompts,
|
prompts,
|
||||||
skillFlow: planData.skillFlow,
|
skillFlow: planData.skillFlow,
|
||||||
|
designSkill: planData.designSkill,
|
||||||
qualityChecks: planData.qualityChecks,
|
qualityChecks: planData.qualityChecks,
|
||||||
uiValidation: planData.uiValidation,
|
uiValidation: planData.uiValidation,
|
||||||
|
brandStyleBoard: planData.brandStyleBoard,
|
||||||
globalStyle: planData.globalStyle,
|
globalStyle: planData.globalStyle,
|
||||||
screens: screenSpecs,
|
screens: screenSpecs,
|
||||||
reviewGroups,
|
reviewGroups,
|
||||||
@ -8797,11 +8948,14 @@ export function CanvasChatPanel({
|
|||||||
activeComposerMode === "agent" &&
|
activeComposerMode === "agent" &&
|
||||||
!isPresetAgentSelection &&
|
!isPresetAgentSelection &&
|
||||||
Boolean(activeAgentTargetLock);
|
Boolean(activeAgentTargetLock);
|
||||||
|
const isAgentTargetClarificationActive =
|
||||||
|
shouldShowAgentTargetLockCard && activeAgentTargetLock?.stage === "clarify";
|
||||||
const shouldShowAgentProgressCard =
|
const shouldShowAgentProgressCard =
|
||||||
!isDetailGenerationMode &&
|
!isDetailGenerationMode &&
|
||||||
activeComposerMode === "agent" &&
|
activeComposerMode === "agent" &&
|
||||||
!isPresetAgentSelection &&
|
!isPresetAgentSelection &&
|
||||||
Boolean(activeAgentProgress) &&
|
Boolean(activeAgentProgress) &&
|
||||||
|
!(activeAgentProgress?.stage === "clarify" && isAgentTargetClarificationActive) &&
|
||||||
(isLoading ||
|
(isLoading ||
|
||||||
activeAgentProgress?.stage === "clarify" ||
|
activeAgentProgress?.stage === "clarify" ||
|
||||||
activeAgentProgress?.stage === "failed");
|
activeAgentProgress?.stage === "failed");
|
||||||
@ -8818,6 +8972,21 @@ export function CanvasChatPanel({
|
|||||||
const agentTargetResolutionLabel = getAgentTargetResolutionLabel(
|
const agentTargetResolutionLabel = getAgentTargetResolutionLabel(
|
||||||
activeAgentTargetLock?.resolutionMode
|
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 =
|
const agentProgressTitle =
|
||||||
activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress);
|
activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress);
|
||||||
const agentProgressMessage =
|
const agentProgressMessage =
|
||||||
@ -10958,13 +11127,19 @@ export function CanvasChatPanel({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs leading-5 text-[#5b6472]">
|
<div
|
||||||
{activeAgentTargetLock.reason || activeAgentTargetLock.clarificationMessage || "系统正在根据当前上下文锁定目标图。"}
|
className={cn(
|
||||||
|
"mt-1 whitespace-pre-wrap text-xs leading-5",
|
||||||
|
activeAgentTargetLock.stage === "clarify"
|
||||||
|
? "text-[#8a5a14]"
|
||||||
|
: "text-[#5b6472]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{agentTargetLockPrimaryMessage}
|
||||||
</div>
|
</div>
|
||||||
{activeAgentTargetLock.stage === "clarify" &&
|
{agentTargetLockSecondaryMessage ? (
|
||||||
activeAgentTargetLock.clarificationMessage ? (
|
<div className="mt-1 text-[11px] leading-5 text-[#6b7280]">
|
||||||
<div className="mt-1 text-[11px] leading-5 text-[#8a5a14]">
|
系统依据:{agentTargetLockSecondaryMessage}
|
||||||
{activeAgentTargetLock.clarificationMessage}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -11281,13 +11456,33 @@ export function CanvasChatPanel({
|
|||||||
>
|
>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
{activeComposerMode === "video" ? (
|
{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" ? (
|
) : 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" ? (
|
) : 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}
|
{activeModeLabel}
|
||||||
</span>
|
</span>
|
||||||
@ -11321,8 +11516,14 @@ export function CanvasChatPanel({
|
|||||||
: "hover:bg-white/70"
|
: "hover:bg-white/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
<span className="flex-1">Agent对话</span>
|
className="h-4 w-4"
|
||||||
|
fallback={Sparkles}
|
||||||
|
item={getToolbarItem("chatModeAgentMenu")}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
{getToolbarItem("chatModeAgentMenu").label}
|
||||||
|
</span>
|
||||||
{activeComposerMode === "agent" ? (
|
{activeComposerMode === "agent" ? (
|
||||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
||||||
) : null}
|
) : null}
|
||||||
@ -11345,8 +11546,14 @@ export function CanvasChatPanel({
|
|||||||
: "hover:bg-white/70"
|
: "hover:bg-white/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
<span className="flex-1">AI详情生成</span>
|
className="h-4 w-4"
|
||||||
|
fallback={LayoutGrid}
|
||||||
|
item={getToolbarItem("chatModeDetailMenu")}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
{getToolbarItem("chatModeDetailMenu").label}
|
||||||
|
</span>
|
||||||
{activeComposerMode === "detail" ? (
|
{activeComposerMode === "detail" ? (
|
||||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
||||||
) : null}
|
) : null}
|
||||||
@ -11368,8 +11575,14 @@ export function CanvasChatPanel({
|
|||||||
: "hover:bg-white/70"
|
: "hover:bg-white/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
<span className="flex-1">图像生成</span>
|
className="h-4 w-4"
|
||||||
|
fallback={ImagePlus}
|
||||||
|
item={getToolbarItem("chatModeImageMenu")}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
{getToolbarItem("chatModeImageMenu").label}
|
||||||
|
</span>
|
||||||
{activeComposerMode === "image" ? (
|
{activeComposerMode === "image" ? (
|
||||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
||||||
) : null}
|
) : null}
|
||||||
@ -11390,8 +11603,14 @@ export function CanvasChatPanel({
|
|||||||
: "hover:bg-white/70"
|
: "hover:bg-white/70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Video className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
<span className="flex-1">视频生成</span>
|
className="h-4 w-4"
|
||||||
|
fallback={Video}
|
||||||
|
item={getToolbarItem("chatModeVideoMenu")}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
{getToolbarItem("chatModeVideoMenu").label}
|
||||||
|
</span>
|
||||||
{activeComposerMode === "video" ? (
|
{activeComposerMode === "video" ? (
|
||||||
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
<Check className={cn("h-4 w-4", isVideoWorkspace ? "text-primary" : "text-[#3b82f6]")} />
|
||||||
) : null}
|
) : null}
|
||||||
@ -11412,8 +11631,13 @@ export function CanvasChatPanel({
|
|||||||
)}
|
)}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
title={getToolbarItem("chatAttach").label}
|
||||||
>
|
>
|
||||||
<Paperclip className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Paperclip}
|
||||||
|
item={getToolbarItem("chatAttach")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -11451,7 +11675,7 @@ export function CanvasChatPanel({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title={`深度思考:${deepThinkingEnabled ? "开" : "关"}`}
|
title={`${getToolbarItem("chatDeepThinking").label}:${deepThinkingEnabled ? "开" : "关"}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 shrink-0 rounded-full border-0 bg-transparent p-0 shadow-none transition-colors",
|
"h-8 w-8 shrink-0 rounded-full border-0 bg-transparent p-0 shadow-none transition-colors",
|
||||||
isVideoWorkspace
|
isVideoWorkspace
|
||||||
@ -11465,7 +11689,11 @@ export function CanvasChatPanel({
|
|||||||
onClick={() => setDeepThinkingEnabled((prev) => !prev)}
|
onClick={() => setDeepThinkingEnabled((prev) => !prev)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<Brain className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Brain}
|
||||||
|
item={getToolbarItem("chatDeepThinking")}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -11497,6 +11725,7 @@ export function CanvasChatPanel({
|
|||||||
? "border border-border/60 bg-background"
|
? "border border-border/60 bg-background"
|
||||||
: "border border-[#cfd4dc] bg-white"
|
: "border border-[#cfd4dc] bg-white"
|
||||||
)}
|
)}
|
||||||
|
title={getToolbarItem("chatModelSelector").label}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -11504,7 +11733,13 @@ export function CanvasChatPanel({
|
|||||||
isVideoWorkspace ? "bg-background" : "bg-white"
|
isVideoWorkspace ? "bg-background" : "bg-white"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectDisplayModel?.avatarUrl ? (
|
{getToolbarItem("chatModelSelector").iconUrl ? (
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Bot}
|
||||||
|
item={getToolbarItem("chatModelSelector")}
|
||||||
|
/>
|
||||||
|
) : selectDisplayModel?.avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
src={buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64)}
|
src={buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64)}
|
||||||
alt={selectDisplayModel.displayName || "模型头像"}
|
alt={selectDisplayModel.displayName || "模型头像"}
|
||||||
@ -11543,11 +11778,15 @@ export function CanvasChatPanel({
|
|||||||
)}
|
)}
|
||||||
title={estimatedPointsCost.title}
|
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">
|
<span className="font-medium text-foreground">
|
||||||
{estimatedPointsCost.cost}
|
{estimatedPointsCost.cost}
|
||||||
</span>
|
</span>
|
||||||
<span>积分</span>
|
<span>{getToolbarItem("chatPoints").label}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -11571,6 +11810,11 @@ export function CanvasChatPanel({
|
|||||||
)}
|
)}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
disabled={isSubmitting || !canSend}
|
disabled={isSubmitting || !canSend}
|
||||||
|
title={
|
||||||
|
isWorkspacePresentation || isDetailGenerationMode
|
||||||
|
? getToolbarItem("chatStartGeneration").label
|
||||||
|
: getToolbarItem("chatSend").label
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@ -11579,13 +11823,33 @@ export function CanvasChatPanel({
|
|||||||
</>
|
</>
|
||||||
) : isDetailGenerationMode ? (
|
) : isDetailGenerationMode ? (
|
||||||
<>
|
<>
|
||||||
<Sparkles className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
{isWorkspacePresentation ? <span className="ml-2">开始生成</span> : null}
|
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" />
|
<CanvasToolbarIcon
|
||||||
{isWorkspacePresentation ? <span className="ml-2">开始生成</span> : null}
|
className="h-4 w-4"
|
||||||
|
fallback={ArrowUp}
|
||||||
|
item={getToolbarItem(
|
||||||
|
isWorkspacePresentation
|
||||||
|
? "chatStartGeneration"
|
||||||
|
: "chatSend"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isWorkspacePresentation ? (
|
||||||
|
<span className="ml-2">
|
||||||
|
{getToolbarItem("chatStartGeneration").label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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 { ImageNode } from "./nodes/ImageNode";
|
||||||
import { Model3DNode } from "./nodes/Model3DNode";
|
import { Model3DNode } from "./nodes/Model3DNode";
|
||||||
import { TextNode } from "./nodes/TextNode";
|
import { TextNode } from "./nodes/TextNode";
|
||||||
|
import { UiScreenNode } from "./nodes/UiScreenNode";
|
||||||
import { VideoNode } from "./nodes/VideoNode";
|
import { VideoNode } from "./nodes/VideoNode";
|
||||||
import { WorkflowNode } from "./nodes/WorkflowNode";
|
import { WorkflowNode } from "./nodes/WorkflowNode";
|
||||||
import { World3DNode } from "./nodes/World3DNode";
|
import { World3DNode } from "./nodes/World3DNode";
|
||||||
|
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -41,6 +43,11 @@ import {
|
|||||||
VIDEO_CANVAS_MARKER_METADATA_KEY,
|
VIDEO_CANVAS_MARKER_METADATA_KEY,
|
||||||
VIDEO_CANVAS_PRIORITY_MARKERS,
|
VIDEO_CANVAS_PRIORITY_MARKERS,
|
||||||
} from "@/lib/canvas/video-canvas-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> = {
|
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
idea: Lightbulb,
|
idea: Lightbulb,
|
||||||
@ -182,6 +189,7 @@ interface CanvasWorkspaceProps {
|
|||||||
workspaceLabel?: string;
|
workspaceLabel?: string;
|
||||||
emptyStateDescription?: string;
|
emptyStateDescription?: string;
|
||||||
workspaceVariant?: "canvas" | "video";
|
workspaceVariant?: "canvas" | "video";
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
layerSplitEnabled?: boolean;
|
layerSplitEnabled?: boolean;
|
||||||
batchProcessingEnabled?: boolean;
|
batchProcessingEnabled?: boolean;
|
||||||
showEmptyState?: boolean;
|
showEmptyState?: boolean;
|
||||||
@ -197,12 +205,17 @@ export function CanvasWorkspace({
|
|||||||
workspaceLabel = "画布",
|
workspaceLabel = "画布",
|
||||||
emptyStateDescription = '点击顶部"添加"按钮开始创作',
|
emptyStateDescription = '点击顶部"添加"按钮开始创作',
|
||||||
workspaceVariant = "canvas",
|
workspaceVariant = "canvas",
|
||||||
|
canvasToolbarConfig: rawCanvasToolbarConfig,
|
||||||
layerSplitEnabled = false,
|
layerSplitEnabled = false,
|
||||||
batchProcessingEnabled = true,
|
batchProcessingEnabled = true,
|
||||||
showEmptyState = true,
|
showEmptyState = true,
|
||||||
showZoomControls = true,
|
showZoomControls = true,
|
||||||
onBlankCanvasDoubleClick,
|
onBlankCanvasDoubleClick,
|
||||||
}: CanvasWorkspaceProps) {
|
}: CanvasWorkspaceProps) {
|
||||||
|
const canvasToolbarConfig = useMemo(
|
||||||
|
() => normalizeCanvasToolbarConfig(rawCanvasToolbarConfig),
|
||||||
|
[rawCanvasToolbarConfig]
|
||||||
|
);
|
||||||
const SNAP_THRESHOLD = 10;
|
const SNAP_THRESHOLD = 10;
|
||||||
const SNAP_GAP = 12;
|
const SNAP_GAP = 12;
|
||||||
const SELECTION_COLOR = "#27a8ff";
|
const SELECTION_COLOR = "#27a8ff";
|
||||||
@ -1496,25 +1509,53 @@ export function CanvasWorkspace({
|
|||||||
{node.type === 'image' && (
|
{node.type === 'image' && (
|
||||||
<ImageNode
|
<ImageNode
|
||||||
batchProcessingEnabled={batchProcessingEnabled}
|
batchProcessingEnabled={batchProcessingEnabled}
|
||||||
|
canvasToolbarConfig={canvasToolbarConfig}
|
||||||
isLayerSplitEnabled={layerSplitEnabled}
|
isLayerSplitEnabled={layerSplitEnabled}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
node={node}
|
node={node}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{node.type === 'text' && (
|
{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' && (
|
{node.type === 'video' && (
|
||||||
<VideoNode node={node} isSelected={isSelected} />
|
<VideoNode
|
||||||
|
canvasToolbarConfig={canvasToolbarConfig}
|
||||||
|
node={node}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{node.type === 'workflow' && (
|
{node.type === 'workflow' && (
|
||||||
<WorkflowNode node={node} isSelected={isSelected} />
|
<WorkflowNode
|
||||||
|
canvasToolbarConfig={canvasToolbarConfig}
|
||||||
|
node={node}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{node.type === 'model3d' && (
|
{node.type === 'model3d' && (
|
||||||
<Model3DNode node={node} isSelected={isSelected} />
|
<Model3DNode
|
||||||
|
canvasToolbarConfig={canvasToolbarConfig}
|
||||||
|
node={node}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{node.type === 'world3d' && (
|
{node.type === 'world3d' && (
|
||||||
<World3DNode node={node} isSelected={isSelected} />
|
<World3DNode
|
||||||
|
canvasToolbarConfig={canvasToolbarConfig}
|
||||||
|
node={node}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{node.type === 'message' && (
|
{node.type === 'message' && (
|
||||||
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
|
<div className="w-full h-full bg-card rounded-lg p-4 shadow-sm border">
|
||||||
@ -1828,10 +1869,16 @@ export function CanvasWorkspace({
|
|||||||
)}
|
)}
|
||||||
onClick={handleDownloadSelectedImages}
|
onClick={handleDownloadSelectedImages}
|
||||||
disabled={isDownloadSubmitting}
|
disabled={isDownloadSubmitting}
|
||||||
title="下载为文件夹压缩包"
|
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
|
||||||
>
|
>
|
||||||
<Download className="h-3.5 w-3.5" />
|
<CanvasToolbarIcon
|
||||||
{isDownloadSubmitting ? "打包中..." : `下载文件夹(${selectedImageNodes.length})`}
|
className="h-3.5 w-3.5"
|
||||||
|
fallback={Download}
|
||||||
|
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||||
|
/>
|
||||||
|
{isDownloadSubmitting
|
||||||
|
? "打包中..."
|
||||||
|
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1854,9 +1901,13 @@ export function CanvasWorkspace({
|
|||||||
const newZoom = Math.max(0.1, viewport.zoom - 0.1);
|
const newZoom = Math.max(0.1, viewport.zoom - 0.1);
|
||||||
setViewport({ zoom: newZoom });
|
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>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -1866,7 +1917,7 @@ export function CanvasWorkspace({
|
|||||||
isWorkflowCanvas && "rounded-full px-3"
|
isWorkflowCanvas && "rounded-full px-3"
|
||||||
)}
|
)}
|
||||||
onClick={() => setViewport({ zoom: 1, x: 0, y: 0 })}
|
onClick={() => setViewport({ zoom: 1, x: 0, y: 0 })}
|
||||||
title="重置缩放"
|
title={getCanvasToolbarItem(canvasToolbarConfig, "resetZoom").label}
|
||||||
>
|
>
|
||||||
{Math.round(viewport.zoom * 100)}%
|
{Math.round(viewport.zoom * 100)}%
|
||||||
</Button>
|
</Button>
|
||||||
@ -1878,9 +1929,13 @@ export function CanvasWorkspace({
|
|||||||
const newZoom = Math.min(4, viewport.zoom + 0.1);
|
const newZoom = Math.min(4, viewport.zoom + 0.1);
|
||||||
setViewport({ zoom: newZoom });
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Circle as CircleIcon,
|
Circle as CircleIcon,
|
||||||
|
Download,
|
||||||
Eraser,
|
Eraser,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Layers3,
|
Layers3,
|
||||||
@ -31,6 +32,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { EnhanceProWorkspace } from "@/components/canvas/EnhanceProWorkspace";
|
import { EnhanceProWorkspace } from "@/components/canvas/EnhanceProWorkspace";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@ -55,6 +57,13 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import {
|
||||||
|
defaultCanvasToolbarConfig,
|
||||||
|
getCanvasToolbarItem,
|
||||||
|
getDefaultCanvasToolbarItem,
|
||||||
|
normalizeCanvasToolbarConfig,
|
||||||
|
type CanvasToolbarConfig,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
|
|
||||||
@ -867,6 +876,8 @@ export function RealtimeCanvasDialog({
|
|||||||
realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL,
|
realtimeLabel: DEFAULT_REALTIME_CANVAS_MODE_LABEL,
|
||||||
enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL,
|
enhanceLabel: DEFAULT_ENHANCE_CANVAS_MODE_LABEL,
|
||||||
});
|
});
|
||||||
|
const [canvasToolbarConfig, setCanvasToolbarConfig] =
|
||||||
|
useState<CanvasToolbarConfig>(defaultCanvasToolbarConfig);
|
||||||
|
|
||||||
const statusBadge = useMemo(() => {
|
const statusBadge = useMemo(() => {
|
||||||
if (status === "live")
|
if (status === "live")
|
||||||
@ -911,6 +922,89 @@ export function RealtimeCanvasDialog({
|
|||||||
previewUrl,
|
previewUrl,
|
||||||
selectedImageLayer?.src,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@ -926,6 +1020,7 @@ export function RealtimeCanvasDialog({
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data || cancelled) return;
|
if (!data || cancelled) return;
|
||||||
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
|
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
|
||||||
|
setCanvasToolbarConfig(normalizeCanvasToolbarConfig(data.canvasToolbarConfig));
|
||||||
const iconConfig =
|
const iconConfig =
|
||||||
data.realtimeCanvasModeIconConfig &&
|
data.realtimeCanvasModeIconConfig &&
|
||||||
typeof data.realtimeCanvasModeIconConfig === "object"
|
typeof data.realtimeCanvasModeIconConfig === "object"
|
||||||
@ -3171,19 +3266,23 @@ export function RealtimeCanvasDialog({
|
|||||||
floatingSurfaceClass
|
floatingSurfaceClass
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<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"
|
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}
|
onClick={handleCloseRealtime}
|
||||||
title={standalone ? "返回无限画布" : "关闭并返回画布"}
|
title={realtimeReturnCanvasItem.label}
|
||||||
type="button"
|
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)]">
|
<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
|
||||||
</span>
|
className="h-4 w-4 text-[#111111]"
|
||||||
<span className="font-semibold text-[14px] tracking-[-0.02em]">
|
fallback={ArrowLeft}
|
||||||
{standalone ? "返回无限画布" : "返回画布"}
|
item={realtimeReturnCanvasItem}
|
||||||
</span>
|
/>
|
||||||
</button>
|
</span>
|
||||||
|
<span className="font-semibold text-[14px] tracking-[-0.02em]">
|
||||||
|
{realtimeReturnCanvasItem.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="inline-flex h-12 min-w-0 items-center gap-1 rounded-[22px] border border-[#ebebeb] bg-[#fafafa] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
|
<div className="inline-flex h-12 min-w-0 items-center gap-1 rounded-[22px] border border-[#ebebeb] bg-[#fafafa] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
|
||||||
<button
|
<button
|
||||||
@ -3196,24 +3295,15 @@ export function RealtimeCanvasDialog({
|
|||||||
onClick={() => handleWorkspaceModeChange("realtime")}
|
onClick={() => handleWorkspaceModeChange("realtime")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{modeIconConfig.realtimeIconUrl ? (
|
<CanvasToolbarIcon
|
||||||
<img
|
active={workspaceMode === "realtime"}
|
||||||
alt=""
|
className="h-4 w-4 transition group-hover:opacity-100"
|
||||||
aria-hidden="true"
|
fallback={Paintbrush}
|
||||||
className={cn(
|
item={realtimeModeRealtimeItem}
|
||||||
"h-4 w-4 object-contain transition",
|
/>
|
||||||
workspaceMode === "realtime"
|
<span className="max-w-[9rem] truncate">
|
||||||
? "brightness-0 invert"
|
{realtimeModeRealtimeItem.label}
|
||||||
: "brightness-0 opacity-70 group-hover:opacity-100"
|
</span>
|
||||||
)}
|
|
||||||
src={modeIconConfig.realtimeIconUrl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Paintbrush className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="max-w-[9rem] truncate">
|
|
||||||
{modeIconConfig.realtimeLabel}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -3225,24 +3315,15 @@ export function RealtimeCanvasDialog({
|
|||||||
onClick={() => handleWorkspaceModeChange("enhance-pro")}
|
onClick={() => handleWorkspaceModeChange("enhance-pro")}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{modeIconConfig.enhanceIconUrl ? (
|
<CanvasToolbarIcon
|
||||||
<img
|
active={workspaceMode === "enhance-pro"}
|
||||||
alt=""
|
className="h-4 w-4 transition group-hover:opacity-100"
|
||||||
aria-hidden="true"
|
fallback={Wand2}
|
||||||
className={cn(
|
item={realtimeModeEnhanceItem}
|
||||||
"h-4 w-4 object-contain transition",
|
/>
|
||||||
workspaceMode === "enhance-pro"
|
<span className="max-w-[9rem] truncate">
|
||||||
? "brightness-0 invert"
|
{realtimeModeEnhanceItem.label}
|
||||||
: "brightness-0 opacity-70 group-hover:opacity-100"
|
</span>
|
||||||
)}
|
|
||||||
src={modeIconConfig.enhanceIconUrl}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Wand2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="max-w-[9rem] truncate">
|
|
||||||
{modeIconConfig.enhanceLabel}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -3544,11 +3625,18 @@ export function RealtimeCanvasDialog({
|
|||||||
floatingSurfaceClass
|
floatingSurfaceClass
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center border-[#e5e5e5] border-b pb-3">
|
<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]">
|
<div
|
||||||
<Layers3 className="h-4 w-4" />
|
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#111111]"
|
||||||
</div>
|
title={realtimeLayerPanelItem.label}
|
||||||
</div>
|
>
|
||||||
|
<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">
|
<div className="mt-2 flex flex-1 flex-col items-center space-y-2 overflow-y-auto py-1">
|
||||||
{imageLayers.length === 0 ? (
|
{imageLayers.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-[#e5e5e5] border-dashed px-1 py-4 text-center text-[10px] text-[#666666] leading-4">
|
<div className="rounded-2xl border border-[#e5e5e5] border-dashed px-1 py-4 text-center text-[10px] text-[#666666] leading-4">
|
||||||
@ -3609,16 +3697,20 @@ export function RealtimeCanvasDialog({
|
|||||||
)}
|
)}
|
||||||
disabled={isLayerSplitImporting}
|
disabled={isLayerSplitImporting}
|
||||||
onClick={() => splitUploadInputRef.current?.click()}
|
onClick={() => splitUploadInputRef.current?.click()}
|
||||||
title="图层拆分导入"
|
title={realtimeLayerSplitImportItem.label}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{isLayerSplitImporting ? (
|
{isLayerSplitImporting ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Layers3 className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
)}
|
className="h-4 w-4"
|
||||||
</Button>
|
fallback={Layers3}
|
||||||
|
item={realtimeLayerSplitImportItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -3627,12 +3719,16 @@ export function RealtimeCanvasDialog({
|
|||||||
)}
|
)}
|
||||||
disabled={isLayerSplitImporting}
|
disabled={isLayerSplitImporting}
|
||||||
onClick={() => uploadInputRef.current?.click()}
|
onClick={() => uploadInputRef.current?.click()}
|
||||||
title="添加图片图层"
|
title={realtimeAddImageLayerItem.label}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Upload className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="h-4 w-4"
|
||||||
|
fallback={Upload}
|
||||||
|
item={realtimeAddImageLayerItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -3667,12 +3763,18 @@ export function RealtimeCanvasDialog({
|
|||||||
"h-10 rounded-full px-4 font-medium text-sm",
|
"h-10 rounded-full px-4 font-medium text-sm",
|
||||||
softActionButtonClass
|
softActionButtonClass
|
||||||
)}
|
)}
|
||||||
onClick={handleDownloadPreview}
|
onClick={handleDownloadPreview}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
下载图片
|
<CanvasToolbarIcon
|
||||||
</Button>
|
active
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Download}
|
||||||
|
item={realtimeDownloadPreviewItem}
|
||||||
|
/>
|
||||||
|
{realtimeDownloadPreviewItem.label}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@ -3694,16 +3796,24 @@ export function RealtimeCanvasDialog({
|
|||||||
compactToolButtonClass,
|
compactToolButtonClass,
|
||||||
brushMode === "select" && compactToolButtonActiveClass
|
brushMode === "select" && compactToolButtonActiveClass
|
||||||
)}
|
)}
|
||||||
onClick={activateSelectMode}
|
onClick={activateSelectMode}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeSelectToolItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<MousePointer2 className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
active={brushMode === "select"}
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={MousePointer2}
|
||||||
|
item={realtimeSelectToolItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">鼠标</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeSelectToolItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
选择图层并拖动位置
|
选择图层并拖动位置
|
||||||
</div>
|
</div>
|
||||||
@ -3722,13 +3832,19 @@ export function RealtimeCanvasDialog({
|
|||||||
brushMode === "brush" &&
|
brushMode === "brush" &&
|
||||||
compactToolButtonActiveClass
|
compactToolButtonActiveClass
|
||||||
)}
|
)}
|
||||||
onClick={activateBrushMode}
|
onClick={activateBrushMode}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeBrushToolItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<Paintbrush className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
active={brushMode === "brush"}
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={Paintbrush}
|
||||||
|
item={realtimeBrushToolItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
@ -3790,7 +3906,9 @@ export function RealtimeCanvasDialog({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">画笔</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeBrushToolItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
点击后直接调整颜色和粗细
|
点击后直接调整颜色和粗细
|
||||||
</div>
|
</div>
|
||||||
@ -3809,13 +3927,19 @@ export function RealtimeCanvasDialog({
|
|||||||
brushMode === "eraser" &&
|
brushMode === "eraser" &&
|
||||||
compactToolButtonActiveClass
|
compactToolButtonActiveClass
|
||||||
)}
|
)}
|
||||||
onClick={activateEraserMode}
|
onClick={activateEraserMode}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeEraserToolItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<Eraser className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
active={brushMode === "eraser"}
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={Eraser}
|
||||||
|
item={realtimeEraserToolItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
@ -3846,7 +3970,9 @@ export function RealtimeCanvasDialog({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">橡皮</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeEraserToolItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
点击后直接调整橡皮粗细
|
点击后直接调整橡皮粗细
|
||||||
</div>
|
</div>
|
||||||
@ -3857,17 +3983,23 @@ export function RealtimeCanvasDialog({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
compactToolButtonClass,
|
compactToolButtonClass,
|
||||||
isShapeMode(brushMode) &&
|
isShapeMode(brushMode) &&
|
||||||
compactToolButtonActiveClass
|
compactToolButtonActiveClass
|
||||||
)}
|
)}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeShapeToolItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<Square className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
active={isShapeMode(brushMode)}
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={Square}
|
||||||
|
item={realtimeShapeToolItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
@ -3923,7 +4055,9 @@ export function RealtimeCanvasDialog({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">形状</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeShapeToolItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
矩形、圆形与直线
|
矩形、圆形与直线
|
||||||
</div>
|
</div>
|
||||||
@ -3932,18 +4066,25 @@ export function RealtimeCanvasDialog({
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={compactToolButtonClass}
|
className={compactToolButtonClass}
|
||||||
onClick={() => void saveCurrentProject()}
|
onClick={() => void saveCurrentProject()}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeSaveProjectItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<Save className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={Save}
|
||||||
|
item={realtimeSaveProjectItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">保存项目</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeSaveProjectItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
保存当前画布状态
|
保存当前画布状态
|
||||||
</div>
|
</div>
|
||||||
@ -3957,15 +4098,22 @@ export function RealtimeCanvasDialog({
|
|||||||
refreshSavedProjects();
|
refreshSavedProjects();
|
||||||
setProjectPanelOpen((openState) => !openState);
|
setProjectPanelOpen((openState) => !openState);
|
||||||
}}
|
}}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeProjectListItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<FolderOpen className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={FolderOpen}
|
||||||
|
item={realtimeProjectListItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">项目列表</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeProjectListItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
打开已保存项目
|
打开已保存项目
|
||||||
</div>
|
</div>
|
||||||
@ -3975,16 +4123,23 @@ export function RealtimeCanvasDialog({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={compactToolButtonClass}
|
className={compactToolButtonClass}
|
||||||
onClick={startBlankProject}
|
onClick={startBlankProject}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeNewBlankItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<Plus className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={Plus}
|
||||||
|
item={realtimeNewBlankItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">新建空白</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeNewBlankItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
清空并开始新项目
|
清空并开始新项目
|
||||||
</div>
|
</div>
|
||||||
@ -3995,17 +4150,24 @@ export function RealtimeCanvasDialog({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={compactToolButtonClass}
|
className={compactToolButtonClass}
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
onClick={undo}
|
onClick={undo}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeUndoItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<Undo2 className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={Undo2}
|
||||||
|
item={realtimeUndoItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">撤销</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeUndoItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
回退上一步编辑
|
回退上一步编辑
|
||||||
</div>
|
</div>
|
||||||
@ -4015,17 +4177,24 @@ export function RealtimeCanvasDialog({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className={compactToolButtonClass}
|
className={compactToolButtonClass}
|
||||||
disabled={!canRedo}
|
disabled={!canRedo}
|
||||||
onClick={redo}
|
onClick={redo}
|
||||||
size="icon"
|
size="icon"
|
||||||
type="button"
|
title={realtimeRedoItem.label}
|
||||||
variant="ghost"
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
<Redo2 className="h-4.5 w-4.5" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4.5 w-4.5"
|
||||||
|
fallback={Redo2}
|
||||||
|
item={realtimeRedoItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="font-medium text-sm">重做</div>
|
<div className="font-medium text-sm">
|
||||||
|
{realtimeRedoItem.label}
|
||||||
|
</div>
|
||||||
<div className="text-[#666666] text-xs">
|
<div className="text-[#666666] text-xs">
|
||||||
恢复已撤销操作
|
恢复已撤销操作
|
||||||
</div>
|
</div>
|
||||||
@ -4084,29 +4253,44 @@ export function RealtimeCanvasDialog({
|
|||||||
"h-9 shrink-0 rounded-full px-4 font-medium",
|
"h-9 shrink-0 rounded-full px-4 font-medium",
|
||||||
primaryActionButtonClass
|
primaryActionButtonClass
|
||||||
)}
|
)}
|
||||||
disabled={status === "connecting"}
|
disabled={status === "connecting"}
|
||||||
onClick={triggerGenerate}
|
onClick={triggerGenerate}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
title={realtimeGenerateItem.label}
|
||||||
{status === "connecting" ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{status === "connecting" ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
"生成"
|
) : (
|
||||||
)}
|
<>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
active
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Sparkles}
|
||||||
|
item={realtimeGenerateItem}
|
||||||
|
/>
|
||||||
|
{realtimeGenerateItem.label}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 shrink-0 rounded-full px-4 font-medium",
|
"h-9 shrink-0 rounded-full px-4 font-medium",
|
||||||
softActionButtonClass
|
softActionButtonClass
|
||||||
)}
|
)}
|
||||||
disabled={!previewUrl}
|
disabled={!previewUrl}
|
||||||
onClick={handleApplyToCanvas}
|
onClick={handleApplyToCanvas}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
title={realtimeSendToCanvasItem.label}
|
||||||
>
|
variant="outline"
|
||||||
<Send className="mr-1.5 h-4 w-4" />
|
>
|
||||||
发送到画布
|
<CanvasToolbarIcon
|
||||||
</Button>
|
active
|
||||||
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Send}
|
||||||
|
item={realtimeSendToCanvasItem}
|
||||||
|
/>
|
||||||
|
{realtimeSendToCanvasItem.label}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { AgentExecutionSnapshotDialog } from "@/components/agents/AgentExecution
|
|||||||
import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
import { AngleEditDialog } from "@/components/angle-edit-dialog";
|
||||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||||
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { ImageAnnotator } from "@/components/image-annotator";
|
import { ImageAnnotator } from "@/components/image-annotator";
|
||||||
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
import { SceneFusionDialog } from "@/components/scene-fusion-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -79,6 +80,10 @@ import {
|
|||||||
getNodeContentScale,
|
getNodeContentScale,
|
||||||
getScaledNodeContentStyle,
|
getScaledNodeContentStyle,
|
||||||
} from "@/lib/canvas/node-content-scale";
|
} from "@/lib/canvas/node-content-scale";
|
||||||
|
import {
|
||||||
|
getCanvasToolbarItem,
|
||||||
|
type CanvasToolbarConfig,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
CanvasTextEditPayloadItem,
|
CanvasTextEditPayloadItem,
|
||||||
@ -398,6 +403,7 @@ interface ImageNodeProps {
|
|||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isLayerSplitEnabled?: boolean;
|
isLayerSplitEnabled?: boolean;
|
||||||
batchProcessingEnabled?: boolean;
|
batchProcessingEnabled?: boolean;
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageNode({
|
export function ImageNode({
|
||||||
@ -405,8 +411,52 @@ export function ImageNode({
|
|||||||
isSelected,
|
isSelected,
|
||||||
isLayerSplitEnabled = false,
|
isLayerSplitEnabled = false,
|
||||||
batchProcessingEnabled = true,
|
batchProcessingEnabled = true,
|
||||||
|
canvasToolbarConfig,
|
||||||
}: ImageNodeProps) {
|
}: ImageNodeProps) {
|
||||||
const data = node.data as ImageNodeData;
|
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(
|
const contentScale = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getNodeContentScale(node.size, {
|
getNodeContentScale(node.size, {
|
||||||
@ -4052,8 +4102,8 @@ export function ImageNode({
|
|||||||
!boxCutoutMode &&
|
!boxCutoutMode &&
|
||||||
isSelected &&
|
isSelected &&
|
||||||
!isMultiSelected && (
|
!isMultiSelected && (
|
||||||
<button
|
<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"
|
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"
|
data-canvas-no-drag="true"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@ -4069,9 +4119,9 @@ export function ImageNode({
|
|||||||
height: `${compareButtonHitSize}px`,
|
height: `${compareButtonHitSize}px`,
|
||||||
right: `${compareButtonInset}px`,
|
right: `${compareButtonInset}px`,
|
||||||
}}
|
}}
|
||||||
title={isCompareMode ? "对比已开启" : "开启对比"}
|
title={imageCompareItem.label}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] transition-colors hover:bg-[#f7f9ff] hover:text-[#16181d]"
|
className="inline-flex items-center justify-center rounded-full border border-[#dbe2ee] bg-white text-[#556072] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] transition-colors hover:bg-[#f7f9ff] hover:text-[#16181d]"
|
||||||
style={{
|
style={{
|
||||||
@ -4079,30 +4129,16 @@ export function ImageNode({
|
|||||||
height: `${compareButtonVisualSize}px`,
|
height: `${compareButtonVisualSize}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<CanvasToolbarIcon
|
||||||
fill="none"
|
fallback={Scan}
|
||||||
stroke="currentColor"
|
item={imageCompareItem}
|
||||||
strokeLinecap="round"
|
style={{
|
||||||
strokeLinejoin="round"
|
width: `${compareButtonIconSize}px`,
|
||||||
strokeWidth="2"
|
height: `${compareButtonIconSize}px`,
|
||||||
style={{
|
}}
|
||||||
width: `${compareButtonIconSize}px`,
|
/>
|
||||||
height: `${compareButtonIconSize}px`,
|
</span>
|
||||||
}}
|
</button>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : data.status === "generating" ? (
|
) : data.status === "generating" ? (
|
||||||
@ -4174,80 +4210,137 @@ export function ImageNode({
|
|||||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<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]"
|
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"
|
size="icon"
|
||||||
variant="secondary"
|
title={imageNodeMoreItem.label}
|
||||||
>
|
variant="secondary"
|
||||||
<MoreVertical className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={MoreVertical}
|
||||||
|
item={imageNodeMoreItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => handleAction("rmbg")}>
|
<DropdownMenuItem onClick={() => handleAction("rmbg")}>
|
||||||
<Scissors className="mr-2 h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
抠图
|
className="mr-2 h-4 w-4"
|
||||||
</DropdownMenuItem>
|
fallback={Scissors}
|
||||||
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
item={imageRemoveBackgroundItem}
|
||||||
<Eraser className="mr-2 h-4 w-4" />
|
/>
|
||||||
去印
|
{imageRemoveBackgroundItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => handleAction("enhance")}>
|
<DropdownMenuItem onClick={() => handleAction("watermark")}>
|
||||||
<Sparkles className="mr-2 h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
增强
|
className="mr-2 h-4 w-4"
|
||||||
</DropdownMenuItem>
|
fallback={Eraser}
|
||||||
<DropdownMenuItem onClick={() => handleAction("note")}>
|
item={imageRemoveWatermarkItem}
|
||||||
<StickyNote className="mr-2 h-4 w-4" />
|
/>
|
||||||
备注
|
{imageRemoveWatermarkItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isLayerSplitEnabled ? (
|
<DropdownMenuItem onClick={() => handleAction("enhance")}>
|
||||||
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
|
<CanvasToolbarIcon
|
||||||
<Layers className="mr-2 h-4 w-4" />
|
className="mr-2 h-4 w-4"
|
||||||
图层拆分
|
fallback={Sparkles}
|
||||||
</DropdownMenuItem>
|
item={imageEnhanceItem}
|
||||||
) : null}
|
/>
|
||||||
<DropdownMenuItem onClick={() => handleAction("resize")}>
|
{imageEnhanceItem.label}
|
||||||
<Maximize2 className="mr-2 h-4 w-4" />
|
</DropdownMenuItem>
|
||||||
调尺
|
<DropdownMenuItem onClick={() => handleAction("note")}>
|
||||||
</DropdownMenuItem>
|
<CanvasToolbarIcon
|
||||||
<DropdownMenuItem onClick={() => handleAction("angle")}>
|
className="mr-2 h-4 w-4"
|
||||||
<Box className="mr-2 h-4 w-4" />
|
fallback={StickyNote}
|
||||||
视角
|
item={imageNoteItem}
|
||||||
</DropdownMenuItem>
|
/>
|
||||||
<DropdownMenuItem
|
{imageNoteItem.label}
|
||||||
onClick={() => handleAction("prompt-regenerate")}
|
</DropdownMenuItem>
|
||||||
>
|
{isLayerSplitEnabled ? (
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<DropdownMenuItem onClick={() => handleAction("layer-split")}>
|
||||||
重绘
|
<CanvasToolbarIcon
|
||||||
</DropdownMenuItem>
|
className="mr-2 h-4 w-4"
|
||||||
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
|
fallback={Layers}
|
||||||
<Type className="mr-2 h-4 w-4" />
|
item={imageLayerSplitItem}
|
||||||
改字
|
/>
|
||||||
</DropdownMenuItem>
|
{imageLayerSplitItem.label}
|
||||||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
</DropdownMenuItem>
|
||||||
<Expand className="mr-2 h-4 w-4" />
|
) : null}
|
||||||
扩图
|
<DropdownMenuItem onClick={() => handleAction("resize")}>
|
||||||
</DropdownMenuItem>
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Maximize2}
|
||||||
|
item={imageResizeItem}
|
||||||
|
/>
|
||||||
|
{imageResizeItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("angle")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Box}
|
||||||
|
item={imageMultiAngleItem}
|
||||||
|
/>
|
||||||
|
{imageMultiAngleItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleAction("prompt-regenerate")}
|
||||||
|
>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={RefreshCw}
|
||||||
|
item={imagePromptRegenerateItem}
|
||||||
|
/>
|
||||||
|
{imagePromptRegenerateItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("text-edit")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Type}
|
||||||
|
item={imageTextEditItem}
|
||||||
|
/>
|
||||||
|
{imageTextEditItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Expand}
|
||||||
|
item={imageExtendItem}
|
||||||
|
/>
|
||||||
|
{imageExtendItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDraftTitle(displayTitle);
|
setDraftTitle(displayTitle);
|
||||||
setIsEditingTitle(true);
|
setIsEditingTitle(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
改名
|
className="mr-2 h-4 w-4"
|
||||||
</DropdownMenuItem>
|
fallback={Pencil}
|
||||||
<DropdownMenuSeparator />
|
item={imageRenameItem}
|
||||||
<DropdownMenuItem onClick={handleDownload}>
|
/>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
{imageRenameItem.label}
|
||||||
下载
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleDownload}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={Download}
|
||||||
|
item={imageDownloadItem}
|
||||||
|
/>
|
||||||
|
{imageDownloadItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => deleteNode(node.id)}
|
onClick={() => deleteNode(node.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
删除
|
className="mr-2 h-4 w-4"
|
||||||
</DropdownMenuItem>
|
fallback={Trash2}
|
||||||
|
item={imageDeleteItem}
|
||||||
|
/>
|
||||||
|
{imageDeleteItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@ -4261,13 +4354,17 @@ export function ImageNode({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{data.note && !isSelected && (
|
{data.note && !isSelected && (
|
||||||
<div
|
<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)]"
|
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"
|
||||||
</div>
|
fallback={StickyNote}
|
||||||
|
item={imageNoteItem}
|
||||||
|
/>
|
||||||
|
{imageNoteItem.label}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{shortAgentExecutionSnapshotId ? (
|
{shortAgentExecutionSnapshotId ? (
|
||||||
<button
|
<button
|
||||||
@ -4391,188 +4488,273 @@ export function ImageNode({
|
|||||||
scrollbarWidth: "none",
|
scrollbarWidth: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
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"
|
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}
|
disabled={isProcessing || isAddingToChat}
|
||||||
onClick={handleAddToChat}
|
onClick={handleAddToChat}
|
||||||
variant="ghost"
|
title={imageAddToChatItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
{isAddingToChat ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{isAddingToChat ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<MessageSquarePlus className="h-4 w-4" />
|
) : (
|
||||||
)}
|
<CanvasToolbarIcon
|
||||||
<span className="whitespace-nowrap">添加对话</span>
|
active
|
||||||
</Button>
|
className="h-4 w-4 dark:invert-0"
|
||||||
|
fallback={MessageSquarePlus}
|
||||||
|
item={imageAddToChatItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
||||||
onClick={handleStartPicking}
|
onClick={handleStartPicking}
|
||||||
variant="ghost"
|
title={imagePickObjectItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Crosshair className="mr-1.5 h-4 w-4" />
|
>
|
||||||
识物
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Crosshair}
|
||||||
|
item={imagePickObjectItem}
|
||||||
|
/>
|
||||||
|
{imagePickObjectItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("enhance")}
|
onClick={() => handleAction("enhance")}
|
||||||
variant="ghost"
|
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
|
<CanvasToolbarIcon
|
||||||
</span>
|
className="mr-1.5 h-4 w-4"
|
||||||
高清
|
fallback={Sparkles}
|
||||||
</Button>
|
item={imageEnhanceItem}
|
||||||
|
/>
|
||||||
|
{imageEnhanceItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("rmbg")}
|
onClick={() => handleAction("rmbg")}
|
||||||
variant="ghost"
|
title={imageRemoveBackgroundItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Scissors className="mr-1.5 h-4 w-4" />
|
>
|
||||||
抠图
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Scissors}
|
||||||
|
item={imageRemoveBackgroundItem}
|
||||||
|
/>
|
||||||
|
{imageRemoveBackgroundItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("box-cutout")}
|
onClick={() => handleAction("box-cutout")}
|
||||||
variant="ghost"
|
title={imageBoxCutoutItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Scan className="mr-1.5 h-4 w-4" />
|
>
|
||||||
框选抠图
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Scan}
|
||||||
|
item={imageBoxCutoutItem}
|
||||||
|
/>
|
||||||
|
{imageBoxCutoutItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("watermark")}
|
onClick={() => handleAction("watermark")}
|
||||||
variant="ghost"
|
title={imageRemoveWatermarkItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Eraser className="mr-1.5 h-4 w-4" />
|
>
|
||||||
去水印
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Eraser}
|
||||||
|
item={imageRemoveWatermarkItem}
|
||||||
|
/>
|
||||||
|
{imageRemoveWatermarkItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("resize")}
|
onClick={() => handleAction("resize")}
|
||||||
variant="ghost"
|
title={imageResizeItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Maximize2 className="mr-1.5 h-4 w-4" />
|
>
|
||||||
尺寸
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Maximize2}
|
||||||
|
item={imageResizeItem}
|
||||||
|
/>
|
||||||
|
{imageResizeItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||||||
onClick={() => handleAction("text-edit")}
|
onClick={() => handleAction("text-edit")}
|
||||||
variant="ghost"
|
title={imageTextEditItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Type className="mr-1.5 h-4 w-4" />
|
>
|
||||||
文字
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Type}
|
||||||
|
item={imageTextEditItem}
|
||||||
|
/>
|
||||||
|
{imageTextEditItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("angle")}
|
onClick={() => handleAction("angle")}
|
||||||
variant="ghost"
|
title={imageMultiAngleItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Box className="mr-1.5 h-4 w-4" />
|
>
|
||||||
多视角
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Box}
|
||||||
|
item={imageMultiAngleItem}
|
||||||
|
/>
|
||||||
|
{imageMultiAngleItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("generate-3d-model")}
|
onClick={() => handleAction("generate-3d-model")}
|
||||||
variant="ghost"
|
title={imageGenerate3dModelItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Box className="mr-1.5 h-4 w-4" />
|
>
|
||||||
3D模型
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Box}
|
||||||
|
item={imageGenerate3dModelItem}
|
||||||
|
/>
|
||||||
|
{imageGenerate3dModelItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("generate-3d-world")}
|
onClick={() => handleAction("generate-3d-world")}
|
||||||
variant="ghost"
|
title={imageGenerate3dWorldItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Globe2 className="mr-1.5 h-4 w-4" />
|
>
|
||||||
3D世界
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Globe2}
|
||||||
|
item={imageGenerate3dWorldItem}
|
||||||
|
/>
|
||||||
|
{imageGenerate3dWorldItem.label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{isLayerSplitEnabled ? (
|
{isLayerSplitEnabled ? (
|
||||||
<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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={() => handleAction("layer-split")}
|
onClick={() => handleAction("layer-split")}
|
||||||
variant="ghost"
|
title={imageLayerSplitItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Layers className="mr-1.5 h-4 w-4" />
|
>
|
||||||
图层拆分
|
<CanvasToolbarIcon
|
||||||
</Button>
|
className="mr-1.5 h-4 w-4"
|
||||||
|
fallback={Layers}
|
||||||
|
item={imageLayerSplitItem}
|
||||||
|
/>
|
||||||
|
{imageLayerSplitItem.label}
|
||||||
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
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"
|
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}
|
disabled={isProcessing}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
title={imageNodeMoreItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={MoreHorizontal}
|
||||||
|
item={imageNodeMoreItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align="center"
|
align="center"
|
||||||
className="bg-white opacity-100 backdrop-blur-none dark:bg-slate-900 dark:text-slate-100"
|
className="bg-white opacity-100 backdrop-blur-none dark:bg-slate-900 dark:text-slate-100"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
<DropdownMenuItem onClick={() => handleAction("extend")}>
|
||||||
<Expand className="mr-2 h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
扩图
|
className="mr-2 h-4 w-4"
|
||||||
</DropdownMenuItem>
|
fallback={Expand}
|
||||||
|
item={imageExtendItem}
|
||||||
|
/>
|
||||||
|
{imageExtendItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => handleAction("prompt-regenerate")}
|
onClick={() => handleAction("prompt-regenerate")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
重绘
|
className="mr-2 h-4 w-4"
|
||||||
</DropdownMenuItem>
|
fallback={RefreshCw}
|
||||||
<DropdownMenuItem onClick={() => handleAction("note")}>
|
item={imagePromptRegenerateItem}
|
||||||
<StickyNote className="mr-2 h-4 w-4" />
|
/>
|
||||||
备注
|
{imagePromptRegenerateItem.label}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleAction("note")}>
|
||||||
|
<CanvasToolbarIcon
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
fallback={StickyNote}
|
||||||
|
item={imageNoteItem}
|
||||||
|
/>
|
||||||
|
{imageNoteItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-red-600 focus:text-red-700"
|
className="text-red-600 focus:text-red-700"
|
||||||
onClick={() => deleteNode(node.id)}
|
onClick={() => deleteNode(node.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
删除
|
className="mr-2 h-4 w-4"
|
||||||
</DropdownMenuItem>
|
fallback={Trash2}
|
||||||
|
item={imageDeleteItem}
|
||||||
|
/>
|
||||||
|
{imageDeleteItem.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
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"
|
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}
|
disabled={isProcessing}
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
title={imageDownloadItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<Download className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Download}
|
||||||
|
item={imageDownloadItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
document.body
|
document.body
|
||||||
@ -4603,36 +4785,53 @@ export function ImageNode({
|
|||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
scrollbarWidth: "none",
|
scrollbarWidth: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<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">
|
<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
|
||||||
<span className="whitespace-nowrap">{boxCutoutHintText}</span>
|
className="h-4 w-4 text-[#27a8ff]"
|
||||||
</div>
|
fallback={Scan}
|
||||||
|
item={imageBoxCutoutItem}
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap">{boxCutoutHintText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
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"
|
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}
|
disabled={isProcessing || !boxCutoutSelection}
|
||||||
onClick={() => void handleConfirmBoxCutout()}
|
onClick={() => void handleConfirmBoxCutout()}
|
||||||
variant="ghost"
|
title={imageConfirmCutoutItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
{isProcessing ? (
|
>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
{isProcessing ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
<Check className="h-4 w-4" />
|
) : (
|
||||||
)}
|
<CanvasToolbarIcon
|
||||||
<span className="whitespace-nowrap">确认抠图</span>
|
active
|
||||||
</Button>
|
className="h-4 w-4"
|
||||||
|
fallback={Check}
|
||||||
|
item={imageConfirmCutoutItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
{imageConfirmCutoutItem.label}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<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"
|
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()}
|
onClick={() => resetBoxCutoutState()}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
title={imageCancelCutoutItem.label}
|
||||||
>
|
variant="ghost"
|
||||||
<X className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={X}
|
||||||
|
item={imageCancelCutoutItem}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
|
import {
|
||||||
|
getCanvasToolbarItem,
|
||||||
|
type CanvasToolbarConfig,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import type { CanvasNode, TextNodeData } from "@/types/canvas";
|
import type { CanvasNode, TextNodeData } from "@/types/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
@ -11,6 +16,7 @@ import { toast } from "sonner";
|
|||||||
interface TextNodeProps {
|
interface TextNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TEXT_NODE_COLOR = "#111827";
|
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 data = node.data as TextNodeData;
|
||||||
|
const textDeleteItem = getCanvasToolbarItem(canvasToolbarConfig, "textDelete");
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editContent, setEditContent] = useState(data.content);
|
const [editContent, setEditContent] = useState(data.content);
|
||||||
const [isUploadingPastedImage, setIsUploadingPastedImage] = useState(false);
|
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/95 shadow-sm hover:border-destructive/30 hover:bg-red-50"
|
||||||
: "border-slate-200 bg-white hover:border-destructive/30 hover:bg-red-50"
|
: "border-slate-200 bg-white hover:border-destructive/30 hover:bg-red-50"
|
||||||
)}
|
)}
|
||||||
aria-label="删除文本模块"
|
aria-label={textDeleteItem.label}
|
||||||
title="删除文本模块"
|
title={textDeleteItem.label}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Trash2}
|
||||||
|
item={textDeleteItem}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="absolute bottom-1 right-2 text-[10px] text-muted-foreground">
|
<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 React, { useMemo, useRef, useState } from "react";
|
||||||
import { Video, Loader2, AlertCircle, MoreVertical, Download, Trash2, Play, Pause, Volume2, VolumeX } from "lucide-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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -17,6 +18,10 @@ import {
|
|||||||
getNodeContentScale,
|
getNodeContentScale,
|
||||||
getScaledNodeContentStyle,
|
getScaledNodeContentStyle,
|
||||||
} from "@/lib/canvas/node-content-scale";
|
} from "@/lib/canvas/node-content-scale";
|
||||||
|
import {
|
||||||
|
getCanvasToolbarItem,
|
||||||
|
type CanvasToolbarConfig,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
|
import type { CanvasNode, VideoNodeData } from "@/types/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -24,10 +29,23 @@ import { cn } from "@/lib/utils";
|
|||||||
interface VideoNodeProps {
|
interface VideoNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
export function VideoNode({
|
||||||
|
node,
|
||||||
|
isSelected,
|
||||||
|
canvasToolbarConfig,
|
||||||
|
}: VideoNodeProps) {
|
||||||
const data = node.data as VideoNodeData;
|
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(
|
const contentScale = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getNodeContentScale(node.size, {
|
getNodeContentScale(node.size, {
|
||||||
@ -51,6 +69,8 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isMuted, setIsMuted] = useState(true);
|
const [isMuted, setIsMuted] = useState(true);
|
||||||
|
const videoPlaybackItem = isPlaying ? videoPauseItem : videoPlayItem;
|
||||||
|
const videoAudioItem = isMuted ? videoUnmuteItem : videoMuteItem;
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
|
const [snapshotDialogOpen, setSnapshotDialogOpen] = useState(false);
|
||||||
|
|
||||||
@ -185,11 +205,20 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
|||||||
size="icon"
|
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"
|
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}
|
onClick={togglePlay}
|
||||||
|
title={videoPlaybackItem.label}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{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>
|
</Button>
|
||||||
|
|
||||||
@ -212,11 +241,20 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
|
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
|
title={videoPlaybackItem.label}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{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>
|
||||||
<Button
|
<Button
|
||||||
@ -224,11 +262,20 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
|
className="h-7 w-7 text-[#3f4b63] hover:bg-[#f4f7ff]"
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
|
title={videoAudioItem.label}
|
||||||
>
|
>
|
||||||
{isMuted ? (
|
{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>
|
</Button>
|
||||||
</div>
|
</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">
|
<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)]">
|
<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>
|
||||||
<span className="text-sm font-medium text-[#3f4b63] select-none">上传视频</span>
|
|
||||||
<span className="mt-1 text-xs text-[#7a8395] select-none">添加视频素材、参考视频或结果片段</span>
|
<span className="mt-1 text-xs text-[#7a8395] select-none">添加视频素材、参考视频或结果片段</span>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@ -279,22 +332,35 @@ export function VideoNode({ node, isSelected }: VideoNodeProps) {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
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]"
|
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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={handleDownload}>
|
<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>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onClick={() => deleteNode(node.id)}
|
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>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -11,7 +11,12 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
|
import {
|
||||||
|
getCanvasToolbarItem,
|
||||||
|
type CanvasToolbarConfig,
|
||||||
|
} from "@/lib/canvas-toolbar-config";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
getNodeContentScale,
|
getNodeContentScale,
|
||||||
@ -26,6 +31,7 @@ import type { CanvasNode, WorkflowNodeData } from "@/types/canvas";
|
|||||||
interface WorkflowNodeProps {
|
interface WorkflowNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
canvasToolbarConfig?: CanvasToolbarConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAlphaColor(color: string | undefined, alpha: number) {
|
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",
|
"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;
|
} as const;
|
||||||
|
|
||||||
export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
|
export function WorkflowNode({
|
||||||
|
node,
|
||||||
|
isSelected,
|
||||||
|
canvasToolbarConfig,
|
||||||
|
}: WorkflowNodeProps) {
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
const data = node.data as WorkflowNodeData;
|
const data = node.data as WorkflowNodeData;
|
||||||
|
const workflowDeleteItem = getCanvasToolbarItem(
|
||||||
|
canvasToolbarConfig,
|
||||||
|
"workflowDelete"
|
||||||
|
);
|
||||||
const definition = getVideoWorkflowNodeDefinition(data.templateType);
|
const definition = getVideoWorkflowNodeDefinition(data.templateType);
|
||||||
const contentScale = getNodeContentScale(
|
const contentScale = getNodeContentScale(
|
||||||
node.size,
|
node.size,
|
||||||
@ -315,10 +329,14 @@ export function WorkflowNode({ node, isSelected }: WorkflowNodeProps) {
|
|||||||
deleteNode(node.id);
|
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"
|
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="删除模块"
|
aria-label={workflowDeleteItem.label}
|
||||||
title="删除模块"
|
title={workflowDeleteItem.label}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<CanvasToolbarIcon
|
||||||
|
className="h-4 w-4"
|
||||||
|
fallback={Trash2}
|
||||||
|
item={workflowDeleteItem}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 SELECTION_REGEX = /(选中的|当前这张|我选的|这几张|这些图|选中图)/i;
|
||||||
const ACTIONABLE_EDIT_REGEX =
|
const ACTIONABLE_EDIT_REGEX =
|
||||||
/(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格|亮一点|暗一点|白底|透明底)/i;
|
/(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格|亮一点|暗一点|白底|透明底)/i;
|
||||||
|
const ACTIONABLE_GENERATION_REGEX =
|
||||||
|
/(生图|生成|画一张|出图|来一张|再来一张|做一张|做图|做视频|视频生成|生成视频|动起来)/i;
|
||||||
|
const IMPLICIT_HISTORY_TARGET_REGEX =
|
||||||
|
/(继续|接着|沿用|按刚才|照刚才|上次|上一轮|上一版|刚才|刚生成|最新生成|结果图|生成图|这张|这个|当前|原图|上传图|选中)/i;
|
||||||
|
|
||||||
function normalizeStringArray(values?: string[] | null): string[] {
|
function normalizeStringArray(values?: string[] | null): string[] {
|
||||||
if (!Array.isArray(values)) return [];
|
if (!Array.isArray(values)) return [];
|
||||||
@ -378,54 +382,65 @@ export function resolveTargetAssets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workingStateResolvedUrls = normalizeStringArray(workingState?.lastResolvedTargetImageUrls);
|
const workingStateResolvedUrls = normalizeStringArray(workingState?.lastResolvedTargetImageUrls);
|
||||||
if (workingStateResolvedUrls.length > 0) {
|
const allowsImplicitHistoryTarget =
|
||||||
const refs = toWorkingMemoryRefs(workingStateResolvedUrls);
|
IMPLICIT_HISTORY_TARGET_REGEX.test(text) &&
|
||||||
return buildResolvedTargetAssets({
|
(ACTIONABLE_EDIT_REGEX.test(text) ||
|
||||||
targetRefs: refs,
|
ACTIONABLE_GENERATION_REGEX.test(text) ||
|
||||||
candidateRefs,
|
LATEST_RESULT_REGEX.test(text) ||
|
||||||
resolutionMode: "working_memory",
|
ORIGINAL_IMAGE_REGEX.test(text) ||
|
||||||
confidence: 82,
|
DEICTIC_TARGET_REGEX.test(text) ||
|
||||||
needsClarification: false,
|
SELECTION_REGEX.test(text));
|
||||||
reason: "使用会话工作记忆中的上一轮已解析目标图",
|
|
||||||
fromHistory: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workingStateGeneratedUrls.length > 0) {
|
if (allowsImplicitHistoryTarget) {
|
||||||
const refs = toWorkingMemoryRefs(workingStateGeneratedUrls);
|
if (workingStateResolvedUrls.length > 0) {
|
||||||
return buildResolvedTargetAssets({
|
const refs = toWorkingMemoryRefs(workingStateResolvedUrls);
|
||||||
targetRefs: refs,
|
return buildResolvedTargetAssets({
|
||||||
candidateRefs,
|
targetRefs: refs,
|
||||||
resolutionMode: "working_memory",
|
candidateRefs,
|
||||||
confidence: 78,
|
resolutionMode: "working_memory",
|
||||||
needsClarification: false,
|
confidence: 82,
|
||||||
reason: "使用会话工作记忆中的最近生成结果",
|
needsClarification: false,
|
||||||
fromHistory: true,
|
reason: "用户表达了连续处理意图,使用会话工作记忆中的上一轮已解析目标图",
|
||||||
});
|
fromHistory: true,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (generatedRefs.length > 0) {
|
if (workingStateGeneratedUrls.length > 0) {
|
||||||
return buildResolvedTargetAssets({
|
const refs = toWorkingMemoryRefs(workingStateGeneratedUrls);
|
||||||
targetRefs: generatedRefs,
|
return buildResolvedTargetAssets({
|
||||||
candidateRefs,
|
targetRefs: refs,
|
||||||
resolutionMode: "latest_result",
|
candidateRefs,
|
||||||
confidence: 74,
|
resolutionMode: "working_memory",
|
||||||
needsClarification: false,
|
confidence: 78,
|
||||||
reason: "未显式指定时,默认续接最近一轮生成结果",
|
needsClarification: false,
|
||||||
fromHistory: true,
|
reason: "用户表达了连续处理意图,使用会话工作记忆中的最近生成结果",
|
||||||
});
|
fromHistory: true,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (originalRefs.length > 0) {
|
if (generatedRefs.length > 0) {
|
||||||
return buildResolvedTargetAssets({
|
return buildResolvedTargetAssets({
|
||||||
targetRefs: originalRefs,
|
targetRefs: generatedRefs,
|
||||||
candidateRefs,
|
candidateRefs,
|
||||||
resolutionMode: "working_memory",
|
resolutionMode: "latest_result",
|
||||||
confidence: 68,
|
confidence: 74,
|
||||||
needsClarification: false,
|
needsClarification: false,
|
||||||
reason: "回退使用最近一次用户上传图片",
|
reason: "用户表达了连续处理意图,续接最近一轮生成结果",
|
||||||
fromHistory: true,
|
fromHistory: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalRefs.length > 0) {
|
||||||
|
return buildResolvedTargetAssets({
|
||||||
|
targetRefs: originalRefs,
|
||||||
|
candidateRefs,
|
||||||
|
resolutionMode: "working_memory",
|
||||||
|
confidence: 68,
|
||||||
|
needsClarification: false,
|
||||||
|
reason: "用户表达了连续处理意图,回退使用最近一次用户上传图片",
|
||||||
|
fromHistory: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ACTIONABLE_EDIT_REGEX.test(text)) {
|
if (ACTIONABLE_EDIT_REGEX.test(text)) {
|
||||||
@ -435,7 +450,7 @@ export function resolveTargetAssets(
|
|||||||
resolutionMode: "clarification",
|
resolutionMode: "clarification",
|
||||||
confidence: 0,
|
confidence: 0,
|
||||||
needsClarification: true,
|
needsClarification: true,
|
||||||
reason: "用户表达了编辑意图,但当前没有可用图片目标",
|
reason: "用户表达了编辑意图,但没有明确选中或文本定位目标图;不会自动关联历史图片",
|
||||||
fromHistory: false,
|
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 =
|
export const DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY =
|
||||||
"canvas_detail_agent_moonshot_config";
|
"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 =
|
export const DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID =
|
||||||
"__moonshot_kimi_k25_local__";
|
"__moonshot_kimi_k25_local__";
|
||||||
export const DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL =
|
export const DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL =
|
||||||
"https://api.moonshot.cn/v1";
|
"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 = {
|
export type DetailAgentLocalMoonshotConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -19,6 +21,22 @@ export function isDetailAgentLocalMoonshotOption(value: unknown) {
|
|||||||
return String(value || "").trim() === DETAIL_AGENT_LOCAL_MOONSHOT_OPTION_ID;
|
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(
|
export function normalizeDetailAgentLocalMoonshotConfig(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): DetailAgentLocalMoonshotConfig {
|
): DetailAgentLocalMoonshotConfig {
|
||||||
@ -34,8 +52,7 @@ export function normalizeDetailAgentLocalMoonshotConfig(
|
|||||||
baseUrl:
|
baseUrl:
|
||||||
String(raw.baseUrl || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
String(raw.baseUrl || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||||
apiKey: String(raw.apiKey || "").trim(),
|
apiKey: String(raw.apiKey || "").trim(),
|
||||||
modelId:
|
modelId: normalizeMoonshotModelId(raw.modelId),
|
||||||
String(raw.modelId || "").trim() || DETAIL_AGENT_LOCAL_MOONSHOT_MODEL_ID,
|
displayName: normalizeMoonshotDisplayName(raw.displayName),
|
||||||
displayName: String(raw.displayName || "").trim() || "官方 Kimi K2.5",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
isDetailAgentLocalMoonshotOption,
|
isDetailAgentLocalMoonshotOption,
|
||||||
normalizeDetailAgentLocalMoonshotConfig,
|
normalizeDetailAgentLocalMoonshotConfig,
|
||||||
} from "@/lib/ai/detail-agent-model-config";
|
} from "@/lib/ai/detail-agent-model-config";
|
||||||
|
import { createMoonshotKimiFetch } from "@/lib/ai/detail-agent-kimi-profile";
|
||||||
|
|
||||||
export async function resolveDetailAgentLocalMoonshotModel(params: {
|
export async function resolveDetailAgentLocalMoonshotModel(params: {
|
||||||
siteSettingModelId?: string | null;
|
siteSettingModelId?: string | null;
|
||||||
@ -30,6 +31,7 @@ export async function resolveDetailAgentLocalMoonshotModel(params: {
|
|||||||
const provider = createOpenAI({
|
const provider = createOpenAI({
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
baseURL: config.baseUrl || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
baseURL: config.baseUrl || DETAIL_AGENT_LOCAL_MOONSHOT_BASE_URL,
|
||||||
|
fetch: createMoonshotKimiFetch(),
|
||||||
});
|
});
|
||||||
const model = provider.chat(config.modelId);
|
const model = provider.chat(config.modelId);
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,10 @@ import { createRunway } from "./providers/runway";
|
|||||||
import { createKrea } from "./providers/krea";
|
import { createKrea } from "./providers/krea";
|
||||||
import { createVolcanoMultimodal } from "./providers/volcano-multimodal";
|
import { createVolcanoMultimodal } from "./providers/volcano-multimodal";
|
||||||
import { getProviderDefaultBaseUrl } from "./provider-defaults";
|
import { getProviderDefaultBaseUrl } from "./provider-defaults";
|
||||||
|
import {
|
||||||
|
createMoonshotKimiFetch,
|
||||||
|
isMoonshotOfficialBaseUrl,
|
||||||
|
} from "./detail-agent-kimi-profile";
|
||||||
|
|
||||||
// 缓存已创建的 provider 实例
|
// 缓存已创建的 provider 实例
|
||||||
const providerCache = new Map<string, any>();
|
const providerCache = new Map<string, any>();
|
||||||
@ -38,6 +42,10 @@ function isVolcanoMultimodalBaseUrl(baseUrl?: string | null) {
|
|||||||
return /ark\.cn-beijing\.volces\.com/i.test(baseUrl);
|
return /ark\.cn-beijing\.volces\.com/i.test(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOpenAICompatibleFetch(baseUrl?: string | null) {
|
||||||
|
return isMoonshotOfficialBaseUrl(baseUrl) ? createMoonshotKimiFetch() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据提供商类型创建对应的 AI Provider 实例
|
* 根据提供商类型创建对应的 AI Provider 实例
|
||||||
* 不同供应商使用OpenAI兼容的API格式,但可能有细微差异
|
* 不同供应商使用OpenAI兼容的API格式,但可能有细微差异
|
||||||
@ -67,6 +75,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
|||||||
providerInstance = createOpenAI({
|
providerInstance = createOpenAI({
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
baseURL: resolvedBaseUrl,
|
baseURL: resolvedBaseUrl,
|
||||||
|
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
|
||||||
});
|
});
|
||||||
console.log(`[Provider] Created Alibaba DashScope provider: ${resolvedBaseUrl}`);
|
console.log(`[Provider] Created Alibaba DashScope provider: ${resolvedBaseUrl}`);
|
||||||
break;
|
break;
|
||||||
@ -78,6 +87,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
|||||||
providerInstance = createOpenAI({
|
providerInstance = createOpenAI({
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
baseURL: resolvedBaseUrl || "https://api.openai.com/v1",
|
baseURL: resolvedBaseUrl || "https://api.openai.com/v1",
|
||||||
|
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
|
||||||
});
|
});
|
||||||
console.log(`[Provider] Created OpenAI provider: ${resolvedBaseUrl || "https://api.openai.com/v1"}`);
|
console.log(`[Provider] Created OpenAI provider: ${resolvedBaseUrl || "https://api.openai.com/v1"}`);
|
||||||
break;
|
break;
|
||||||
@ -87,6 +97,7 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
|
|||||||
providerInstance = createOpenAI({
|
providerInstance = createOpenAI({
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
baseURL: resolvedBaseUrl || "https://open.bigmodel.cn/api/paas/v4",
|
baseURL: resolvedBaseUrl || "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`[Provider] Created Zhipu Open Platform provider: ${resolvedBaseUrl || "https://open.bigmodel.cn/api/paas/v4"}`
|
`[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({
|
providerInstance = createOpenAI({
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
baseURL: resolvedBaseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
baseURL: resolvedBaseUrl || "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
fetch: getOpenAICompatibleFetch(resolvedBaseUrl),
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`[Provider] Created Doubao video provider: ${resolvedBaseUrl || "https://ark.cn-beijing.volces.com/api/v3"}`
|
`[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({
|
providerInstance = createOpenAI({
|
||||||
apiKey: provider.apiKey,
|
apiKey: provider.apiKey,
|
||||||
baseURL: resolvedBaseUrl || provider.baseUrl,
|
baseURL: resolvedBaseUrl || provider.baseUrl,
|
||||||
|
fetch: getOpenAICompatibleFetch(resolvedBaseUrl || provider.baseUrl),
|
||||||
});
|
});
|
||||||
console.log(
|
console.log(
|
||||||
`[Provider] Created custom OpenAI-compatible provider: ${resolvedBaseUrl || provider.baseUrl}`
|
`[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 = {
|
const BASE_TOKENS: DetailUiDesignTokens = {
|
||||||
canvas: {
|
canvas: {
|
||||||
sectionGap: 16,
|
sectionGap: 16,
|
||||||
sectionRadius: 28,
|
sectionRadius: 12,
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
title: {
|
title: {
|
||||||
fontSize: 52,
|
fontSize: 52,
|
||||||
fontWeight: "800",
|
fontWeight: "700",
|
||||||
lineHeight: 1.08,
|
lineHeight: 1.08,
|
||||||
letterSpacing: -0.8,
|
letterSpacing: 0,
|
||||||
color: "#0f172a",
|
color: "#0f172a",
|
||||||
},
|
},
|
||||||
subtitle: {
|
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 { AgentWorkflowPersistedState } from "@/types/agent";
|
||||||
|
import type { UiScreenDocument } from "@/lib/ui-screen/schema";
|
||||||
|
|
||||||
export type CanvasWorkspaceType = 'canvas' | 'agent' | 'video';
|
export type CanvasWorkspaceType = 'canvas' | 'agent' | 'video';
|
||||||
|
|
||||||
@ -9,6 +10,7 @@ export type NodeType =
|
|||||||
| 'image'
|
| 'image'
|
||||||
| 'video'
|
| 'video'
|
||||||
| 'text'
|
| 'text'
|
||||||
|
| 'ui-screen'
|
||||||
| 'workflow'
|
| 'workflow'
|
||||||
| 'scene-fusion'
|
| 'scene-fusion'
|
||||||
| 'model3d'
|
| 'model3d'
|
||||||
@ -105,6 +107,34 @@ export interface TextNodeData {
|
|||||||
metadata?: Record<string, any>;
|
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 {
|
export interface WorkflowNodeData {
|
||||||
templateType: string;
|
templateType: string;
|
||||||
@ -181,6 +211,7 @@ export interface CanvasNode {
|
|||||||
| ImageNodeData
|
| ImageNodeData
|
||||||
| VideoNodeData
|
| VideoNodeData
|
||||||
| TextNodeData
|
| TextNodeData
|
||||||
|
| UiScreenNodeData
|
||||||
| WorkflowNodeData
|
| WorkflowNodeData
|
||||||
| SceneFusionNodeData
|
| SceneFusionNodeData
|
||||||
| Model3DNodeData
|
| Model3DNodeData
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user