feat(platform): 多模态创作 - 新增统一创作页、历史会话与视频参数控制
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { Infinity as InfinityIcon } from "lucide-react";
|
||||
import { Fragment, useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
|
||||
import { WORKSPACE_SIDEBAR_STORAGE_KEY } from "@/components/layout/workspace-sidebar-state";
|
||||
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,18 +12,13 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem(WORKSPACE_SIDEBAR_STORAGE_KEY) === "1");
|
||||
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
|
||||
const spatialWorkbench = isSpatialWorkbenchPath(pathname);
|
||||
const compactSidebar = sidebarCollapsed;
|
||||
const creationWorkspace = pathname === "/create";
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
const visibleNavigationTools = spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间");
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(WORKSPACE_SIDEBAR_STORAGE_KEY, sidebarCollapsed ? "1" : "0");
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWorkspaceNavigation = (rawEvent: Event) => {
|
||||
const event = rawEvent as CustomEvent<{ to?: string }>;
|
||||
@@ -38,34 +32,21 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full overflow-hidden", spatialWorkbench && "is-spatial")}>
|
||||
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
|
||||
{spatialWorkbench && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
|
||||
{!hideChrome ? (
|
||||
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-[52px]", compactSidebar ? "lg:w-[60px]" : "lg:w-[220px]")}>
|
||||
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden", mobileSidebarExpanded ? "is-mobile-expanded w-[196px]" : "w-[52px]", "lg:w-[88px]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center border-b border-border/55 text-foreground",
|
||||
mobileSidebarExpanded ? "gap-2 px-3" : "justify-center",
|
||||
compactSidebar ? "lg:justify-center lg:px-0" : "lg:justify-start lg:gap-2 lg:px-3",
|
||||
"lg:justify-center lg:px-0",
|
||||
)}
|
||||
>
|
||||
<Link to="/" className={cn("min-w-0 items-center gap-2", mobileSidebarExpanded ? "flex" : "hidden", compactSidebar ? "lg:hidden" : "lg:flex")} title="影策">
|
||||
<Link to="/" className={cn("min-w-0 items-center gap-2", mobileSidebarExpanded || spatialWorkbench ? "flex" : "hidden", "lg:flex")} title="影策">
|
||||
<span className="app-workspace-brand-mark grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
|
||||
{spatialWorkbench ? <span className="min-w-0"><span className="block truncate text-[13px] font-semibold">影策</span><span className="block truncate text-[9px] text-foreground/36">AI 叙事工作台</span></span> : <span className="truncate text-[13px] font-semibold">影策</span>}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={cn("app-workspace-icon-button shrink-0", mobileSidebarExpanded ? "ml-auto" : !compactSidebar ? "lg:ml-auto" : undefined)}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) setMobileSidebarExpanded((value) => !value);
|
||||
else setSidebarCollapsed((value) => !value);
|
||||
}}
|
||||
aria-label="切换侧栏"
|
||||
title="切换侧栏"
|
||||
>
|
||||
{mobileSidebarExpanded ? <PanelLeftClose className="size-4 lg:hidden" /> : <PanelLeftOpen className="size-4 lg:hidden" />}
|
||||
{compactSidebar ? <PanelLeftOpen className="hidden size-4 lg:block" /> : <PanelLeftClose className="hidden size-4 lg:block" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
|
||||
@@ -75,7 +56,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const showSection = index === 0 || tool.section !== visibleNavigationTools[index - 1]?.section;
|
||||
return (
|
||||
<Fragment key={tool.slug}>
|
||||
{showSection ? <div className={cn("mb-2 px-2 text-[10px] font-medium text-foreground/34", index > 0 && "mt-4", mobileSidebarExpanded ? "block" : "hidden", compactSidebar ? "lg:hidden" : "lg:block")}>{tool.section}</div> : null}
|
||||
{showSection ? <div className={cn("mb-2 px-2 text-[10px] font-medium text-foreground/34", index > 0 && "mt-4", mobileSidebarExpanded ? "block" : "hidden", "lg:hidden")}>{tool.section}</div> : null}
|
||||
<Link
|
||||
to={`/${tool.slug}`}
|
||||
title={tool.label}
|
||||
@@ -86,12 +67,12 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
"app-workspace-nav-link relative mb-1 flex shrink-0 items-center rounded-md text-[13px] transition-colors",
|
||||
spatialWorkbench ? "h-11" : "h-9",
|
||||
mobileSidebarExpanded ? "gap-3 px-2.5" : "justify-center px-0",
|
||||
compactSidebar ? "lg:justify-center lg:px-0" : "lg:justify-start lg:gap-3 lg:px-2.5",
|
||||
"lg:justify-center lg:px-0",
|
||||
active ? "is-active font-medium" : "text-foreground/55 hover:bg-foreground/[0.045] hover:text-foreground/85",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className={cn("truncate", mobileSidebarExpanded ? "inline" : "hidden", compactSidebar ? "lg:hidden" : "lg:inline")}>{tool.label}</span>
|
||||
<span className={cn("truncate", mobileSidebarExpanded ? "inline" : "hidden", "lg:inline")}>{tool.label}</span>
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
@@ -101,12 +82,12 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
<WorkspaceSidebarFooter
|
||||
collapsedClassName={cn(
|
||||
mobileSidebarExpanded ? "justify-start gap-2 px-2" : "justify-center gap-0 px-0",
|
||||
compactSidebar ? "lg:justify-center lg:gap-0 lg:px-0" : "lg:justify-start lg:gap-2 lg:px-2",
|
||||
"lg:justify-center lg:gap-0 lg:px-0",
|
||||
)}
|
||||
expandedClassName={cn(mobileSidebarExpanded ? "flex" : "hidden", compactSidebar ? "lg:hidden" : "lg:flex")}
|
||||
expandedClassName={cn(mobileSidebarExpanded ? "flex" : "hidden", "lg:hidden")}
|
||||
accountClassName={cn(
|
||||
mobileSidebarExpanded ? "flex-row gap-2 px-2" : "flex-col gap-0.5 px-0 py-1",
|
||||
compactSidebar ? "lg:flex-col lg:gap-0.5 lg:px-0 lg:py-1" : "lg:flex-row lg:gap-2 lg:px-2 lg:py-0",
|
||||
"lg:flex-col lg:gap-0.5 lg:px-0 lg:py-1",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -17,9 +17,10 @@ type ModelPickerProps = {
|
||||
placeholder?: string;
|
||||
onMissingConfig?: () => void;
|
||||
showSelectedPrice?: boolean;
|
||||
variant?: "default" | "creation";
|
||||
};
|
||||
|
||||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true }: ModelPickerProps) {
|
||||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) {
|
||||
const pickerId = useId();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -44,6 +45,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
}, [config, options]);
|
||||
const current = value || "";
|
||||
const currentPrice = modelMenuPrice(config, current);
|
||||
const creationVariant = variant === "creation";
|
||||
|
||||
useEffect(() => {
|
||||
const closeOtherPicker = (event: Event) => {
|
||||
@@ -90,7 +92,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-canvas-no-zoom
|
||||
className="canvas-model-picker-menu w-[320px] max-w-[calc(100vw-24px)]"
|
||||
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[320px]")}
|
||||
style={{ background: theme.node.panel, color: theme.node.text }}
|
||||
role="listbox"
|
||||
aria-label={placeholder}
|
||||
@@ -98,6 +100,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{creationVariant ? <div className="creation-model-picker-heading"><span>选择模型</span>{current ? <strong>{modelDisplayName(config, current)}</strong> : null}</div> : null}
|
||||
{optionGroups.length ? optionGroups.map((group) => (
|
||||
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
|
||||
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
|
||||
@@ -121,7 +124,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} />
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} />
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
@@ -145,7 +148,11 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
placement="bottomLeft"
|
||||
arrow={false}
|
||||
content={content}
|
||||
classNames={{ root: "canvas-model-picker-popover", container: "canvas-composer-popover-surface", content: "canvas-composer-popover-content" }}
|
||||
classNames={{
|
||||
root: cn("canvas-model-picker-popover", creationVariant && "creation-model-picker-popover"),
|
||||
container: cn("canvas-composer-popover-surface", creationVariant && "creation-model-picker-surface"),
|
||||
content: "canvas-composer-popover-content",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
@@ -159,7 +166,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
>
|
||||
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
|
||||
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}><ModelIcon model={current} /></span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? modelOptionLabel(config, current) : placeholder}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current) : placeholder}</span>
|
||||
{showSelectedPrice ? <ModelPrice price={currentPrice} compact /> : null}
|
||||
</span>
|
||||
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
|
||||
@@ -175,7 +182,7 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
}
|
||||
|
||||
function ModelLabel({ config, model, capability, theme }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
|
||||
function ModelLabel({ config, model, capability, theme, creationVariant }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean }) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
return (
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||||
@@ -184,10 +191,10 @@ function ModelLabel({ config, model, capability, theme }: { config: AiConfig; mo
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[11px] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }} title={`${modelOptionName(model)} · ${meta.description}`}>{modelOptionName(model)} · {meta.description}</span>
|
||||
<span className="mt-1 block truncate text-[10px]" style={{ color: theme.node.muted }} title={`${modelOptionName(model)} · ${meta.description}`}>{creationVariant ? meta.description : `${modelOptionName(model)} · ${meta.description}`}</span>
|
||||
</span>
|
||||
<ModelPrice price={modelMenuPrice(config, model)} />
|
||||
{meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { BookOpenCheck, Coins, FolderKanban, Images, ListChecks, Maximize2, Settings2 } from "lucide-react";
|
||||
import { BookOpenCheck, Coins, FolderKanban, Images, ListChecks, Maximize2, MessageSquarePlus, Settings2 } from "lucide-react";
|
||||
|
||||
export const navigationTools = [
|
||||
{
|
||||
slug: "create",
|
||||
label: "创作",
|
||||
icon: MessageSquarePlus,
|
||||
section: "创作空间",
|
||||
},
|
||||
{
|
||||
slug: "projects",
|
||||
label: "短剧创作",
|
||||
|
||||
@@ -78,13 +78,14 @@ export function isArkPlanBaseUrl(baseUrl: string) {
|
||||
|
||||
export function normalizeSeedanceResolution(value: string, model = "") {
|
||||
const normalized = normalizeResolutionToken(value);
|
||||
if (isSeedanceFastModel(model) && normalized === "1080p") return "720p";
|
||||
return seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p";
|
||||
if (isSeedanceFastModel(model) && (normalized === "1080p" || normalized === "2160p")) return "720p";
|
||||
return normalized === "2160p" || seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p";
|
||||
}
|
||||
|
||||
export function normalizeResolutionToken(value: string) {
|
||||
if (value === "low") return "480p";
|
||||
if (value === "auto" || value === "high" || value === "medium") return "720p";
|
||||
if (value.toLowerCase() === "4k") return "2160p";
|
||||
const resolution = String(value || "").replace(/p$/i, "") || "720";
|
||||
return `${resolution}p`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const VIDEO_DURATION_OPTIONS = [6, 9, 10, 15] as const;
|
||||
export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080] as const;
|
||||
export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 2160] as const;
|
||||
export const VIDEO_DURATION_MIN = 1;
|
||||
|
||||
export function normalizeVideoDuration(value: string | number | undefined) {
|
||||
@@ -11,6 +11,7 @@ export function normalizeVideoResolution(value: string | number | undefined) {
|
||||
const token = String(value || "").trim().toLowerCase();
|
||||
if (token === "low") return "480";
|
||||
if (token === "auto" || token === "medium" || token === "high") return "720";
|
||||
if (token === "4k") return "2160";
|
||||
const resolution = Number(token.replace(/p$/i, "")) || 720;
|
||||
return String(nearestOption(resolution, VIDEO_RESOLUTION_OPTIONS));
|
||||
}
|
||||
|
||||
450
web/src/pages/create/index.tsx
Normal file
450
web/src/pages/create/index.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode, type RefObject } from "react";
|
||||
import localforage from "localforage";
|
||||
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||||
import { ArrowUp, Check, ChevronDown, Clock3, Download, Film, History, Image as ImageIcon, Maximize2, MessageSquareText, Plus, RefreshCw, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
import { requestVideoGeneration } from "@/services/api/video";
|
||||
import { modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
type CreationMode = "text" | "image" | "video";
|
||||
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
|
||||
type CreationAttachment = ReferenceImage & { previewUrl: string };
|
||||
type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string };
|
||||
type CreationMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
mode?: CreationMode;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
status?: CreationStatus;
|
||||
model?: string;
|
||||
resultUrls?: string[];
|
||||
error?: string;
|
||||
attachments?: CreationAttachment[];
|
||||
settings?: CreationSettings;
|
||||
};
|
||||
type CreationConversation = { id: string; title: string; updatedAt: string; messages: CreationMessage[] };
|
||||
|
||||
const STORAGE_KEY = "creation-conversations-v1";
|
||||
const modeLabels: Record<CreationMode, string> = { text: "文本", image: "图片", video: "视频" };
|
||||
const ratioOptions = [
|
||||
{ value: "1:1", label: "方形" },
|
||||
{ value: "16:9", label: "横屏" },
|
||||
{ value: "9:16", label: "竖屏" },
|
||||
{ value: "4:3", label: "标准横屏" },
|
||||
{ value: "3:4", label: "标准竖屏" },
|
||||
{ value: "21:9", label: "宽银幕" },
|
||||
];
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动", description: "由模型决定" },
|
||||
{ value: "low", label: "低", description: "更快生成" },
|
||||
{ value: "medium", label: "中", description: "均衡模式" },
|
||||
{ value: "high", label: "高", description: "优先细节" },
|
||||
];
|
||||
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
|
||||
const countOptions = ["1", "2", "3", "4"];
|
||||
const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
function newConversation(): CreationConversation {
|
||||
return { id: crypto.randomUUID(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
|
||||
}
|
||||
|
||||
function newMessage(role: CreationMessage["role"], content: string, extra: Partial<CreationMessage> = {}): CreationMessage {
|
||||
return { id: crypto.randomUUID(), role, content, createdAt: new Date().toISOString(), ...extra };
|
||||
}
|
||||
|
||||
export default function CreatePage() {
|
||||
const { message: toast } = App.useApp();
|
||||
const config = useEffectiveConfig();
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const [conversations, setConversations] = useState<CreationConversation[]>([]);
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [mode, setMode] = useState<CreationMode>("video");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [attachments, setAttachments] = useState<CreationAttachment[]>([]);
|
||||
const [ratio, setRatio] = useState("16:9");
|
||||
const [seconds, setSeconds] = useState("6");
|
||||
const [quality, setQuality] = useState("auto");
|
||||
const [videoQuality, setVideoQuality] = useState(config.vquality || "720");
|
||||
const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1))));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]);
|
||||
const historyConversations = useMemo(
|
||||
() => conversations.filter((conversation) => conversation.id === activeId || conversation.messages.length > 0).sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)),
|
||||
[activeId, conversations],
|
||||
);
|
||||
const selectedModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel;
|
||||
const isEmpty = !activeConversation?.messages.length;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void localforage.getItem<CreationConversation[]>(STORAGE_KEY).then((stored) => {
|
||||
if (cancelled) return;
|
||||
const next = stored?.length ? stored : [newConversation()];
|
||||
setConversations(next);
|
||||
setActiveId(next[0].id);
|
||||
setHydrated(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated) void localforage.setItem(STORAGE_KEY, conversations);
|
||||
}, [conversations, hydrated]);
|
||||
|
||||
const updateActive = useCallback((updater: (conversation: CreationConversation) => CreationConversation) => {
|
||||
setConversations((current) => current.map((item) => item.id === activeId ? updater(item) : item));
|
||||
}, [activeId]);
|
||||
|
||||
const updateAssistant = useCallback((id: string, updater: (item: CreationMessage) => CreationMessage) => {
|
||||
updateActive((conversation) => ({
|
||||
...conversation,
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages: conversation.messages.map((item) => item.id === id ? updater(item) : item),
|
||||
}));
|
||||
}, [updateActive]);
|
||||
|
||||
const selectMode = (next: CreationMode) => {
|
||||
setMode(next);
|
||||
const nextModels = selectableModelsByCapability(config, next);
|
||||
const current = next === "text" ? config.textModel : next === "image" ? config.imageModel : config.videoModel;
|
||||
if (!nextModels.includes(current) && nextModels[0]) {
|
||||
updateConfig(next === "text" ? "textModel" : next === "image" ? "imageModel" : "videoModel", nextModels[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const addAttachments = (files: FileList | File[]) => {
|
||||
const next = Array.from(files).filter((file) => file.type.startsWith("image/"));
|
||||
if (!next.length) return;
|
||||
void Promise.all(next.slice(0, 6).map(async (file) => {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
return { id: crypto.randomUUID(), name: file.name, type: file.type, dataUrl, previewUrl: dataUrl } satisfies CreationAttachment;
|
||||
})).then((items) => setAttachments((current) => [...current, ...items].slice(0, 6))).catch(() => toast.error("参考图读取失败,请重试"));
|
||||
};
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files) addAttachments(event.target.files);
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const text = prompt.trim();
|
||||
if (!text || busy || !activeConversation) return;
|
||||
if (!selectedModel) {
|
||||
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
|
||||
return;
|
||||
}
|
||||
const settings = { ratio, seconds, quality, videoQuality, count };
|
||||
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, settings });
|
||||
const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings });
|
||||
updateActive((conversation) => ({
|
||||
...conversation,
|
||||
title: conversation.messages.length ? conversation.title : text.slice(0, 24),
|
||||
updatedAt: new Date().toISOString(),
|
||||
messages: [...conversation.messages, userMessage, assistantMessage],
|
||||
}));
|
||||
setPrompt("");
|
||||
setAttachments([]);
|
||||
setBusy(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
const requestConfig = { ...config, model: selectedModel, imageModel: selectedModel, videoModel: selectedModel, textModel: selectedModel, size: ratio, videoSeconds: seconds, quality, vquality: videoQuality, count };
|
||||
try {
|
||||
if (mode === "text") {
|
||||
const history = [...(activeConversation.messages || []), userMessage].map((item) => ({
|
||||
role: item.role,
|
||||
content: item.role === "user" && item.attachments?.length
|
||||
? [{ type: "text" as const, text: item.content }, ...item.attachments.map((attachment) => ({ type: "image_url" as const, image_url: { url: attachment.dataUrl || attachment.url || attachment.previewUrl } }))]
|
||||
: item.content,
|
||||
}));
|
||||
await requestImageQuestion(requestConfig, history, (delta) => updateAssistant(assistantMessage.id, (item) => ({ ...item, content: item.content + delta })), { signal: controller.signal });
|
||||
} else if (mode === "image") {
|
||||
const result = attachments.length ? await requestEdit(requestConfig, text, attachments, undefined, { signal: controller.signal }) : await requestGeneration(requestConfig, text, { signal: controller.signal });
|
||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "图片已生成", resultUrls: result.map((entry) => entry.dataUrl) }));
|
||||
} else {
|
||||
const result = await requestVideoGeneration(requestConfig, text, attachments, [], [], { signal: controller.signal });
|
||||
const resultUrl = result.url || (result.blob ? URL.createObjectURL(result.blob) : "");
|
||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done", content: "视频已生成", resultUrls: resultUrl ? [resultUrl] : [] }));
|
||||
}
|
||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "done" }));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "cancelled", content: "已停止" }));
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : "生成失败,请重试";
|
||||
updateAssistant(assistantMessage.id, (item) => ({ ...item, status: "error", error: message, content: "生成失败" }));
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startNewConversation = () => {
|
||||
const next = newConversation();
|
||||
setConversations((current) => [next, ...current]);
|
||||
setActiveId(next.id);
|
||||
setPrompt("");
|
||||
setAttachments([]);
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
|
||||
const selectConversation = (conversation: CreationConversation) => {
|
||||
setActiveId(conversation.id);
|
||||
setPrompt("");
|
||||
setAttachments([]);
|
||||
setHistoryOpen(false);
|
||||
};
|
||||
|
||||
const retryMessage = (item: CreationMessage, index: number) => {
|
||||
const previous = item.role === "assistant" ? activeConversation?.messages[index - 1] : item;
|
||||
if (!previous?.content) return;
|
||||
setMode(previous.mode || "text");
|
||||
setPrompt(previous.content);
|
||||
setAttachments(previous.attachments || []);
|
||||
};
|
||||
|
||||
if (!hydrated || !activeConversation) return <div className="grid h-full place-items-center"><Spin /></div>;
|
||||
|
||||
const composerProps = {
|
||||
mode,
|
||||
prompt,
|
||||
setPrompt,
|
||||
busy,
|
||||
attachments,
|
||||
onRemoveAttachment: (id: string) => setAttachments((current) => current.filter((item) => item.id !== id)),
|
||||
fileInputRef,
|
||||
onFileChange: handleFileChange,
|
||||
onModeChange: selectMode,
|
||||
model: selectedModel,
|
||||
config,
|
||||
onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value),
|
||||
ratio,
|
||||
setRatio,
|
||||
seconds,
|
||||
setSeconds,
|
||||
quality,
|
||||
setQuality,
|
||||
videoQuality,
|
||||
setVideoQuality,
|
||||
count,
|
||||
setCount,
|
||||
onSubmit: submit,
|
||||
onStop: () => { abortRef.current?.abort(); setBusy(false); },
|
||||
};
|
||||
|
||||
return <>
|
||||
<main className="creation-home relative flex h-full min-h-0 flex-col overflow-y-auto">
|
||||
<div className="creation-top-actions">
|
||||
{!isEmpty ? <Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-top-action" onClick={startNewConversation}><Plus /></button></Tooltip> : null}
|
||||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" aria-expanded={historyOpen} className="creation-top-action" onClick={() => setHistoryOpen(true)}><History /></button></Tooltip>
|
||||
</div>
|
||||
{isEmpty ? <section className="creation-empty-workspace">
|
||||
<CreationIntro mode={mode} />
|
||||
<div className="creation-empty-composer"><CreationComposer {...composerProps} variant="empty" /></div>
|
||||
</section> : <>
|
||||
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <CreationMessageView key={item.id} item={item} onRetry={() => retryMessage(item, index)} />)}</div></section>
|
||||
<section className="creation-thread-composer">
|
||||
<CreationComposer {...composerProps} variant="thread" />
|
||||
</section>
|
||||
</>}
|
||||
</main>
|
||||
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) {
|
||||
return <Drawer open={open} onClose={onClose} placement="right" size="min(360px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||||
<ol className="creation-history-timeline" aria-label="历史对话,按更新时间倒序排列">
|
||||
{conversations.map((conversation) => {
|
||||
const latest = conversationPreviewMessage(conversation);
|
||||
const active = conversation.id === activeId;
|
||||
return <li key={conversation.id} className={active ? "is-active" : ""}>
|
||||
<span className="creation-history-dot" aria-hidden="true" />
|
||||
<button type="button" aria-current={active ? "page" : undefined} onClick={() => onSelect(conversation)}>
|
||||
<span className="creation-history-time"><time dateTime={conversation.updatedAt}>{formatConversationTime(conversation.updatedAt)}</time><em>{latest?.mode ? modeLabels[latest.mode] : "创作"}</em></span>
|
||||
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
|
||||
<span className="creation-history-snippet">{latest?.content.trim() || "还没有开始创作"}</span>
|
||||
</button>
|
||||
</li>;
|
||||
})}
|
||||
</ol>
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
function CreationMessageView({ item, onRetry }: { item: CreationMessage; onRetry: () => void }) {
|
||||
if (item.role === "user") return <div className="creation-user-message"><div>{item.content}</div>{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => <img key={attachment.id} src={attachment.previewUrl} alt={attachment.name} />)}</div> : null}</div>;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : "";
|
||||
return <article className="creation-assistant-message"><div className="creation-message-heading"><span className="creation-message-mark"><Sparkles /></span><span>{modeLabels[mode]}</span>{item.model ? <span className="creation-message-model">{modelOptionName(item.model)}</span> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</div>{mode === "text" ? <div className="creation-message-content">{item.content ? <ReactMarkdown>{item.content}</ReactMarkdown> : <span>正在生成…</span>}</div> : <MediaResult item={item} onRetry={onRetry} />}{item.error ? <div className="creation-message-error"><span>{item.error}</span><button type="button" onClick={onRetry}><RefreshCw />重新生成</button></div> : null}</article>;
|
||||
}
|
||||
|
||||
function MediaResult({ item, onRetry }: { item: CreationMessage; onRetry: () => void }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); };
|
||||
if (item.status === "pending") return <div className="creation-media-pending"><Spin size="small" />正在生成{item.mode === "video" ? "视频" : "图片"}…</div>;
|
||||
if (!item.resultUrls?.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetry}>重试</button></div>;
|
||||
return <div className="creation-media-result">{item.mode === "video" ? <button type="button" className="creation-video-result" onClick={() => openPreview(item.resultUrls![0], "video")} aria-label="预览生成视频"><video muted preload="metadata" className="size-full object-cover" src={item.resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{item.resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => openPreview(url, "image")} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}<div className="creation-media-actions"><span>{item.mode === "video" ? "视频结果" : `${item.resultUrls.length} 张图片`}</span><button type="button" onClick={onRetry}><RefreshCw />生成变体</button><Link to="/canvas">添加到画布</Link>{item.resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{item.resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div><Modal open={Boolean(previewUrl)} title={null} footer={null} centered destroyOnHidden width={previewType === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={() => setPreviewUrl("")} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{previewUrl ? previewType === "video" ? <video controls autoPlay className="creation-media-preview-video" src={previewUrl} /> : <img className="creation-media-preview-image" src={previewUrl} alt="生成图片预览" /> : null}</Modal></div>;
|
||||
}
|
||||
|
||||
type ComposerProps = {
|
||||
variant: "empty" | "thread";
|
||||
mode: CreationMode;
|
||||
prompt: string;
|
||||
setPrompt: (value: string) => void;
|
||||
busy: boolean;
|
||||
attachments: CreationAttachment[];
|
||||
onRemoveAttachment: (id: string) => void;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onModeChange: (mode: CreationMode) => void;
|
||||
model: string;
|
||||
config: ReturnType<typeof useEffectiveConfig>;
|
||||
onModelChange: (value: string) => void;
|
||||
ratio: string;
|
||||
setRatio: (value: string) => void;
|
||||
seconds: string;
|
||||
setSeconds: (value: string) => void;
|
||||
quality: string;
|
||||
setQuality: (value: string) => void;
|
||||
videoQuality: string;
|
||||
setVideoQuality: (value: string) => void;
|
||||
count: string;
|
||||
setCount: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop: () => void;
|
||||
};
|
||||
|
||||
function CreationComposer(props: ComposerProps) {
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !props.busy;
|
||||
const placeholder = props.mode === "text"
|
||||
? "描述你的故事、角色或想继续讨论的创意"
|
||||
: props.mode === "image"
|
||||
? "描述画面、人物、场景、构图与风格"
|
||||
: "描述镜头内容、运动、光线与节奏";
|
||||
const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作";
|
||||
return <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<input ref={props.fileInputRef} type="file" hidden accept="image/*" multiple onChange={props.onFileChange} />
|
||||
<Tooltip title="添加参考图片"><button type="button" className={`creation-chat-reference ${props.variant === "empty" ? "is-paper" : ""}`} onClick={() => props.fileInputRef.current?.click()} disabled={props.busy} aria-label="添加参考图片"><Plus />{props.variant === "empty" ? <span>参考内容</span> : null}</button></Tooltip>
|
||||
<div className="creation-chat-editor">
|
||||
<textarea value={props.prompt} onChange={(event) => props.setPrompt(event.target.value)} onKeyDown={(event: KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); props.onSubmit(); } }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} disabled={props.busy} />
|
||||
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => <div key={item.id} className="creation-chat-attachment"><img src={item.previewUrl} alt={item.name} /><button type="button" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="creation-chat-dock">
|
||||
<div className="creation-chat-controls">
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
|
||||
{props.mode !== "text" ? <GenerationSettingsMenu {...props} /> : null}
|
||||
{props.mode === "video" ? <DurationMenu model={props.model} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||||
</div>
|
||||
{props.busy ? <button type="button" className="creation-chat-submit is-stopping" onClick={props.onStop} aria-label="停止生成"><Square className="size-3.5 fill-current" /></button> : <button type="button" className="creation-chat-submit" disabled={!canSubmit} onClick={props.onSubmit} aria-label="发送"><ArrowUp className="size-4" /></button>}
|
||||
</footer>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
|
||||
{ mode: "video", icon: <Film />, label: "视频生成" },
|
||||
{ mode: "image", icon: <ImageIcon />, label: "图片生成" },
|
||||
{ mode: "text", icon: <MessageSquareText />, label: "文本创作" },
|
||||
];
|
||||
const current = items.find((item) => item.mode === mode) || items[0];
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-mode-picker-menu" role="listbox" aria-label="选择生成类型">{items.map((item) => <button key={item.mode} type="button" role="option" aria-selected={item.mode === mode} className={item.mode === mode ? "is-selected" : ""} onClick={() => { onModeChange(item.mode); setOpen(false); }}><span className="creation-menu-icon">{item.icon}</span><span>{item.label}</span>{item.mode === mode ? <Check /> : null}</button>)}</div>}>
|
||||
<button type="button" className="creation-chat-control is-mode" aria-label={`生成类型:${current.label}`}>{current.icon}<span>{current.label}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
|
||||
function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio));
|
||||
const qualityLabel = qualityOptions.find((item) => item.value === props.quality)?.label || "自动";
|
||||
const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : `${props.ratio} · ${qualityLabel} · ${props.count}`;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
<SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratioOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.ratio} className={option.value === props.ratio ? "is-selected" : ""} onClick={() => { props.setRatio(option.value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(option.value)} /></span><span>{option.value}</span></button>)}</div>{customRatioOpen ? <label className="creation-custom-value"><span>宽 : 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义画幅,支持宽x高或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义比例</button>}</div></SettingSection>
|
||||
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutionOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
|
||||
<SettingSection title="图片质量" value={qualityLabel}><div className="creation-choice-grid is-quality">{qualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection>
|
||||
<SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(event.target.value)} aria-label="生成数量,范围 1 到 15" /><em>张</em></label></div></SettingSection>
|
||||
</>}
|
||||
</div>;
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={panel}>
|
||||
<button type="button" className="creation-chat-control" aria-label={`生成设置:${summary}`}><SlidersHorizontal /><span>{summary}</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
|
||||
function SettingSection({ title, value, children }: { title: string; value?: string; children: ReactNode }) {
|
||||
return <section className="creation-parameter-section"><header><h3>{title}</h3>{value ? <span>{value}</span> : null}</header>{children}</section>;
|
||||
}
|
||||
|
||||
function DurationMenu({ model, seconds, onChange }: { model: string; seconds: string; onChange: (value: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const value = Math.max(1, Math.floor(Number(seconds) || 6));
|
||||
const presets = durationPresets(model);
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-duration-menu"><div className="creation-duration-heading"><span>时长</span><strong>{value} 秒</strong></div><div className="creation-duration-choices">{presets.map((item) => <button key={item} type="button" className={item === value ? "is-selected" : ""} onClick={() => onChange(String(item))}>{item}s</button>)}</div><label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min="1" step="1" inputMode="numeric" value={seconds} onFocus={(event) => event.currentTarget.select()} onBlur={() => onChange(String(value))} onChange={(event) => onChange(event.target.value)} aria-label="自定义视频时长,单位秒" /><em>秒</em></span></label></div>}>
|
||||
<button type="button" className="creation-chat-control is-duration" aria-label={`视频时长:${value}秒`}><Clock3 /><span>{value}s</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
|
||||
function CreationIntro({ mode }: { mode: CreationMode }) {
|
||||
const copy = mode === "video" ? ["让", "想象", ",先在镜头里发生", "影策 · AI 叙事创作"] : mode === "image" ? ["让", "画面", ",从一个想法开始", "影策 · 视觉创作"] : ["把", "故事", ",写在第一句话里", "影策 · 叙事创作"];
|
||||
return <header className="creation-chat-intro" aria-live="polite"><span className="creation-intro-signal" aria-hidden="true" /><h1>{copy[0]}<span>{copy[1]}</span>{copy[2]}</h1><p>{copy[3]}</p></header>;
|
||||
}
|
||||
|
||||
function durationPresets(model: string) {
|
||||
const name = modelOptionName(model).toLowerCase();
|
||||
if (name.includes("veo")) return [4, 6, 8];
|
||||
if (name.includes("seedance")) return [4, 5, 8, 10, 15];
|
||||
return [5, 10, 15, 20, 30];
|
||||
}
|
||||
|
||||
function videoResolutionLabel(value: string | number) {
|
||||
return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`;
|
||||
}
|
||||
|
||||
function conversationPreviewMessage(conversation: CreationConversation) {
|
||||
let fallback: CreationMessage | undefined;
|
||||
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = conversation.messages[index];
|
||||
if (!message.content.trim()) continue;
|
||||
fallback ||= message;
|
||||
if (message.role === "user") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function conversationTimestamp(value: string) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||
}
|
||||
|
||||
function formatConversationTime(value: string) {
|
||||
const timestamp = conversationTimestamp(value);
|
||||
if (!timestamp) return "时间未知";
|
||||
return conversationTimeFormatter.format(timestamp);
|
||||
}
|
||||
|
||||
function ratioPreviewStyle(value: string) {
|
||||
const [width, height] = value.replace("x", ":").split(":").map(Number);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 14, height: 14 };
|
||||
const scale = Math.min(28 / width, 20 / height);
|
||||
return { width: Math.max(8, width * scale), height: Math.max(8, height * scale) };
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file: File) { return new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error || new Error("文件读取失败")); reader.readAsDataURL(file); }); }
|
||||
@@ -18,6 +18,7 @@ import RegisterPage from "@/pages/auth/register";
|
||||
import CanvasPage from "@/pages/canvas";
|
||||
import CanvasProjectPage from "@/pages/canvas/project";
|
||||
import SharedCanvasPage from "@/pages/canvas/shared";
|
||||
import CreatePage from "@/pages/create";
|
||||
import HomePage from "@/pages/home";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import RouteErrorPage from "@/pages/route-error";
|
||||
@@ -46,7 +47,9 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
errorElement: <RouteErrorPage />,
|
||||
children: [
|
||||
{ path: "/", element: <HomePage /> },
|
||||
{ path: "/", element: <Navigate to="/create" replace /> },
|
||||
{ path: "/create", element: <RequireAuth><CreatePage /></RequireAuth> },
|
||||
{ path: "/home", element: <HomePage /> },
|
||||
{ path: "/tasks", element: <RequireAuth><TasksPage /></RequireAuth> },
|
||||
{ path: "/assets", element: <RequireAuth><AssetsPage /></RequireAuth> },
|
||||
{ path: "/skills", element: <RequireAuth><SkillsPage /></RequireAuth> },
|
||||
|
||||
@@ -531,14 +531,23 @@ function normalizeVideoSeconds(value: string) {
|
||||
|
||||
function normalizeVideoSize(value: string) {
|
||||
if (value === "auto") return null;
|
||||
const size = value || "1280x720";
|
||||
const size = (value || "1280x720").trim().toLowerCase().replace("×", "x");
|
||||
if (/^\d+x\d+$/.test(size)) return size;
|
||||
return ["9:16", "2:3", "3:4"].includes(size) ? "720x1280" : "1280x720";
|
||||
const ratio = size.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!ratio) return "1280x720";
|
||||
const widthRatio = Number(ratio[1]);
|
||||
const heightRatio = Number(ratio[2]);
|
||||
if (!Number.isFinite(widthRatio) || !Number.isFinite(heightRatio) || widthRatio <= 0 || heightRatio <= 0) return "1280x720";
|
||||
const aspect = widthRatio / heightRatio;
|
||||
const width = aspect >= 1 ? 1280 : Math.max(256, Math.round((720 * aspect) / 2) * 2);
|
||||
const height = aspect >= 1 ? Math.max(256, Math.round((1280 / aspect) / 2) * 2) : 720;
|
||||
return `${width}x${height}`;
|
||||
}
|
||||
|
||||
function normalizeVideoResolution(value: string) {
|
||||
if (value === "low") return "480p";
|
||||
if (value === "auto" || value === "high" || value === "medium") return "720p";
|
||||
if (value.toLowerCase() === "4k") return "2160p";
|
||||
const resolution = value.replace(/p$/i, "") || "720";
|
||||
return `${resolution}p`;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user