feat: add style pro canvas workspace
This commit is contained in:
parent
e51de15aac
commit
5387d6f1d2
@ -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>
|
||||||
|
|||||||
16
app/(admin)/settings/style-pro/page.tsx
Normal file
16
app/(admin)/settings/style-pro/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
|
|
||||||
651
app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Normal file
651
app/(admin)/settings/style-pro/style-pro-settings-client.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
30
app/api/style-pro/config/route.ts
Normal file
30
app/api/style-pro/config/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
238
app/api/style-pro/generate/route.ts
Normal file
238
app/api/style-pro/generate/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
128
app/canvas/[id]/style-pro/page.tsx
Normal file
128
app/canvas/[id]/style-pro/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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" />
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
? "先选择当前步骤的主卡片"
|
? "先选择当前步骤的主卡片"
|
||||||
: "当前层级只展示这一层的子节点卡片,选完再进入下一层"}
|
: "当前层级只展示这一层的子节点卡片,选完再进入下一层"}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
886
components/style-pro/StyleProWorkspace.tsx
Normal file
886
components/style-pro/StyleProWorkspace.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
docs/更新日志-2026-05-07至2026-05-12.md
Normal file
73
docs/更新日志-2026-05-07至2026-05-12.md
Normal 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 提交记录整理,已按用户可感知体验做口径归纳。
|
||||||
237
docs/更新日志-2026-05-10至2026-05-14.md
Normal file
237
docs/更新日志-2026-05-10至2026-05-14.md
Normal 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`。
|
||||||
|
- 当前工作区存在构建缓存/未跟踪文档类文件时,不影响本日志对已提交历史的统计。
|
||||||
@ -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))))
|
||||||
|
|||||||
@ -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
417
lib/style-pro/config.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 画布项目
|
// 画布项目
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user