Files
ttstd-infinite-canvas/web/src/components/canvas/style-asset-binding-modal.tsx

353 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Button, Input, InputNumber } from "antd";
import { AppModal } from "@/components/ui/product/app-modal";
import { Switch } from "@/components/ui/base/switch";
import { AlertTriangle, Box, Check, FileImage, Link2, Plus, Save, SlidersHorizontal, Trash2 } from "lucide-react";
import { nanoid } from "nanoid";
import { Select } from "@/components/ui/base/select";
import {
createStyleProfileSnapshot,
MAX_STYLE_ASSETS,
styleAssetValidationMessage,
type StyleAssetBinding,
type StyleAssetKind,
type StyleAssetStatus,
type StyleProfileSnapshot,
} from "@/lib/canvas/style-profile";
const kindOptions: Array<{ value: StyleAssetKind; label: string }> = [
{ value: "lora", label: "LoRA" },
{ value: "template", label: "图片模板" },
{ value: "reference", label: "参考图组" },
{ value: "prompt", label: "提示词模块" },
];
const statusOptions: Array<{ value: StyleAssetStatus; label: string }> = [
{ value: "draft", label: "待验证" },
{ value: "validated", label: "已验证" },
{ value: "unavailable", label: "不可用" },
];
const policyOptions: Array<{ value: NonNullable<StyleProfileSnapshot["executionPolicy"]>; label: string }> = [
{ value: "compatible-fallback", label: "兼容降级" },
{ value: "strict-assets", label: "严格阻止" },
];
const emptyAsset = (): StyleAssetBinding => ({
id: nanoid(),
kind: "lora",
provider: "liblib",
title: "",
enabled: false,
weight: 0.8,
status: "draft",
});
type StyleAssetBindingModalProps = {
open: boolean;
profile: StyleProfileSnapshot | null;
onClose: () => void;
onApply: (profile: StyleProfileSnapshot) => void;
};
export function StyleAssetBindingModal({ open, profile, onClose, onApply }: StyleAssetBindingModalProps) {
const [draft, setDraft] = useState<StyleProfileSnapshot | null>(profile);
const [editingId, setEditingId] = useState("");
useEffect(() => {
if (!open) return;
setDraft(profile ? createStyleProfileSnapshot(profile) : null);
setEditingId(profile?.assets[0]?.id || "");
}, [open, profile]);
const editing = useMemo(() => draft?.assets.find((asset) => asset.id === editingId), [draft, editingId]);
const issues = useMemo(() => draft?.assets.map((asset) => ({ id: asset.id, message: styleAssetValidationMessage(asset) })).filter((issue) => issue.message) || [], [draft]);
if (!draft) return null;
const updateAsset = (patch: Partial<StyleAssetBinding>) => {
setDraft((current) => current ? { ...current, assets: current.assets.map((asset) => asset.id === editingId ? { ...asset, ...patch } : asset) } : current);
};
const addAsset = () => {
const asset = emptyAsset();
setDraft((current) => current ? { ...current, assets: [...current.assets, asset] } : current);
setEditingId(asset.id);
};
const deleteAsset = (id: string) => {
setDraft((current) => current ? { ...current, assets: current.assets.filter((asset) => asset.id !== id) } : current);
setEditingId((current) => current === id ? draft.assets.find((asset) => asset.id !== id)?.id || "" : current);
};
return (
<AppModal
rootClassName="style-asset-binding-modal"
open={open}
title={null}
footer={null}
centered
width="min(980px, calc(100vw - 24px))"
onCancel={onClose}
flush
>
<div className="flex max-h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground">
<header className="flex items-start justify-between gap-3 border-b border-border px-4 py-3 pr-12 sm:items-center sm:px-5">
<div className="min-w-0">
<h2 className="text-sm font-semibold">风格执行资产</h2>
<p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">绑定模型资产、兼容范围、执行参数和许可快照</p>
</div>
<Button className="shrink-0" icon={<Plus className="size-3.5" />} disabled={draft.assets.length >= MAX_STYLE_ASSETS} title={draft.assets.length >= MAX_STYLE_ASSETS ? `最多绑定 ${MAX_STYLE_ASSETS} 个资产` : undefined} onClick={addAsset}>添加资产</Button>
</header>
<div className="grid min-h-0 flex-1 md:grid-cols-3">
<aside className="thin-scrollbar max-h-52 min-h-0 overflow-y-auto border-b border-border md:col-span-1 md:max-h-none md:border-b-0 md:border-r">
<div className="sticky top-0 z-10 border-b border-border bg-background px-4 py-3">
<label className="flex items-center justify-between gap-3 text-xs">
<span className="min-w-0">
<span className="block font-medium">执行策略</span>
<span className="mt-0.5 block text-[var(--fs-tiny)] text-foreground/42">不兼容、未验证资产如何处理</span>
</span>
<Select<NonNullable<StyleProfileSnapshot["executionPolicy"]>>
className="shrink-0"
size="small"
value={draft.executionPolicy || "compatible-fallback"}
options={policyOptions}
onChange={(executionPolicy) => setDraft({ ...draft, executionPolicy })}
/>
</label>
</div>
{draft.assets.length ? (
<div className="divide-y divide-border">
{draft.assets.map((asset) => {
const issue = styleAssetValidationMessage(asset);
return (
<button
key={asset.id}
type="button"
className={`flex w-full items-start gap-3 px-4 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset ${editingId === asset.id ? "bg-foreground/10" : "hover:bg-foreground/5"}`}
onClick={() => setEditingId(asset.id)}
>
<AssetKindIcon kind={asset.kind} />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium">{asset.title || "未命名资产"}</span>
<span className="mt-1 block truncate text-[var(--fs-tiny)] text-foreground/42">{assetKindLabel(asset.kind)} · {assetStatusLabel(asset.status)}</span>
</span>
<span className={`mt-1 size-1.5 shrink-0 rounded-full ${issue ? "bg-red-500" : asset.enabled ? asset.status === "validated" ? "bg-emerald-500" : "bg-amber-500" : "bg-foreground/20"}`} />
</button>
);
})}
</div>
) : (
<EmptyAssetList />
)}
</aside>
<main className="thin-scrollbar min-h-0 overflow-y-auto p-4 sm:p-5 md:col-span-2">
{editing ? (
<AssetEditor asset={editing} onChange={updateAsset} onDelete={() => deleteAsset(editing.id)} />
) : (
<div className="grid min-h-72 place-items-center text-center">
<div><Plus className="mx-auto size-5 text-foreground/25" /><p className="mt-2 text-xs text-foreground/50">添加或选择一个执行资产</p></div>
</div>
)}
</main>
</div>
<footer className="flex flex-col gap-3 border-t border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex min-w-0 items-start gap-2 text-[var(--fs-tiny)] text-foreground/45">
{issues.length ? <AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-red-500" /> : <Check className="mt-0.5 size-3.5 shrink-0" />}
<span className="min-w-0 leading-5">{issues.length ? `${issues.length} 个资产需要处理:${issues[0].message}` : `${draft.assets.length} 个资产,${draft.assets.filter((asset) => asset.enabled).length} 个已启用`}</span>
</div>
<div className="flex shrink-0 justify-end gap-2">
<Button onClick={onClose}>取消</Button>
<Button
type="primary"
icon={<Save className="size-3.5" />}
disabled={issues.length > 0}
onClick={() => onApply(createStyleProfileSnapshot({ ...draft, source: "user", revision: draft.revision + 1 }))}
>
应用配置
</Button>
</div>
</footer>
</div>
</AppModal>
);
}
function AssetEditor({ asset, onChange, onDelete }: { asset: StyleAssetBinding; onChange: (patch: Partial<StyleAssetBinding>) => void; onDelete: () => void }) {
const validationMessage = styleAssetValidationMessage(asset);
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-sm font-semibold">资产配置</h3>
<p className="mt-1 text-[var(--fs-label)] text-foreground/45">已验证且启用的资产才会进入执行计划</p>
</div>
<Button danger type="text" icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
</div>
{validationMessage ? (
<div className="flex gap-2 border-l-2 border-red-500 bg-red-500/5 px-3 py-2 text-[var(--fs-label)] leading-5 text-foreground/65">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-red-500" />
<span>{validationMessage}</span>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field label="资产类型">
<Select<StyleAssetKind> value={asset.kind} options={kindOptions} onChange={(kind) => onChange({ kind })} />
</Field>
<Field label="启用状态">
<div className="flex h-8 items-center justify-between border-b border-border">
<span className="text-xs text-foreground/55">参与生成执行计划</span>
<Switch size="sm" checked={asset.enabled !== false} onChange={(enabled) => onChange({ enabled })} />
</div>
</Field>
<Field label="资产名称">
<Input value={asset.title} placeholder="例如:东方玄幻武侠修仙" onChange={(event) => onChange({ title: event.target.value })} />
</Field>
<Field label="验证状态">
<Select<StyleAssetStatus> value={asset.status} options={statusOptions} onChange={(status) => onChange({ status })} />
</Field>
<Field label="来源平台 / 适配器">
<Input value={asset.provider} placeholder="liblib、comfyui 或自定义适配器" onChange={(event) => onChange({ provider: event.target.value })} />
</Field>
<Field label="来源资产 ID">
<Input value={asset.sourceId} placeholder="模型 UUID 或模板 ID" onChange={(event) => onChange({ sourceId: event.target.value })} />
</Field>
<Field label="来源页面 URL" className="sm:col-span-2">
<Input value={asset.sourceUrl} placeholder="记录资产出处,不会自动向该地址发送生成请求" onChange={(event) => onChange({ sourceUrl: event.target.value })} />
</Field>
<Field label="资产模型标识">
<Input value={asset.model} placeholder="例如 Liblib 模型名称" onChange={(event) => onChange({ model: event.target.value })} />
</Field>
<Field label="模型版本">
<Input value={asset.version} placeholder="版本 UUID 或版本号" onChange={(event) => onChange({ version: event.target.value })} />
</Field>
<Field label="兼容基础模型" className="sm:col-span-2">
<Select<string[]> mode="tags" value={asset.baseModels || []} tokenSeparators={[","]} placeholder="填写实际生成模型名,输入后回车" onChange={(baseModels) => onChange({ baseModels })} />
</Field>
</div>
{asset.kind === "lora" ? <LoraFields asset={asset} onChange={onChange} /> : null}
{asset.kind === "reference" ? <ReferenceFields asset={asset} onChange={onChange} /> : null}
{asset.kind === "prompt" || asset.kind === "template" ? <PromptFields asset={asset} onChange={onChange} /> : null}
<GenerationParameterFields asset={asset} onChange={onChange} />
<LicenseFields asset={asset} onChange={onChange} />
{asset.enabled !== false && (asset.kind === "lora" || asset.kind === "reference") ? (
<div className="flex gap-2 border-l-2 border-amber-500 bg-amber-500/5 px-3 py-2 text-[var(--fs-label)] leading-5 text-foreground/60">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
<span>当前通用生成协议尚未启用{asset.kind === "lora" ? " LoRA" : "参考图组自动注入"}适配器。兼容降级会继续执行项目 Prompt;严格策略会在生成前阻止任务。</span>
</div>
) : null}
</div>
);
}
function LoraFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="grid gap-4 border-t border-border pt-4 sm:grid-cols-2">
<Field label="LoRA 权重">
<InputNumber className="w-full" min={0} max={2} step={0.05} value={asset.weight} onChange={(weight) => onChange({ weight: weight ?? undefined })} />
</Field>
<Field label="触发词">
<Select<string[]> mode="tags" value={asset.triggerWords || []} tokenSeparators={[","]} placeholder="输入后回车" onChange={(triggerWords) => onChange({ triggerWords })} />
</Field>
</section>
);
}
function ReferenceFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="grid gap-4 border-t border-border pt-4">
<Field label="参考图 URL">
<Select<string[]> mode="tags" value={asset.referenceUrls || []} tokenSeparators={[","]} placeholder="输入可访问的参考图地址后回车" onChange={(referenceUrls) => onChange({ referenceUrls })} />
</Field>
<Field label="项目资源 ID">
<Select<string[]> mode="tags" value={asset.referenceResourceIds || []} tokenSeparators={[","]} placeholder="输入已上传的 resource ID 后回车" onChange={(referenceResourceIds) => onChange({ referenceResourceIds })} />
</Field>
</section>
);
}
function PromptFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="border-t border-border pt-4">
<Field label="提示词模块">
<Input.TextArea autoSize={{ minRows: 4, maxRows: 8 }} value={asset.promptFragment} placeholder="只填写该资产需要追加的稳定视觉要求" onChange={(event) => onChange({ promptFragment: event.target.value })} />
</Field>
</section>
);
}
function GenerationParameterFields({ asset, onChange }: AssetFieldProps) {
const parameters = asset.parameters || {};
const updateParameters = (patch: NonNullable<StyleAssetBinding["parameters"]>) => onChange({ parameters: { ...parameters, ...patch } });
return (
<section className="border-t border-border pt-4">
<h4 className="mb-3 text-xs font-semibold">建议生成参数</h4>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label="Sampler"><Input value={parameters.sampler} placeholder="可选" onChange={(event) => updateParameters({ sampler: event.target.value })} /></Field>
<Field label="Steps"><InputNumber className="w-full" min={1} max={200} value={parameters.steps} onChange={(steps) => updateParameters({ steps: steps ?? undefined })} /></Field>
<Field label="CFG"><InputNumber className="w-full" min={0} max={50} step={0.5} value={parameters.cfg} onChange={(cfg) => updateParameters({ cfg: cfg ?? undefined })} /></Field>
<Field label="推荐尺寸"><Input value={parameters.size} placeholder="例如 1024x1536" onChange={(event) => updateParameters({ size: event.target.value })} /></Field>
</div>
</section>
);
}
function LicenseFields({ asset, onChange }: AssetFieldProps) {
return (
<section className="border-t border-border pt-4">
<h4 className="mb-3 text-xs font-semibold">许可快照</h4>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="商业使用">
<Select
value={asset.license?.commercial === true ? "yes" : asset.license?.commercial === false ? "no" : "unknown"}
options={[{ value: "unknown", label: "尚未确认" }, { value: "yes", label: "允许商用" }, { value: "no", label: "不可商用" }]}
onChange={(value: "unknown" | "yes" | "no") => onChange({ license: { ...asset.license, commercial: value === "unknown" ? undefined : value === "yes" } })}
/>
</Field>
<Field label="许可来源"><Input value={asset.license?.source} placeholder="许可页面或协议版本" onChange={(event) => onChange({ license: { ...asset.license, source: event.target.value } })} /></Field>
<Field label="许可说明" className="sm:col-span-2"><Input value={asset.license?.note} placeholder="记录会员限制、署名或转售限制" onChange={(event) => onChange({ license: { ...asset.license, note: event.target.value } })} /></Field>
</div>
</section>
);
}
type AssetFieldProps = {
asset: StyleAssetBinding;
onChange: (patch: Partial<StyleAssetBinding>) => void;
};
function AssetKindIcon({ kind }: { kind: StyleAssetKind }) {
return (
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded bg-foreground/5 text-foreground/55">
{kind === "lora" ? <SlidersHorizontal className="size-3.5" /> : kind === "reference" ? <FileImage className="size-3.5" /> : kind === "template" ? <Box className="size-3.5" /> : <Link2 className="size-3.5" />}
</span>
);
}
function EmptyAssetList() {
return (
<div className="grid min-h-52 place-items-center px-6 text-center">
<div><Box className="mx-auto size-5 text-foreground/25" /><p className="mt-2 text-xs font-medium">尚未绑定执行资产</p><p className="mt-1 text-[var(--fs-tiny)] leading-5 text-foreground/42">当前画风只通过项目 Prompt 执行</p></div>
</div>
);
}
function assetKindLabel(kind: StyleAssetKind) {
return kindOptions.find((item) => item.value === kind)?.label || kind;
}
function assetStatusLabel(status: StyleAssetStatus) {
return statusOptions.find((item) => item.value === status)?.label || status;
}
function Field({ label, className = "", children }: { label: string; className?: string; children: ReactNode }) {
return <label className={`grid gap-1.5 text-xs ${className}`}><span className="font-medium text-foreground/60">{label}</span>{children}</label>;
}