|
|
|
|
@@ -1,13 +1,14 @@
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
|
|
|
|
|
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
|
|
|
|
import { Reorder } from "motion/react";
|
|
|
|
|
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react";
|
|
|
|
|
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, WandSparkles, X } from "lucide-react";
|
|
|
|
|
import { Link } from "react-router";
|
|
|
|
|
|
|
|
|
|
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
|
|
|
|
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
|
|
|
|
|
import { MessageReasoning } from "@/components/ai/message-reasoning";
|
|
|
|
|
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
|
|
|
|
import { CachedResourceImage } from "@/components/cached-resource-image";
|
|
|
|
|
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
|
|
|
|
import { CanvasPromptOptimizerDrawer } from "@/components/canvas/canvas-prompt-optimizer-drawer";
|
|
|
|
|
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
|
|
|
|
@@ -29,6 +30,7 @@ import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerati
|
|
|
|
|
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
|
|
|
|
import { subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
|
|
|
|
import { isLocalDreaminaWaitStopped, localDreaminaCancellationMessage } from "@/services/local-dreamina-task-projection";
|
|
|
|
|
import { resolveResourceUrl } from "@/services/api/resources";
|
|
|
|
|
import { uploadMediaFile } from "@/services/file-storage";
|
|
|
|
|
import { uploadImage } from "@/services/image-storage";
|
|
|
|
|
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
|
|
|
|
|
@@ -1086,7 +1088,9 @@ function CreationUserMessage({ item }: { item: CreationMessage }) {
|
|
|
|
|
const kind = creationAttachmentKind(attachment);
|
|
|
|
|
const previewable = kind === "image" || kind === "video";
|
|
|
|
|
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
|
|
|
|
|
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : url); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !url}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <img src={url} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
|
|
|
|
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
|
|
|
|
|
const previewUrl = kind === "image" ? imageUrl : url;
|
|
|
|
|
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
|
|
|
|
})}</div> : null}
|
|
|
|
|
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
|
|
|
|
</article>;
|
|
|
|
|
@@ -1117,7 +1121,8 @@ function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: str
|
|
|
|
|
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
|
|
|
|
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
|
|
|
|
|
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
|
|
|
|
|
return <span key={reference.id} className="creation-user-message-reference">{reference.previewUrl && reference.kind === "video" ? <video src={reference.previewUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : reference.previewUrl && reference.kind === "image" ? <img src={reference.previewUrl} alt="" /> : <Icon />}<span>{reference.label}</span></span>;
|
|
|
|
|
const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl;
|
|
|
|
|
return <span key={reference.id} className="creation-user-message-reference">{imageUrl && reference.kind === "video" ? <video src={imageUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : imageUrl && reference.kind === "image" ? <CachedResourceImage storageKey={reference.storageKey} src={imageUrl} alt="" loading="lazy" decoding="async" /> : <Icon />}<span>{reference.label}</span></span>;
|
|
|
|
|
})}</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -1133,9 +1138,11 @@ function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
|
|
|
|
|
const kind = creationAttachmentKind(item);
|
|
|
|
|
const previewable = kind === "image" || kind === "video";
|
|
|
|
|
const url = (kind === "video" ? item.url : item.previewUrl) || "";
|
|
|
|
|
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <img src={item.previewUrl} alt={item.name} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
|
|
|
|
|
const imageUrl = kind === "image" ? resolveResourceUrl(item.storageKey, item.previewUrl) : "";
|
|
|
|
|
const previewUrl = kind === "image" ? imageUrl : url;
|
|
|
|
|
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <CachedResourceImage storageKey={item.storageKey} src={imageUrl} alt={item.name} loading="lazy" decoding="async" fallback={<span className="creation-chat-file-icon"><ImageIcon /></span>} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
|
|
|
|
|
return <div className="creation-reference-card-content">
|
|
|
|
|
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", url)} aria-label={`放大预览 ${item.name}`} disabled={!url}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
|
|
|
|
|
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", previewUrl)} aria-label={`放大预览 ${item.name}`} disabled={!previewUrl}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
|
|
|
|
|
<button type="button" className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()} onMouseDownCapture={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
|
|
|
|
|
</div>;
|
|
|
|
|
}
|
|
|
|
|
@@ -1194,7 +1201,8 @@ function CreationComposer(props: ComposerProps) {
|
|
|
|
|
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: false, isDragging: false });
|
|
|
|
|
const [trackState, setTrackState] = useState({ canScrollLeft: false, canScrollRight: false, isExpanded: true, isDragging: false });
|
|
|
|
|
const previousAttachmentCountRef = useRef(props.attachments.length);
|
|
|
|
|
const interactionBusy = props.busy || props.referenceReplacementBusy;
|
|
|
|
|
const canSubmit = Boolean(props.prompt.trim()) && !interactionBusy;
|
|
|
|
|
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
|
|
|
|
@@ -1244,7 +1252,10 @@ function CreationComposer(props: ComposerProps) {
|
|
|
|
|
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(() => {
|
|
|
|
|
@@ -1405,9 +1416,8 @@ function CreationComposer(props: ComposerProps) {
|
|
|
|
|
<span>优化</span>
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip> : null}
|
|
|
|
|
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={interactionBusy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
|
|
|
|
|
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={interactionBusy || !referencesSupported} aria-label="打开素材库选择参考内容"><FolderOpen /><span>素材库</span></button></Tooltip>
|
|
|
|
|
<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 variant="creation" />
|
|
|
|
|
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={interactionBusy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
|
|
|
|
|
<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} 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}
|
|
|
|
|
</div>
|
|
|
|
|
@@ -1723,7 +1733,9 @@ function StoryboardBriefAttachments({ attachments }: { attachments: CreationAtta
|
|
|
|
|
const kind = creationAttachmentKind(attachment);
|
|
|
|
|
const previewable = kind === "image" || kind === "video";
|
|
|
|
|
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
|
|
|
|
|
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : url); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !url}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <img src={url} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
|
|
|
|
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
|
|
|
|
|
const previewUrl = kind === "image" ? imageUrl : url;
|
|
|
|
|
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
|
|
|
|
|
})}</div><CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|