feat: add style pro canvas workspace
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-16 15:06:12 +08:00
parent e51de15aac
commit 5387d6f1d2
26 changed files with 3451 additions and 99 deletions

View File

@ -928,6 +928,7 @@ const CANVAS_TOOLBAR_FALLBACK_ICONS: Record<CanvasToolbarItemKey, LucideIcon> =
markerPanel: Tag, markerPanel: Tag,
workflowConnection: GitBranch, workflowConnection: GitBranch,
styleMode: Wand2, styleMode: Wand2,
stylePro: Sparkles,
batchProcessing: Layers, batchProcessing: Layers,
addImage: ImagePlus, addImage: ImagePlus,
addImageInput: ImagePlus, addImageInput: ImagePlus,
@ -11356,6 +11357,11 @@ export function AdminSettingsClient() {
管理PSD模板 管理PSD模板
</Link> </Link>
</Button> </Button>
<Button asChild variant="outline">
<Link href="/settings/style-pro">
风格化PRO设置
</Link>
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/middleware/admin";
import { StyleProSettingsClient } from "./style-pro-settings-client";
export const dynamic = "force-dynamic";
export default async function StyleProSettingsPage() {
try {
await requireAdmin();
} catch {
redirect("/");
}
return <StyleProSettingsClient />;
}

View File

@ -0,0 +1,651 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Plus, Save, Trash2, Wand2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
import {
STYLE_PRO_CONFIG_KEY,
defaultStyleProConfig,
normalizeStyleProConfig,
type StyleProCategory,
type StyleProConfig,
type StyleProOption,
} from "@/lib/style-pro/config";
type SettingsResponse = {
setting?: {
value?: unknown;
};
};
type AiModelOption = {
id: string;
modelId: string;
displayName: string;
supportsImageGen?: boolean;
isEnabled?: boolean;
isHidden?: boolean;
};
type AiModelsResponse = {
models?: AiModelOption[];
};
const AUTO_MODEL_VALUE = "__auto__";
function getId(prefix: string) {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}
return `${prefix}-${Date.now().toString(36)}`;
}
function reindexCategories(categories: StyleProCategory[]) {
return categories.map((category, index) => ({
...category,
order: (index + 1) * 10,
options: category.options.map((option, optionIndex) => ({
...option,
order: (optionIndex + 1) * 10,
})),
}));
}
function createCategory(): StyleProCategory {
const id = getId("category");
return {
id,
title: "新分类",
description: "",
required: true,
minSelections: 1,
maxSelections: 1,
enabled: true,
order: 999,
options: [createOption(id)],
};
}
function createOption(categoryId: string): StyleProOption {
return {
id: getId("option"),
label: "新按钮",
description: "",
prompt: "请填写这个按钮对应的隐藏提示词。",
jsonKey: categoryId,
enabled: true,
order: 999,
};
}
function modelLabel(model: AiModelOption) {
const name = model.displayName || model.modelId || model.id;
const id = model.modelId && model.modelId !== name ? ` · ${model.modelId}` : "";
return `${name}${id}`;
}
export function StyleProSettingsClient() {
const { data, mutate } = useSWR<SettingsResponse>(
`/api/admin/settings?key=${STYLE_PRO_CONFIG_KEY}`,
fetcher
);
const { data: modelsData } = useSWR<AiModelsResponse>(
"/api/admin/ai-models",
fetcher
);
const [config, setConfig] = useState<StyleProConfig>(defaultStyleProConfig);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!data) return;
setConfig(normalizeStyleProConfig(data.setting?.value));
}, [data]);
const imageModels = useMemo(
() =>
(modelsData?.models || []).filter(
(model) =>
model.isEnabled !== false &&
model.isHidden !== true &&
model.supportsImageGen
),
[modelsData?.models]
);
const updateConfig = (updates: Partial<StyleProConfig>) => {
setConfig((current) => ({ ...current, ...updates }));
};
const updateCategory = (
categoryId: string,
updates: Partial<StyleProCategory>
) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId ? { ...category, ...updates } : category
),
}));
};
const updateOption = (
categoryId: string,
optionId: string,
updates: Partial<StyleProOption>
) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
options: category.options.map((option) =>
option.id === optionId ? { ...option, ...updates } : option
),
}
: category
),
}));
};
const addCategory = () => {
setConfig((current) => ({
...current,
categories: reindexCategories([...current.categories, createCategory()]),
}));
};
const removeCategory = (categoryId: string) => {
setConfig((current) => ({
...current,
categories: reindexCategories(
current.categories.filter((category) => category.id !== categoryId)
),
}));
};
const moveCategory = (categoryId: string, direction: -1 | 1) => {
setConfig((current) => {
const categories = [...current.categories];
const index = categories.findIndex((category) => category.id === categoryId);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= categories.length) {
return current;
}
const [category] = categories.splice(index, 1);
categories.splice(nextIndex, 0, category);
return { ...current, categories: reindexCategories(categories) };
});
};
const addOption = (categoryId: string) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
options: [
...category.options,
{
...createOption(category.id),
order: (category.options.length + 1) * 10,
},
],
}
: category
),
}));
};
const removeOption = (categoryId: string, optionId: string) => {
setConfig((current) => ({
...current,
categories: current.categories.map((category) =>
category.id === categoryId
? {
...category,
options: category.options.filter((option) => option.id !== optionId),
}
: category
),
}));
};
const save = async () => {
setSaving(true);
try {
const normalized = normalizeStyleProConfig(config);
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: STYLE_PRO_CONFIG_KEY,
value: normalized,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "保存失败");
}
setConfig(normalized);
await mutate();
toast.success("风格化 PRO 配置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<div className="mb-3">
<Button asChild variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
返回设置
</Link>
</Button>
</div>
<h1 className="font-semibold text-3xl tracking-tight">风格化 PRO 设置</h1>
<p className="mt-2 max-w-2xl text-muted-foreground">
配置无限画布里的风格化 PRO 子页面。用户只看到分类和按钮名称,隐藏提示词和模型只在服务端生成时使用。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
setConfig(defaultStyleProConfig);
toast.info("已恢复默认配置,保存后生效");
}}
variant="outline"
>
恢复默认
</Button>
<Button disabled={saving} onClick={save}>
<Save className="mr-2 h-4 w-4" />
{saving ? "保存中" : "保存配置"}
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>基础设置</CardTitle>
<CardDescription>
控制功能开关、最终生图模型、默认提示词和生成规格。
</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border p-4 md:col-span-2">
<div>
<Label>启用风格化 PRO</Label>
<p className="mt-1 text-muted-foreground text-sm">
关闭后前台页面会提示管理员暂未启用,生成接口也会拒绝执行。
</p>
</div>
<Switch
checked={config.enabled}
onCheckedChange={(enabled) => updateConfig({ enabled })}
/>
</div>
<div className="space-y-2">
<Label>最终生图模型</Label>
<Select
onValueChange={(value) =>
updateConfig({
modelId: value === AUTO_MODEL_VALUE ? "" : value,
})
}
value={config.modelId || AUTO_MODEL_VALUE}
>
<SelectTrigger>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_MODEL_VALUE}>自动选择可用图片模型</SelectItem>
{config.modelId &&
!imageModels.some((model) => model.modelId === config.modelId || model.id === config.modelId) ? (
<SelectItem value={config.modelId}>
当前保存值:{config.modelId}
</SelectItem>
) : null}
{imageModels.map((model) => {
const value = model.modelId || model.id;
return (
<SelectItem key={model.id} value={value}>
{modelLabel(model)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>图片比例</Label>
<Select
onValueChange={(aspectRatio) => updateConfig({ aspectRatio })}
value={config.aspectRatio || "auto"}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动延续原图</SelectItem>
<SelectItem value="1:1">1:1</SelectItem>
<SelectItem value="4:3">4:3</SelectItem>
<SelectItem value="3:4">3:4</SelectItem>
<SelectItem value="16:9">16:9</SelectItem>
<SelectItem value="9:16">9:16</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>图片尺寸</Label>
<Select
onValueChange={(imageSize) => updateConfig({ imageSize })}
value={config.imageSize || "2K"}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1K">1K</SelectItem>
<SelectItem value="2K">2K</SelectItem>
<SelectItem value="4K">4K</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label>默认系统提示词</Label>
<Textarea
className="min-h-[140px]"
onChange={(event) =>
updateConfig({ defaultPrompt: event.target.value })
}
value={config.defaultPrompt}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>负向提示词</Label>
<Textarea
className="min-h-[90px]"
onChange={(event) =>
updateConfig({ negativePrompt: event.target.value })
}
value={config.negativePrompt}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<CardTitle>分类与按钮</CardTitle>
<CardDescription>
分类和按钮会按顺序显示在前台,按钮提示词只在服务端参与生成。
</CardDescription>
</div>
<Button onClick={addCategory}>
<Plus className="mr-2 h-4 w-4" />
添加分类
</Button>
</CardHeader>
<CardContent className="space-y-5">
{config.categories.map((category, categoryIndex) => (
<div className="rounded-xl border p-4" key={category.id}>
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 font-semibold text-primary">
{categoryIndex + 1}
</span>
<div>
<div className="font-medium">{category.title || "未命名分类"}</div>
<div className="text-muted-foreground text-xs">
{category.required ? "必填分类" : "选填分类"} · {category.options.length} 个按钮
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
disabled={categoryIndex === 0}
onClick={() => moveCategory(category.id, -1)}
size="sm"
variant="outline"
>
上移
</Button>
<Button
disabled={categoryIndex === config.categories.length - 1}
onClick={() => moveCategory(category.id, 1)}
size="sm"
variant="outline"
>
下移
</Button>
<Button
onClick={() => removeCategory(category.id)}
size="sm"
variant="destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
删除分类
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>分类名称</Label>
<Input
onChange={(event) =>
updateCategory(category.id, { title: event.target.value })
}
value={category.title}
/>
</div>
<div className="space-y-2">
<Label>分类 ID</Label>
<Input
onChange={(event) =>
updateCategory(category.id, { id: event.target.value })
}
value={category.id}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>分类说明</Label>
<Input
onChange={(event) =>
updateCategory(category.id, {
description: event.target.value,
})
}
value={category.description || ""}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>最少选择</Label>
<Input
min={0}
onChange={(event) =>
updateCategory(category.id, {
minSelections: Number(event.target.value) || 0,
})
}
type="number"
value={category.minSelections}
/>
</div>
<div className="space-y-2">
<Label>最多选择</Label>
<Input
min={1}
onChange={(event) =>
updateCategory(category.id, {
maxSelections: Number(event.target.value) || 1,
})
}
type="number"
value={category.maxSelections}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex items-center justify-between rounded-lg border p-3">
<span className="text-sm">必填</span>
<Switch
checked={category.required}
onCheckedChange={(required) =>
updateCategory(category.id, { required })
}
/>
</label>
<label className="flex items-center justify-between rounded-lg border p-3">
<span className="text-sm">启用</span>
<Switch
checked={category.enabled}
onCheckedChange={(enabled) =>
updateCategory(category.id, { enabled })
}
/>
</label>
</div>
</div>
<div className="mt-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">按钮配置</h3>
<Button onClick={() => addOption(category.id)} size="sm" variant="outline">
<Plus className="mr-2 h-4 w-4" />
添加按钮
</Button>
</div>
{category.options.map((option) => (
<div className="rounded-lg border bg-muted/20 p-4" key={option.id}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 text-primary" />
<span className="font-medium text-sm">
{option.label || "未命名按钮"}
</span>
</div>
<div className="flex items-center gap-2">
<Switch
checked={option.enabled}
onCheckedChange={(enabled) =>
updateOption(category.id, option.id, { enabled })
}
/>
<Button
onClick={() => removeOption(category.id, option.id)}
size="sm"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>前台按钮名</Label>
<Input
onChange={(event) =>
updateOption(category.id, option.id, {
label: event.target.value,
})
}
value={option.label}
/>
</div>
<div className="space-y-2">
<Label>按钮 ID</Label>
<Input
onChange={(event) =>
updateOption(category.id, option.id, {
id: event.target.value,
})
}
value={option.id}
/>
</div>
<div className="space-y-2">
<Label>JSON Key</Label>
<Input
onChange={(event) =>
updateOption(category.id, option.id, {
jsonKey: event.target.value,
})
}
placeholder={category.id}
value={option.jsonKey || ""}
/>
</div>
<div className="space-y-2">
<Label>说明</Label>
<Input
onChange={(event) =>
updateOption(category.id, option.id, {
description: event.target.value,
})
}
value={option.description || ""}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>隐藏提示词</Label>
<Textarea
className="min-h-[110px]"
onChange={(event) =>
updateOption(category.id, option.id, {
prompt: event.target.value,
})
}
value={option.prompt}
/>
</div>
</div>
</div>
))}
</div>
</div>
))}
</CardContent>
</Card>
</main>
);
}

View File

@ -59,6 +59,10 @@ import {
DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY, DINGTALK_NOTIFICATION_DRAFT_CONFIG_KEY,
normalizeDingTalkNotificationDraftConfig, normalizeDingTalkNotificationDraftConfig,
} from "@/lib/notifications/dingtalk-notification-draft-config"; } from "@/lib/notifications/dingtalk-notification-draft-config";
import {
STYLE_PRO_CONFIG_KEY,
normalizeStyleProConfig,
} from "@/lib/style-pro/config";
const settingSchema = z.object({ const settingSchema = z.object({
key: z.string(), key: z.string(),
@ -118,6 +122,12 @@ export async function GET(request: Request) {
setting: setting ? { ...setting, value } : { key, value }, setting: setting ? { ...setting, value } : { key, value },
}); });
} }
if (key === STYLE_PRO_CONFIG_KEY) {
const value = normalizeStyleProConfig(setting?.value);
return Response.json({
setting: setting ? { ...setting, value } : { key, value },
});
}
return Response.json({ setting }); return Response.json({ setting });
} }
@ -260,6 +270,10 @@ export async function POST(request: Request) {
nextValue = normalizeDingTalkNotificationDraftConfig(value); nextValue = normalizeDingTalkNotificationDraftConfig(value);
} }
if (key === STYLE_PRO_CONFIG_KEY) {
nextValue = normalizeStyleProConfig(value);
}
if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) { if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) {
const existingSetting = await getSystemSetting({ const existingSetting = await getSystemSetting({
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY, key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,

View File

@ -10,6 +10,9 @@ type StitchReferenceInput = {
id: string; id: string;
label?: string | null; label?: string | null;
imageUrl: string; imageUrl: string;
previewUrl?: string | null;
width?: number | null;
height?: number | null;
candidateId?: string | null; candidateId?: string | null;
sortOrder?: number | null; sortOrder?: number | null;
passthrough?: boolean | null; passthrough?: boolean | null;
@ -98,10 +101,16 @@ function sanitizeReferenceInput(input: unknown, origin: string): StitchReference
const raw = (item || {}) as Record<string, unknown>; const raw = (item || {}) as Record<string, unknown>;
const imageUrl = resolveHttpUrl(raw.imageUrl, origin); const imageUrl = resolveHttpUrl(raw.imageUrl, origin);
if (!imageUrl) return null; if (!imageUrl) return null;
const previewUrl = resolveHttpUrl(raw.previewUrl, origin);
const width = Number(raw.width);
const height = Number(raw.height);
return { return {
id: String(raw.id || `reference-${index + 1}`).trim() || `reference-${index + 1}`, id: String(raw.id || `reference-${index + 1}`).trim() || `reference-${index + 1}`,
label: String(raw.label || "").trim() || `参考图 ${index + 1}`, label: String(raw.label || "").trim() || `参考图 ${index + 1}`,
imageUrl, imageUrl,
previewUrl: previewUrl || null,
width: Number.isFinite(width) && width > 0 ? Math.round(width) : null,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : null,
candidateId: String(raw.candidateId || "").trim() || null, candidateId: String(raw.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Number(raw.sortOrder) : index, sortOrder: Number.isFinite(Number(raw.sortOrder)) ? Number(raw.sortOrder) : index,
passthrough: raw.passthrough === true, passthrough: raw.passthrough === true,
@ -217,24 +226,44 @@ async function prepareStitch(params: {
requestOrigin: string; requestOrigin: string;
cookie: string; cookie: string;
}) { }) {
const sharp = (await import("sharp")).default; let sharp: ((input: Buffer, options?: import("sharp").SharpOptions) => import("sharp").Sharp) | null = null;
const fetched = await runBoundedParallel( const fetched = await runBoundedParallel(
params.references, params.references,
FETCH_CONCURRENCY, FETCH_CONCURRENCY,
async (card, index) => { async (card, index) => {
try { try {
if (card.width && card.height) {
return {
ok: true as const,
card,
index,
width: card.width,
height: card.height,
};
}
const buffer = await fetchImageBuffer( const buffer = await fetchImageBuffer(
buildReferenceFetchUrl(card.imageUrl), buildReferenceFetchUrl(card.imageUrl),
params.requestOrigin, params.requestOrigin,
params.cookie, params.cookie,
); );
sharp = sharp || (await import("sharp")).default;
const metadata = await sharp(buffer, { failOn: "none" }).rotate().metadata(); const metadata = await sharp(buffer, { failOn: "none" }).rotate().metadata();
const width = metadata.width || 0; const width = metadata.width || 0;
const height = metadata.height || 0; const height = metadata.height || 0;
if (!width || !height) { if (!width || !height) {
throw new Error("无法读取图片尺寸"); throw new Error("无法读取图片尺寸");
} }
return { ok: true as const, card, index, buffer, width, height }; return {
ok: true as const,
card: {
...card,
width,
height,
},
index,
width,
height,
};
} catch (error) { } catch (error) {
return { return {
ok: false as const, ok: false as const,
@ -432,6 +461,9 @@ async function applyStitch(params: {
id: `detail-stitch-slice-${Date.now()}-${segment.index + 1}`, id: `detail-stitch-slice-${Date.now()}-${segment.index + 1}`,
label: `拼合切片 ${segment.index + 1}`, label: `拼合切片 ${segment.index + 1}`,
imageUrl: upload.url, imageUrl: upload.url,
previewUrl: upload.url,
width: params.width,
height: segmentHeight,
candidateId: sourceSegment?.card.candidateId || null, candidateId: sourceSegment?.card.candidateId || null,
sortOrder: segment.index, sortOrder: segment.index,
passthrough: passthroughSet.has(segment.index), passthrough: passthroughSet.has(segment.index),
@ -474,10 +506,13 @@ export async function POST(request: NextRequest) {
const raw = (item || {}) as Record<string, unknown>; const raw = (item || {}) as Record<string, unknown>;
const rawCard = (raw.card || {}) as Record<string, unknown>; const rawCard = (raw.card || {}) as Record<string, unknown>;
const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin); const imageUrl = resolveHttpUrl(rawCard.imageUrl, requestOrigin);
const previewUrl = resolveHttpUrl(rawCard.previewUrl, requestOrigin);
const top = Math.round(Number(raw.top)); const top = Math.round(Number(raw.top));
const segmentHeight = Math.round(Number(raw.height)); const segmentHeight = Math.round(Number(raw.height));
const sourceWidth = Math.round(Number(raw.sourceWidth)); const sourceWidth = Math.round(Number(raw.sourceWidth));
const sourceHeight = Math.round(Number(raw.sourceHeight)); const sourceHeight = Math.round(Number(raw.sourceHeight));
const cardWidth = Math.round(Number(rawCard.width));
const cardHeight = Math.round(Number(rawCard.height));
if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) { if (!imageUrl || !Number.isFinite(top) || !Number.isFinite(segmentHeight)) {
return null; return null;
} }
@ -490,6 +525,9 @@ export async function POST(request: NextRequest) {
id: String(rawCard.id || "").trim() || `source-${top}`, id: String(rawCard.id || "").trim() || `source-${top}`,
label: String(rawCard.label || "").trim() || "参考图", label: String(rawCard.label || "").trim() || "参考图",
imageUrl, imageUrl,
previewUrl: previewUrl || null,
width: Number.isFinite(cardWidth) && cardWidth > 0 ? cardWidth : null,
height: Number.isFinite(cardHeight) && cardHeight > 0 ? cardHeight : null,
candidateId: String(rawCard.candidateId || "").trim() || null, candidateId: String(rawCard.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(rawCard.sortOrder)) ? Number(rawCard.sortOrder) : null, sortOrder: Number.isFinite(Number(rawCard.sortOrder)) ? Number(rawCard.sortOrder) : null,
passthrough: rawCard.passthrough === true, passthrough: rawCard.passthrough === true,

View File

@ -369,6 +369,7 @@ function buildReferenceItems(params: {
id: `${params.candidate.id}-${params.target}-${index + 1}`, id: `${params.candidate.id}-${params.target}-${index + 1}`,
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`, label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
imageUrl: url, imageUrl: url,
previewUrl: url,
candidateId: params.candidate.id, candidateId: params.candidate.id,
sortOrder: index, sortOrder: index,
passthrough: false, passthrough: false,

View File

@ -1,5 +1,6 @@
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries"; import { getSystemSetting } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { import {
DEFAULT_BFL_MODEL, DEFAULT_BFL_MODEL,
DEFAULT_REPLICATE_BASE_URL, DEFAULT_REPLICATE_BASE_URL,
@ -27,6 +28,8 @@ type GeneratePayload = {
prompt?: string; prompt?: string;
seed?: number; seed?: number;
imageSize?: "square" | "square_hd"; imageSize?: "square" | "square_hd";
aspectRatio?: "1:1" | "4:3" | "2:3";
generationMode?: "manual" | "auto";
steps?: number; steps?: number;
cfgScale?: number; cfgScale?: number;
strength?: number; strength?: number;
@ -65,6 +68,10 @@ type FalSubmitResult = {
const FALLBACK_REALTIME_PROMPT = const FALLBACK_REALTIME_PROMPT =
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。"; "严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
const REALTIME_GENERATION_COSTS = {
manual: 1,
auto: 1,
} as const;
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
@ -79,6 +86,82 @@ function resolveSquareSize(
return clamp(Math.round(capped / 64) * 64, 512, 1536); return clamp(Math.round(capped / 64) * 64, 512, 1536);
} }
function normalizeRealtimeGenerationMode(
raw: GeneratePayload["generationMode"]
): keyof typeof REALTIME_GENERATION_COSTS {
return raw === "auto" ? "auto" : "manual";
}
function normalizeRealtimeAspectRatio(
raw: GeneratePayload["aspectRatio"]
): NonNullable<GeneratePayload["aspectRatio"]> {
if (raw === "4:3" || raw === "2:3") return raw;
return "1:1";
}
function resolveRatioDimensions(
widthUnits: number,
heightUnits: number,
maxEdge: number
) {
const dominantUnits = Math.max(widthUnits, heightUnits);
const unit = Math.max(
128,
Math.floor(maxEdge / dominantUnits / 64) * 64
);
return {
width: clamp(widthUnits * unit, 256, 1536),
height: clamp(heightUnits * unit, 256, 1536),
};
}
function resolveRealtimeDimensions(
imageSize: "square" | "square_hd" | undefined,
aspectRatio: GeneratePayload["aspectRatio"],
maxInputResolution: number
) {
const baseSize = resolveSquareSize(imageSize, maxInputResolution);
const normalizedRatio = normalizeRealtimeAspectRatio(aspectRatio);
if (normalizedRatio === "4:3") {
const dimensions = resolveRatioDimensions(4, 3, baseSize);
return {
...dimensions,
aspectRatio: normalizedRatio,
};
}
if (normalizedRatio === "2:3") {
const dimensions = resolveRatioDimensions(2, 3, baseSize);
return {
...dimensions,
aspectRatio: normalizedRatio,
};
}
return {
width: baseSize,
height: baseSize,
aspectRatio: normalizedRatio,
};
}
function getRealtimePointsChargeNote(params: {
mode: keyof typeof REALTIME_GENERATION_COSTS;
requestId?: string;
model?: string;
}) {
const modeLabel = params.mode === "auto" ? "自动" : "手动";
const requestLabel = String(params.requestId || "").trim();
const modelLabel = String(params.model || "").trim();
return [
`实时绘画${modeLabel}生成消耗积分`,
requestLabel || modelLabel
? `(${[requestLabel, modelLabel].filter(Boolean).join(" / ")})`
: "",
].join("");
}
function resolveSeed(seed: unknown) { function resolveSeed(seed: unknown) {
const parsed = Number(seed); const parsed = Number(seed);
if (!Number.isFinite(parsed)) return undefined; if (!Number.isFinite(parsed)) return undefined;
@ -531,7 +614,11 @@ async function generateWithProvider(params: {
provider: RealtimeProviderConfig; provider: RealtimeProviderConfig;
imagePrompt: string; imagePrompt: string;
prompt: string; prompt: string;
size: number; dimensions: {
width: number;
height: number;
aspectRatio: "1:1" | "4:3" | "2:3";
};
seed?: number; seed?: number;
body: GeneratePayload; body: GeneratePayload;
config: RealtimeCanvasConfig; config: RealtimeCanvasConfig;
@ -578,8 +665,8 @@ async function generateWithProvider(params: {
selectedImageField = imageFieldCandidates[index] as "input_image" | "image_prompt"; selectedImageField = imageFieldCandidates[index] as "input_image" | "image_prompt";
const requestBody: Record<string, unknown> = { const requestBody: Record<string, unknown> = {
prompt: params.prompt, prompt: params.prompt,
width: params.size, width: params.dimensions.width,
height: params.size, height: params.dimensions.height,
output_format: "jpeg", output_format: "jpeg",
safety_tolerance: 2, safety_tolerance: 2,
[selectedImageField]: params.imagePrompt, [selectedImageField]: params.imagePrompt,
@ -724,8 +811,27 @@ export async function POST(request: Request) {
} }
const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536); const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536);
const size = resolveSquareSize(body.imageSize, maxInputResolution); const dimensions = resolveRealtimeDimensions(
body.imageSize,
body.aspectRatio,
maxInputResolution
);
const seed = resolveSeed(body.seed); const seed = resolveSeed(body.seed);
const generationMode = normalizeRealtimeGenerationMode(body.generationMode);
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: REALTIME_GENERATION_COSTS[generationMode],
reason: "realtime_canvas_generation",
modelId: providers[0]?.model || null,
note: getRealtimePointsChargeNote({
mode: generationMode,
requestId: body.requestId,
model: providers[0]?.model,
}),
});
if (!pointsCharge.ok) {
return Response.json(pointsCharge.payload, { status: pointsCharge.status });
}
const rawPrompt = sanitizeText(String(body.prompt || "").trim()) || FALLBACK_REALTIME_PROMPT; const rawPrompt = sanitizeText(String(body.prompt || "").trim()) || FALLBACK_REALTIME_PROMPT;
const negativePrompt = const negativePrompt =
@ -783,7 +889,7 @@ export async function POST(request: Request) {
provider, provider,
imagePrompt: providerImageInput, imagePrompt: providerImageInput,
prompt: positivePrompt, prompt: positivePrompt,
size, dimensions,
seed, seed,
body, body,
config, config,
@ -799,6 +905,27 @@ export async function POST(request: Request) {
warningParts.push("主实时服务异常,已自动切换到备用服务。"); warningParts.push("主实时服务异常,已自动切换到备用服务。");
} }
const warning = warningParts.filter(Boolean).join(" "); const warning = warningParts.filter(Boolean).join(" ");
const chargeResult = await pointsCharge
.consume({
modelId: result.provider.model,
note: getRealtimePointsChargeNote({
mode: generationMode,
requestId: body.requestId,
model: result.provider.model,
}),
})
.catch(() => null);
if (pointsCharge.chargeable && !chargeResult?.ok) {
return Response.json(
{
code: "INSUFFICIENT_POINTS",
message: `积分扣减失败,本次实时绘画需要 ${pointsCharge.unitCost} 积分,请确认余额后重试。`,
expectedCost: pointsCharge.unitCost,
},
{ status: 402 }
);
}
console.log("[RealtimeBFL] frame success", { console.log("[RealtimeBFL] frame success", {
userId: session.user.id, userId: session.user.id,
@ -810,7 +937,12 @@ export async function POST(request: Request) {
hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT), hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT),
imageField: result.imageField, imageField: result.imageField,
qualityProfile: body.qualityProfile || "refine", qualityProfile: body.qualityProfile || "refine",
size, width: dimensions.width,
height: dimensions.height,
aspectRatio: dimensions.aspectRatio,
generationMode,
pointsCost: pointsCharge.unitCost,
pointsCharged: Boolean(chargeResult && !chargeResult.skipped),
roi: body.roi || null, roi: body.roi || null,
hasMask: Boolean(body.maskImageUrl), hasMask: Boolean(body.maskImageUrl),
warning: warning || null, warning: warning || null,
@ -829,6 +961,12 @@ export async function POST(request: Request) {
? `data:image/jpeg;base64,${result.imageUrl}` ? `data:image/jpeg;base64,${result.imageUrl}`
: undefined, : undefined,
imageUrl: /^https?:\/\//i.test(result.imageUrl) ? result.imageUrl : undefined, imageUrl: /^https?:\/\//i.test(result.imageUrl) ? result.imageUrl : undefined,
cost: pointsCharge.unitCost,
pointsCost: pointsCharge.unitCost,
pointsCharged: Boolean(chargeResult && !chargeResult.skipped),
aspectRatio: dimensions.aspectRatio,
width: dimensions.width,
height: dimensions.height,
warning: warning || undefined, warning: warning || undefined,
}); });
} }

View File

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getSystemSetting } from "@/lib/db/queries";
import {
STYLE_PRO_CONFIG_KEY,
normalizeStyleProConfig,
toPublicStyleProConfig,
} from "@/lib/style-pro/config";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const setting = await getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(
() => null
);
const config = normalizeStyleProConfig(setting?.value);
return NextResponse.json({
success: true,
config: toPublicStyleProConfig(config),
});
} catch (error) {
console.error("[Style Pro Config] Failed:", error);
return NextResponse.json(
{ success: false, error: "获取风格化 PRO 配置失败" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,238 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { getSystemSetting } from "@/lib/db/queries";
import {
STYLE_PRO_CONFIG_KEY,
type StyleProCategory,
type StyleProOption,
normalizeStyleProConfig,
} from "@/lib/style-pro/config";
export const maxDuration = 120;
export const dynamic = "force-dynamic";
const generateRequestSchema = z.object({
canvasId: z.string().trim().min(1).max(160).optional(),
sourceImageUrl: z.string().trim().min(1).max(4000),
selections: z.record(z.array(z.string().trim().min(1).max(120)).max(20)),
});
type SelectedOption = {
category: StyleProCategory;
option: StyleProOption;
};
function isDisallowedImageUrl(value: string) {
const trimmed = value.trim().toLowerCase();
return trimmed.startsWith("data:") || trimmed.startsWith("javascript:");
}
function buildSelectionPayload(selectedOptions: SelectedOption[]) {
return selectedOptions.map(({ category, option }) => ({
categoryId: category.id,
categoryTitle: category.title,
jsonKey: option.jsonKey || category.id,
optionId: option.id,
optionLabel: option.label,
prompt: option.prompt,
}));
}
function composePrompt({
defaultPrompt,
selectedOptions,
}: {
defaultPrompt: string;
selectedOptions: SelectedOption[];
}) {
const selectionPayload = buildSelectionPayload(selectedOptions);
const groupedPrompt = selectionPayload.reduce<Record<string, string[]>>(
(acc, item) => {
const key = item.jsonKey || item.categoryId;
acc[key] = acc[key] || [];
acc[key].push(item.prompt);
return acc;
},
{}
);
return [
defaultPrompt,
"请严格依据以下风格化 PRO 选择执行最终图片生成。用户不可见配置 JSON:",
JSON.stringify(
{
mode: "style-pro-final",
selections: selectionPayload,
promptByKey: groupedPrompt,
},
null,
2
),
]
.filter(Boolean)
.join("\n\n");
}
function validateSelections(
categories: StyleProCategory[],
rawSelections: Record<string, string[]>
) {
const categoryById = new Map(categories.map((category) => [category.id, category]));
const selectedOptions: SelectedOption[] = [];
const normalizedSelections: Record<string, string[]> = {};
for (const [categoryId, optionIds] of Object.entries(rawSelections)) {
const category = categoryById.get(categoryId);
if (!category) {
return {
ok: false as const,
error: `无效分类:${categoryId}`,
};
}
const enabledOptions = new Map(
category.options
.filter((option) => option.enabled)
.map((option) => [option.id, option])
);
const uniqueOptionIds = Array.from(new Set(optionIds));
if (uniqueOptionIds.length > category.maxSelections) {
return {
ok: false as const,
error: `${category.title} 最多选择 ${category.maxSelections} 项`,
};
}
for (const optionId of uniqueOptionIds) {
const option = enabledOptions.get(optionId);
if (!option) {
return {
ok: false as const,
error: `无效选项:${category.title} / ${optionId}`,
};
}
selectedOptions.push({ category, option });
}
normalizedSelections[category.id] = uniqueOptionIds;
}
const missingCategory = categories.find((category) => {
if (!category.required && category.minSelections <= 0) return false;
const selectedCount = normalizedSelections[category.id]?.length || 0;
return selectedCount < Math.max(category.required ? 1 : 0, category.minSelections);
});
if (missingCategory) {
return {
ok: false as const,
error: `请完成必选项:${missingCategory.title}`,
};
}
return {
ok: true as const,
selectedOptions,
normalizedSelections,
};
}
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 = generateRequestSchema.parse(await request.json());
if (isDisallowedImageUrl(body.sourceImageUrl)) {
return NextResponse.json(
{ success: false, error: "请使用已上传的图片链接生成" },
{ status: 400 }
);
}
const setting = await getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(
() => null
);
const config = normalizeStyleProConfig(setting?.value);
if (!config.enabled) {
return NextResponse.json(
{ success: false, error: "风格化 PRO 暂未启用" },
{ status: 403 }
);
}
const enabledCategories = config.categories
.filter((category) => category.enabled)
.map((category) => ({
...category,
options: category.options.filter((option) => option.enabled),
}))
.filter((category) => category.options.length > 0);
const selectionResult = validateSelections(enabledCategories, body.selections);
if (!selectionResult.ok) {
return NextResponse.json(
{ success: false, error: selectionResult.error },
{ status: 400 }
);
}
const prompt = composePrompt({
defaultPrompt: config.defaultPrompt,
selectedOptions: selectionResult.selectedOptions,
});
const result = await generateCanvasImage({
prompt,
negativePrompt: config.negativePrompt || undefined,
model: config.modelId || undefined,
aspectRatio: config.aspectRatio || "auto",
imageSize: config.imageSize || "2K",
imageCount: 1,
referenceImage: body.sourceImageUrl,
ownerKey: session.user.id,
debugLabel: "style-pro",
debugMeta: {
canvasId: body.canvasId || null,
selectedOptionCount: selectionResult.selectedOptions.length,
},
});
const image = result.images[0];
if (!image?.originalUrl) {
return NextResponse.json(
{ success: false, error: "生成结果为空" },
{ status: 502 }
);
}
return NextResponse.json({
success: true,
data: {
imageUrl: image.originalUrl,
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
model: result.model,
modelRef: result.modelRef,
selections: selectionResult.normalizedSelections,
optionLabels: selectionResult.selectedOptions.map(
({ category, option }) => `${category.title}:${option.label}`
),
},
});
} catch (error) {
console.error("[Style Pro Generate] Failed:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ success: false, error: "请求参数不完整" },
{ status: 400 }
);
}
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "风格化 PRO 生成失败",
},
{ status: 500 }
);
}
}

View File

@ -151,6 +151,7 @@ import {
import { import {
getCanvasToolbarItem, getCanvasToolbarItem,
getDefaultCanvasToolbarItem, getDefaultCanvasToolbarItem,
isCanvasToolbarItemEnabled,
normalizeCanvasToolbarConfig, normalizeCanvasToolbarConfig,
type CanvasToolbarConfig, type CanvasToolbarConfig,
type CanvasToolbarItemConfig, type CanvasToolbarItemConfig,
@ -159,6 +160,7 @@ import {
import { import {
buildAgentWorkflowComposerContext, buildAgentWorkflowComposerContext,
} from "@/lib/agents/workflow-composer"; } from "@/lib/agents/workflow-composer";
import { normalizeStyleProPersistedState } from "@/lib/style-pro/config";
import { import {
createEmptyAgentWorkflowMaterialSelections, createEmptyAgentWorkflowMaterialSelections,
normalizeAgentWorkflowMaterialSelections, normalizeAgentWorkflowMaterialSelections,
@ -952,6 +954,7 @@ function normalizeImportedCanvasState(payload: unknown): {
workflowState: isPlainObject(rawCanvasData.workflowState) workflowState: isPlainObject(rawCanvasData.workflowState)
? (rawCanvasData.workflowState as unknown as CanvasState["workflowState"]) ? (rawCanvasData.workflowState as unknown as CanvasState["workflowState"])
: undefined, : undefined,
styleProState: normalizeStyleProPersistedState(rawCanvasData.styleProState),
}, },
}; };
} }
@ -1968,6 +1971,10 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
const isBatchProcessingEnabled = const isBatchProcessingEnabled =
workflowPageFeatureSettings.batchProcessingEnabled !== false && workflowPageFeatureSettings.batchProcessingEnabled !== false &&
sceneFusionConfigData?.enabled !== false; sceneFusionConfigData?.enabled !== false;
const isStyleProToolbarEnabled = isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"stylePro"
);
const sanitizedWorkflowSelections = useMemo( const sanitizedWorkflowSelections = useMemo(
() => () =>
sanitizeAgentWorkflowSelections({ sanitizeAgentWorkflowSelections({
@ -10207,6 +10214,35 @@ export default function CanvasEditorLegacyPage({ params }: PageProps) {
</Tooltip> </Tooltip>
) : null} ) : null}
{isStyleProToolbarEnabled && !isVideoMode ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
className={cn(
isVideoMode
? VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS
: "h-9 w-9 rounded-xl"
)}
onClick={() => router.push(`/canvas/${id}/style-pro`)}
size="icon"
variant="ghost"
>
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"stylePro"
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{getCanvasToolbarItem(canvasToolbarConfig, "stylePro").label}
</TooltipContent>
</Tooltip>
) : null}
{isBatchProcessingEnabled ? ( {isBatchProcessingEnabled ? (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>

View File

@ -0,0 +1,128 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback } from "react";
import { toast } from "sonner";
import { StyleProWorkspace } from "@/components/style-pro/StyleProWorkspace";
import { canvasService } from "@/services/canvas";
import { canvasSelectors, useCanvasStore } from "@/store/canvas";
import type {
StyleProHistoryItem,
StyleProPersistedState,
} from "@/lib/style-pro/config";
export default function CanvasStyleProPage() {
const router = useRouter();
const routeParams = useParams();
const id = String(routeParams?.id || "");
const viewport = useCanvasStore(canvasSelectors.viewport);
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
const nodes = useCanvasStore(canvasSelectors.nodes);
const styleProState = useCanvasStore((state) => state.styleProState);
const addNode = useCanvasStore((state) => state.addNode);
const selectNodes = useCanvasStore((state) => state.selectNodes);
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const selectedImageNode =
selectedNodeIds.length === 1
? nodes.find(
(node) => node.id === selectedNodeIds[0] && node.type === "image"
)
: null;
const selectedImageUrl = selectedImageNode
? String((selectedImageNode.data as any)?.src || "").trim()
: "";
const handleBack = useCallback(() => {
router.push(`/canvas/${id}`);
}, [id, router]);
const handlePersistState = useCallback(
async (nextState: StyleProPersistedState) => {
useCanvasStore.getState().setStyleProState(nextState);
const result = await canvasService.saveCanvasState(
id,
useCanvasStore.getState().getCanvasState()
);
if (!result.success) {
console.error("[CanvasStyleProPage] 保存风格化 PRO 状态失败:", result.error);
}
},
[id]
);
const handleAddToCanvas = useCallback(
async (item: StyleProHistoryItem) => {
if (!item.resultImageUrl) {
toast.error("没有可添加到画布的结果图");
return;
}
const viewportX = viewport.x || 0;
const viewportY = viewport.y || 0;
const viewportZoom = viewport.zoom || 1;
const stageWidth = typeof window !== "undefined" ? window.innerWidth : 1280;
const stageHeight = typeof window !== "undefined" ? window.innerHeight : 720;
const naturalWidth = Number(item.width || 0);
const naturalHeight = Number(item.height || 0);
const hasNaturalSize = naturalWidth > 0 && naturalHeight > 0;
const maxPreviewEdge = 460;
const fallbackSize = 360;
const scale = hasNaturalSize
? Math.min(1, maxPreviewEdge / Math.max(naturalWidth, naturalHeight))
: 1;
const targetWidth = hasNaturalSize
? Math.max(160, Math.round(naturalWidth * scale))
: fallbackSize;
const targetHeight = hasNaturalSize
? Math.max(160, Math.round(naturalHeight * scale))
: fallbackSize;
const x =
(stageWidth * 0.46 - viewportX) / viewportZoom - targetWidth / 2;
const y =
(stageHeight * 0.46 - viewportY) / viewportZoom - targetHeight / 2;
const nodeId = addNode({
type: "image",
position: { x, y },
size: { width: targetWidth, height: targetHeight },
data: {
src: item.resultImageUrl,
originalSrc: item.sourceImageUrl,
status: "completed",
title: "风格化 PRO 结果",
prompt: item.optionLabels.join(" / ") || "风格化 PRO",
model: item.model || "Style Pro",
metadata: {
compareSourceUrl: item.sourceImageUrl,
styleProHistoryId: item.id,
styleProSelections: item.selections,
},
},
connections: [],
});
selectNodes([nodeId]);
const result = await canvasService.saveCanvasState(id, getCanvasState());
if (!result.success) {
toast.error(result.error || "保存画布失败");
return;
}
toast.success("风格化 PRO 结果已添加到画布");
},
[addNode, getCanvasState, id, selectNodes, viewport.x, viewport.y, viewport.zoom]
);
return (
<StyleProWorkspace
canvasId={id}
initialSourceImageUrl={selectedImageUrl}
onAddToCanvas={handleAddToCanvas}
onBack={handleBack}
onPersistState={handlePersistState}
persistedState={styleProState}
/>
);
}

View File

@ -24,6 +24,9 @@ import { buildImageProxyUrl } from "@/lib/image-delivery";
import type { AgentDetailReplacementReferenceItem } from "@/types/agent"; import type { AgentDetailReplacementReferenceItem } from "@/types/agent";
const MIN_SLICE_HEIGHT = 120; const MIN_SLICE_HEIGHT = 120;
const FAST_PREVIEW_WIDTH = 720;
const ESTIMATED_REFERENCE_HEIGHT = 960;
const SEGMENT_PRELOAD_CONCURRENCY = 4;
type StitchSourceSegment = { type StitchSourceSegment = {
card: AgentDetailReplacementReferenceItem; card: AgentDetailReplacementReferenceItem;
@ -77,11 +80,97 @@ function buildStitchPreviewImageUrl(url: string, width: number) {
}); });
} }
function buildStitchSegmentDisplayImageUrl(url: string, width: number) {
const normalizedUrl = String(url || "").trim();
if (!normalizedUrl) return "";
return buildImageProxyUrl(normalizedUrl, {
width: Math.min(Math.max(Math.round(width || FAST_PREVIEW_WIDTH), 96), 960),
quality: 78,
format: "jpeg",
delivery: "display",
});
}
function getReferenceDimension(value: unknown) {
const normalized = Math.round(Number(value));
return Number.isFinite(normalized) && normalized > 0 ? normalized : null;
}
function resolveReferencePreviewHeight(card: AgentDetailReplacementReferenceItem) {
const width = getReferenceDimension(card.width);
const height = getReferenceDimension(card.height);
if (width && height) {
return Math.max(MIN_SLICE_HEIGHT, Math.round((FAST_PREVIEW_WIDTH / width) * height));
}
return ESTIMATED_REFERENCE_HEIGHT;
}
function buildOptimisticSnapshot(
cards: Array<AgentDetailReplacementReferenceItem & { imageUrl: string }>
): MergedSnapshot {
let top = 0;
const defaultCutLines: number[] = [];
const sourceSegments = cards.map((card, index) => {
const height = resolveReferencePreviewHeight(card);
const currentTop = top;
top += height;
if (index < cards.length - 1) {
defaultCutLines.push(top);
}
return {
card,
top: currentTop,
height,
sourceWidth: getReferenceDimension(card.width) || undefined,
sourceHeight: getReferenceDimension(card.height) || undefined,
};
});
return {
previewUrl: "",
width: FAST_PREVIEW_WIDTH,
height: Math.max(1, top),
defaultCutLines,
sourceSegments,
};
}
function areCutLinesEqual(left: number[], right: number[]) {
if (left.length !== right.length) return false;
return left.every((value, index) => Math.round(value) === Math.round(right[index] || 0));
}
function scaleCutLines(lines: number[], fromHeight: number, toHeight: number) {
const ratio = toHeight / Math.max(1, fromHeight);
return Array.from(
new Set(
lines
.map((line) => Math.round(line * ratio))
.filter((line) => Number.isFinite(line) && line > 0 && line < toHeight)
)
).sort((left, right) => left - right);
}
function preloadBrowserImage(url: string) {
return new Promise<void>((resolve) => {
if (typeof window === "undefined" || !url) {
resolve();
return;
}
const image = new Image();
image.decoding = "async";
image.onload = () => resolve();
image.onerror = () => resolve();
image.src = url;
});
}
interface AgentDetailReferenceStitchDialogProps { interface AgentDetailReferenceStitchDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
cards: AgentDetailReplacementReferenceItem[]; cards: AgentDetailReplacementReferenceItem[];
sourceLabel?: string; sourceLabel?: string;
onPrepared?: (nextCards: AgentDetailReplacementReferenceItem[]) => void;
onApply: (nextCards: AgentDetailReplacementReferenceItem[]) => void; onApply: (nextCards: AgentDetailReplacementReferenceItem[]) => void;
} }
@ -90,6 +179,7 @@ export function AgentDetailReferenceStitchDialog({
onOpenChange, onOpenChange,
cards, cards,
sourceLabel = "详情参考图", sourceLabel = "详情参考图",
onPrepared,
onApply, onApply,
}: AgentDetailReferenceStitchDialogProps) { }: AgentDetailReferenceStitchDialogProps) {
const previewContainerRef = useRef<HTMLDivElement | null>(null); const previewContainerRef = useRef<HTMLDivElement | null>(null);
@ -99,6 +189,7 @@ export function AgentDetailReferenceStitchDialog({
const [isAddingCutLine, setIsAddingCutLine] = useState(false); const [isAddingCutLine, setIsAddingCutLine] = useState(false);
const [previewCutLineY, setPreviewCutLineY] = useState<number | null>(null); const [previewCutLineY, setPreviewCutLineY] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isPreparing, setIsPreparing] = useState(false);
const [isApplying, setIsApplying] = useState(false); const [isApplying, setIsApplying] = useState(false);
const [applyProgressText, setApplyProgressText] = useState(""); const [applyProgressText, setApplyProgressText] = useState("");
const [loadingProgressText, setLoadingProgressText] = useState(""); const [loadingProgressText, setLoadingProgressText] = useState("");
@ -118,7 +209,10 @@ export function AgentDetailReferenceStitchDialog({
const validCardLoadKey = useMemo( const validCardLoadKey = useMemo(
() => () =>
validCards validCards
.map((card) => `${card.id}::${String(card.imageUrl || "").trim()}`) .map(
(card) =>
`${card.id}::${String(card.imageUrl || "").trim()}::${String(card.previewUrl || "").trim()}::${card.width || ""}x${card.height || ""}`
)
.join("|"), .join("|"),
[validCards] [validCards]
); );
@ -166,6 +260,8 @@ export function AgentDetailReferenceStitchDialog({
setIsAddingCutLine(false); setIsAddingCutLine(false);
setPreviewCutLineY(null); setPreviewCutLineY(null);
setLoadingProgressText(""); setLoadingProgressText("");
setIsLoading(false);
setIsPreparing(false);
setWarningText(null); setWarningText(null);
setPreviewImageLoadFailed(false); setPreviewImageLoadFailed(false);
setPassthroughSegmentIndexes([]); setPassthroughSegmentIndexes([]);
@ -175,20 +271,27 @@ export function AgentDetailReferenceStitchDialog({
if (setupCards.length === 0) { if (setupCards.length === 0) {
setSnapshot(null); setSnapshot(null);
setCutLines([]); setCutLines([]);
setIsLoading(false);
setIsPreparing(false);
setErrorText(`当前没有可拼合的${sourceLabel}`); setErrorText(`当前没有可拼合的${sourceLabel}`);
return; return;
} }
let cancelled = false; let cancelled = false;
const optimisticSnapshot = buildOptimisticSnapshot(setupCards);
setSnapshot(optimisticSnapshot);
setCutLines(optimisticSnapshot.defaultCutLines);
setPassthroughSegmentIndexes([]);
setErrorText(null);
setWarningText(null);
setPreviewImageLoadFailed(true);
setIsLoading(false);
setIsPreparing(true);
setLoadingProgressText(`正在后台校准 ${setupCards.length} 张参考图尺寸`);
const setup = async () => { const setup = async () => {
try { try {
setIsLoading(true);
setErrorText(null);
setWarningText(null);
setPreviewImageLoadFailed(false);
setLoadingProgressText(`正在读取 ${setupCards.length} 张参考图尺寸`);
const response = await fetch("/api/agent-workflow/detail-stitch", { const response = await fetch("/api/agent-workflow/detail-stitch", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -229,16 +332,44 @@ export function AgentDetailReferenceStitchDialog({
throw new Error("拼合结果无效,请稍后重试"); throw new Error("拼合结果无效,请稍后重试");
} }
setSnapshot({ const preciseSnapshot = {
width, width,
height, height,
previewUrl, previewUrl,
defaultCutLines, defaultCutLines,
sourceSegments, sourceSegments,
}); };
setSnapshot(preciseSnapshot);
setPreviewImageLoadFailed(!previewUrl); setPreviewImageLoadFailed(!previewUrl);
setCutLines(defaultCutLines); setCutLines((current) =>
setPassthroughSegmentIndexes([]); areCutLinesEqual(current, optimisticSnapshot.defaultCutLines)
? defaultCutLines
: scaleCutLines(current, optimisticSnapshot.height, height)
);
if (onPrepared) {
const dimensionByCardId = new Map(
sourceSegments.map((segment) => [
segment.card.id,
{
previewUrl: segment.card.previewUrl || segment.card.imageUrl || null,
width: segment.sourceWidth || segment.card.width || null,
height: segment.sourceHeight || segment.card.height || null,
},
])
);
onPrepared(
setupCards.map((card, index) => {
const dimensions = dimensionByCardId.get(card.id);
return {
...card,
sortOrder: card.sortOrder ?? index,
previewUrl: dimensions?.previewUrl || card.previewUrl || card.imageUrl || null,
width: dimensions?.width || card.width || null,
height: dimensions?.height || card.height || null,
};
})
);
}
setWarningText( setWarningText(
(result.data.failed?.length || 0) > 0 (result.data.failed?.length || 0) > 0
? `有 ${result.data.failed?.length || 0} 张参考图加载失败,已自动跳过异常图片。` ? `有 ${result.data.failed?.length || 0} 张参考图加载失败,已自动跳过异常图片。`
@ -247,12 +378,12 @@ export function AgentDetailReferenceStitchDialog({
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error); console.error("[AgentDetailReferenceStitchDialog] Failed to prepare merged image:", error);
setSnapshot(null); setWarningText(
setCutLines([]); `精准尺寸校准失败,已先使用快速预览:${error instanceof Error ? error.message : "拼合参考图失败"}`
setErrorText(error instanceof Error ? error.message : "拼合参考图失败"); );
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setIsLoading(false); setIsPreparing(false);
setLoadingProgressText(""); setLoadingProgressText("");
} }
} }
@ -263,7 +394,7 @@ export function AgentDetailReferenceStitchDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, setupCards, sourceLabel]); }, [onPrepared, open, setupCards, sourceLabel]);
useEffect(() => { useEffect(() => {
const validIndexes = new Set(sliceSegments.map((segment) => segment.index)); const validIndexes = new Set(sliceSegments.map((segment) => segment.index));
@ -272,6 +403,46 @@ export function AgentDetailReferenceStitchDialog({
); );
}, [sliceSegments]); }, [sliceSegments]);
useEffect(() => {
if (!open || !snapshot || !useVirtualPreview) return;
const urls = Array.from(
new Set(
snapshot.sourceSegments
.map((segment) => {
const originalImageUrl = String(segment.card.imageUrl || "").trim();
const segmentImageUrl =
String(segment.card.previewUrl || "").trim() || originalImageUrl;
return buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width);
})
.filter(Boolean)
)
);
if (urls.length <= 2) return;
let cancelled = false;
let cursor = 2;
const timer = window.setTimeout(() => {
const worker = async () => {
while (!cancelled && cursor < urls.length) {
const currentIndex = cursor;
cursor += 1;
await preloadBrowserImage(urls[currentIndex] || "");
}
};
void Promise.all(
Array.from(
{ length: Math.min(SEGMENT_PRELOAD_CONCURRENCY, Math.max(urls.length - 2, 0)) },
() => worker()
)
);
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [open, snapshot, useVirtualPreview]);
const resolveInsertCutLineY = useCallback( const resolveInsertCutLineY = useCallback(
(rawY: number) => { (rawY: number) => {
if (!snapshot) return null; if (!snapshot) return null;
@ -590,7 +761,9 @@ export function AgentDetailReferenceStitchDialog({
清空切线 清空切线
</Button> </Button>
<div className="text-xs text-white/42"> <div className="text-xs text-white/42">
默认切线已按原始详情页边界生成,可直接拖动横线微调。 {isPreparing
? loadingProgressText || "正在后台校准参考图尺寸,完成后会自动修正切线。"
: "默认切线已按原始详情页边界生成,可直接拖动横线微调。"}
{isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""} {isAddingCutLine ? " 当前已进入加线模式,移动鼠标预览位置后点击长图即可落线。" : ""}
</div> </div>
</div> </div>
@ -629,29 +802,35 @@ export function AgentDetailReferenceStitchDialog({
{useVirtualPreview ? ( {useVirtualPreview ? (
<div className="w-full bg-white"> <div className="w-full bg-white">
{snapshot.sourceSegments.map((segment, index) => { {snapshot.sourceSegments.map((segment, index) => {
const segmentImageUrl = String(segment.card.imageUrl || "").trim(); const originalImageUrl = String(segment.card.imageUrl || "").trim();
const segmentImageUrl =
String(segment.card.previewUrl || "").trim() || originalImageUrl;
return ( return (
<div <div
key={`${segment.card.id || "segment"}-${index}-${segment.top}`} key={`${segment.card.id || "segment"}-${index}-${segment.top}`}
className="relative overflow-hidden bg-white" className="relative overflow-hidden bg-white"
style={{ style={{
aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`, aspectRatio: `${snapshot.width} / ${Math.max(segment.height, 1)}`,
}} contentVisibility: index > 4 ? "auto" : undefined,
> containIntrinsicSize:
index > 4 ? `${Math.max(segment.height, 1)}px` : undefined,
}}
>
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400"> <div className="absolute inset-0 flex items-center justify-center px-4 text-center text-xs text-neutral-400">
参考图 {index + 1} 加载失败 参考图 {index + 1} 加载失败
</div> </div>
{segmentImageUrl ? ( {segmentImageUrl ? (
<img <img
src={buildStitchPreviewImageUrl(segmentImageUrl, snapshot.width)} src={buildStitchSegmentDisplayImageUrl(segmentImageUrl, snapshot.width)}
alt={`拼合分段预览 ${index + 1}`} alt={`拼合分段预览 ${index + 1}`}
className="relative z-10 block h-full w-full select-none object-fill" className="relative z-10 block h-full w-full select-none object-fill"
draggable={false} draggable={false}
decoding="async" decoding="async"
loading={index > 2 ? "lazy" : "eager"} loading={index > 2 ? "lazy" : "eager"}
onError={handleSegmentPreviewImageError(segmentImageUrl)} fetchPriority={index < 2 ? "high" : "low"}
/> onError={handleSegmentPreviewImageError(originalImageUrl)}
) : null} />
) : null}
</div> </div>
); );
})} })}
@ -800,7 +979,7 @@ export function AgentDetailReferenceStitchDialog({
<Button <Button
type="button" type="button"
onClick={handleApply} onClick={handleApply}
disabled={isLoading || isApplying || !snapshot || sliceSegments.length === 0} disabled={isLoading || isPreparing || isApplying || !snapshot || sliceSegments.length === 0}
className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50" className="h-11 w-full rounded-[18px] bg-emerald-500 text-black hover:bg-emerald-400 disabled:opacity-50"
> >
{isApplying ? ( {isApplying ? (
@ -808,6 +987,11 @@ export function AgentDetailReferenceStitchDialog({
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
{applyProgressText || "正在应用切割"} {applyProgressText || "正在应用切割"}
</> </>
) : isPreparing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在校准尺寸
</>
) : ( ) : (
<> <>
<Scissors className="mr-2 h-4 w-4" /> <Scissors className="mr-2 h-4 w-4" />

View File

@ -744,6 +744,7 @@ function buildReferenceCardsFromCandidate(params: {
id: `${candidate.id}-${params.target}-${index + 1}`, id: `${candidate.id}-${params.target}-${index + 1}`,
label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`, label: `${prefix} ${targetLabel} ${String(index + 1).padStart(2, "0")}`,
imageUrl: url, imageUrl: url,
previewUrl: url,
candidateId: candidate.id, candidateId: candidate.id,
sortOrder: index, sortOrder: index,
passthrough: false, passthrough: false,
@ -754,6 +755,28 @@ function buildReferenceCardsFromCandidate(params: {
})); }));
} }
function readLocalImageDimensions(file: File): Promise<{ width: number | null; height: number | null }> {
if (typeof window === "undefined") {
return Promise.resolve({ width: null, height: null });
}
return new Promise((resolve) => {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
const finish = (width: number | null, height: number | null) => {
URL.revokeObjectURL(objectUrl);
resolve({ width, height });
};
image.onload = () => {
finish(image.naturalWidth || null, image.naturalHeight || null);
};
image.onerror = () => {
finish(null, null);
};
image.src = objectUrl;
});
}
function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) { function formatCandidateMeta(candidate: AgentModeReferenceCandidate, target: AgentTaskDetailReferenceSource) {
const rawImageCount = const rawImageCount =
target === "main" target === "main"
@ -4839,6 +4862,7 @@ export function AgentGenerationWorkspaceDarkV2({
try { try {
const uploadedItems = await Promise.all( const uploadedItems = await Promise.all(
uploadFiles.map(async (file, index) => { uploadFiles.map(async (file, index) => {
const dimensions = await readLocalImageDimensions(file);
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const response = await fetchUploadWithRetry(formData); const response = await fetchUploadWithRetry(formData);
@ -4850,6 +4874,9 @@ export function AgentGenerationWorkspaceDarkV2({
index, index,
file, file,
url: String(data.url), url: String(data.url),
previewUrl: String(data.previewUrl || data.thumbnailUrl || data.url || ""),
width: dimensions.width,
height: dimensions.height,
}; };
}) })
); );
@ -4866,6 +4893,9 @@ export function AgentGenerationWorkspaceDarkV2({
? `上传详情图 ${baseLength + index + 1} · ${item.file.name}` ? `上传详情图 ${baseLength + index + 1} · ${item.file.name}`
: `上传详情图 ${baseLength + index + 1}`, : `上传详情图 ${baseLength + index + 1}`,
imageUrl: item.url, imageUrl: item.url,
previewUrl: item.previewUrl || item.url,
width: item.width,
height: item.height,
candidateId: null, candidateId: null,
sortOrder: baseLength + index, sortOrder: baseLength + index,
passthrough: false, passthrough: false,
@ -5019,6 +5049,25 @@ export function AgentGenerationWorkspaceDarkV2({
references: [], references: [],
})); }));
}, [updateDetailReplacementState]); }, [updateDetailReplacementState]);
const handlePreparedDetailReferenceCards = useCallback(
(preparedCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
const preparedById = new Map(preparedCards.map((card) => [card.id, card]));
updateDetailReplacementState((current) => ({
...current,
references: current.references.map((card) => {
const prepared = preparedById.get(card.id);
if (!prepared) return card;
return {
...card,
previewUrl: prepared.previewUrl || card.previewUrl || card.imageUrl || null,
width: prepared.width || card.width || null,
height: prepared.height || card.height || null,
};
}),
}));
},
[updateDetailReplacementState]
);
const handleApplyStitchedDetailReferenceCards = useCallback( const handleApplyStitchedDetailReferenceCards = useCallback(
(nextCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => { (nextCards: AgentModeWorkspaceState["detailReplacement"]["references"]) => {
updateDetailReplacementState((current) => ({ updateDetailReplacementState((current) => ({
@ -5641,8 +5690,8 @@ export function AgentGenerationWorkspaceDarkV2({
) : null} ) : null}
{hasWorkflowStyleStep ? ( {hasWorkflowStyleStep ? (
<section id="agent-anchor-style" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-4"> <section id="agent-anchor-style" className="scroll-mt-24 rounded-[24px] border border-white/10 bg-black/20 p-5 md:p-6">
<div className="mb-4 space-y-1 border-b border-white/10 pb-4"> <div className="mb-5 space-y-1 border-b border-white/10 pb-5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200"> <span className="rounded-full bg-emerald-500/18 px-2.5 py-1 text-[11px] font-semibold text-emerald-200">
{styleStepBadge} {styleStepBadge}
@ -5654,7 +5703,7 @@ export function AgentGenerationWorkspaceDarkV2({
</p> </p>
</div> </div>
<div className="space-y-4"> <div className="space-y-7">
{shouldShowDirectUploadInStyleStep ? attachmentSlotPanel : null} {shouldShowDirectUploadInStyleStep ? attachmentSlotPanel : null}
{workflowStylePanel} {workflowStylePanel}
{hasBrandModelStep ? brandModelPanel : null} {hasBrandModelStep ? brandModelPanel : null}
@ -6396,11 +6445,12 @@ export function AgentGenerationWorkspaceDarkV2({
{isDetailReplacementPreset && !isDetailAiGenerationMethod ? ( {isDetailReplacementPreset && !isDetailAiGenerationMethod ? (
<AgentDetailReferenceStitchDialog <AgentDetailReferenceStitchDialog
open={detailReferenceStitchDialogOpen} open={detailReferenceStitchDialogOpen}
onOpenChange={setDetailReferenceStitchDialogOpen} onOpenChange={setDetailReferenceStitchDialogOpen}
cards={detailReplacementReferenceCards} cards={detailReplacementReferenceCards}
sourceLabel={`${detailReferenceSourceLabel}参考图`} sourceLabel={`${detailReferenceSourceLabel}参考图`}
onApply={handleApplyStitchedDetailReferenceCards} onPrepared={handlePreparedDetailReferenceCards}
/> onApply={handleApplyStitchedDetailReferenceCards}
/>
) : null} ) : null}
<motion.section <motion.section
@ -6556,7 +6606,9 @@ export function AgentGenerationWorkspaceDarkV2({
type="button" type="button"
onClick={() => onClick={() =>
openPsdTemplateDialog({ openPsdTemplateDialog({
sourceImageUrl: displayedResultUrl,
mode: getPsdTemplateModeByResultMode(activeResultModeType), mode: getPsdTemplateModeByResultMode(activeResultModeType),
resultModeType: activeResultModeType,
}) })
} }
className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/72 transition-colors hover:border-white/20 hover:bg-white/[0.08] hover:text-white" className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-white/72 transition-colors hover:border-white/20 hover:bg-white/[0.08] hover:text-white"

View File

@ -850,6 +850,23 @@ function clampImageScale(value: number) {
return Math.max(MIN_IMAGE_SCALE, Math.min(MAX_IMAGE_SCALE, value)); return Math.max(MIN_IMAGE_SCALE, Math.min(MAX_IMAGE_SCALE, value));
} }
function getDefaultSourceImageLayer(
layers: AgentPsdTemplateLayerSlot[]
): AgentPsdTemplateImageLayerSlot | null {
const editableImageLayers = layers.filter(
(layer): layer is AgentPsdTemplateImageLayerSlot =>
layer.isEditable &&
!isAgentPsdLayerSuperLocked(layer) &&
isAgentPsdImageLayerSlot(layer)
);
return (
editableImageLayers.find((layer) => layer.isDefaultResultSource) ||
editableImageLayers[0] ||
null
);
}
function createInitialState( function createInitialState(
template: AgentPsdTemplate | null, template: AgentPsdTemplate | null,
sourceImageUrl: string sourceImageUrl: string
@ -872,6 +889,10 @@ function createInitialState(
}; };
} }
const defaultSourceImageLayerId = sourceImageUrl
? getDefaultSourceImageLayer(template.layers)?.id || ""
: "";
for (const layer of template.layers) { for (const layer of template.layers) {
layerOrderIds.push(layer.id); layerOrderIds.push(layer.id);
if (isAgentPsdLayerSuperLocked(layer)) { if (isAgentPsdLayerSuperLocked(layer)) {
@ -879,7 +900,7 @@ function createInitialState(
} }
if ( if (
isAgentPsdImageLayerSlot(layer) && isAgentPsdImageLayerSlot(layer) &&
layer.isDefaultResultSource && layer.id === defaultSourceImageLayerId &&
sourceImageUrl sourceImageUrl
) { ) {
imageAssignments[layer.id] = sourceImageUrl; imageAssignments[layer.id] = sourceImageUrl;
@ -2022,6 +2043,10 @@ export function AgentPsdTemplateEditorDialog({
return nextAssignments; return nextAssignments;
} }
const defaultSourceImageLayerId = sourceImageUrl?.trim()
? getDefaultSourceImageLayer(editableImageLayers)?.id || ""
: "";
for (const layer of editableImageLayers) { for (const layer of editableImageLayers) {
const explicit = String(imageAssignments[layer.id] || "").trim(); const explicit = String(imageAssignments[layer.id] || "").trim();
if (explicit) { if (explicit) {
@ -2029,7 +2054,7 @@ export function AgentPsdTemplateEditorDialog({
continue; continue;
} }
if (layer.isDefaultResultSource && sourceImageUrl?.trim()) { if (layer.id === defaultSourceImageLayerId && sourceImageUrl?.trim()) {
nextAssignments[layer.id] = sourceImageUrl.trim(); nextAssignments[layer.id] = sourceImageUrl.trim();
} }
} }
@ -2733,7 +2758,7 @@ export function AgentPsdTemplateEditorDialog({
return () => { return () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [open, selectedTemplate?.id]); }, [open, selectedTemplate?.id, sourceImageUrl]);
useEffect(() => { useEffect(() => {
if (hiddenLayerIds.length === 0) { if (hiddenLayerIds.length === 0) {
@ -3458,9 +3483,7 @@ export function AgentPsdTemplateEditorDialog({
setRenderedResultUrl(""); setRenderedResultUrl("");
const normalizedRenderInputs = await normalizeRenderImageInputs(); const normalizedRenderInputs = await normalizeRenderImageInputs();
if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) { if (normalizedRenderInputs.sourceImageUrl !== (sourceImageUrl || "")) {
const defaultSourceLayer = editableImageLayers.find( const defaultSourceLayer = getDefaultSourceImageLayer(editableImageLayers);
(layer) => layer.isDefaultResultSource
);
if (defaultSourceLayer) { if (defaultSourceLayer) {
setImageAssignments((current) => ({ setImageAssignments((current) => ({
...current, ...current,

View File

@ -1681,17 +1681,17 @@ function AgentWorkflowPanelDarkV2Component({
activeEmbeddedStepEntry ? ( activeEmbeddedStepEntry ? (
<motion.section <motion.section
key={activeEmbeddedStepEntry.step.key} key={activeEmbeddedStepEntry.step.key}
className={cn( className={cn(
"rounded-2xl border border-white/12 bg-white/[0.04] p-5 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]", "rounded-[24px] border border-white/10 bg-white/[0.035] p-6 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)] md:p-7",
activeEmbeddedStepEntry.depth > 1 && activeEmbeddedStepEntry.depth > 1 &&
"border-l-4 border-l-emerald-500/30" "border-l-4 border-l-emerald-500/30"
)} )}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
> >
<div className="mb-4 space-y-3"> <div className="mb-7 space-y-3">
<div className="mt-2"> <div className="space-y-3">
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
{flattenedResolvedSteps.map(({ step }, stepIndex) => { {flattenedResolvedSteps.map(({ step }, stepIndex) => {
const completed = isAgentWorkflowStepSelectionComplete({ const completed = isAgentWorkflowStepSelectionComplete({
step, step,
@ -1715,15 +1715,15 @@ function AgentWorkflowPanelDarkV2Component({
<div <div
key={step.key} key={step.key}
className={cn( className={cn(
"group flex min-h-[84px] flex-col justify-between rounded-[18px] border px-3 py-2.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]", "group flex min-h-[64px] rounded-[14px] border px-3 py-2.5 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]",
active active
? "border-emerald-300/28 bg-emerald-400/[0.08]" ? "border-emerald-300/34 bg-emerald-400/[0.075]"
: completed : completed
? "border-white/14 bg-white/[0.05]" ? "border-white/14 bg-white/[0.045]"
: "border-white/10 bg-white/[0.025]" : "border-white/10 bg-white/[0.022]"
)} )}
> >
<div className="flex items-start gap-2.5 text-left"> <div className="flex w-full items-center gap-2.5 text-left">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@ -1732,11 +1732,11 @@ function AgentWorkflowPanelDarkV2Component({
setActiveEmbeddedStepKey(step.key); setActiveEmbeddedStepKey(step.key);
setActiveEmbeddedOptionDepth(1); setActiveEmbeddedOptionDepth(1);
}} }}
className="flex min-w-0 flex-1 items-start gap-2.5 text-left transition-transform duration-200 hover:-translate-y-[1px]" className="flex min-w-0 flex-1 items-center gap-2.5 text-left transition-transform duration-200 hover:-translate-y-[1px]"
> >
<span <span
className={cn( className={cn(
"mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition-colors", "flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold transition-colors",
active active
? "border-emerald-300 bg-emerald-300 text-black shadow-[0_0_18px_rgba(52,211,153,0.28)]" ? "border-emerald-300 bg-emerald-300 text-black shadow-[0_0_18px_rgba(52,211,153,0.28)]"
: completed : completed
@ -1746,11 +1746,11 @@ function AgentWorkflowPanelDarkV2Component({
> >
{stepIndex + 1} {stepIndex + 1}
</span> </span>
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<div <div
className={cn( className={cn(
"truncate text-[12px] font-semibold leading-5 transition-colors", "truncate text-[13px] font-semibold leading-5 transition-colors",
active active
? "text-white" ? "text-white"
: completed : completed
@ -1761,12 +1761,12 @@ function AgentWorkflowPanelDarkV2Component({
{step.title} {step.title}
</div> </div>
{active ? ( {active ? (
<span className="inline-flex h-5 items-center rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2 py-0 text-[9px] font-medium leading-none text-emerald-200"> <span className="inline-flex h-5 items-center rounded-full border border-emerald-300/28 bg-emerald-400/10 px-2 py-0 text-[9px] font-medium leading-none text-emerald-200">
当前步骤 当前步骤
</span> </span>
) : null} ) : null}
</div> </div>
<div className="flex min-w-0 items-center gap-1.5 pt-0.5"> <div className="flex min-w-0 items-center gap-1.5">
<span <span
className={cn( className={cn(
"min-w-0 flex-1 truncate text-[11px] leading-4", "min-w-0 flex-1 truncate text-[11px] leading-4",
@ -1797,17 +1797,17 @@ function AgentWorkflowPanelDarkV2Component({
); );
})} })}
</div> </div>
<div className="overflow-hidden rounded-[16px] border border-white/10 bg-white/[0.025]"> <div className="overflow-hidden rounded-[14px] border border-white/8 bg-black/20">
<button <button
type="button" type="button"
onClick={() => onClick={() =>
setIsEmbeddedPathExpanded((current) => !current) setIsEmbeddedPathExpanded((current) => !current)
} }
aria-expanded={isEmbeddedPathExpanded} aria-expanded={isEmbeddedPathExpanded}
className="flex w-full items-center justify-between gap-3 px-3.5 py-2.5 text-left transition-colors hover:bg-white/[0.03]" className="flex min-h-[44px] w-full items-center justify-between gap-3 px-3.5 py-2 text-left transition-colors hover:bg-white/[0.035]"
> >
<div className="min-w-0 flex-1"> <div className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 shrink-0 flex-wrap items-center gap-2">
<span className="rounded-full border border-white/12 px-2 py-0.5 text-[10px] font-medium text-white/55"> <span className="rounded-full border border-white/12 px-2 py-0.5 text-[10px] font-medium text-white/55">
当前步骤 当前步骤
</span> </span>
@ -1815,8 +1815,8 @@ function AgentWorkflowPanelDarkV2Component({
{activeEmbeddedStepEntry.step.title} {activeEmbeddedStepEntry.step.title}
</span> </span>
</div> </div>
<div <div
className="mt-1 truncate text-xs text-white/68" className="min-w-0 truncate text-xs text-white/68"
title={activeEmbeddedPathSummary} title={activeEmbeddedPathSummary}
> >
{activeEmbeddedPrimaryOption?.buttonName || {activeEmbeddedPrimaryOption?.buttonName ||
@ -1892,8 +1892,8 @@ function AgentWorkflowPanelDarkV2Component({
</div> </div>
</div> </div>
{activeEmbeddedOptionLevel ? ( {activeEmbeddedOptionLevel ? (
<div className="space-y-4"> <div className="space-y-5">
{autoAdvanceNotice ? ( {autoAdvanceNotice ? (
<motion.div <motion.div
key={`${autoAdvanceNotice.completedStepTitle}-${autoAdvanceNotice.nextStepTitle}`} key={`${autoAdvanceNotice.completedStepTitle}-${autoAdvanceNotice.nextStepTitle}`}
@ -1920,13 +1920,13 @@ function AgentWorkflowPanelDarkV2Component({
</motion.div> </motion.div>
) : null} ) : null}
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-3">
<span className="rounded-full border border-white/12 px-2.5 py-1 text-[11px] font-medium text-white/45"> <span className="rounded-full border border-white/12 bg-white/[0.03] px-3 py-1 text-[11px] font-medium text-white/52">
{activeEmbeddedOptionLevel.depth === 1 {activeEmbeddedOptionLevel.depth === 1
? "主卡片" ? "主卡片"
: `${activeEmbeddedOptionLevel.depth}级子卡片`} : `${activeEmbeddedOptionLevel.depth}级子卡片`}
</span> </span>
<span className="text-xs text-white/35"> <span className="text-xs leading-5 text-white/35">
{activeEmbeddedOptionLevel.depth === 1 {activeEmbeddedOptionLevel.depth === 1
? "先选择当前步骤的主卡片" ? "先选择当前步骤的主卡片"
: "当前层级只展示这一层的子节点卡片,选完再进入下一层"} : "当前层级只展示这一层的子节点卡片,选完再进入下一层"}

View File

@ -11,6 +11,7 @@ import {
Settings2, Settings2,
SlidersHorizontal, SlidersHorizontal,
Sparkles, Sparkles,
Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import { import {
@ -780,6 +781,13 @@ export function EnhanceProWorkspace({
); );
}; };
const handleRequestChangeImage = useCallback(() => {
requestIdRef.current = null;
requestSignatureRef.current = null;
setLastErrorMessage("");
onRequestAddImage?.();
}, [onRequestAddImage]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!config.enabled) { if (!config.enabled) {
toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用"); toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用");
@ -1071,8 +1079,18 @@ export function EnhanceProWorkspace({
<ImageIcon className="h-3.5 w-3.5" /> <ImageIcon className="h-3.5 w-3.5" />
<span>{activePreviewLabel}</span> <span>{activePreviewLabel}</span>
</div> </div>
<div className="pointer-events-none absolute top-6 right-8 hidden items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] lg:inline-flex"> <div className="pointer-events-auto absolute top-6 right-8">
图片固定显示 <Button
className="h-9 rounded-full border-[#e5e5e5] bg-white/96 px-3.5 font-medium text-[#111111] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] backdrop-blur-sm hover:bg-white"
disabled={!onRequestAddImage}
onClick={handleRequestChangeImage}
size="sm"
type="button"
variant="outline"
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
更换图片
</Button>
</div> </div>
</div> </div>
) : ( ) : (
@ -1083,7 +1101,7 @@ export function EnhanceProWorkspace({
PRIMARY_ACTION_BUTTON_CLASS PRIMARY_ACTION_BUTTON_CLASS
)} )}
disabled={!onRequestAddImage} disabled={!onRequestAddImage}
onClick={onRequestAddImage} onClick={handleRequestChangeImage}
type="button" type="button"
> >
<ImageIcon className="mr-2 h-5 w-5 sm:h-6 sm:w-6" /> <ImageIcon className="mr-2 h-5 w-5 sm:h-6 sm:w-6" />

View File

@ -92,6 +92,8 @@ type RealtimeConfig = {
type RealtimeStatus = "idle" | "connecting" | "live" | "error"; type RealtimeStatus = "idle" | "connecting" | "live" | "error";
type RealtimeWorkspaceMode = "realtime" | "enhance-pro"; type RealtimeWorkspaceMode = "realtime" | "enhance-pro";
type RealtimeGenerationMode = "manual" | "auto";
type RealtimeAspectRatio = "1:1" | "4:3" | "2:3";
type RealtimeCanvasModeIconConfig = { type RealtimeCanvasModeIconConfig = {
realtimeIconUrl: string; realtimeIconUrl: string;
enhanceIconUrl: string; enhanceIconUrl: string;
@ -184,6 +186,26 @@ const CANVAS_BACKGROUND = "#ffffff";
const DEFAULT_BRUSH_COLOR = "#18181b"; const DEFAULT_BRUSH_COLOR = "#18181b";
const DRAW_LAYER_ID = "__draw_layer__"; const DRAW_LAYER_ID = "__draw_layer__";
const REALTIME_PROJECTS_STORAGE_KEY = "ai-chatbot:realtime-canvas-projects"; const REALTIME_PROJECTS_STORAGE_KEY = "ai-chatbot:realtime-canvas-projects";
const REALTIME_GENERATION_MODE_OPTIONS: Array<{
value: RealtimeGenerationMode;
label: string;
description: string;
points: number;
}> = [
{
value: "manual",
label: "手动",
description: "点击生成时扣除",
points: 1,
},
{
value: "auto",
label: "自动",
description: "绘制停顿后自动扣除",
points: 1,
},
];
const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = ["1:1", "4:3", "2:3"];
function cloneImageLayers(layers: ImageLayer[]) { function cloneImageLayers(layers: ImageLayer[]) {
return layers.map((layer) => ({ ...layer })); return layers.map((layer) => ({ ...layer }));
@ -808,6 +830,10 @@ export function RealtimeCanvasDialog({
defaultRealtimeConfig.transport.throttleInterval defaultRealtimeConfig.transport.throttleInterval
); );
const pumpFlushRef = useRef<(() => void) | null>(null); const pumpFlushRef = useRef<(() => void) | null>(null);
const realtimeGenerationModeRef =
useRef<RealtimeGenerationMode>("manual");
const manualGenerateRequestRef = useRef(false);
const realtimeAspectRatioRef = useRef<RealtimeAspectRatio>("1:1");
const warningMessageRef = useRef(""); const warningMessageRef = useRef("");
const seqRef = useRef(0); const seqRef = useRef(0);
const requestStartMapRef = useRef<Map<string, number>>(new Map()); const requestStartMapRef = useRef<Map<string, number>>(new Map());
@ -844,6 +870,10 @@ export function RealtimeCanvasDialog({
const [previewUrl, setPreviewUrl] = useState(""); const [previewUrl, setPreviewUrl] = useState("");
const [workspaceMode, setWorkspaceMode] = const [workspaceMode, setWorkspaceMode] =
useState<RealtimeWorkspaceMode>(initialMode); useState<RealtimeWorkspaceMode>(initialMode);
const [realtimeGenerationMode, setRealtimeGenerationMode] =
useState<RealtimeGenerationMode>("manual");
const [realtimeAspectRatio, setRealtimeAspectRatio] =
useState<RealtimeAspectRatio>("1:1");
const [enhanceUploadedSourceImageUrl, setEnhanceUploadedSourceImageUrl] = const [enhanceUploadedSourceImageUrl, setEnhanceUploadedSourceImageUrl] =
useState(""); useState("");
const [brushMode, setBrushMode] = useState<RealtimeBrushMode>("select"); const [brushMode, setBrushMode] = useState<RealtimeBrushMode>("select");
@ -908,6 +938,18 @@ export function RealtimeCanvasDialog({
"border border-[#e5e5e5] bg-white text-[#666666] dark:border-white/12 dark:bg-white/8 dark:text-white/58", "border border-[#e5e5e5] bg-white text-[#666666] dark:border-white/12 dark:bg-white/8 dark:text-white/58",
}; };
}, [status]); }, [status]);
const currentRealtimeModeOption =
REALTIME_GENERATION_MODE_OPTIONS.find(
(option) => option.value === realtimeGenerationMode
) || REALTIME_GENERATION_MODE_OPTIONS[0];
useEffect(() => {
realtimeGenerationModeRef.current = realtimeGenerationMode;
}, [realtimeGenerationMode]);
useEffect(() => {
realtimeAspectRatioRef.current = realtimeAspectRatio;
}, [realtimeAspectRatio]);
const selectedImageLayer = useMemo( const selectedImageLayer = useMemo(
() => imageLayers.find((layer) => layer.id === selectedLayerId) || null, () => imageLayers.find((layer) => layer.id === selectedLayerId) || null,
@ -915,10 +957,11 @@ export function RealtimeCanvasDialog({
); );
const enhanceHistoryScopeKey = useMemo(() => { const enhanceHistoryScopeKey = useMemo(() => {
return ( return (
initialImageUrl ||
enhanceUploadedSourceImageUrl || enhanceUploadedSourceImageUrl ||
selectedImageLayer?.src ||
imageLayers[0]?.src || imageLayers[0]?.src ||
previewUrl || previewUrl ||
initialImageUrl ||
"realtime-canvas-enhance" "realtime-canvas-enhance"
); );
}, [ }, [
@ -926,12 +969,13 @@ export function RealtimeCanvasDialog({
imageLayers, imageLayers,
initialImageUrl, initialImageUrl,
previewUrl, previewUrl,
selectedImageLayer?.src,
]); ]);
const enhanceSourceImageUrl = useMemo(() => { const enhanceSourceImageUrl = useMemo(() => {
return ( return (
enhanceUploadedSourceImageUrl ||
selectedImageLayer?.src || selectedImageLayer?.src ||
imageLayers[0]?.src || imageLayers[0]?.src ||
enhanceUploadedSourceImageUrl ||
previewUrl || previewUrl ||
initialImageUrl || initialImageUrl ||
"" ""
@ -1662,6 +1706,8 @@ export function RealtimeCanvasDialog({
seed: seedRef.current, seed: seedRef.current,
strictSketch: config.behavior?.strictSketch !== false, strictSketch: config.behavior?.strictSketch !== false,
imageSize: config.model.defaults.imageSize, imageSize: config.model.defaults.imageSize,
aspectRatio: realtimeAspectRatioRef.current,
generationMode: realtimeGenerationModeRef.current,
steps: config.model.defaults.steps, steps: config.model.defaults.steps,
cfgScale: config.model.defaults.cfgScale, cfgScale: config.model.defaults.cfgScale,
strength: config.model.defaults.strength, strength: config.model.defaults.strength,
@ -1795,6 +1841,19 @@ export function RealtimeCanvasDialog({
if (markDirty) { if (markDirty) {
markCanvasDirty(null); markCanvasDirty(null);
} }
if (
realtimeGenerationModeRef.current === "manual" &&
!manualGenerateRequestRef.current
) {
if (pendingFlushTimerRef.current) {
window.clearTimeout(pendingFlushTimerRef.current);
pendingFlushTimerRef.current = null;
}
pendingFlushModeRef.current = null;
pendingFlushForceRef.current = false;
return;
}
manualGenerateRequestRef.current = false;
const nextMode: FlushMode = mode || (force ? "refine" : "preview"); const nextMode: FlushMode = mode || (force ? "refine" : "preview");
if (pendingFlushModeRef.current === "refine" || nextMode === "refine") { if (pendingFlushModeRef.current === "refine" || nextMode === "refine") {
pendingFlushModeRef.current = "refine"; pendingFlushModeRef.current = "refine";
@ -1839,6 +1898,36 @@ export function RealtimeCanvasDialog({
} }
}, []); }, []);
const handleRealtimeGenerationModeChange = useCallback(
(nextMode: RealtimeGenerationMode) => {
if (nextMode === realtimeGenerationModeRef.current) return;
realtimeGenerationModeRef.current = nextMode;
manualGenerateRequestRef.current = false;
setRealtimeGenerationMode(nextMode);
if (nextMode === "manual") {
cancelPendingRealtimeFlush(false);
return;
}
scheduleFlush(false, false, "preview");
},
[cancelPendingRealtimeFlush, scheduleFlush]
);
const handleRealtimeAspectRatioChange = useCallback(
(nextRatio: RealtimeAspectRatio) => {
if (nextRatio === realtimeAspectRatioRef.current) return;
realtimeAspectRatioRef.current = nextRatio;
setRealtimeAspectRatio(nextRatio);
if (realtimeGenerationModeRef.current === "auto") {
scheduleFlush(true, false, "refine");
}
},
[scheduleFlush]
);
const reorderLayersFromSidebar = useCallback( const reorderLayersFromSidebar = useCallback(
(draggedLayerId: string, targetLayerId: string) => { (draggedLayerId: string, targetLayerId: string) => {
if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId) if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId)
@ -1976,7 +2065,8 @@ export function RealtimeCanvasDialog({
toast.error("请先上传图片或绘制内容"); toast.error("请先上传图片或绘制内容");
return; return;
} }
scheduleFlush(true); manualGenerateRequestRef.current = true;
scheduleFlush(true, true, "refine");
}, [hasRenderableInput, scheduleFlush]); }, [hasRenderableInput, scheduleFlush]);
const refreshSavedProjects = useCallback(() => { const refreshSavedProjects = useCallback(() => {
@ -4279,10 +4369,56 @@ export function RealtimeCanvasDialog({
value={prompt} value={prompt}
/> />
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-9 items-center gap-1 rounded-full border border-[#e5e5e5] bg-white p-1 shadow-[0_6px_16px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/8">
{REALTIME_GENERATION_MODE_OPTIONS.map((option) => {
const active = option.value === realtimeGenerationMode;
return (
<button
className={cn(
"h-7 rounded-full px-3 font-medium text-xs transition",
active
? "bg-[#111111] text-white dark:bg-white dark:text-[#111111]"
: "text-[#666666] hover:bg-[#f4f4f4] dark:text-white/58 dark:hover:bg-white/10"
)}
key={option.value}
onClick={() =>
handleRealtimeGenerationModeChange(option.value)
}
title={option.description}
type="button"
>
{option.label}
</button>
);
})}
</div>
<div className="inline-flex h-9 items-center gap-1 rounded-full border border-[#e5e5e5] bg-white p-1 shadow-[0_6px_16px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/8">
{REALTIME_ASPECT_RATIO_OPTIONS.map((ratio) => {
const active = ratio === realtimeAspectRatio;
return (
<button
className={cn(
"h-7 rounded-full px-3 font-medium text-xs transition",
active
? "bg-[#111111] text-white dark:bg-white dark:text-[#111111]"
: "text-[#666666] hover:bg-[#f4f4f4] dark:text-white/58 dark:hover:bg-white/10"
)}
key={ratio}
onClick={() => handleRealtimeAspectRatioChange(ratio)}
type="button"
>
{ratio}
</button>
);
})}
</div>
<div className="flex h-9 items-center rounded-full border border-[#e5e5e5] bg-white px-3 font-semibold text-[#111111] text-xs shadow-[0_6px_16px_rgba(0,0,0,0.04)] dark:border-white/12 dark:bg-white/8 dark:text-white">
{currentRealtimeModeOption.points} 积分
</div>
<div <div
className={cn( className={cn(
"flex h-6 items-center gap-1.5 rounded-full px-2.5 font-medium text-xs", "hidden h-7 items-center gap-1.5 rounded-full px-2.5 font-medium text-xs sm:flex",
statusBadge.className statusBadge.className
)} )}
> >
@ -4296,9 +4432,6 @@ export function RealtimeCanvasDialog({
/> />
{statusBadge.label} {statusBadge.label}
</div> </div>
<div className="flex h-6 items-center rounded-full border border-[#e5e5e5] bg-white px-2.5 font-medium text-[#666666] text-xs dark:border-white/12 dark:bg-white/8 dark:text-white/58">
延迟 {adaptiveThrottleMs}ms
</div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button

View File

@ -0,0 +1,886 @@
"use client";
import {
ArrowLeft,
Clock3,
Home,
ImagePlus,
Loader2,
RotateCcw,
Sparkles,
Upload,
UserRound,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { imageGenerationService } from "@/services/canvas";
import { cn, fetcher } from "@/lib/utils";
import type {
PublicStyleProCategory,
PublicStyleProConfig,
StyleProHistoryItem,
StyleProPersistedState,
StyleProSelectionMap,
} from "@/lib/style-pro/config";
type BrandingSettings = {
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
};
type StyleProConfigResponse = {
success: boolean;
config?: PublicStyleProConfig;
error?: string;
};
type StyleProGenerateResponse = {
success: boolean;
error?: string;
data?: {
imageUrl: string;
thumbnailUrl?: string;
model?: string;
modelRef?: string;
selections: StyleProSelectionMap;
optionLabels: string[];
};
};
type StyleProWorkspaceProps = {
canvasId: string;
initialSourceImageUrl?: string | null;
persistedState?: StyleProPersistedState | null;
onAddToCanvas?: (item: StyleProHistoryItem) => void | Promise<void>;
onBack?: () => void;
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
};
function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function firstEnabledOptionId(category: PublicStyleProCategory) {
return category.options.find((option) => option.enabled)?.id || "";
}
function loadImageDimensions(url: string) {
return new Promise<{ width?: number; height?: number }>((resolve) => {
if (typeof window === "undefined" || !url) {
resolve({});
return;
}
const image = new window.Image();
image.onload = () => {
resolve({
width: image.naturalWidth || undefined,
height: image.naturalHeight || undefined,
});
};
image.onerror = () => resolve({});
image.src = url;
});
}
function normalizeHistory(history: StyleProHistoryItem[] | undefined) {
return Array.isArray(history) ? history.slice(0, 4) : [];
}
function ImagePreview({
alt,
className,
imageUrl,
}: {
alt: string;
className?: string;
imageUrl?: string | null;
}) {
if (!imageUrl) {
return (
<div
className={cn(
"flex h-full min-h-[220px] items-center justify-center rounded-lg border border-dashed border-white/15 bg-white/[0.03] text-sm text-white/45",
className
)}
>
等待图片
</div>
);
}
return (
<img
alt={alt}
className={cn("h-full w-full rounded-lg object-contain", className)}
draggable={false}
src={imageUrl}
/>
);
}
function GeneratingImagePlaceholder() {
return (
<div className="min-h-[460px] rounded-lg border border-white/10 bg-white p-5 text-neutral-800">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-[15px] text-neutral-700">
正在创建图片
</div>
<div className="mt-1 text-xs text-neutral-400">
正在理解图片与提示词组合
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-lg bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
生成中
</span>
</div>
<div className="style-pro-generating-frame relative mx-auto h-[390px] max-h-[58vh] w-full max-w-[340px] overflow-hidden rounded-lg bg-[#f4f4f4]">
<div className="style-pro-dot-field absolute inset-0" />
<div className="style-pro-light-field absolute inset-0" />
<div className="style-pro-scan-field absolute inset-0" />
</div>
<style>{`
@keyframes styleProDotDrift {
0% { transform: translate3d(-10px, -8px, 0); opacity: 0.35; }
45% { transform: translate3d(8px, 18px, 0); opacity: 0.78; }
100% { transform: translate3d(18px, -4px, 0); opacity: 0.42; }
}
@keyframes styleProLightFloat {
0% { transform: translate3d(-18%, -10%, 0) scale(1); opacity: 0.72; }
50% { transform: translate3d(18%, 16%, 0) scale(1.06); opacity: 0.98; }
100% { transform: translate3d(34%, -4%, 0) scale(1); opacity: 0.74; }
}
@keyframes styleProScanMove {
0% { transform: translateX(-80%); opacity: 0; }
18% { opacity: 0.65; }
54% { opacity: 0.82; }
100% { transform: translateX(80%); opacity: 0; }
}
.style-pro-generating-frame {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.025), 0 18px 50px rgba(0,0,0,0.08);
}
.style-pro-dot-field {
background-image: radial-gradient(circle, rgba(116,116,116,0.32) 1.05px, transparent 1.05px);
background-size: 16px 16px;
mask-image: radial-gradient(ellipse 88% 72% at 50% 44%, #000 0%, rgba(0,0,0,0.78) 48%, transparent 82%);
animation: styleProDotDrift 3.8s ease-in-out infinite alternate;
}
.style-pro-light-field {
background:
radial-gradient(circle at 24% 24%, rgba(255,255,255,0.96) 0%, rgba(255,255,255,0.82) 22%, transparent 45%),
radial-gradient(circle at 70% 72%, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 20%, transparent 44%);
animation: styleProLightFloat 4.4s ease-in-out infinite alternate;
}
.style-pro-scan-field {
width: 150%;
left: -25%;
background: linear-gradient(105deg, transparent 20%, rgba(255,255,255,0.92) 48%, transparent 74%);
animation: styleProScanMove 3.4s ease-in-out infinite;
}
`}</style>
</div>
);
}
function BeforeAfterSlider({
afterUrl,
beforeUrl,
generating = false,
}: {
afterUrl?: string | null;
beforeUrl?: string | null;
generating?: boolean;
}) {
const [position, setPosition] = useState(50);
if (generating) {
return <GeneratingImagePlaceholder />;
}
if (!beforeUrl && !afterUrl) {
return (
<div className="flex min-h-[460px] items-center justify-center rounded-lg border border-dashed border-white/15 bg-white/[0.03] text-white/45">
上传图片后开始生成
</div>
);
}
if (!afterUrl) {
return (
<div className="min-h-[460px] rounded-lg border border-white/10 bg-[#111] p-3">
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
</div>
);
}
return (
<div className="relative min-h-[460px] overflow-hidden rounded-lg border border-white/10 bg-[#111]">
<ImagePreview
alt="生成后"
className="absolute inset-0 h-full w-full"
imageUrl={afterUrl}
/>
<div
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
>
<ImagePreview
alt="生成前"
className="h-full w-full"
imageUrl={beforeUrl}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white"
style={{ left: `${position}%` }}
>
<span className="-translate-x-1/2 absolute top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/70 bg-black/70 text-[11px] font-semibold text-white shadow-lg">
对比
</span>
</div>
<input
aria-label="图像对比滑块"
className="absolute inset-x-4 bottom-5 z-10 accent-emerald-400"
max={100}
min={0}
onChange={(event) => setPosition(Number(event.target.value))}
type="range"
value={position}
/>
<div className="absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1 text-xs text-white/80">
原图
</div>
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
结果
</div>
</div>
);
}
export function StyleProWorkspace({
canvasId,
initialSourceImageUrl,
onAddToCanvas,
onBack,
onPersistState,
persistedState,
}: StyleProWorkspaceProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { data: brandingData } = useSWR<BrandingSettings>(
"/api/site-settings?scope=branding",
fetcher
);
const { data: configData, isLoading: configLoading } =
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
const config = configData?.config;
const enabledCategories = useMemo(
() =>
(config?.categories || [])
.filter((category) => category.enabled)
.map((category) => ({
...category,
options: category.options.filter((option) => option.enabled),
}))
.filter((category) => category.options.length > 0),
[config?.categories]
);
const [sourceImageUrl, setSourceImageUrl] = useState(
persistedState?.sourceImageUrl || initialSourceImageUrl || ""
);
const [currentResult, setCurrentResult] =
useState<StyleProHistoryItem | null>(
normalizeHistory(persistedState?.history)[0] || null
);
const [history, setHistory] = useState<StyleProHistoryItem[]>(
normalizeHistory(persistedState?.history)
);
const [selections, setSelections] = useState<StyleProSelectionMap>({});
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
const nextSource =
persistedState?.sourceImageUrl || initialSourceImageUrl || "";
setSourceImageUrl((current) => current || nextSource);
const nextHistory = normalizeHistory(persistedState?.history);
setHistory(nextHistory);
setCurrentResult((current) => current || nextHistory[0] || null);
}, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]);
const siteName = brandingData?.siteName || "由壹点AI";
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
const selectedOptionLabels = useMemo(() => {
const labels: string[] = [];
for (const category of enabledCategories) {
const selected = selections[category.id] || [];
for (const optionId of selected) {
const option = category.options.find((item) => item.id === optionId);
if (option) {
labels.push(`${category.title}:${option.label}`);
}
}
}
return labels;
}, [enabledCategories, selections]);
const missingRequiredCategories = useMemo(
() =>
enabledCategories.filter((category) => {
if (!category.required && category.minSelections <= 0) return false;
const selectedCount = selections[category.id]?.length || 0;
return (
selectedCount <
Math.max(category.required ? 1 : 0, category.minSelections)
);
}),
[enabledCategories, selections]
);
const persistState = useCallback(
async (nextState: StyleProPersistedState) => {
await onPersistState?.({
sourceImageUrl: nextState.sourceImageUrl,
history: normalizeHistory(nextState.history),
});
},
[onPersistState]
);
const updateSourceImage = useCallback(
async (url: string) => {
const nextUrl = String(url || "").trim();
setSourceImageUrl(nextUrl);
setCurrentResult(null);
await persistState({ sourceImageUrl: nextUrl, history });
},
[history, persistState]
);
const handleFile = useCallback(
async (file: File | null | undefined) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
setUploading(true);
try {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.url) {
throw new Error(result.error || "上传失败");
}
await updateSourceImage(result.data.url);
toast.success("图片已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
},
[updateSourceImage]
);
const toggleOption = useCallback(
(category: PublicStyleProCategory, optionId: string) => {
setSelections((current) => {
const previous = current[category.id] || [];
const selected = previous.includes(optionId);
let nextSelected = selected
? previous.filter((id) => id !== optionId)
: [...previous, optionId];
if (!selected && category.maxSelections <= 1) {
nextSelected = [optionId];
}
if (nextSelected.length > category.maxSelections) {
nextSelected = nextSelected.slice(-category.maxSelections);
}
return {
...current,
[category.id]: nextSelected,
};
});
},
[]
);
const quickFillRequired = useCallback(() => {
setSelections((current) => {
const next = { ...current };
for (const category of enabledCategories) {
if (!category.required || next[category.id]?.length) continue;
const firstId = firstEnabledOptionId(category);
if (firstId) next[category.id] = [firstId];
}
return next;
});
}, [enabledCategories]);
const handleGenerate = useCallback(async () => {
if (!sourceImageUrl) {
toast.error("请先上传一张图片");
return;
}
if (!config?.enabled) {
toast.error("风格化 PRO 暂未启用");
return;
}
if (missingRequiredCategories.length > 0) {
toast.error(`请完成必选项:${missingRequiredCategories[0].title}`);
return;
}
setGenerating(true);
try {
const response = await fetch("/api/style-pro/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasId,
sourceImageUrl,
selections,
}),
});
const payload = (await response.json().catch(() => ({}))) as
| StyleProGenerateResponse
| undefined;
if (!response.ok || !payload?.success || !payload.data?.imageUrl) {
throw new Error(payload?.error || "生成失败");
}
const dimensions = await loadImageDimensions(payload.data.imageUrl);
const item: StyleProHistoryItem = {
id: getHistoryId(),
sourceImageUrl,
resultImageUrl: payload.data.imageUrl,
selections: payload.data.selections || selections,
optionLabels: payload.data.optionLabels || selectedOptionLabels,
createdAt: new Date().toISOString(),
model: payload.data.model,
modelRef: payload.data.modelRef,
width: dimensions.width,
height: dimensions.height,
};
const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice(
0,
4
);
setHistory(nextHistory);
setCurrentResult(item);
await persistState({ sourceImageUrl, history: nextHistory });
toast.success("风格化 PRO 生成完成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成失败");
} finally {
setGenerating(false);
}
}, [
canvasId,
config?.enabled,
history,
missingRequiredCategories,
persistState,
selectedOptionLabels,
selections,
sourceImageUrl,
]);
const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => {
setSourceImageUrl(item.sourceImageUrl);
setSelections(item.selections || {});
setCurrentResult(item);
}, []);
const disabledReason = !sourceImageUrl
? "请先上传图片"
: missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: "";
return (
<div className="flex h-dvh w-full overflow-hidden bg-[#050606] text-white">
<aside className="flex w-[76px] shrink-0 flex-col items-center border-white/10 border-r bg-black">
<button
className="mt-5 flex h-11 w-11 items-center justify-center rounded-lg bg-white/[0.06] text-white"
onClick={onBack}
title="返回画布"
type="button"
>
{logo ? (
<img alt={siteName} className="h-6 w-6 object-contain" src={logo} />
) : (
<Sparkles className="h-5 w-5 text-emerald-300" />
)}
</button>
<div className="mt-8 flex flex-col gap-3">
<button
className="flex h-11 w-11 items-center justify-center rounded-lg bg-emerald-400 text-black"
onClick={onBack}
title="返回画布"
type="button"
>
<Home className="h-5 w-5" />
</button>
<button
className="flex h-11 w-11 items-center justify-center rounded-lg bg-white/[0.05] text-white/75 hover:bg-white/[0.09] hover:text-white"
onClick={() => fileInputRef.current?.click()}
title="上传图片"
type="button"
>
<ImagePlus className="h-5 w-5" />
</button>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex h-16 shrink-0 items-center justify-between border-white/10 border-b bg-black/85 px-6">
<div className="flex items-center gap-3">
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
onClick={onBack}
size="sm"
variant="outline"
>
<ArrowLeft className="mr-2 h-4 w-4" />
返回画布
</Button>
<div>
<div className="text-sm text-white/45">{siteName}</div>
<h1 className="font-semibold text-xl">风格化 PRO</h1>
</div>
</div>
<button
className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white/80 hover:bg-white/[0.08] hover:text-white"
onClick={() => toast.info("个人中心入口已保留,可在首页打开完整账户面板")}
type="button"
>
<UserRound className="h-4 w-4" />
个人中心
</button>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[360px_minmax(0,1fr)] overflow-hidden">
<section className="flex min-h-0 flex-col border-white/10 border-r bg-[#080909]">
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-5">
<div>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">上传图片</h2>
<p className="mt-1 text-sm text-white/45">
上传后选择按钮组合生成最终图。
</p>
</div>
<Badge className="border-emerald-400/30 bg-emerald-400/10 text-emerald-200">
Final
</Badge>
</div>
<button
className="group relative flex min-h-[230px] w-full items-center justify-center overflow-hidden rounded-lg border border-dashed border-white/15 bg-white/[0.03] transition hover:border-emerald-300/60 hover:bg-white/[0.05]"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void handleFile(event.dataTransfer.files?.[0]);
}}
type="button"
>
{sourceImageUrl ? (
<img
alt="上传图片"
className="h-full max-h-[300px] w-full object-contain"
src={sourceImageUrl}
/>
) : (
<span className="flex flex-col items-center gap-3 text-white/55">
{uploading ? (
<Loader2 className="h-7 w-7 animate-spin" />
) : (
<Upload className="h-7 w-7" />
)}
<span className="text-sm">
点击上传,或拖入图片
</span>
</span>
)}
</button>
<input
accept="image/*"
className="hidden"
onChange={(event) => {
void handleFile(event.target.files?.[0]);
event.currentTarget.value = "";
}}
ref={fileInputRef}
type="file"
/>
</div>
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="font-semibold text-base">历史记录</h2>
<span className="text-xs text-white/40">最近 4 条</span>
</div>
<div className="grid grid-cols-2 gap-3">
{history.length > 0 ? (
history.map((item) => (
<button
className={cn(
"group overflow-hidden rounded-lg border bg-white/[0.03] text-left transition hover:border-emerald-300/70",
currentResult?.id === item.id
? "border-emerald-300/80"
: "border-white/10"
)}
key={item.id}
onClick={() => restoreHistoryItem(item)}
type="button"
>
<div className="aspect-square bg-black">
<img
alt="风格化历史"
className="h-full w-full object-cover"
src={item.resultImageUrl}
/>
</div>
<div className="space-y-1 p-2">
<div className="flex items-center gap-1 text-[11px] text-white/50">
<Clock3 className="h-3 w-3" />
{new Date(item.createdAt).toLocaleTimeString(
"zh-CN",
{
hour: "2-digit",
minute: "2-digit",
}
)}
</div>
<div className="line-clamp-1 text-xs text-white/80">
{item.optionLabels[0] || "风格化结果"}
</div>
</div>
</button>
))
) : (
<div className="col-span-2 rounded-lg border border-dashed border-white/12 p-5 text-center text-sm text-white/40">
生成后会在这里保存历史。
</div>
)}
</div>
</div>
</div>
</ScrollArea>
</section>
<section className="flex min-w-0 flex-col overflow-hidden bg-[#0b0c0c]">
<div className="border-white/10 border-b p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-300" />
<h2 className="font-semibold text-lg">提示词按钮组合</h2>
</div>
<p className="mt-1 text-sm text-white/45">
完成必选项后执行,后台隐藏提示词会参与最终生成。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
onClick={quickFillRequired}
size="sm"
variant="outline"
>
<Sparkles className="mr-2 h-4 w-4" />
自动补全必选
</Button>
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
onClick={() => setSelections({})}
size="sm"
variant="outline"
>
<RotateCcw className="mr-2 h-4 w-4" />
清空选择
</Button>
</div>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-6 p-5">
{configLoading ? (
<div className="flex min-h-[180px] items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-white/55">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载配置
</div>
) : !config?.enabled ? (
<div className="rounded-lg border border-amber-400/30 bg-amber-400/10 p-5 text-amber-100">
风格化 PRO 暂未启用,请联系管理员开启。
</div>
) : (
<div className="space-y-4">
{enabledCategories.map((category, index) => {
const selectedIds = selections[category.id] || [];
const complete =
selectedIds.length >=
Math.max(
category.required ? 1 : 0,
category.minSelections
);
return (
<div
className="rounded-lg border border-white/10 bg-white/[0.035] p-4"
key={category.id}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-semibold",
complete
? "bg-emerald-300 text-black"
: "bg-white/[0.08] text-white/65"
)}
>
{index + 1}
</span>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-white">
{category.title}
</h3>
{category.required ? (
<Badge className="border-emerald-400/30 bg-emerald-400/10 text-emerald-200">
必选
</Badge>
) : (
<Badge
className="border-white/10 text-white/55"
variant="outline"
>
可选
</Badge>
)}
</div>
{category.description ? (
<p className="mt-1 text-sm text-white/45">
{category.description}
</p>
) : null}
</div>
</div>
<span className="rounded-full border border-white/10 px-3 py-1 text-xs text-white/45">
已选 {selectedIds.length}/{category.maxSelections}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{category.options.map((option) => {
const selected = selectedIds.includes(option.id);
return (
<button
className={cn(
"min-h-[96px] rounded-lg border p-4 text-left transition",
selected
? "border-emerald-300/80 bg-emerald-300/12 shadow-[0_0_0_1px_rgba(110,231,183,0.25)]"
: "border-white/10 bg-black/25 hover:border-white/25 hover:bg-white/[0.05]"
)}
key={option.id}
onClick={() => toggleOption(category, option.id)}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="font-semibold text-sm text-white">
{option.label}
</span>
{selected ? (
<span className="rounded-full bg-emerald-300 px-2 py-0.5 text-[11px] font-semibold text-black">
已选
</span>
) : null}
</div>
{option.description ? (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-white/45">
{option.description}
</p>
) : null}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
<div className="rounded-lg border border-white/10 bg-white/[0.035] p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-lg">图像对比</h2>
<p className="mt-1 text-sm text-white/45">
生成完成后拖动滑块查看前后差异。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
className="rounded-lg bg-emerald-400 text-black hover:bg-emerald-300"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{generating ? "生成中" : "开始生成"}
</Button>
<Button
className="rounded-lg border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]"
disabled={!currentResult}
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
variant="outline"
>
添加到画布
</Button>
</div>
</div>
{disabledReason ? (
<div className="mb-3 rounded-lg border border-white/10 bg-black/35 px-3 py-2 text-sm text-white/45">
{disabledReason}
</div>
) : null}
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
generating={generating}
/>
{selectedOptionLabels.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{selectedOptionLabels.map((label) => (
<Badge
className="border-white/10 bg-white/[0.04] text-white/70"
key={label}
variant="outline"
>
{label}
</Badge>
))}
</div>
) : null}
</div>
</div>
</ScrollArea>
</section>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,73 @@
# 更新日志(2026 年 5 月 7 日 - 2026 年 5 月 12 日)
本次更新重点围绕出图稳定性、无限画布效率、PSD 模板编辑能力和任务调度能力展开,整体目标是让高频创作场景更稳、更快、更顺手。
## 本次重点
- 生图并发与调度能力升级,高峰期多人同时使用时更稳定。
- 无限画布继续增强,新增画布内图片裁剪,并优化多角度预览、缩放和批量工作流体验。
- PSD 模板编辑器能力升级,图层编辑、素材回填和预览链路更完整。
- 多个生图模型的尺寸、比例和参考图适配进一步修正,结果更贴近预期。
- 3D 和增强类功能补齐显隐、计费与失败处理逻辑,使用更稳妥。
- 钉钉登录、商品详情图、场景融合等链路稳定性持续提升。
## 详细更新
### 1. AI 生图与任务调度
- 提升了全站生图任务的并发处理能力,减少高峰时段的等待感。
- 新增模型健康切换与恢复机制,当个别模型异常时,系统可更平滑地切换与恢复。
- 优化图片意图识别与尺寸映射,减少提示词理解偏差和尺寸不匹配问题。
- 修复多个图像模型在参考图比例保留上的问题,生成结果更贴近原图构图和画幅。
### 2. 无限画布与图片工作流
- 新增画布内图片裁剪能力,常用裁切操作无需离开当前工作区。
- 多角度预览面板持续优化,界面层次、预览结构和交互手感更清晰。
- 扩展批量处理与图片工作流入口,连续处理多张素材时更高效。
- 修复画布缩放手势、工具栏开关同步和侧边工具弹层等交互问题。
- 增强图片下载兜底逻辑,降低下载失败概率。
### 3. PSD 模板编辑器
- 强化 PSD 模板图层编辑能力,可编辑内容展示更完整。
- 图层列表补充滚动能力,长列表场景下浏览和定位更顺畅。
- 修复侧边栏溢出与局部重叠问题,编辑区域布局更稳定。
- 优化 PSD 模板素材回填、解析、超时处理和渲染链路,复杂模板的可用性进一步提升。
### 4. 3D、增强与图片延展
- 3D 模型相关功能支持更细的后台显隐控制,前台展示更可控。
- 补齐 3D 任务失败后的积分退回处理,扣费逻辑更稳妥。
- Enhance Pro 配置完成新一轮升级,增强类能力的展示和配置更清晰。
- 优化延展图和部分模型的尺寸参数传递,减少因参数不一致导致的异常。
### 5. 商品详情图与场景融合
- 商品详情图工作流已支持更稳定的参考图接入,细节生成更容易对齐素材。
- 修复商品详情 API 兼容和渠道透传问题,减少链路丢参。
- 优化场景融合的素材上传和替换流程,批量上传场景下更稳定。
### 6. 登录与基础稳定性
- 钉钉登录链路稳定性提升,登录与回调流程更可靠。
- 调整部分 CSP 与中间件代理细节,提升页面脚本与请求链路兼容性。
## 可直接发给用户的简版
```md
【本周更新】
过去一周,我们重点升级了出图稳定性、无限画布效率和 PSD 模板编辑能力。
1. 生图任务调度升级,多人同时使用时更稳定,等待感更低。
2. 无限画布新增图片裁剪,并持续优化多角度预览、缩放和批量处理体验。
3. PSD 模板编辑器进一步增强,图层编辑、素材回填和预览链路更完整。
4. 多个生图模型的尺寸与参考图比例适配已修复,结果更贴近预期。
5. 3D 与增强类功能补齐失败处理和配置能力,整体使用更稳妥。
6. 钉钉登录、商品详情图、场景融合等链路稳定性继续提升。
```
---
整理说明:本文根据 2026 年 5 月 7 日至 2026 年 5 月 12 日的 Git 提交记录整理,已按用户可感知体验做口径归纳。

View File

@ -0,0 +1,237 @@
# Git 更新日志(2026-05-10 至 2026-05-14)
生成日期:2026-05-14
统计分支:`main`
统计范围:`2026-05-10 00:00:00` 至当前 `HEAD`
基准提交:`510e3dc323f9cb501f390cb909941ec14d0b5c9d`
当前提交:`769862c`(`fix: improve detail stitch and model recovery`)
## 总览
最近 4 天内,当前仓库共包含 `29` 个提交,主要集中在风格化详情替换、PSD 模板编辑、3D/世界查看器加载、AI 生图模型适配、画布生成结果同步等方向。
整体变更规模:
- 涉及文件:`72` 个
- 代码增量:`5458 insertions`
- 代码删减:`1440 deletions`
- 主要目录分布:`lib` 30 个文件、`app` 19 个文件、`components` 16 个文件、`public` 3 个文件
按日期提交数量:
- `2026-05-14`:15 个提交
- `2026-05-13`:5 个提交
- `2026-05-12`:4 个提交
- `2026-05-11`:5 个提交
## 核心更新摘要
### 1. 风格化详情替换与拼合切割流程增强
本轮更新对风格化页面的详情图替换能力做了持续强化,重点在“参考图拼合切割”“长图预览格式”“产品-only 替换”“下载速度”“模型恢复”几个方面。
主要变化:
- 新增/强化详情参考图拼合切割接口与前端弹窗交互。
- 支持将详情参考图按切片结果进入替换流程,并保留当前工作台预览。
- 优化详情替换下载链路,减少等待时间。
- 增加产品-only 详情替换能力,适配只替换主体产品、不重绘整张详情图的场景。
- 修复长图预览格式问题,降低拼合后预览比例异常的概率。
- 优化详情拼合与模型恢复逻辑,提升失败后恢复和继续处理的稳定性。
重点相关提交:
- `769862c`:优化详情拼合与模型恢复
- `25d331f`:优化风格化图片拼合与预览
- `147f939`:支持 product-only 详情替换
- `000ac13`:加速详情替换下载
- `5f90dbd`:修复详情拼合长预览格式
- `d0bb8b7`:支持批量商品 ID 填充参考图
- `da6cae9`:收紧风格化详情 UI
### 2. PSD 模板编辑器持续修复
PSD 模板编辑器在这几天修复了图层交互、命中测试、预览层级、图层滚动等问题,整体目标是让模板编辑更稳定,减少误点、错层、无法滚动和预览不一致。
主要变化:
- 增强 PSD 图层编辑能力。
- 修复图层列表无滚动条问题。
- 改善图层交互命中区域,减少点不中或点错层。
- 修复 PSD 预览层级堆叠问题。
- 移除不合适的 PSD 图层位置控制,避免用户误操作或造成层级错乱。
重点相关提交:
- `4db458a`:改进 PSD 模板图层编辑
- `1ed3184`:增加 PSD 图层列表滚动条
- `5e78096`:优化 PSD 模板图层交互
- `a3a41ef`:对齐 PSD 图层命中测试
- `96d9551`:恢复 PSD 预览图层堆叠
- `359b964`:移除 PSD 图层位置控制
### 3. 3D / 世界查看器加载与纹理修复
3D 相关模块本轮改动较多,集中解决浏览器加载远程模型、纹理、PBR 贴图、Tripo 资源代理、Basis 转码资源等问题,同时升级世界查看器体验。
主要变化:
- 恢复画布 3D 查看器的 PBR 纹理。
- 加快世界查看器加载速度。
- 增加 3D 归档资源代理,解决浏览器跨域/私有资源加载问题。
- 代理 Tripo 贴图资源,保证模型贴图能在前端稳定加载。
- 加入 Basis transcoder 资源,支持压缩纹理转码。
- 世界查看器升级到 Spark 2。
重点相关提交:
- `89ec01c`:恢复 3D PBR 纹理
- `dee8019`:加速 world viewer 加载
- `2f5c2fc`:代理归档 3D 资产供浏览器加载
- `5864e9f`:代理 Tripo 纹理资源
- `95572bd`:允许 Basis transcoder 资产用于 3D 纹理
- `1c935bf`:升级 world viewer 到 Spark 2
### 4. AI 生图模型、价格与故障恢复
AI 模型层面,本轮主要围绕多服务商模型适配、图像比例参数、GPT Image 尺寸映射、模型健康恢复池和价格配置展开。
主要变化:
- 增加 AI 模型健康恢复池,便于模型失败后进行自动恢复或备用调度。
- 修复/适配 GRSai GPT Image 尺寸映射。
- 保留 GRSai GPT Image 参考图比例。
- 保留 Volcano 图片比例。
- 修复 BLTCY 图片比例参数传递。
- 适配 Ruxa Nano Banana 2 运行时。
- 增加折扣模型价格配置。
重点相关提交:
- `3ebe7db`:增加 AI 模型健康恢复池
- `2cf2162`:映射 GRSai GPT Image 尺寸
- `44d4a80`:保留 GRSai GPT Image 参考图比例
- `a0e303a`:保留 Volcano 图片比例
- `40e7ecc`:传递 BLTCY 图片比例参数
- `f0cb1a4`:适配 Ruxa Nano Banana 2 运行时
- `1b1ff65`:增加折扣模型价格并修复实时 3D 媒体处理
### 5. 画布生成结果同步与输入体验
画布侧主要改动是让生成结果能更快同步到前端,减少“后端已完成但前端不显示”的体验问题;同时对图片意图判断、输入图尺寸和部分实时媒体处理做了修复。
主要变化:
- 画布生成结果立即同步。
- 改善智能体图片意图识别,减少模型选错工作流。
- 修复实时 3D 媒体处理。
- 修复登录与图片尺寸相关问题。
重点相关提交:
- `86b1ee9`:立即同步画布生成结果
- `dd4426d`:改进智能体图片意图处理
- `1b1ff65`:修复实时 3D 媒体处理
- `40848ff`:稳定钉钉登录和图片尺寸
## 按日期提交明细
### 2026-05-14
| 提交 | 时间 | 说明 | 规模 |
| --- | --- | --- | --- |
| `769862c` | 16:35:16 | 修复详情拼合与模型恢复 | 7 files, +273/-152 |
| `359b964` | 16:22:59 | 移除 PSD 图层位置控制 | 1 file, +4/-107 |
| `96d9551` | 16:15:32 | 恢复 PSD 预览图层堆叠 | 1 file, +1/-2 |
| `5f90dbd` | 15:48:48 | 修复详情拼合长图预览格式 | 1 file, +5/-4 |
| `a3a41ef` | 15:48:13 | 修复 PSD 图层命中测试 | 1 file, +23/-2 |
| `5e78096` | 15:35:54 | 优化 PSD 模板图层交互 | 1 file, +78/-46 |
| `25d331f` | 15:28:09 | 优化风格化图片拼合与预览 | 4 files, +782/-387 |
| `95572bd` | 15:08:33 | 允许 Basis transcoder 资源加载 3D 纹理 | 1 file, +8/-1 |
| `1c935bf` | 14:57:56 | 世界查看器升级到 Spark 2 | 4 files, +96/-21 |
| `147f939` | 14:52:01 | 支持 product-only 详情替换 | 1 file, +78/-77 |
| `000ac13` | 14:36:37 | 加速详情替换下载 | 7 files, +658/-258 |
| `5864e9f` | 14:34:29 | 代理 Tripo 纹理资源 | 2 files, +3 |
| `2f5c2fc` | 12:04:45 | 代理归档 3D 资产供浏览器加载 | 2 files, +3 |
| `dee8019` | 11:46:16 | 加快世界查看器加载 | 6 files, +105/-15 |
| `89ec01c` | 10:48:39 | 恢复画布 3D PBR 纹理 | 4 files, +93/-14 |
### 2026-05-13
| 提交 | 时间 | 说明 | 规模 |
| --- | --- | --- | --- |
| `86b1ee9` | 20:36:08 | 立即同步画布生成结果 | 1 file, +202/-2 |
| `1b1ff65` | 20:02:52 | 增加折扣模型价格并修复实时 3D 媒体处理 | 20 files, +583/-145 |
| `d0bb8b7` | 18:37:03 | 支持批量商品 ID 参考图填充 | 2 files, +293/-6 |
| `da6cae9` | 18:33:43 | 收紧风格化详情 UI | 2 files, +575/-397 |
| `f0cb1a4` | 12:25:16 | 适配 Ruxa Nano Banana 2 运行时 | 1 file, +39/-4 |
### 2026-05-12
| 提交 | 时间 | 说明 | 规模 |
| --- | --- | --- | --- |
| `40e7ecc` | 17:18:26 | 传递 BLTCY 图片比例参数 | 1 file, +7/-8 |
| `a0e303a` | 16:40:21 | 保留 Volcano 图片比例 | 1 file, +264/-17 |
| `4db458a` | 14:44:29 | 改进 PSD 模板图层编辑 | 5 files, +693/-60 |
| `44d4a80` | 14:36:56 | 保留 GRSai GPT Image 参考图比例 | 1 file, +52/-2 |
### 2026-05-11
| 提交 | 时间 | 说明 | 规模 |
| --- | --- | --- | --- |
| `2cf2162` | 21:03:53 | 映射 GRSai GPT Image 尺寸 | 4 files, +123/-6 |
| `dd4426d` | 20:31:17 | 改进智能体图片意图处理 | 9 files, +413/-60 |
| `3ebe7db` | 20:27:21 | 增加 AI 模型健康恢复池 | 7 files, +274/-17 |
| `1ed3184` | 15:01:27 | 增加 PSD 图层列表滚动条 | 1 file, +29/-8 |
| `40848ff` | 14:24:34 | 稳定钉钉登录与图片尺寸 | 5 files, +157/-78 |
## 高频改动文件
以下文件在最近 4 天内被多次修改,属于本轮迭代的重点区域:
- `components/agents/AgentPsdTemplateEditorDialog.tsx`:7 次,PSD 模板编辑器核心交互。
- `app/(chat)/api/chat/route.ts`:4 次,聊天/生图请求主链路。
- `app/api/canvas/3d/model-proxy/route.ts`:4 次,3D 模型与纹理资源代理。
- `components/agents/AgentDetailReferenceStitchDialog.tsx`:3 次,详情参考图拼合切割弹窗。
- `app/api/agent-workflow/detail-stitch/route.ts`:3 次,详情拼合切割后端接口。
- `components/canvas/CanvasChatPanel.tsx`:3 次,画布聊天与生成结果同步。
- `lib/db/queries.ts`:3 次,数据库查询与任务/模型状态相关逻辑。
## 重点影响面
### 用户侧
- 风格化详情替换流程的可用性明显增强,尤其是拼合切割、参考图替换、长图预览和产品-only 替换。
- PSD 模板编辑体验更稳定,图层滚动、命中、预览层级等问题被集中修复。
- 3D/世界查看器加载更稳定,贴图丢失和远程资源加载失败问题被重点处理。
- 画布生图结果同步更及时,减少等待或刷新才能看到结果的情况。
### 管理侧
- AI 模型配置能力增强,包括健康恢复池、价格配置和多服务商比例参数适配。
- PSD 模板和详情替换相关后台接口更完整。
- 钉钉登录回调和图片尺寸处理更稳。
### 技术侧
- 新增 `app/api/agent-workflow/detail-stitch/route.ts`,说明详情拼合切割进入了独立 API 化阶段。
- 新增 `lib/ai/image-model-pricing.ts` 和模型健康恢复相关逻辑,说明模型调度正在从“单次调用”走向“可配置、可恢复、可计费”的系统能力。
- 新增 `public/three/basis/*`,说明 3D 纹理加载开始引入浏览器侧转码资产。
- `lib/ai/providers/*` 多个供应商适配被修改,近期如果新增模型,需要重点检查比例、尺寸和参考图参数是否一致。
## 建议验证清单
上线或回归时建议重点验证以下路径:
- 风格化详情模式:搜索详情图、拼合切割、标记不处理、生成替换、结果预览、下载。
- PSD 模板:图层点击、文字编辑、预览层级、图层列表滚动、导出结果一致性。
- 画布生图:生成完成后是否自动显示结果,不需要刷新页面。
- 3D 查看器:不同来源的模型、PBR 纹理、Tripo 贴图、归档资源是否能正常加载。
- AI 模型:GRSai GPT Image、Volcano、BLTCY、Ruxa Nano Banana 2 的比例与尺寸参数是否符合预期。
- 登录:钉钉登录、回调页、图片尺寸处理是否正常。
## 注意事项
- 这份日志基于本地 `main` 分支 Git 历史生成,没有包含未提交文件。
- 当前统计范围从 `2026-05-10 00:00:00` 开始,但该日没有查询到提交,实际提交集中在 `2026-05-11` 至 `2026-05-14`。
- 当前工作区存在构建缓存/未跟踪文档类文件时,不影响本日志对已提交历史的统计。

View File

@ -58,10 +58,16 @@ function normalizeReferenceItems(value: unknown): AgentDetailReplacementReferenc
const id = String(raw.id || "").trim() || `reference-${index + 1}`; const id = String(raw.id || "").trim() || `reference-${index + 1}`;
const label = String(raw.label || "").trim() || `参考图 ${index + 1}`; const label = String(raw.label || "").trim() || `参考图 ${index + 1}`;
const imageUrl = String(raw.imageUrl || "").trim() || null; const imageUrl = String(raw.imageUrl || "").trim() || null;
const previewUrl = String(raw.previewUrl || "").trim() || null;
const width = Number(raw.width);
const height = Number(raw.height);
return { return {
id, id,
label, label,
imageUrl, imageUrl,
previewUrl,
width: Number.isFinite(width) && width > 0 ? Math.round(width) : null,
height: Number.isFinite(height) && height > 0 ? Math.round(height) : null,
candidateId: String(raw.candidateId || "").trim() || null, candidateId: String(raw.candidateId || "").trim() || null,
sortOrder: Number.isFinite(Number(raw.sortOrder)) sortOrder: Number.isFinite(Number(raw.sortOrder))
? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder)))) ? Math.max(0, Math.min(999, Math.round(Number(raw.sortOrder))))

View File

@ -43,6 +43,12 @@ export const DEFAULT_CANVAS_TOOLBAR_ITEMS = [
label: "风格化模式", label: "风格化模式",
description: "进入无限画布风格化工作区", description: "进入无限画布风格化工作区",
}, },
{
key: "stylePro",
group: "画布主工具栏",
label: "风格化 PRO",
description: "进入风格化 PRO 子页面",
},
{ {
key: "batchProcessing", key: "batchProcessing",
group: "画布主工具栏", group: "画布主工具栏",

417
lib/style-pro/config.ts Normal file
View File

@ -0,0 +1,417 @@
export const STYLE_PRO_CONFIG_KEY = "style_pro_config";
export type StyleProOption = {
id: string;
label: string;
description?: string;
prompt: string;
jsonKey?: string;
enabled: boolean;
order: number;
};
export type StyleProCategory = {
id: string;
title: string;
description?: string;
required: boolean;
minSelections: number;
maxSelections: number;
enabled: boolean;
order: number;
options: StyleProOption[];
};
export type StyleProConfig = {
enabled: boolean;
modelId: string;
defaultPrompt: string;
negativePrompt: string;
aspectRatio: string;
imageSize: string;
categories: StyleProCategory[];
};
export type PublicStyleProOption = Omit<StyleProOption, "prompt">;
export type PublicStyleProCategory = Omit<StyleProCategory, "options"> & {
options: PublicStyleProOption[];
};
export type PublicStyleProConfig = Omit<
StyleProConfig,
"modelId" | "defaultPrompt" | "negativePrompt" | "categories"
> & {
categories: PublicStyleProCategory[];
};
export type StyleProSelectionMap = Record<string, string[]>;
export type StyleProHistoryItem = {
id: string;
sourceImageUrl: string;
resultImageUrl: string;
selections: StyleProSelectionMap;
optionLabels: string[];
createdAt: string;
model?: string;
modelRef?: string;
width?: number;
height?: number;
};
export type StyleProPersistedState = {
sourceImageUrl?: string;
history: StyleProHistoryItem[];
};
const DEFAULT_PROMPT = [
"你是专业商业视觉风格化摄影师。",
"请以用户上传的图片作为主体参考,保持主体结构、数量、相对位置和核心识别特征一致。",
"可以根据用户选择调整光线、材质、氛围、机位与画面质感。",
"不要新增无关主体,不要改变产品类别,不要破坏原始构图逻辑。",
].join("\n");
export const defaultStyleProConfig: StyleProConfig = {
enabled: true,
modelId: "",
defaultPrompt: DEFAULT_PROMPT,
negativePrompt: "低清晰度,畸变,文字乱码,主体变形,产品结构错误,多余物体,水印",
aspectRatio: "auto",
imageSize: "2K",
categories: [
{
id: "audience",
title: "人群选择",
description: "定义画面服务的人群与生活方式基调。",
required: true,
minSelections: 1,
maxSelections: 1,
enabled: true,
order: 10,
options: [
{
id: "young-family",
label: "有孩家庭",
description: "温暖、亲近、真实生活气息。",
prompt:
"目标人群为有孩家庭,画面需要温暖、亲近、可信,呈现真实生活场景中的舒适使用感。",
jsonKey: "audience",
enabled: true,
order: 10,
},
{
id: "young-couple",
label: "年轻情侣 / 夫妻",
description: "轻松、共享空间、现代审美。",
prompt:
"目标人群为年轻情侣或年轻夫妻,画面强调共享空间、轻松陪伴和现代审美。",
jsonKey: "audience",
enabled: true,
order: 20,
},
{
id: "solo-youth",
label: "独居青年",
description: "自我表达、松弛、精致小空间。",
prompt:
"目标人群为独居青年,画面强调自我表达、松弛感、精致小空间和个人生活方式。",
jsonKey: "audience",
enabled: true,
order: 30,
},
],
},
{
id: "style",
title: "主题风格",
description: "确定整体视觉方向。",
required: true,
minSelections: 1,
maxSelections: 1,
enabled: true,
order: 20,
options: [
{
id: "modern-natural",
label: "自然生活",
description: "自然光、柔和材质、干净舒服。",
prompt:
"主题风格为自然生活方式,使用自然光、柔和材质、干净舒适的空间层次,避免过度商业棚拍感。",
jsonKey: "style",
enabled: true,
order: 10,
},
{
id: "commercial-premium",
label: "商业高级",
description: "精致布光、高质感、成片感强。",
prompt:
"主题风格为商业高级视觉,画面需要精致布光、高质感材质、清晰主体和成熟广告成片感。",
jsonKey: "style",
enabled: true,
order: 20,
},
{
id: "retro-warm",
label: "复古暖调",
description: "胶片感、温暖、怀旧但干净。",
prompt:
"主题风格为复古暖调,保留干净商业画质,同时加入温暖胶片质感和轻微怀旧氛围。",
jsonKey: "style",
enabled: true,
order: 30,
},
],
},
{
id: "light",
title: "光氛选择",
description: "定义光线时间和明暗关系。",
required: true,
minSelections: 1,
maxSelections: 1,
enabled: true,
order: 30,
options: [
{
id: "sunrise-edge",
label: "日出边缘光",
description: "短暂临界时段,明暗对比强。",
prompt:
"时间效果:太阳上边缘接触地平线至完全跃出地平线的 1-3 分钟;光线特征:直射光强但不刺眼,红橙光占比高,色温约 1800K-2500K,带有明显体积感;视觉特征:以主体为核心视点,地面形成长阴影,画面暖调饱满,明暗对比强烈。",
jsonKey: "light",
enabled: true,
order: 10,
},
{
id: "soft-window",
label: "自然窗光",
description: "柔和、真实、生活感。",
prompt:
"光线为自然窗光,柔和漫射,保留真实阴影层次,画面舒适通透,避免硬阴影和过曝。",
jsonKey: "light",
enabled: true,
order: 20,
},
{
id: "cinematic",
label: "电影氛围光",
description: "方向明确,层次和空间感强。",
prompt:
"光线为电影氛围光,方向明确,明暗层次清晰,空间纵深强,主体边缘有细腻轮廓光。",
jsonKey: "light",
enabled: true,
order: 30,
},
],
},
{
id: "camera",
title: "机位选择",
description: "控制视角和镜头语言。",
required: false,
minSelections: 0,
maxSelections: 1,
enabled: true,
order: 40,
options: [
{
id: "wide-perspective",
label: "广角透视",
description: "空间开阔,纵深明显。",
prompt:
"机位使用广角透视,空间开阔,纵深明显,但主体比例保持自然,不产生夸张畸变。",
jsonKey: "camera",
enabled: true,
order: 10,
},
{
id: "eye-level",
label: "平视机位",
description: "稳定真实,适合商品主体。",
prompt:
"机位使用平视视角,画面稳定真实,主体呈现清晰完整,适合商业商品展示。",
jsonKey: "camera",
enabled: true,
order: 20,
},
{
id: "close-detail",
label: "近景细节",
description: "突出材质和光影细节。",
prompt:
"机位偏近景细节,突出材质、纹理、边缘光和主体工艺感,同时保留可识别的整体结构。",
jsonKey: "camera",
enabled: true,
order: 30,
},
],
},
],
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function cleanString(value: unknown, fallback = "", maxLength = 4000) {
const text = String(value ?? "").trim();
return (text || fallback).slice(0, maxLength);
}
function cleanId(value: unknown, fallback: string) {
const text = cleanString(value, fallback, 80)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return text || fallback;
}
function cleanNumber(value: unknown, fallback: number, min: number, max: number) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.min(max, Math.max(min, Math.round(numeric)));
}
function sortByOrder<T extends { order: number; id: string }>(items: T[]) {
return [...items].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
}
export function normalizeStyleProConfig(value: unknown): StyleProConfig {
const raw = asRecord(value);
const fallback = defaultStyleProConfig;
const rawCategories = Array.isArray(raw.categories)
? raw.categories
: fallback.categories;
const categories = rawCategories
.map((categoryValue, categoryIndex) => {
const categoryRaw = asRecord(categoryValue);
const id = cleanId(categoryRaw.id, `category-${categoryIndex + 1}`);
const rawOptions = Array.isArray(categoryRaw.options)
? categoryRaw.options
: [];
const maxSelections = cleanNumber(categoryRaw.maxSelections, 1, 1, 20);
const minSelections = cleanNumber(
categoryRaw.minSelections,
categoryRaw.required === false ? 0 : 1,
0,
maxSelections
);
const options = rawOptions
.map((optionValue, optionIndex) => {
const optionRaw = asRecord(optionValue);
const optionId = cleanId(optionRaw.id, `${id}-option-${optionIndex + 1}`);
return {
id: optionId,
label: cleanString(optionRaw.label, `选项 ${optionIndex + 1}`, 80),
description: cleanString(optionRaw.description, "", 240) || undefined,
prompt: cleanString(optionRaw.prompt, "", 4000),
jsonKey: cleanString(optionRaw.jsonKey, "", 80) || undefined,
enabled:
typeof optionRaw.enabled === "boolean" ? optionRaw.enabled : true,
order: cleanNumber(optionRaw.order, (optionIndex + 1) * 10, -9999, 9999),
} satisfies StyleProOption;
})
.filter((option) => option.label && option.prompt);
return {
id,
title: cleanString(categoryRaw.title, `分类 ${categoryIndex + 1}`, 80),
description: cleanString(categoryRaw.description, "", 240) || undefined,
required:
typeof categoryRaw.required === "boolean" ? categoryRaw.required : true,
minSelections,
maxSelections,
enabled:
typeof categoryRaw.enabled === "boolean" ? categoryRaw.enabled : true,
order: cleanNumber(categoryRaw.order, (categoryIndex + 1) * 10, -9999, 9999),
options: sortByOrder(options),
} satisfies StyleProCategory;
})
.filter((category) => category.title && category.options.length > 0);
return {
enabled: typeof raw.enabled === "boolean" ? raw.enabled : fallback.enabled,
modelId: cleanString(raw.modelId, fallback.modelId, 160),
defaultPrompt: cleanString(raw.defaultPrompt, fallback.defaultPrompt, 8000),
negativePrompt: cleanString(raw.negativePrompt, fallback.negativePrompt, 4000),
aspectRatio: cleanString(raw.aspectRatio, fallback.aspectRatio, 32),
imageSize: cleanString(raw.imageSize, fallback.imageSize, 32),
categories: sortByOrder(categories.length ? categories : fallback.categories),
};
}
export function normalizeStyleProPersistedState(
value: unknown
): StyleProPersistedState {
const raw = asRecord(value);
const historyRaw = Array.isArray(raw.history) ? raw.history : [];
const history = historyRaw
.map((historyValue, index) => {
const itemRaw = asRecord(historyValue);
const sourceImageUrl = cleanString(itemRaw.sourceImageUrl, "", 2000);
const resultImageUrl = cleanString(itemRaw.resultImageUrl, "", 2000);
if (!sourceImageUrl || !resultImageUrl) return null;
const selectionsRaw = asRecord(itemRaw.selections);
const selections = Object.fromEntries(
Object.entries(selectionsRaw).map(([key, value]) => [
cleanId(key, key),
Array.isArray(value)
? value.map((entry) => cleanId(entry, "")).filter(Boolean)
: [],
])
);
const normalizedItem: StyleProHistoryItem = {
id: cleanString(itemRaw.id, `style-pro-history-${index + 1}`, 120),
sourceImageUrl,
resultImageUrl,
selections,
optionLabels: Array.isArray(itemRaw.optionLabels)
? itemRaw.optionLabels
.map((entry) => cleanString(entry, "", 80))
.filter(Boolean)
: [],
createdAt:
cleanString(itemRaw.createdAt, "", 80) || new Date(0).toISOString(),
};
const model = cleanString(itemRaw.model, "", 120);
const modelRef = cleanString(itemRaw.modelRef, "", 160);
const width = cleanNumber(itemRaw.width, 0, 0, 20000);
const height = cleanNumber(itemRaw.height, 0, 0, 20000);
if (model) normalizedItem.model = model;
if (modelRef) normalizedItem.modelRef = modelRef;
if (width) normalizedItem.width = width;
if (height) normalizedItem.height = height;
return normalizedItem;
})
.filter((item): item is StyleProHistoryItem => Boolean(item))
.slice(0, 4);
return {
sourceImageUrl: cleanString(raw.sourceImageUrl, "", 2000) || undefined,
history,
};
}
export function toPublicStyleProConfig(
config: StyleProConfig
): PublicStyleProConfig {
return {
enabled: config.enabled,
aspectRatio: config.aspectRatio,
imageSize: config.imageSize,
categories: config.categories
.filter((category) => category.enabled)
.map((category) => ({
...category,
options: category.options
.filter((option) => option.enabled)
.map(({ prompt: _prompt, ...option }) => option),
}))
.filter((category) => category.options.length > 0),
};
}

View File

@ -11,6 +11,10 @@ import type {
Size, Size,
NodeType, NodeType,
} from '@/types/canvas'; } from '@/types/canvas';
import {
normalizeStyleProPersistedState,
type StyleProPersistedState,
} from '@/lib/style-pro/config';
// 生成唯一 ID // 生成唯一 ID
const generateId = () => crypto.randomUUID(); const generateId = () => crypto.randomUUID();
@ -88,6 +92,7 @@ interface CanvasStore {
viewport: Viewport; viewport: Viewport;
tool: ToolType; tool: ToolType;
suppressedMediaKeys: string[]; suppressedMediaKeys: string[];
styleProState: StyleProPersistedState;
// 历史记录(用于撤销/重做) // 历史记录(用于撤销/重做)
history: CanvasState[]; history: CanvasState[];
@ -151,6 +156,7 @@ interface CanvasStore {
// UI 操作 // UI 操作
togglePanel: () => void; togglePanel: () => void;
setGenerating: (isGenerating: boolean) => void; setGenerating: (isGenerating: boolean) => void;
setStyleProState: (styleProState: StyleProPersistedState) => void;
// 批量操作 // 批量操作
loadCanvas: (state: Partial<CanvasState>) => void; loadCanvas: (state: Partial<CanvasState>) => void;
@ -196,6 +202,7 @@ export const useCanvasStore = create<CanvasStore>()(
viewport: defaultViewport, viewport: defaultViewport,
tool: 'select', tool: 'select',
suppressedMediaKeys: [], suppressedMediaKeys: [],
styleProState: { history: [] },
history: [], history: [],
historyIndex: -1, historyIndex: -1,
isPanelOpen: true, isPanelOpen: true,
@ -508,6 +515,8 @@ export const useCanvasStore = create<CanvasStore>()(
selectedNodeIds: [...state.selectedNodeIds], selectedNodeIds: [...state.selectedNodeIds],
viewport: { ...state.viewport }, viewport: { ...state.viewport },
tool: state.tool, tool: state.tool,
suppressedMediaKeys: [...state.suppressedMediaKeys],
styleProState: normalizeStyleProPersistedState(state.styleProState),
}; };
// 移除当前位置之后的历史 // 移除当前位置之后的历史
@ -531,6 +540,10 @@ export const useCanvasStore = create<CanvasStore>()(
state.isGenerating = isGenerating; state.isGenerating = isGenerating;
}), }),
setStyleProState: (styleProState) => set((state) => {
state.styleProState = normalizeStyleProPersistedState(styleProState);
}),
// 批量操作 // 批量操作
loadCanvas: (canvasState) => set((state) => { loadCanvas: (canvasState) => set((state) => {
if (canvasState.nodes) state.nodes = canvasState.nodes; if (canvasState.nodes) state.nodes = canvasState.nodes;
@ -542,6 +555,7 @@ export const useCanvasStore = create<CanvasStore>()(
state.suppressedMediaKeys = Array.isArray(canvasState.suppressedMediaKeys) state.suppressedMediaKeys = Array.isArray(canvasState.suppressedMediaKeys)
? canvasState.suppressedMediaKeys.map((item) => String(item || "").trim()).filter(Boolean) ? canvasState.suppressedMediaKeys.map((item) => String(item || "").trim()).filter(Boolean)
: []; : [];
state.styleProState = normalizeStyleProPersistedState(canvasState.styleProState);
}), }),
clearCanvas: () => set((state) => { clearCanvas: () => set((state) => {
@ -550,6 +564,7 @@ export const useCanvasStore = create<CanvasStore>()(
state.selectedNodeIds = []; state.selectedNodeIds = [];
state.viewport = defaultViewport; state.viewport = defaultViewport;
state.suppressedMediaKeys = []; state.suppressedMediaKeys = [];
state.styleProState = { history: [] };
state.history = []; state.history = [];
state.historyIndex = -1; state.historyIndex = -1;
}), }),
@ -563,6 +578,7 @@ export const useCanvasStore = create<CanvasStore>()(
viewport: state.viewport, viewport: state.viewport,
tool: state.tool, tool: state.tool,
suppressedMediaKeys: state.suppressedMediaKeys, suppressedMediaKeys: state.suppressedMediaKeys,
styleProState: normalizeStyleProPersistedState(state.styleProState),
}; };
}, },

View File

@ -402,6 +402,9 @@ export interface AgentDetailReplacementReferenceItem {
id: string; id: string;
label: string; label: string;
imageUrl?: string | null; imageUrl?: string | null;
previewUrl?: string | null;
width?: number | null;
height?: number | null;
candidateId?: string | null; candidateId?: string | null;
sortOrder?: number; sortOrder?: number;
assignedProductIndex?: number | null; assignedProductIndex?: number | null;

View File

@ -1,5 +1,6 @@
import type { AgentWorkflowPersistedState } from "@/types/agent"; import type { AgentWorkflowPersistedState } from "@/types/agent";
import type { UiScreenDocument } from "@/lib/ui-screen/schema"; import type { UiScreenDocument } from "@/lib/ui-screen/schema";
import type { StyleProPersistedState } from "@/lib/style-pro/config";
export type CanvasWorkspaceType = 'canvas' | 'agent' | 'video'; export type CanvasWorkspaceType = 'canvas' | 'agent' | 'video';
@ -239,6 +240,7 @@ export interface CanvasState {
tool: ToolType; tool: ToolType;
suppressedMediaKeys?: string[]; suppressedMediaKeys?: string[];
workflowState?: AgentWorkflowPersistedState; workflowState?: AgentWorkflowPersistedState;
styleProState?: StyleProPersistedState;
} }
// 画布项目 // 画布项目