541 lines
43 KiB
TypeScript
541 lines
43 KiB
TypeScript
import { ImageSizePicker } from "@/components/image-size-picker";
|
||
import { imageResolutionUsesQuality } from "@/lib/image-size-presets";
|
||
import { createPortal } from "react-dom";
|
||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
|
||
import { Button, Popover } from "antd";
|
||
import { useWorkspaceTopBarMount } from "@/components/layout/workspace-top-bar-extension";
|
||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||
import { Reorder, LayoutGroup, motion, useReducedMotion } from "motion/react";
|
||
import { ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Plus, SlidersHorizontal, Trash2, WandSparkles, Waves, X } from "lucide-react";
|
||
|
||
import { WorkingGlow } from "@/components/ai/working-indicator";
|
||
import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options";
|
||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||
import { HoverBorderGradient } from "@/components/ui/aceternity/hover-border-gradient";
|
||
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
|
||
import { ModelPicker } from "@/components/model-picker";
|
||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
|
||
import { formatShotOrdinal } from "@/lib/shot-label";
|
||
import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers";
|
||
import { normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||
import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
|
||
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
|
||
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
|
||
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||
import { useUserStore } from "@/stores/use-user-store";
|
||
import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
|
||
import { type CreationReference } from "./creation-references";
|
||
import { creationAttachmentKind, type CreationAttachment, type CreationMode } from "./creation-assets";
|
||
import { countOptions, modeLabels, qualityOptions, resolutionOptions, type CreationShotRailEntry } from "./creation-types";
|
||
import "./creation-product.css";
|
||
import "./creation-scrollbars.css";
|
||
import { CreationAttachmentThumbnail, CreationMediaPreviewModal } from "./creation-workspace-messages";
|
||
|
||
export { CreationHistoryDrawer } from "./creation-workspace-history";
|
||
export { CreationMessageView } from "./creation-workspace-messages";
|
||
export { CreationEmptyBanner, CreationEmptySuggest, CreationFeaturedWorks } from "./creation-workspace-empty";
|
||
|
||
const CanvasPromptOptimizerDrawer = lazy(() => import("@/components/canvas/canvas-prompt-optimizer-drawer").then((module) => ({ default: module.CanvasPromptOptimizerDrawer })));
|
||
|
||
export const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
|
||
|
||
export function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory, onContinueCanvas, openingCanvas }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void; onContinueCanvas: () => void; openingCanvas: boolean }) {
|
||
const [railOpen, setRailOpen] = useState(false);
|
||
const railRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
if (!railOpen) return;
|
||
const onPointerDown = (event: MouseEvent) => { if (railRef.current && !railRef.current.contains(event.target as Node)) setRailOpen(false); };
|
||
window.addEventListener("mousedown", onPointerDown);
|
||
return () => window.removeEventListener("mousedown", onPointerDown);
|
||
}, [railOpen]);
|
||
const mount = useWorkspaceTopBarMount();
|
||
const toolbar = <header className="creation-thread-toolbar">
|
||
<div className="creation-toolbar-shots" ref={railRef}>
|
||
<button type="button" className="creation-rail-trigger" aria-expanded={railOpen} aria-haspopup="listbox" onClick={() => setRailOpen((open) => !open)}><Clapperboard />镜头时间线{shots.length > 0 ? <em className="creation-rail-count">{shots.length}</em> : null}</button>
|
||
{railOpen ? <div className="creation-rail-pop" role="listbox" aria-label="镜头时间线">
|
||
<div className="creation-rail-pop-head"><span className="creation-rail-pop-title">镜头时间线<small>{shots.length ? `共 ${shots.length} 镜` : "空轨道"}</small></span><button type="button" className="creation-rail-pop-close" aria-label="关闭镜头列表" onClick={() => setRailOpen(false)}><X /></button></div>
|
||
{shots.length ? <ol className="creation-rail-list">{shots.map((shot) => {
|
||
const resultStatus = shot.result?.status;
|
||
const statusLabel = resultStatus === "done" ? "完成" : resultStatus === "error" ? "生成失败" : resultStatus === "pending" ? "生成中" : resultStatus === "cancelled" ? "已停止" : "待生成";
|
||
return <li key={shot.key}><button type="button" role="option" aria-selected="false" className="creation-rail-row" onClick={() => { setRailOpen(false); onJumpToShot(shot); }}>
|
||
<span className="creation-rail-row-shot">{formatShotOrdinal(shot.ordinal - 1)}</span>
|
||
<span className="creation-rail-row-prompt">{shot.user.content || "视频镜头"}</span>
|
||
<span className={`creation-rail-row-state is-${resultStatus || "idle"}`}>{statusLabel}</span>
|
||
</button></li>;
|
||
})}</ol> : <p className="creation-rail-empty">在下方发送一条视频消息,就会自动成为第 1 镜。</p>}
|
||
</div> : null}
|
||
</div>
|
||
<div className="creation-toolbar-actions">
|
||
<Button size="small" loading={openingCanvas} onClick={onContinueCanvas}>画布中继续</Button>
|
||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="creation-toolbar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="creation-toolbar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||
</div>
|
||
</header>;
|
||
if (mount) return createPortal(toolbar, mount);
|
||
if (mount === null) return null;
|
||
return toolbar;
|
||
}
|
||
|
||
type ComposerProps = {
|
||
variant: "empty" | "thread";
|
||
mode: CreationMode;
|
||
prompt: string;
|
||
setPrompt: (value: string) => void;
|
||
busy: boolean;
|
||
generationActive: boolean;
|
||
referenceReplacementBusy: boolean;
|
||
attachments: CreationAttachment[];
|
||
referenceImageSize?: { width: number; height: number };
|
||
maxReferences: number;
|
||
references: CreationReference[];
|
||
onRemoveAttachment: (id: string) => void;
|
||
onClearAttachments: () => void;
|
||
onClearComposer: () => void;
|
||
onReorderAttachments: (attachments: CreationAttachment[]) => void;
|
||
onReplaceAttachment: (targetAttachmentId: string, replacement: CreationAttachment) => void;
|
||
onReplaceReferenceFiles: (targetAttachmentId: string, files: File[]) => void;
|
||
onOpenLibrary: () => void;
|
||
onModeChange: (mode: CreationMode) => void;
|
||
model: string;
|
||
modelRequirements: ModelRequirements;
|
||
videoProfile: VideoCapabilityConfig;
|
||
imageProfile: ImageCapabilityConfig;
|
||
config: AiConfig;
|
||
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;
|
||
textStreaming: boolean;
|
||
setTextStreaming: (value: boolean) => void;
|
||
textThinking: boolean;
|
||
setTextThinking: (value: boolean) => void;
|
||
promptOptimizerProvider: PromptOptimizerProvider | null;
|
||
composerFocusRef: RefObject<HTMLTextAreaElement | null>;
|
||
onPromptFocus: () => void;
|
||
placeholderOverride?: string;
|
||
onSubmit: () => void;
|
||
};
|
||
|
||
type CreationReferenceFilter = "all" | "image" | "video" | "audio" | "file";
|
||
|
||
export function CreationComposer(props: ComposerProps) {
|
||
const [previewUrl, setPreviewUrl] = useState("");
|
||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
|
||
const [referenceFilter, setReferenceFilter] = useState<CreationReferenceFilter>("all");
|
||
const [canDragReferences, setCanDragReferences] = useState(false);
|
||
const [dropTargetReferenceId, setDropTargetReferenceId] = useState<string | null>(null);
|
||
const attachmentTrackRef = useRef<HTMLUListElement>(null);
|
||
const cardDragRef = useRef<{ startX: number; startY: number; moved: boolean } | null>(null);
|
||
const suppressAttachmentClickRef = useRef(false);
|
||
const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false });
|
||
const previousAttachmentCountRef = useRef(0);
|
||
const interactionBusy = props.busy || props.referenceReplacementBusy;
|
||
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
|
||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||
const priceChannel = resolveModelChannel(props.config, props.model);
|
||
const quoteRequest = useMemo(() => modelQuoteRequest(props.config, props.model, props.mode, props.modelRequirements), [props.config, props.mode, props.model, props.modelRequirements]);
|
||
const [routeQuote, setRouteQuote] = useState<LogicalModelQuote | null>(null);
|
||
const canOptimizePrompt = Boolean(props.promptOptimizerProvider) && (props.mode === "image" || props.mode === "video");
|
||
const optimizerReferences = props.references.filter((reference) => reference.active && reference.kind !== "skill");
|
||
const credits = requestCreditCost({
|
||
channelMode: priceChannel.scope === "system" ? "remote" : "local",
|
||
modelCosts: priceChannel.modelCosts,
|
||
model: modelOptionName(props.model),
|
||
count: props.mode === "image" ? props.count : 1,
|
||
seconds: props.mode === "video" ? props.seconds : 1,
|
||
capability: props.mode,
|
||
config: props.config,
|
||
requirements: props.modelRequirements,
|
||
});
|
||
useEffect(() => {
|
||
if (!creditsEnabled || !quoteRequest) {
|
||
setRouteQuote(null);
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
setRouteQuote(null);
|
||
quoteModel(quoteRequest, controller.signal)
|
||
.then(({ quote }) => setRouteQuote(quote))
|
||
.catch(() => {
|
||
if (!controller.signal.aborted) setRouteQuote(null);
|
||
});
|
||
return () => controller.abort();
|
||
}, [creditsEnabled, quoteRequest]);
|
||
const generationCredits = routeQuote ? routeQuote.amountMicrocredits / 1_000_000 : credits;
|
||
const showCost = creditsEnabled && generationCredits !== null && generationCredits !== undefined;
|
||
const formattedCredits = generationCredits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||
const actionLabel = props.referenceReplacementBusy ? "正在替换参考图" : interactionBusy || (props.generationActive && !canSubmit) ? "生成中" : showCost ? `${routeQuote?.estimated ? "预估" : "消耗"} ${formattedCredits} 积分,发送` : "发送";
|
||
// Send-button working state must span the WHOLE generation (not just the
|
||
// submit-lock window): spinner + glow stay while a message is pending and
|
||
// the composer is empty; typing a next prompt returns the arrow so the
|
||
// user knows a new send is possible.
|
||
const showWorkingSpinner = interactionBusy || (props.generationActive && !canSubmit);
|
||
const showWorkingGlow = props.generationActive && !canSubmit;
|
||
const placeholder = props.mode === "text"
|
||
? "描述你的故事、角色或想继续讨论的创意"
|
||
: props.mode === "image"
|
||
? "描述画面、人物、场景、构图与风格"
|
||
: "描述镜头内容、运动、光线与节奏";
|
||
const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作";
|
||
const imageReferencesSupported = props.imageProfile.references.maxImages > 0;
|
||
const referencesSupported = props.mode === "image" ? imageReferencesSupported : props.mode !== "video" || props.maxReferences > 0;
|
||
const canAddMoreReferences = referencesSupported && props.attachments.length < props.maxReferences;
|
||
const addReferenceLabel = interactionBusy ? (props.referenceReplacementBusy ? "正在替换参考图" : "生成中暂不能添加参考内容") : canAddMoreReferences ? "添加更多参考内容" : `已达到当前模型的参考内容上限(${props.maxReferences} 个)`;
|
||
const referenceCounts = useMemo(() => props.attachments.reduce((counts, attachment) => {
|
||
const kind = creationAttachmentKind(attachment);
|
||
counts[kind] += 1;
|
||
return counts;
|
||
}, { image: 0, video: 0, audio: 0, file: 0 }), [props.attachments]);
|
||
const visibleAttachments = useMemo(() => referenceFilter === "all"
|
||
? props.attachments
|
||
: props.attachments.filter((attachment) => creationAttachmentKind(attachment) === referenceFilter), [props.attachments, referenceFilter]);
|
||
const imageSettingsSupported = props.imageProfile.size.parameter !== "none" || props.imageProfile.quality.supported || props.imageProfile.maxOutputs > 1;
|
||
const updateTrackScrollState = useCallback(() => {
|
||
const track = attachmentTrackRef.current;
|
||
if (!track) return;
|
||
setTrackState((current) => ({
|
||
...current,
|
||
canScrollLeft: track.scrollLeft > 1,
|
||
canScrollRight: track.scrollLeft + track.clientWidth < track.scrollWidth - 1,
|
||
}));
|
||
}, []);
|
||
const setReferencePanelExpanded = useCallback((isExpanded: boolean) => {
|
||
setTrackState((current) => ({ ...current, isExpanded }));
|
||
if (!isExpanded) setReferenceFilter("all");
|
||
}, []);
|
||
useEffect(() => {
|
||
const hadAttachments = previousAttachmentCountRef.current > 0;
|
||
if (!props.attachments.length) setReferencePanelExpanded(false);
|
||
else if (!hadAttachments) setReferencePanelExpanded(true);
|
||
previousAttachmentCountRef.current = props.attachments.length;
|
||
updateTrackScrollState();
|
||
}, [props.attachments.length, setReferencePanelExpanded, updateTrackScrollState]);
|
||
useEffect(() => {
|
||
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||
const update = () => setCanDragReferences(query.matches);
|
||
update();
|
||
query.addEventListener("change", update);
|
||
return () => query.removeEventListener("change", update);
|
||
}, []);
|
||
useEffect(() => {
|
||
const frame = window.requestAnimationFrame(updateTrackScrollState);
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [referenceFilter, trackState.isExpanded, updateTrackScrollState, visibleAttachments.length]);
|
||
const beginCardDrag = (event: PointerEvent<HTMLElement>) => {
|
||
if (event.button !== 0 || interactionBusy || !trackState.isExpanded) return;
|
||
if ((event.target as HTMLElement).closest(".creation-reference-card-remove")) return;
|
||
cardDragRef.current = { startX: event.clientX, startY: event.clientY, moved: false };
|
||
};
|
||
const endCardDrag = (event: PointerEvent<HTMLElement>) => {
|
||
const drag = cardDragRef.current;
|
||
if (!drag) return;
|
||
cardDragRef.current = null;
|
||
if (drag.moved) {
|
||
suppressAttachmentClickRef.current = true;
|
||
window.setTimeout(() => { suppressAttachmentClickRef.current = false; }, 0);
|
||
}
|
||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||
setTrackState((current) => ({ ...current, isDragging: false }));
|
||
};
|
||
const moveCardDrag = (event: PointerEvent<HTMLElement>) => {
|
||
const drag = cardDragRef.current;
|
||
if (!drag || drag.moved) return;
|
||
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) <= 4) return;
|
||
drag.moved = true;
|
||
setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true }));
|
||
};
|
||
const previewAttachment = (type: "image" | "video", url: string) => {
|
||
if (suppressAttachmentClickRef.current || cardDragRef.current?.moved) return;
|
||
setPreviewType(type);
|
||
setPreviewUrl(url);
|
||
};
|
||
const reorderVisibleAttachments = useCallback((next: CreationAttachment[]) => {
|
||
if (referenceFilter === "all") {
|
||
props.onReorderAttachments(next);
|
||
return;
|
||
}
|
||
const visibleIds = new Set(visibleAttachments.map((attachment) => attachment.id));
|
||
const reordered = [...next];
|
||
props.onReorderAttachments(props.attachments.map((attachment) => visibleIds.has(attachment.id) ? reordered.shift() || attachment : attachment));
|
||
}, [props.attachments, props.onReorderAttachments, referenceFilter, visibleAttachments]);
|
||
useEffect(() => {
|
||
if (!canOptimizePrompt) setPromptOptimizerOpen(false);
|
||
}, [canOptimizePrompt]);
|
||
|
||
const scrollAttachmentTrack = (direction: -1 | 1) => {
|
||
const track = attachmentTrackRef.current;
|
||
if (!track) return;
|
||
track.scrollBy({ left: direction * Math.max(track.clientWidth * 0.72, 120), behavior: "smooth" });
|
||
window.setTimeout(updateTrackScrollState, 180);
|
||
};
|
||
const imageReferenceAtPoint = (x: number, y: number) => {
|
||
for (const element of document.elementsFromPoint(x, y)) {
|
||
const chip = element.closest<HTMLElement>("[data-mention-reference-id]");
|
||
const referenceId = chip?.dataset.mentionReferenceId;
|
||
const reference = referenceId ? props.references.find((item) => item.id === referenceId) : undefined;
|
||
if (reference?.kind === "image" && reference.attachmentId) return reference;
|
||
}
|
||
return undefined;
|
||
};
|
||
const composer = <HoverBorderGradient as="div" duration={2.2} containerClassName="creation-composer-shell" className="creation-composer-shell-inner">
|
||
<SpotlightSurface
|
||
className={`creation-chat-composer is-${props.variant}`}
|
||
contentClassName="contents"
|
||
spotlightColor="color-mix(in srgb, var(--user-ink) 12%, transparent)"
|
||
spotlightRadius={280}
|
||
>
|
||
<div className="creation-chat-writing-surface">
|
||
<div className="creation-chat-editor">
|
||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter onFocus={props.onPromptFocus} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.placeholderOverride || (props.variant === "empty" ? emptyPlaceholder : placeholder)} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能;回车发送,Shift+回车换行" spellCheck disabled={interactionBusy} activeDropReferenceId={dropTargetReferenceId} onReferenceFilesDrop={(reference, files) => { const target = props.references.find((item) => item.id === reference.id); if (target?.attachmentId) props.onReplaceReferenceFiles(target.attachmentId, files); }} />
|
||
{props.attachments.length || referencesSupported ? <div className={`creation-reference-panel${trackState.isExpanded ? " is-expanded" : ""}`} aria-busy={interactionBusy}>
|
||
{trackState.isExpanded ? <div className="creation-reference-panel-header">
|
||
<div className="creation-reference-filter-tabs" role="group" aria-label="筛选参考内容">
|
||
{([
|
||
{ id: "all", label: "全部", count: props.attachments.length },
|
||
{ id: "image", label: "图片", count: referenceCounts.image },
|
||
{ id: "video", label: "视频", count: referenceCounts.video },
|
||
{ id: "audio", label: "音频", count: referenceCounts.audio },
|
||
{ id: "file", label: "文件", count: referenceCounts.file },
|
||
] as const).map((filter) => <button key={filter.id} type="button" aria-pressed={referenceFilter === filter.id} className={referenceFilter === filter.id ? "is-active" : undefined} onClick={() => setReferenceFilter(filter.id)}>{filter.label}{filter.count ? ` (${filter.count})` : ""}</button>)}
|
||
</div>
|
||
<div className="creation-reference-panel-actions">
|
||
{props.attachments.length ? <button type="button" onClick={props.onClearAttachments} disabled={interactionBusy}>清空全部素材</button> : null}
|
||
<Tooltip title="收起素材面板"><button type="button" className="creation-reference-panel-collapse" onClick={() => setReferencePanelExpanded(false)} aria-label="收起素材面板"><Minimize2 aria-hidden="true" /></button></Tooltip>
|
||
</div>
|
||
</div> : null}
|
||
<div className="creation-reference-track-wrapper">
|
||
<div className="creation-reference-stack-shell">
|
||
{trackState.canScrollLeft ? <button type="button" className="creation-reference-track-button is-left" onClick={() => scrollAttachmentTrack(-1)} aria-label="向左浏览参考内容" title="向左浏览参考内容"><ChevronLeft aria-hidden="true" /></button> : null}
|
||
<Reorder.Group<CreationAttachment[]>
|
||
as="ul"
|
||
ref={attachmentTrackRef}
|
||
className={`creation-reference-track${trackState.isExpanded ? " is-expanded" : ""}${trackState.isDragging ? " is-dragging" : ""}${visibleAttachments.length ? "" : " is-empty"}`}
|
||
axis="x"
|
||
values={visibleAttachments}
|
||
onReorder={reorderVisibleAttachments}
|
||
layoutScroll
|
||
role="list"
|
||
aria-label="参考内容轨道"
|
||
onScroll={updateTrackScrollState}
|
||
>
|
||
{visibleAttachments.map((item) => <Reorder.Item<CreationAttachment>
|
||
key={item.id}
|
||
value={item}
|
||
layout="position"
|
||
drag={trackState.isExpanded && canDragReferences && !interactionBusy}
|
||
className="creation-reference-stack-card"
|
||
onPointerDown={beginCardDrag}
|
||
onPointerMove={moveCardDrag}
|
||
onPointerUp={endCardDrag}
|
||
onPointerCancel={endCardDrag}
|
||
onDragStart={() => { setDropTargetReferenceId(null); setTrackState((current) => ({ ...current, isDragging: true, isExpanded: true })); }}
|
||
onDrag={(_, info) => {
|
||
if (creationAttachmentKind(item) !== "image") return;
|
||
const target = imageReferenceAtPoint(info.point.x, info.point.y);
|
||
setDropTargetReferenceId(target?.attachmentId !== item.id ? target?.id || null : null);
|
||
}}
|
||
onDragEnd={(_, info) => {
|
||
const target = creationAttachmentKind(item) === "image" ? imageReferenceAtPoint(info.point.x, info.point.y) : undefined;
|
||
setDropTargetReferenceId(null);
|
||
setTrackState((current) => ({ ...current, isDragging: false, isExpanded: true }));
|
||
if (target?.attachmentId && target.attachmentId !== item.id) props.onReplaceAttachment(target.attachmentId, item);
|
||
}}
|
||
>
|
||
<CreationAttachmentThumbnail item={item} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} />
|
||
</Reorder.Item>)}
|
||
{!visibleAttachments.length && props.attachments.length ? <li className="creation-reference-filter-empty">该类型暂无参考内容</li> : null}
|
||
{referencesSupported ? <li className="creation-reference-add-slot"><Tooltip title={addReferenceLabel}><button type="button" className="creation-reference-add-button" onClick={props.onOpenLibrary} disabled={interactionBusy || !canAddMoreReferences} aria-label={addReferenceLabel}><Plus aria-hidden="true" /><span>参考内容</span></button></Tooltip></li> : null}
|
||
</Reorder.Group>
|
||
{trackState.canScrollRight ? <button type="button" className="creation-reference-track-button is-right" onClick={() => scrollAttachmentTrack(1)} aria-label="向右浏览参考内容" title="向右浏览参考内容"><ChevronRight aria-hidden="true" /></button> : null}
|
||
{!trackState.isExpanded && props.attachments.length ? <Tooltip title="查看全部"><button type="button" className="creation-reference-panel-expand" onClick={() => setReferencePanelExpanded(true)} aria-label={`查看全部 ${props.attachments.length} 个参考内容`} aria-expanded="false"><Maximize2 aria-hidden="true" /></button></Tooltip> : null}
|
||
</div>
|
||
</div>
|
||
</div> : null}
|
||
</div>
|
||
</div>
|
||
<footer className="creation-chat-dock">
|
||
<div className="creation-chat-controls">
|
||
{props.variant === "thread" ? <ModePicker mode={props.mode} onModeChange={props.onModeChange} /> : null}
|
||
<VoiceRecordingButton
|
||
className="creation-voice-trigger"
|
||
disabled={interactionBusy}
|
||
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
|
||
/>
|
||
{canOptimizePrompt ? <Tooltip title="用 AI 优化提示词">
|
||
<button
|
||
type="button"
|
||
className="creation-chat-control"
|
||
onClick={() => setPromptOptimizerOpen(true)}
|
||
aria-label="优化提示词"
|
||
aria-expanded={promptOptimizerOpen}
|
||
aria-haspopup="dialog"
|
||
>
|
||
<WandSparkles />
|
||
<span>优化</span>
|
||
</button>
|
||
</Tooltip> : null}
|
||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} requirements={props.modelRequirements} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} showOptionPrices variant="creation" />
|
||
{props.mode === "video" || (props.mode === "image" && imageSettingsSupported) ? <GenerationSettingsMenu {...props} /> : null}
|
||
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||
{props.mode === "text" ? <>
|
||
<Tooltip title={interactionBusy ? "生成中,此开关将在下次发送时生效" : (props.textStreaming ? "流式输出已开启" : "流式输出已关闭")}><button type="button" className="creation-chat-control" aria-pressed={props.textStreaming} disabled={interactionBusy} onClick={() => props.setTextStreaming(!props.textStreaming)}><Waves /><span>流式</span></button></Tooltip>
|
||
<Tooltip title={interactionBusy ? "生成中,此开关将在下次发送时生效" : (props.textThinking ? "思考已开启,会展示模型返回的推理摘要" : "开启模型思考")}><button type="button" className="creation-chat-control" aria-pressed={props.textThinking} disabled={interactionBusy} onClick={() => props.setTextThinking(!props.textThinking)}><Brain /><span>思考</span></button></Tooltip>
|
||
</> : null}
|
||
{props.prompt.trim() || props.attachments.length || props.references.some((reference) => reference.active) ? <Tooltip title="清空提示词和参考内容"><button type="button" className="creation-chat-control is-clear" onClick={props.onClearComposer} disabled={interactionBusy} aria-label="清空提示词和参考内容"><Trash2 /><span>清空</span></button></Tooltip> : null}
|
||
</div>
|
||
<Button
|
||
type="text"
|
||
className={`creation-submit ${showCost ? "has-cost" : ""}`}
|
||
disabled={interactionBusy || !canSubmit}
|
||
style={{
|
||
position: "relative",
|
||
color: "var(--user-ink)",
|
||
} as CSSProperties}
|
||
onClick={interactionBusy ? undefined : props.onSubmit}
|
||
aria-label={actionLabel}
|
||
title={!canSubmit && !interactionBusy ? "输入创作想法后即可生成" : actionLabel}
|
||
>
|
||
{showWorkingGlow ? <WorkingGlow active color="var(--creation-text)" radius="999px" /> : null}
|
||
{showCost ? <span className="creation-submit-cost" title={routeQuote ? modelQuoteDescription(routeQuote) : undefined}><CreditSymbol /><span>{routeQuote?.estimated ? `预估:${formattedCredits}` : formattedCredits}</span></span> : null}
|
||
<span className="creation-submit-action" aria-hidden>{showWorkingSpinner ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}<span>{showWorkingSpinner ? "生成中" : "开始创作"}</span></span>
|
||
</Button>
|
||
</footer>
|
||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||
</SpotlightSurface>
|
||
</HoverBorderGradient>;
|
||
|
||
if (!promptOptimizerOpen) return composer;
|
||
|
||
return (
|
||
<Suspense fallback={composer}><CanvasPromptOptimizerDrawer
|
||
open={promptOptimizerOpen}
|
||
prompt={props.prompt}
|
||
generationMode={props.mode === "video" ? "video" : "image"}
|
||
targetModel={modelOptionName(props.model) || props.model}
|
||
targetProtocol={priceChannel.modelCosts?.find((item) => item.model === modelOptionName(props.model))?.protocol || priceChannel.interfaceType}
|
||
config={props.config}
|
||
optimizerModel={props.config.textModel}
|
||
references={optimizerReferences}
|
||
provider={props.promptOptimizerProvider}
|
||
onClose={() => setPromptOptimizerOpen(false)}
|
||
onApply={props.setPrompt}
|
||
>
|
||
{composer}
|
||
</CanvasPromptOptimizerDrawer></Suspense>
|
||
);
|
||
}
|
||
|
||
export function CreationModeTabs({ mode, onModeChange, agentActive = false, onAgentSelect, orientation = "horizontal" }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void; agentActive?: boolean; onAgentSelect?: () => void; orientation?: "horizontal" | "vertical" }) {
|
||
const reducedMotion = useReducedMotion();
|
||
const items: { mode: CreationMode; icon: ReactNode; label: string }[] = [
|
||
{ mode: "video", icon: <Film />, label: "视频" },
|
||
{ mode: "image", icon: <ImageIcon />, label: "图片" },
|
||
{ mode: "text", icon: <MessageSquareText />, label: "文本" },
|
||
];
|
||
const indicator = (pressed: boolean) => pressed ? (
|
||
<motion.span
|
||
layoutId={`creation-mode-indicator-${orientation}`}
|
||
className="creation-mode-indicator"
|
||
aria-hidden
|
||
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock}
|
||
/>
|
||
) : null;
|
||
return <LayoutGroup id={`creation-mode-tabs-${orientation}`}>
|
||
<div className="creation-mode-tabs" role="group" aria-label="创作模式" data-active-mode={agentActive ? "agent" : mode} data-orientation={orientation} style={{ gridTemplateColumns: orientation === "vertical" ? "minmax(0, 1fr)" : `repeat(${onAgentSelect ? 4 : 3}, minmax(0, 1fr))` }}>
|
||
{items.map((item) => (
|
||
<button key={item.mode} type="button" className="creation-mode-button" data-mode={item.mode} aria-pressed={!agentActive && item.mode === mode} aria-label={`${item.label}生成`} onClick={() => onModeChange(item.mode)}>
|
||
{indicator(!agentActive && item.mode === mode)}
|
||
{item.icon}
|
||
<span>{item.label}</span>
|
||
</button>
|
||
))}
|
||
{onAgentSelect ? <button type="button" className="creation-mode-button" data-mode="agent" aria-pressed={agentActive} onClick={onAgentSelect}>{indicator(agentActive)}<Brain /><span>Agent</span><i className="creation-mode-spark" aria-hidden /></button> : null}
|
||
</div>
|
||
</LayoutGroup>;
|
||
}
|
||
|
||
function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: (mode: CreationMode) => void }) {
|
||
return <CreationModeTabs mode={mode} onModeChange={onModeChange} />;
|
||
}
|
||
|
||
function GenerationSettingsMenu(props: ComposerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" });
|
||
const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动";
|
||
// 尺寸/比例/分辨率选项取同显示名分组内全部模型的并集,路由模型只决定发送参数。
|
||
const mergedProfile = mergedImageCapabilityConfig(props.config, props.model || props.config.imageModel);
|
||
const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(mergedProfile.size);
|
||
const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(mergedProfile.size.values) : [];
|
||
const ratios = props.videoProfile.ratios;
|
||
const referenceImageSize = props.mode === "image" && mergedProfile.size.allowCustom ? props.referenceImageSize : undefined;
|
||
const referenceImageSizeValue = referenceImageSize ? String(referenceImageSize.width) + "x" + String(referenceImageSize.height) : "";
|
||
const referenceImageSizeLabel = referenceImageSize ? String(referenceImageSize.width) + " × " + String(referenceImageSize.height) : "";
|
||
const referenceImageSizeSelected = Boolean(referenceImageSizeValue && props.ratio === referenceImageSizeValue);
|
||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||
const selectReferenceImageSize = () => {
|
||
if (!referenceImageSizeValue) return;
|
||
props.setRatio(referenceImageSizeValue);
|
||
};
|
||
const videoResolutionSupported = props.mode === "video" && resolutions.length > 0;
|
||
const imageSummary = [
|
||
...(mergedProfile.size.parameter !== "none" ? [referenceImageSizeSelected ? referenceImageSizeLabel : usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
|
||
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
|
||
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
|
||
].join(" · ");
|
||
const videoRatioSupported = props.mode === "video" && ratios.length > 0;
|
||
const summary = props.mode === "video" ? [...(videoRatioSupported ? [props.ratio] : []), ...(videoResolutionSupported ? [videoResolutionLabel(props.videoQuality)] : [])].join(" · ") : imageSummary;
|
||
const panel = <div className="creation-parameter-menu">
|
||
{props.mode === "image" ? <ImageSizePicker profile={mergedProfile} size={props.ratio} quality={props.quality} onChange={(size, quality) => { props.setRatio(size); if (quality) props.setQuality(quality); }} /> : videoRatioSupported ? <SettingSection title="画幅" value={props.ratio}><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => props.setRatio(value)}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div></SettingSection> : null}
|
||
{props.mode === "image" && referenceImageSizeValue ? <button type="button" className="creation-custom-trigger" onClick={selectReferenceImageSize}>使用参考图尺寸 · {referenceImageSizeLabel}</button> : null}
|
||
{props.mode === "video" ? (videoResolutionSupported ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.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> : null) : <>
|
||
|
||
{props.imageProfile.quality.supported && !imageResolutionUsesQuality(mergedProfile) ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.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> : null}
|
||
{props.imageProfile.maxOutputs > 1 ? <SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).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(String(Math.max(1, Math.min(props.imageProfile.maxOutputs, Number(event.target.value) || 1))))} aria-label={`生成数量,范围 1 到 ${props.imageProfile.maxOutputs}`} /><em>张</em></label></div></SettingSection> : null}
|
||
</>}
|
||
</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({ profile, seconds, onChange }: { profile: VideoCapabilityConfig; seconds: string; onChange: (value: string) => void }) {
|
||
const [open, setOpen] = useState(false);
|
||
const value = Number(normalizeVideoValue(profile, { seconds }).seconds);
|
||
const presets = profile.duration.selection === "enum" ? videoDurationOptions(profile) : [];
|
||
const fallbackPreset = presets.length ? presets : [profile.duration.default];
|
||
const min = profile.duration.selection === "range" ? profile.duration.min || 1 : Math.min(...fallbackPreset);
|
||
const max = profile.duration.selection === "range" ? Math.max(min, profile.duration.max || min) : Math.max(...fallbackPreset);
|
||
const step = Math.max(1, profile.duration.step || 1);
|
||
const durationControl = profile.duration.selection === "range" ? <>
|
||
<input className="h-8 w-full" style={{ accentColor: "var(--creation-text)" }} type="range" min={min} max={max} step={step} value={value} aria-label="视频时长(秒)" onChange={(event) => onChange(event.target.value)} />
|
||
<div className="flex justify-between px-0.5 text-[var(--fs-tiny)] text-[var(--creation-muted)]"><span>{min}s</span><span>{max}s</span></div>
|
||
<label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min={min} max={max} step={step} 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 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>;
|
||
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>{durationControl}</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 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: 10, height: 10 };
|
||
// 画幅容器的可用空间是 14×10;同时计算宽高,避免 CSS 的 max-width/max-height 把宽银幕比例压扁。
|
||
const scale = Math.min(14 / width, 10 / height);
|
||
return { width: Math.max(4, Math.round(width * scale)), height: Math.max(4, Math.round(height * scale)) };
|
||
}
|