feat(*): 分镜创作体系 - 重构画风、提示词模板与视频生成链路
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Form, Input, InputNumber, Popconfirm, Select, Tag, Tooltip } from "antd";
|
||||
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, Info, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, Info, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
@@ -26,13 +26,15 @@ import {
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ChannelModelSettings } from "./channel-video-pricing";
|
||||
import { ModelDefaultGrid } from "./model-default-grid";
|
||||
import { PromptPreferencesPane } from "./prompt-preferences-pane";
|
||||
|
||||
type ConfigSectionKey = "channels" | "models" | "preferences" | "storage";
|
||||
type ConfigSectionKey = "channels" | "models" | "preferences" | "prompts" | "storage";
|
||||
|
||||
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
|
||||
{ key: "channels", label: "自定义渠道", description: "连接你自己的模型服务", icon: <RadioTower className="size-4" /> },
|
||||
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
|
||||
{ key: "preferences", label: "生成偏好", description: "画布、音频与系统提示词", icon: <SlidersHorizontal className="size-4" /> },
|
||||
{ key: "preferences", label: "生成偏好", description: "画布、视频与音频默认值", icon: <SlidersHorizontal className="size-4" /> },
|
||||
{ key: "prompts", label: "提示词偏好", description: "按任务定制平台模板", icon: <MessageSquareText className="size-4" /> },
|
||||
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
];
|
||||
|
||||
@@ -279,7 +281,7 @@ export default function SettingsPage() {
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 md:px-6 md:py-5">
|
||||
<div className="mx-auto w-full max-w-[1180px]">
|
||||
<div className={activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-[1180px]"}>
|
||||
{([
|
||||
{
|
||||
key: "channels",
|
||||
@@ -489,20 +491,22 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
|
||||
<section className="pt-6">
|
||||
<div className="mb-4"><h3 className="text-sm font-semibold">默认指令</h3><p className="mt-1 text-xs text-foreground/55">在未单独填写时附加到对应生成请求。</p></div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Form.Item label="音频指令" className="mb-0">
|
||||
<div className="mb-4"><h3 className="text-sm font-semibold">音频指令</h3><p className="mt-1 text-xs text-foreground/55">在音频节点没有单独填写时使用。</p></div>
|
||||
<div className="max-w-2xl">
|
||||
<Form.Item label="默认音频指令" className="mb-0">
|
||||
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统提示词" className="mb-0">
|
||||
<Input.TextArea rows={5} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
</Form>
|
||||
</SettingsPane>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prompts",
|
||||
label: "提示词偏好",
|
||||
children: <SettingsPane fill><PromptPreferencesPane /></SettingsPane>,
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
label: (
|
||||
@@ -526,8 +530,8 @@ export default function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPane({ children }: { children: ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
function SettingsPane({ children, fill = false }: { children: ReactNode; fill?: boolean }) {
|
||||
return <div className={fill ? "h-full" : undefined}>{children}</div>;
|
||||
}
|
||||
|
||||
function ChannelStatus({ channel }: { channel: ModelChannel }) {
|
||||
|
||||
226
web/src/pages/settings/prompt-preferences-pane.tsx
Normal file
226
web/src/pages/settings/prompt-preferences-pane.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Alert, App, Button, Input, Segmented, Select, Skeleton, Tabs, Tag } from "antd";
|
||||
import { RotateCcw, Save, ShieldCheck, Undo2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PromptCodeEditor } from "@/components/prompt/prompt-code-editor";
|
||||
import {
|
||||
listUserPromptPreferences,
|
||||
resetUserPromptCustomization,
|
||||
updateUserPromptCustomization,
|
||||
type UserPromptCustomization,
|
||||
type UserPromptPreference,
|
||||
} from "@/services/api/auth";
|
||||
|
||||
type CustomizationMode = UserPromptCustomization["mode"];
|
||||
|
||||
const modeOptions = [
|
||||
{ label: "跟随平台", value: "inherit" },
|
||||
{ label: "追加要求", value: "append" },
|
||||
{ label: "高级改写", value: "rewrite" },
|
||||
];
|
||||
|
||||
export function PromptPreferencesPane() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [preferences, setPreferences] = useState<UserPromptPreference[]>([]);
|
||||
const [selectedOperation, setSelectedOperation] = useState("");
|
||||
const [mode, setMode] = useState<CustomizationMode>("inherit");
|
||||
const [appendContent, setAppendContent] = useState("");
|
||||
const [rewriteContent, setRewriteContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const reload = async (preferredOperation?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listUserPromptPreferences();
|
||||
setPreferences(result.preferences);
|
||||
setSelectedOperation((current) => preferredOperation || current || result.preferences[0]?.definition.operation || "");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取提示词偏好失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void reload(); }, []);
|
||||
|
||||
const selected = useMemo(() => preferences.find((item) => item.definition.operation === selectedOperation), [preferences, selectedOperation]);
|
||||
const savedMode = selected?.customization?.mode || "inherit";
|
||||
const savedAppendContent = savedMode === "append" ? selected?.customization?.content || "" : "";
|
||||
const savedRewriteContent = savedMode === "rewrite" ? selected?.customization?.content || "" : selected?.template?.content || "";
|
||||
const activeContent = mode === "append" ? appendContent : mode === "rewrite" ? rewriteContent : "";
|
||||
const savedActiveContent = savedMode === "append" ? savedAppendContent : savedMode === "rewrite" ? savedRewriteContent : "";
|
||||
const dirty = mode !== savedMode || activeContent !== savedActiveContent;
|
||||
|
||||
const restoreDraft = (preference = selected) => {
|
||||
const customization = preference?.customization;
|
||||
setMode(customization?.mode || "inherit");
|
||||
setAppendContent(customization?.mode === "append" ? customization.content : "");
|
||||
setRewriteContent(customization?.mode === "rewrite" ? customization.content : preference?.template?.content || "");
|
||||
};
|
||||
|
||||
useEffect(() => { restoreDraft(selected); }, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const preventUnload = (event: BeforeUnloadEvent) => event.preventDefault();
|
||||
window.addEventListener("beforeunload", preventUnload);
|
||||
return () => window.removeEventListener("beforeunload", preventUnload);
|
||||
}, [dirty]);
|
||||
|
||||
const selectOperation = (operation: string) => {
|
||||
if (operation === selectedOperation) return;
|
||||
if (!dirty) {
|
||||
setSelectedOperation(operation);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "切换模板并放弃修改?",
|
||||
content: "当前模板还有未保存内容。切换后这些修改将丢失。",
|
||||
okText: "放弃并切换",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => setSelectedOperation(operation),
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
const content = mode === "append" ? appendContent : mode === "rewrite" ? rewriteContent : "";
|
||||
if (mode !== "inherit" && !content.trim()) {
|
||||
message.warning("请填写个人提示词内容");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateUserPromptCustomization(selected.definition.operation, { mode, content });
|
||||
await reload(selected.definition.operation);
|
||||
message.success("提示词偏好已保存");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存提示词偏好失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (!selected) return;
|
||||
modal.confirm({
|
||||
title: "恢复平台模板?",
|
||||
content: `将删除“${selected.definition.label}”的个人定制,后续自动跟随平台版本。`,
|
||||
okText: "恢复平台模板",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await resetUserPromptCustomization(selected.definition.operation);
|
||||
await reload(selected.definition.operation);
|
||||
message.success("已恢复平台模板");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "恢复平台模板失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading && preferences.length === 0) return <Skeleton active paragraph={{ rows: 10 }} />;
|
||||
if (!selected) return <div className="py-16 text-center text-sm text-foreground/50">暂无可配置的提示词模板</div>;
|
||||
|
||||
const templateContent = selected.template?.content || "当前没有启用的平台模板";
|
||||
const previewCreative = mode === "inherit" ? templateContent : mode === "append" ? `${templateContent}\n\n【用户个性化创作要求】\n${appendContent}` : rewriteContent;
|
||||
const outputLabel = selected.definition.outputType === "json" ? selected.definition.schemaKey || "JSON" : "文本";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="shrink-0 border-b border-border pb-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-foreground/55" htmlFor="prompt-template-select">提示词模板</label>
|
||||
<Select
|
||||
id="prompt-template-select"
|
||||
className="w-full max-w-md"
|
||||
value={selectedOperation}
|
||||
onChange={selectOperation}
|
||||
options={preferences.map((item) => ({
|
||||
value: item.definition.operation,
|
||||
label: `${item.definition.category} · ${item.definition.label}${item.customization && item.customization.mode !== "inherit" ? " · 已定制" : ""}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button icon={<Undo2 className="size-4" />} disabled={!dirty || saving} onClick={() => restoreDraft()}>撤销修改</Button>
|
||||
<Button icon={<RotateCcw className="size-4" />} disabled={!selected.customization || saving} onClick={reset}>恢复平台</Button>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty} onClick={() => void save()}>保存更改</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold">{selected.definition.label}</h2>
|
||||
<Tag variant="filled">平台 v{selected.template?.version || "--"}</Tag>
|
||||
<Tag variant="filled">{outputLabel}</Tag>
|
||||
{dirty ? <Tag variant="filled" color="warning">未保存</Tag> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">{selected.definition.description}</p>
|
||||
</div>
|
||||
<Segmented value={mode} options={modeOptions} onChange={(value) => setMode(value as CustomizationMode)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{selected.outdated ? <Alert className="mt-4" type="warning" showIcon title="平台模板已更新" description="当前高级改写基于旧版本。可以保留现有改写,或恢复平台后再基于新版本调整。" /> : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 pt-4 lg:grid-cols-3">
|
||||
<section className="flex min-h-0 flex-col lg:col-span-2">
|
||||
<div className="mb-3 shrink-0">
|
||||
<h3 className="text-sm font-semibold">{mode === "inherit" ? "当前平台模板" : mode === "append" ? "追加个人要求" : "改写创作策略"}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/50">
|
||||
{mode === "inherit" ? "平台升级后自动使用新版本。" : mode === "append" ? "内容追加在平台策略之后,仍会自动继承平台升级。" : "只替换创作策略;动态项目数据和输出契约仍由服务端强制注入。"}
|
||||
</p>
|
||||
</div>
|
||||
{mode === "append" ? (
|
||||
<Input.TextArea
|
||||
className="min-h-96 resize-none"
|
||||
value={appendContent}
|
||||
maxLength={12000}
|
||||
showCount
|
||||
placeholder="例如:仙侠项目采用明亮、宏大、高清的休闲剧质感;避免阴森恐怖色调,人物表演自然、轻松。"
|
||||
onChange={(event) => setAppendContent(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-96 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<PromptCodeEditor
|
||||
value={mode === "inherit" ? templateContent : rewriteContent}
|
||||
readOnly={mode === "inherit"}
|
||||
ariaLabel={mode === "inherit" ? "平台提示词模板" : "个人提示词改写"}
|
||||
onChange={mode === "rewrite" ? setRewriteContent : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="min-h-0 border-t border-border pt-4 lg:border-l lg:border-t-0 lg:pl-4 lg:pt-0">
|
||||
<Tabs
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "baseline",
|
||||
label: "平台基线",
|
||||
children: <pre className="thin-scrollbar max-h-96 overflow-auto whitespace-pre-wrap text-xs leading-6 text-foreground/65">{templateContent}</pre>,
|
||||
},
|
||||
{
|
||||
key: "contract",
|
||||
label: "输出契约",
|
||||
children: <div><div className="mb-3 flex items-center gap-2 text-xs font-medium"><ShieldCheck className="size-4" />服务端只读</div><pre className="thin-scrollbar max-h-96 overflow-auto whitespace-pre-wrap text-xs leading-6 text-foreground/65">{selected.definition.outputContract}</pre></div>,
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "最终结构",
|
||||
children: <div className="space-y-4 text-xs leading-6"><section><div className="mb-2 font-medium text-foreground/80">创作策略</div><pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap text-foreground/65">{previewCreative || "尚未填写"}</pre></section><section className="border-t border-border pt-4"><div className="mb-2 font-medium text-foreground/80">运行时强制追加</div><p className="text-foreground/55">当前剧情、项目画风、当前角色版本、画布资产与受保护输出契约。</p></section></div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user