@@ -145,14 +145,17 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
duration: { selection: "range", min: 1, max: 15, step: 1, default: 6 },
|
||||
ratios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
|
||||
defaultRatio: "16:9",
|
||||
resolutions: ["480p", "720p", "1080p", "2160p"],
|
||||
resolutions: ["480p", "720p", "1080p", "1440p", "2160p"],
|
||||
defaultResolution: "720p",
|
||||
generateAudio: { supported: false, default: false },
|
||||
watermark: { supported: false, default: false },
|
||||
operations: ["text_to_video", "image_to_video"],
|
||||
defaultOperation: "text_to_video",
|
||||
};
|
||||
if (protocol === "volcengine-jimeng-video") video.duration = { selection: "enum", values: [5, 10], default: 5 };
|
||||
if (protocol === "volcengine-jimeng-video") {
|
||||
video.duration = { selection: "enum", values: [5, 10], default: 5 };
|
||||
video.resolutions = ["720p"];
|
||||
}
|
||||
if (protocol === "gemini-veo") {
|
||||
video.duration = { selection: "enum", values: [4, 6, 8], default: 6 };
|
||||
video.resolutions = ["720p", "1080p"];
|
||||
@@ -166,6 +169,7 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
|
||||
video.references.maxAudioDurationSeconds = 15;
|
||||
video.generateAudio = { supported: true, default: true };
|
||||
}
|
||||
if (protocol === "volcengine-ark-video" || protocol === "newapi-channel-1") video.resolutions = ["480p", "720p", "1080p"];
|
||||
if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false };
|
||||
if (protocol === "novita-video") {
|
||||
video.references.maxImages = 1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const VIDEO_DURATION_OPTIONS = [6, 9, 10, 15] as const;
|
||||
export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 2160] as const;
|
||||
export const VIDEO_RESOLUTION_OPTIONS = [480, 720, 1080, 1440, 2160] as const;
|
||||
export const VIDEO_DURATION_MIN = 1;
|
||||
|
||||
export function normalizeVideoDuration(value: string | number | undefined) {
|
||||
@@ -11,11 +11,8 @@ 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 === "2k") return "1440";
|
||||
if (token === "4k") return "2160";
|
||||
const resolution = Number(token.replace(/p$/i, "")) || 720;
|
||||
return String(nearestOption(resolution, VIDEO_RESOLUTION_OPTIONS));
|
||||
}
|
||||
|
||||
function nearestOption(value: number, options: readonly number[]) {
|
||||
return options.reduce((nearest, option) => Math.abs(option - value) < Math.abs(nearest - value) ? option : nearest, options[0]);
|
||||
const resolution = Number(token.replace(/p$/i, ""));
|
||||
return Number.isFinite(resolution) && resolution > 0 ? String(Math.floor(resolution)) : "720";
|
||||
}
|
||||
|
||||
@@ -4,9 +4,20 @@ import type { Asset, AudioAsset, ImageAsset, NewAsset } from "@/stores/use-asset
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
export type CreationAttachment = (ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string };
|
||||
export type CreationDocumentAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
url: string;
|
||||
storageKey: string;
|
||||
bytes: number;
|
||||
previewUrl: string;
|
||||
};
|
||||
export type CreationAttachment = ((ReferenceImage | ReferenceVideo | ReferenceAudio) & { previewUrl: string }) | CreationDocumentAttachment;
|
||||
export type CreationMode = "text" | "image" | "video";
|
||||
export type CreationAttachmentKind = "image" | "video" | "audio";
|
||||
export type CreationAttachmentKind = "image" | "video" | "audio" | "file";
|
||||
|
||||
const textDocumentExtensions = [".pdf", ".txt", ".md", ".csv", ".json", ".html", ".xml", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"];
|
||||
|
||||
export type CreationAssetIdentity = {
|
||||
taskId?: string;
|
||||
@@ -15,17 +26,24 @@ export type CreationAssetIdentity = {
|
||||
};
|
||||
|
||||
export function creationUploadAccept(mode: CreationMode) {
|
||||
return mode === "video" ? "image/*,video/*,audio/*" : "image/*";
|
||||
if (mode === "video") return "image/*,video/*,audio/*";
|
||||
if (mode === "text") return `image/*,video/*,audio/*,${textDocumentExtensions.join(",")}`;
|
||||
return "image/*";
|
||||
}
|
||||
|
||||
export function creationFileAccepted(mode: CreationMode, file: Pick<File, "type">) {
|
||||
return file.type.startsWith("image/") || (mode === "video" && (file.type.startsWith("video/") || file.type.startsWith("audio/")));
|
||||
export function creationFileAccepted(mode: CreationMode, file: Pick<File, "type" | "name">) {
|
||||
if (file.type.startsWith("image/")) return true;
|
||||
if (mode === "video") return file.type.startsWith("video/") || file.type.startsWith("audio/");
|
||||
if (mode !== "text") return false;
|
||||
const name = file.name.toLowerCase();
|
||||
return file.type.startsWith("video/") || file.type.startsWith("audio/") || file.type.startsWith("text/") || textDocumentExtensions.some((extension) => name.endsWith(extension));
|
||||
}
|
||||
|
||||
export function creationAttachmentKind(attachment: Pick<CreationAttachment, "type">): CreationAttachmentKind {
|
||||
if (attachment.type.startsWith("video/")) return "video";
|
||||
if (attachment.type.startsWith("audio/")) return "audio";
|
||||
return "image";
|
||||
if (attachment.type.startsWith("image/")) return "image";
|
||||
return "file";
|
||||
}
|
||||
|
||||
export function splitCreationAttachments(attachments: CreationAttachment[]) {
|
||||
@@ -42,6 +60,16 @@ export function creationAttachmentPreview(attachment: CreationAttachment): { kin
|
||||
return { kind, url };
|
||||
}
|
||||
|
||||
export function creationMediaAspectRatio(value: string | undefined, mode: CreationMode) {
|
||||
const fallback = mode === "video" ? "16 / 9" : "1 / 1";
|
||||
const match = value?.trim().match(/^(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)$/i);
|
||||
if (!match) return fallback;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return fallback;
|
||||
return `${width} / ${height}`;
|
||||
}
|
||||
|
||||
export function removeCreationAttachment<T extends { id: string }>(attachments: T[], id: string) {
|
||||
return attachments.filter((attachment) => attachment.id !== id);
|
||||
}
|
||||
@@ -108,6 +136,18 @@ export function creationAttachmentFromAudio(file: File, uploaded: UploadedFile):
|
||||
};
|
||||
}
|
||||
|
||||
export function creationAttachmentFromDocument(file: File, uploaded: UploadedFile): CreationAttachment {
|
||||
return {
|
||||
id: `upload:${file.name}:${uploaded.storageKey}`,
|
||||
name: file.name,
|
||||
type: uploaded.mimeType || file.type || "application/octet-stream",
|
||||
url: uploaded.url,
|
||||
storageKey: uploaded.storageKey,
|
||||
bytes: uploaded.bytes,
|
||||
previewUrl: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachment {
|
||||
const url = asset.data.dataUrl || asset.coverUrl;
|
||||
return {
|
||||
|
||||
@@ -52,7 +52,7 @@ export function expandCreationPrompt(prompt: string, references: CreationReferen
|
||||
}
|
||||
if (reference.attachmentId) {
|
||||
const position = attachmentPositions.get(reference.attachmentId);
|
||||
const kindLabel = reference.kind === "video" ? "视频" : reference.kind === "audio" ? "音频" : "图片";
|
||||
const kindLabel = reference.kind === "video" ? "视频" : reference.kind === "audio" ? "音频" : reference.kind === "text" ? "文件" : "图片";
|
||||
mediaMappings.push(`- @${reference.label}:参考${kindLabel} ${position || 1}`);
|
||||
return;
|
||||
}
|
||||
@@ -70,11 +70,11 @@ export function creationReferenceMetadata(references: CreationReference[]) {
|
||||
|
||||
function attachmentReference(attachment: CreationAttachment, index: number): CreationReference {
|
||||
const kind = creationAttachmentKind(attachment);
|
||||
const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : "图片";
|
||||
const label = kind === "video" ? "视频" : kind === "audio" ? "音频" : kind === "file" ? "文件" : "图片";
|
||||
return {
|
||||
id: `upload:${attachment.id}`,
|
||||
nodeId: `upload:${attachment.id}`,
|
||||
kind,
|
||||
kind: kind === "file" ? "text" : kind,
|
||||
label: `${label}${index + 1}`,
|
||||
title: "当前参考内容",
|
||||
previewUrl: attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react";
|
||||
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
|
||||
import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
|
||||
import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
@@ -8,31 +8,35 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen
|
||||
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers";
|
||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { requestImageQuestion } from "@/services/api/image";
|
||||
import { requestImageQuestion, type AiTextContentPart } from "@/services/api/image";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { cancelGenerationTask, subscribeGenerationTasks, type GenerationTask } from "@/services/api/task-center";
|
||||
import { isLocalDreaminaTaskId, isLocalDreaminaWaitStopped, localDreaminaCancellationCopy, localDreaminaCancellationMessage, localDreaminaDetachOutcome } from "@/services/local-dreamina-task-projection";
|
||||
import { uploadMediaFile } from "@/services/file-storage";
|
||||
import { getMediaBlob, uploadMediaFile } from "@/services/file-storage";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
|
||||
import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
|
||||
import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
|
||||
import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
|
||||
import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, reconcileCreationAttachmentLimit, removeCreationReferenceTokens, selectedCreationReferences, type CreationReference } from "./creation-references";
|
||||
import { creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||||
import { creationAttachmentFromAsset, creationAttachmentFromAudio, creationAttachmentFromAudioAsset, creationAttachmentFromDocument, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationAttachmentKind, creationAudioAsset, creationFileAccepted, creationImageAsset, creationMediaAspectRatio, creationUploadAccept, creationVideoAsset, splitCreationAttachments, type CreationAttachment } from "./creation-assets";
|
||||
|
||||
type CreationMode = "text" | "image" | "video";
|
||||
type CreationViewMode = "chat" | "storyboard";
|
||||
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
|
||||
type CreationSettings = { ratio: string; seconds: string; quality: string; videoQuality: string; count: string };
|
||||
type CreationRetryContext = GenerationRetryContext & { retryContextsByBatchIndex?: GenerationRetryContext[] };
|
||||
@@ -81,6 +85,7 @@ const qualityOptions = [
|
||||
];
|
||||
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
|
||||
const countOptions = ["1", "2", "3", "4"];
|
||||
const TEXT_ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
|
||||
const conversationTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
const messageTimeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
|
||||
@@ -135,6 +140,7 @@ export default function CreatePage() {
|
||||
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 [viewMode, setViewMode] = useState<CreationViewMode>("chat");
|
||||
const [selectedShotIndex, setSelectedShotIndex] = useState(-1);
|
||||
const [composingNextShot, setComposingNextShot] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -311,15 +317,15 @@ export default function CreatePage() {
|
||||
};
|
||||
|
||||
const libraryItems = useMemo<AssetLibraryPickerItem[]>(() => assets
|
||||
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" }> => asset.kind === "image" || asset.kind === "video")
|
||||
.filter((asset): asset is Extract<Asset, { kind: "image" | "video" | "audio" }> => asset.kind === "image" || asset.kind === "video" || asset.kind === "audio")
|
||||
.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "video" ? "视频" : "图片",
|
||||
kindLabel: asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "图片",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
disabledReason: mode !== "video" && asset.kind === "video" ? "视频仅支持视频创作" : undefined,
|
||||
disabledReason: mode === "image" && asset.kind !== "image" ? "图片创作仅支持参考图" : undefined,
|
||||
})), [assets, mode]);
|
||||
const uploadCreationAsset = async (file: File) => {
|
||||
if (file.type.startsWith("video/")) {
|
||||
@@ -329,6 +335,17 @@ export default function CreatePage() {
|
||||
attachment: creationAttachmentFromVideo(file, uploaded),
|
||||
};
|
||||
}
|
||||
if (file.type.startsWith("audio/")) {
|
||||
const uploaded = await uploadMediaFile(file, "create-upload");
|
||||
return {
|
||||
asset: creationAudioAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
|
||||
attachment: creationAttachmentFromAudio(file, uploaded),
|
||||
};
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
const uploaded = await uploadMediaFile(file, "create-upload");
|
||||
return { attachment: creationAttachmentFromDocument(file, uploaded) };
|
||||
}
|
||||
const uploaded = await uploadImage(file);
|
||||
return {
|
||||
asset: creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }),
|
||||
@@ -341,12 +358,12 @@ export default function CreatePage() {
|
||||
return;
|
||||
}
|
||||
const next = Array.from(files)
|
||||
.filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/")))
|
||||
.filter((file) => creationFileAccepted(mode, file))
|
||||
.slice(0, Math.max(0, maxReferences - attachments.length));
|
||||
if (!next.length) return;
|
||||
void Promise.allSettled(next.map(async (file) => {
|
||||
const { asset, attachment } = await uploadCreationAsset(file);
|
||||
addAsset(asset);
|
||||
if (asset) addAsset(asset);
|
||||
return attachment;
|
||||
})).then((settled) => {
|
||||
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||||
@@ -357,13 +374,13 @@ export default function CreatePage() {
|
||||
};
|
||||
|
||||
const uploadLibraryAssets = async (files: FileList | File[]) => {
|
||||
const next = Array.from(files).filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/")));
|
||||
const next = Array.from(files).filter((file) => creationFileAccepted(mode, file));
|
||||
if (!next.length) return [];
|
||||
const settled = await Promise.allSettled(next.map(async (file) => {
|
||||
const { asset } = await uploadCreationAsset(file);
|
||||
return addAsset(asset);
|
||||
return asset ? addAsset(asset) : "";
|
||||
}));
|
||||
const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||||
const assetIds = settled.flatMap((entry) => entry.status === "fulfilled" && entry.value ? [entry.value] : []);
|
||||
const failed = settled.filter((entry) => entry.status === "rejected");
|
||||
if (assetIds.length) toast.success(`${assetIds.length} 个素材已上传到素材库并自动选中`);
|
||||
if (failed.length) toast.error(`${failed.length} 个素材上传失败,请重试`);
|
||||
@@ -378,7 +395,8 @@ export default function CreatePage() {
|
||||
const handleLibrarySelect = (selected: Asset[]) => {
|
||||
const next = selected.flatMap((asset): CreationAttachment[] => {
|
||||
if (asset.kind === "image") return [creationAttachmentFromAsset(asset)];
|
||||
if (asset.kind === "video" && mode === "video") return [creationAttachmentFromVideoAsset(asset)];
|
||||
if (asset.kind === "video" && mode !== "image") return [creationAttachmentFromVideoAsset(asset)];
|
||||
if (asset.kind === "audio" && mode !== "image") return [creationAttachmentFromAudioAsset(asset)];
|
||||
return [];
|
||||
});
|
||||
if (!next.length) return;
|
||||
@@ -458,12 +476,12 @@ export default function CreatePage() {
|
||||
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) => ({
|
||||
const history = await Promise.all([...(activeConversation.messages || []), userMessage].map(async (item) => ({
|
||||
role: item.role,
|
||||
content: item.role === "user"
|
||||
? buildTextMessageContent(item)
|
||||
? await buildTextMessageContent(item)
|
||||
: item.content,
|
||||
}));
|
||||
})));
|
||||
await requestImageQuestion(requestConfig, history, (text) => updateOriginAssistant((item) => ({ ...item, content: text })), { signal: requestLifecycle.signal });
|
||||
} else if (mode === "image") {
|
||||
const taskCount = Math.max(1, Math.min(imageProfile.maxOutputs, Math.floor(Number(count) || 1)));
|
||||
@@ -702,7 +720,7 @@ export default function CreatePage() {
|
||||
count,
|
||||
setCount,
|
||||
composerFocusRef,
|
||||
placeholderOverride: composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined,
|
||||
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined,
|
||||
onSubmit: () => void submit(),
|
||||
onStop: () => abortRef.current?.abort(),
|
||||
};
|
||||
@@ -731,7 +749,20 @@ export default function CreatePage() {
|
||||
onOpenLibrary={() => { selectMode("image"); setLibraryOpen(true); }}
|
||||
/>
|
||||
</main>
|
||||
</> : <div className="storyboard-workbench">
|
||||
</> : viewMode === "chat" ? <div className="creation-thread-workbench">
|
||||
<CreationWorkspaceToolbar viewMode={viewMode} onViewModeChange={setViewMode} onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} />
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-thread-scroll creation-scrollbar">
|
||||
<section className="creation-thread-stage"><div className="creation-results">{activeConversation.messages.map((item, index) => <CreationMessageView
|
||||
key={item.id}
|
||||
item={item}
|
||||
modelName={item.model ? modelDisplayName(config, item.model) : ""}
|
||||
onRetryFailure={() => retryFailedMessage(item, index)}
|
||||
onCreateVariant={() => createVariant(item, index)}
|
||||
onCancel={() => void cancelPendingMessage(item)}
|
||||
/>)}</div></section>
|
||||
</main>
|
||||
<section className="creation-thread-composer"><CreationComposer {...composerProps} variant="thread" /></section>
|
||||
</div> : <div className="storyboard-workbench">
|
||||
<StoryboardToolbar
|
||||
shots={shots}
|
||||
activeIndex={visibleShotIndex}
|
||||
@@ -741,6 +772,8 @@ export default function CreatePage() {
|
||||
onCancelCompose={cancelComposeNextShot}
|
||||
onNewConversation={startNewConversation}
|
||||
onOpenHistory={() => setHistoryOpen(true)}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="storyboard-workbench-stage creation-scrollbar">
|
||||
<div className="storyboard-workbench-stage-inner">
|
||||
@@ -766,7 +799,7 @@ export default function CreatePage() {
|
||||
items={libraryItems}
|
||||
categoryLabels={creationAssetCategoryLabels}
|
||||
initialSelectedIds={attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6))}
|
||||
upload={{ accept: mode === "video" ? "image/*,video/*" : "image/*", description: `支持图片${mode === "video" ? "和视频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }}
|
||||
upload={{ accept: creationUploadAccept(mode), description: mode === "text" ? "支持图片、视频、音频和常用文档;媒体会保存到素材库" : `支持图片${mode === "video" ? "、视频和音频" : ""},上传后保存到素材库`, onUpload: uploadLibraryAssets }}
|
||||
onClose={() => setLibraryOpen(false)}
|
||||
onConfirm={(ids) => handleLibrarySelect(assets.filter((asset) => ids.includes(asset.id)))}
|
||||
/>
|
||||
@@ -820,6 +853,75 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec
|
||||
</Drawer>;
|
||||
}
|
||||
|
||||
function CreationViewSwitch({ viewMode, onChange }: { viewMode: CreationViewMode; onChange: (mode: CreationViewMode) => void }) {
|
||||
return <div className="creation-view-switch" role="group" aria-label="创作视图">
|
||||
<button type="button" aria-pressed={viewMode === "chat"} onClick={() => onChange("chat")}><MessageSquareText />连续对话</button>
|
||||
<button type="button" aria-pressed={viewMode === "storyboard"} onClick={() => onChange("storyboard")}><Clapperboard />镜头创作</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversation, onOpenHistory }: { viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void; onNewConversation: () => void; onOpenHistory: () => void }) {
|
||||
return <header className="creation-thread-toolbar">
|
||||
<CreationViewSwitch viewMode={viewMode} onChange={onViewModeChange} />
|
||||
<div className="storyboard-workbench-bar-actions">
|
||||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="storyboard-workbench-bar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="storyboard-workbench-bar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||||
</div>
|
||||
</header>;
|
||||
}
|
||||
|
||||
function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||||
if (item.role === "user") return <CreationUserMessage item={item} />;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
|
||||
return <article className={`creation-assistant-message is-${mode}`}>
|
||||
<div className="creation-message-heading"><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</div>
|
||||
{mode === "text" ? <div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div> : <MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onCancel={onCancel} />}
|
||||
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
|
||||
</article>;
|
||||
}
|
||||
|
||||
function CreationUserMessage({ item }: { item: CreationMessage }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const copyText = useCopyText();
|
||||
const visiblePrompt = displayCreationPrompt(item.content, item.references || []);
|
||||
return <article className="creation-user-message">
|
||||
<div className="creation-user-message-meta"><span>你</span>{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}<Tooltip title="复制消息"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip></div>
|
||||
<div className="creation-user-message-copy-wrap"><p>{visiblePrompt}</p></div>
|
||||
{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}
|
||||
{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
|
||||
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>;
|
||||
})}</div> : null}
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</article>;
|
||||
}
|
||||
|
||||
function MediaResult({ item, onRetryFailure, onCreateVariant, onCancel }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onCancel: () => void }) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const resultUrls = item.resultUrls || [];
|
||||
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
|
||||
const canvasPath = creationCanvasHandoffPath(resultAssetIds) || "/canvas";
|
||||
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} onCancel={item.taskIds?.length ? onCancel : undefined} />;
|
||||
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
|
||||
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
|
||||
const isVideo = item.mode === "video";
|
||||
return <div className="creation-media-result">
|
||||
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
|
||||
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><button type="button" onClick={onCreateVariant}><RefreshCw />生成同款</button><Link to={canvasPath}>{resultAssetIds.length ? "添加到画布" : "打开画布"}</Link>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function CreationMediaPending({ mode, ratio, onCancel }: { mode: CreationMode; ratio?: string; onCancel?: () => void }) {
|
||||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><Sparkles /></span><span className="sr-only">影策正在生成{mode === "video" ? "视频" : "图像"}</span>{onCancel ? <button type="button" onClick={onCancel}>取消任务</button> : null}</div>;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -839,12 +941,13 @@ function CreationAttachmentThumbnail({ item, primary = false, canAddMore = false
|
||||
onRemove: (id: string) => void;
|
||||
onAdd?: () => void;
|
||||
}) {
|
||||
const isVideo = isVideoAttachment(item);
|
||||
const url = isVideo ? item.url : item.previewUrl;
|
||||
const kind = creationAttachmentKind(item);
|
||||
const previewable = kind === "image" || kind === "video";
|
||||
const url = (kind === "video" ? item.url : item.previewUrl) || "";
|
||||
return <div className={primary ? "creation-chat-reference is-paper creation-chat-reference-media" : "creation-chat-attachment"}>
|
||||
<button type="button" className="creation-chat-attachment-preview" onClick={() => onPreview(isVideo ? "video" : "image", url)} aria-label={`放大预览 ${item.name}`} disabled={!url}>
|
||||
{isVideo ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : <img src={item.previewUrl} alt={item.name} />}
|
||||
<span aria-hidden="true"><Maximize2 /></span>
|
||||
<button type="button" className={`creation-chat-attachment-preview${previewable ? "" : " is-file"}`} onClick={() => { if (previewable) onPreview(kind === "video" ? "video" : "image", url); }} aria-label={previewable ? `放大预览 ${item.name}` : item.name} disabled={previewable && !url}>
|
||||
{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>}
|
||||
{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}
|
||||
</button>
|
||||
<button type="button" className="creation-chat-attachment-remove" onClick={() => onRemove(item.id)} aria-label={`移除 ${item.name}`}><X /></button>
|
||||
{primary && canAddMore && onAdd ? <Tooltip title="添加更多参考内容"><button type="button" className="creation-chat-reference-add" onClick={onAdd} aria-label="添加更多参考内容"><Plus /></button></Tooltip> : null}
|
||||
@@ -891,6 +994,18 @@ function CreationComposer(props: ComposerProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const canSubmit = Boolean(props.prompt.trim()) && !props.busy;
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const priceChannel = resolveModelChannel(props.config, props.model);
|
||||
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,
|
||||
});
|
||||
const showCost = creditsEnabled && credits !== null;
|
||||
const formattedCredits = credits?.toLocaleString("zh-CN", { maximumFractionDigits: 6 });
|
||||
const actionLabel = props.busy ? "停止生成" : showCost ? `预计消耗 ${formattedCredits} 积分,发送` : "发送";
|
||||
const placeholder = props.mode === "text"
|
||||
? "描述你的故事、角色或想继续讨论的创意"
|
||||
: props.mode === "image"
|
||||
@@ -908,7 +1023,7 @@ function CreationComposer(props: ComposerProps) {
|
||||
};
|
||||
return <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<input ref={props.fileInputRef} type="file" hidden accept={props.mode === "video" ? "image/*,video/*" : "image/*"} multiple onChange={props.onFileChange} />
|
||||
<input ref={props.fileInputRef} type="file" hidden accept={creationUploadAccept(props.mode)} multiple onChange={props.onFileChange} />
|
||||
{primaryAttachment ? <CreationAttachmentThumbnail item={primaryAttachment} primary canAddMore={canAddMoreReferences && !props.busy} onPreview={previewAttachment} onRemove={props.onRemoveAttachment} onAdd={props.onOpenLibrary} /> : <Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-reference is-paper" onClick={props.onOpenLibrary} disabled={props.busy || !referencesSupported} aria-label="打开素材库选择参考内容"><Plus /><span>参考内容</span></button></Tooltip>}
|
||||
<div className="creation-chat-editor">
|
||||
<CanvasResourceMentionTextarea ref={props.composerFocusRef} value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} 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="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||||
@@ -922,12 +1037,29 @@ function CreationComposer(props: ComposerProps) {
|
||||
onTranscribed={(text) => props.setPrompt(props.prompt.trim() ? `${props.prompt} ${text}` : text)}
|
||||
/>
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
<Tooltip title="从本机上传附件"><button type="button" className="creation-chat-control" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || !referencesSupported} aria-label="从本机上传附件"><Paperclip /><span>附件</span></button></Tooltip>
|
||||
<Tooltip title={!referencesSupported ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-control" onClick={props.onOpenLibrary} disabled={props.busy || !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={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>
|
||||
{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>}
|
||||
<Button
|
||||
type="text"
|
||||
className={`canvas-node-composer-submit ${showCost ? "has-cost" : ""}`}
|
||||
danger={props.busy}
|
||||
disabled={!props.busy && !canSubmit}
|
||||
style={{
|
||||
color: !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action": !props.busy && !canSubmit ? "var(--creation-surface-hover)" : props.busy ? "var(--status-error)" : "var(--creation-text)",
|
||||
"--canvas-composer-submit-action-fg": !props.busy && !canSubmit ? "var(--creation-faint)" : "var(--creation-bg)",
|
||||
} as CSSProperties}
|
||||
onClick={props.busy ? props.onStop : props.onSubmit}
|
||||
aria-label={actionLabel}
|
||||
title={actionLabel}
|
||||
>
|
||||
{showCost ? <span className="canvas-node-composer-submit-cost"><CreditSymbol /><span>{formattedCredits}</span></span> : null}
|
||||
<span className="canvas-node-composer-submit-action" aria-hidden>{props.busy ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}</span>
|
||||
</Button>
|
||||
</footer>
|
||||
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
|
||||
</section>;
|
||||
@@ -951,17 +1083,42 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio));
|
||||
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 ratios = props.mode === "video" ? props.videoProfile.ratios : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value);
|
||||
const usesImageResolutionPicker = props.mode === "image" && supportsImageResolutionPresets(props.imageProfile.size);
|
||||
const imageResolutionOptions = usesImageResolutionPicker ? buildImageResolutionOptions(props.imageProfile.size.values) : [];
|
||||
const activeImageResolution = usesImageResolutionPicker ? imageResolutionOption(imageResolutionOptions, props.ratio) : undefined;
|
||||
const activeImageRatio = activeImageResolution?.ratio || imageRatioForSize(props.ratio) || (props.ratio.includes(":") ? props.ratio : "1:1");
|
||||
const activeImageResolutionChoice: ImageResolutionChoice = activeImageResolution?.tier || "auto";
|
||||
const imageResolutionChoiceOptions = usesImageResolutionPicker ? imageResolutionChoices(props.imageProfile.size.values) : [];
|
||||
const imageRatios = usesImageResolutionPicker
|
||||
? Array.from(new Set(imageResolutionOptions.filter((item) => !activeImageResolution || item.tier === activeImageResolution.tier).map((item) => item.ratio)))
|
||||
: props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value);
|
||||
const ratios = props.mode === "video" ? props.videoProfile.ratios : imageRatios;
|
||||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||||
const selectImageRatio = (nextRatio: string) => {
|
||||
if (!usesImageResolutionPicker || activeImageResolutionChoice === "auto") {
|
||||
props.setRatio(nextRatio);
|
||||
return;
|
||||
}
|
||||
props.setRatio(imageSizeForResolution(imageResolutionOptions, activeImageResolutionChoice, nextRatio) || nextRatio);
|
||||
};
|
||||
const selectImageResolution = (choice: ImageResolutionChoice) => {
|
||||
if (choice === "auto") {
|
||||
props.setRatio(props.imageProfile.size.values.includes("auto") ? "auto" : activeImageRatio);
|
||||
return;
|
||||
}
|
||||
const nextSize = imageSizeForResolution(imageResolutionOptions, choice, activeImageRatio) || imageResolutionOptions.find((item) => item.tier === choice)?.size;
|
||||
if (nextSize) props.setRatio(nextSize);
|
||||
};
|
||||
const imageSummary = [
|
||||
...(props.imageProfile.size.parameter !== "none" ? [props.ratio] : []),
|
||||
...(props.imageProfile.size.parameter !== "none" ? [usesImageResolutionPicker ? formatImageResolutionSize(props.ratio, imageResolutionOptions) : props.ratio] : []),
|
||||
...(props.imageProfile.quality.supported ? [qualityLabel] : []),
|
||||
...(props.imageProfile.maxOutputs > 1 ? [props.count] : []),
|
||||
].join(" · ");
|
||||
const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : imageSummary;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><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); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div>{props.mode !== "video" && props.imageProfile.size.allowCustom && (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> : null}
|
||||
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.mode === "image" && usesImageResolutionPicker ? activeImageRatio : props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => { const selected = props.mode === "image" && usesImageResolutionPicker ? value === activeImageRatio : value === props.ratio; return <button key={value} type="button" aria-pressed={selected} className={selected ? "is-selected" : ""} onClick={() => { if (props.mode === "image") selectImageRatio(value); else props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>; })}</div>{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 x 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义图片尺寸或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义尺寸</button>)}</div></SettingSection> : null}
|
||||
{props.mode === "video" ? <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> : <>
|
||||
{imageResolutionChoiceOptions.length ? <SettingSection title="分辨率" value={activeImageResolutionChoice === "auto" ? "自动" : activeImageResolutionChoice.toUpperCase()}><div className="creation-choice-grid is-resolution">{imageResolutionChoiceOptions.map((choice) => <button key={choice} type="button" aria-pressed={choice === activeImageResolutionChoice} className={choice === activeImageResolutionChoice ? "is-selected" : ""} onClick={() => selectImageResolution(choice)}>{choice === "auto" ? "自动" : choice.toUpperCase()}</button>)}</div></SettingSection> : null}
|
||||
{props.imageProfile.quality.supported ? <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}
|
||||
</>}
|
||||
@@ -1042,7 +1199,7 @@ function directorNoteFor(mode: CreationMode, settings: CreationSettings): string
|
||||
return "";
|
||||
}
|
||||
|
||||
function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void }) {
|
||||
function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCompose, onCancelCompose, onNewConversation, onOpenHistory, viewMode, onViewModeChange }: { shots: CreationShot[]; activeIndex: number; composing: boolean; onSelect: (index: number) => void; onBeginCompose: () => void; onCancelCompose: () => void; onNewConversation: () => void; onOpenHistory: () => void; viewMode: CreationViewMode; onViewModeChange: (mode: CreationViewMode) => void }) {
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const nextShotNumber = shots.length + 1;
|
||||
const closeRail = () => setRailOpen(false);
|
||||
@@ -1075,6 +1232,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="storyboard-workbench-bar-actions">
|
||||
<CreationViewSwitch viewMode={viewMode} onChange={onViewModeChange} />
|
||||
<Tooltip title={composing ? "收起下一镜" : "新增镜头"}><button type="button" aria-label={composing ? "收起下一镜" : "新增镜头"} className="storyboard-workbench-bar-action" onClick={composing ? onCancelCompose : onBeginCompose}>{composing ? <X /> : <Clapperboard />}</button></Tooltip>
|
||||
<Tooltip title="新建创作"><button type="button" aria-label="新建创作" className="storyboard-workbench-bar-action" onClick={onNewConversation}><Plus /></button></Tooltip>
|
||||
<Tooltip title="历史对话"><button type="button" aria-label="查看历史对话" className="storyboard-workbench-bar-action" onClick={onOpenHistory}><History /></button></Tooltip>
|
||||
@@ -1114,9 +1272,8 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
|
||||
<div className="storyboard-workbench-thread" aria-label={`镜头 ${shotNumber} 的对话过程`}>
|
||||
{briefVisible && user ? <div className="storyboard-workbench-turn is-user">
|
||||
<div className="storyboard-workbench-turn-copy">
|
||||
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role">{shotScriptLabels[mode]}</span>{user.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={user.createdAt}>{formatMessageTime(user.createdAt)}</time> : null}</div>
|
||||
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role">{shotScriptLabels[mode]}</span>{user.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={user.createdAt}>{formatMessageTime(user.createdAt)}</time> : null}<Tooltip title="复制消息"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip></div>
|
||||
<div className="storyboard-workbench-turn-bubble">
|
||||
<button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button>
|
||||
<p className="storyboard-workbench-turn-text">{visiblePrompt}</p>
|
||||
{user.references?.length ? <CreationMessageReferences references={user.references} /> : null}
|
||||
{user.attachments?.length ? <StoryboardBriefAttachments attachments={user.attachments} /> : null}
|
||||
@@ -1164,9 +1321,10 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number;
|
||||
function StoryboardBriefAttachments({ attachments }: { attachments: CreationAttachment[] }) { const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
return <><div className="creation-user-message-attachments storyboard-workbench-brief-attachments">{attachments.map((attachment) => {
|
||||
const isVideo = isVideoAttachment(attachment);
|
||||
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" onClick={() => { setPreviewType(isVideo ? "video" : "image"); setPreviewUrl(isVideo ? attachment.url : url); }} aria-label={`预览 ${attachment.name}`} disabled={!url}>{isVideo ? <video src={attachment.url} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : <img src={url} alt={attachment.name} width={44} height={44} loading="lazy" />}<span aria-hidden="true"><Maximize2 /></span></button>;
|
||||
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>;
|
||||
})}</div><CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></>;
|
||||
}
|
||||
|
||||
@@ -1222,19 +1380,50 @@ function conversationPreviewMessage(conversation: CreationConversation) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildTextMessageContent(item: CreationMessage) {
|
||||
async function buildTextMessageContent(item: CreationMessage) {
|
||||
const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []);
|
||||
const images = (item.attachments || []).filter(isImageAttachment);
|
||||
if (!images.length) return content;
|
||||
return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))];
|
||||
const attachments = item.attachments || [];
|
||||
if (!attachments.length) return content;
|
||||
const parts: AiTextContentPart[] = [{ type: "text", text: content }];
|
||||
for (const attachment of attachments) {
|
||||
if (isImageAttachment(attachment)) {
|
||||
parts.push({ type: "image_url", image_url: { url: attachment.dataUrl || attachment.url || "" } });
|
||||
continue;
|
||||
}
|
||||
const url = await creationAttachmentDataUrl(attachment);
|
||||
parts.push({ type: "file_url", file_url: { url, name: attachment.name || "附件", mimeType: attachment.type || "application/octet-stream" } });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function creationAttachmentDataUrl(attachment: CreationAttachment) {
|
||||
if ((attachment.bytes || 0) > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`);
|
||||
const attachmentUrl = attachment.url || "";
|
||||
if (attachmentUrl.startsWith("data:")) return attachmentUrl;
|
||||
const blob = attachment.storageKey ? await getMediaBlob(attachment.storageKey) : null;
|
||||
if (blob) {
|
||||
if (blob.size > TEXT_ATTACHMENT_MAX_BYTES) throw new Error(`${attachment.name} 超过 20MB,当前文本模型附件需要压缩后再上传`);
|
||||
return blobToDataUrl(blob);
|
||||
}
|
||||
if (/^https:\/\//i.test(attachmentUrl)) return attachmentUrl;
|
||||
throw new Error(`${attachment.name} 无法读取,请重新上传后再试`);
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob) {
|
||||
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(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } {
|
||||
return attachment.type.startsWith("video/");
|
||||
return creationAttachmentKind(attachment) === "video";
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } {
|
||||
return !isVideoAttachment(attachment);
|
||||
return creationAttachmentKind(attachment) === "image";
|
||||
}
|
||||
|
||||
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
|
||||
|
||||
@@ -14,9 +14,14 @@ import { imageSizeRequest, modelCapabilityConfigFor, normalizeImageValue, type I
|
||||
|
||||
export type AiTextMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
|
||||
content: string | AiTextContentPart[];
|
||||
};
|
||||
|
||||
export type AiTextContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } }
|
||||
| { type: "file_url"; file_url: { url: string; name: string; mimeType: string } };
|
||||
|
||||
export type ResponseToolCall = {
|
||||
id: string;
|
||||
type: "function";
|
||||
@@ -43,7 +48,7 @@ export type ToolResponseResult = {
|
||||
|
||||
type ToolChoice = "auto" | "required" | { type: "function"; name: string };
|
||||
type ResponseMessageContent = AiTextMessage["content"] | string;
|
||||
type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string };
|
||||
type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string };
|
||||
type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string };
|
||||
type ResponseApiToolDefinition = {
|
||||
type: "function";
|
||||
@@ -347,7 +352,13 @@ function toResponseInput(messages: ResponseInputMessage[]): ResponseInputItem[]
|
||||
|
||||
function toResponseContent(content: ResponseMessageContent): string | ResponseInputContent[] {
|
||||
if (!Array.isArray(content)) return String(content || "");
|
||||
return content.map((item) => (item.type === "text" ? { type: "input_text" as const, text: item.text } : { type: "input_image" as const, image_url: item.image_url.url }));
|
||||
return content.map((item) => {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text };
|
||||
if (item.type === "image_url") return { type: "input_image" as const, image_url: item.image_url.url };
|
||||
return item.file_url.url.startsWith("data:")
|
||||
? { type: "input_file" as const, filename: item.file_url.name, file_data: item.file_url.url }
|
||||
: { type: "input_file" as const, filename: item.file_url.name, file_url: item.file_url.url };
|
||||
});
|
||||
}
|
||||
|
||||
function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefinition {
|
||||
@@ -377,13 +388,24 @@ function toChatCompletionMessages(messages: ResponseInputMessage[]) {
|
||||
if (message.role === "tool") {
|
||||
result.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
|
||||
} else {
|
||||
result.push({ role: message.role, content: message.content });
|
||||
result.push({ role: message.role, content: toChatCompletionContent(message.content) });
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toChatCompletionContent(content: ResponseMessageContent) {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content.map((item) => {
|
||||
if (item.type !== "file_url") return item;
|
||||
const file = item.file_url.url.startsWith("data:")
|
||||
? { filename: item.file_url.name, file_data: item.file_url.url }
|
||||
: { filename: item.file_url.name, file_url: item.file_url.url };
|
||||
return { type: "file", file };
|
||||
});
|
||||
}
|
||||
|
||||
function toChatCompletionToolChoice(toolChoice: ToolChoice) {
|
||||
return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
|
||||
}
|
||||
@@ -653,18 +675,22 @@ function toGeminiContents(messages: ResponseInputMessage[]): GeminiContent[] {
|
||||
|
||||
function toGeminiParts(content: ResponseMessageContent): GeminiPart[] {
|
||||
if (!Array.isArray(content)) return [{ text: String(content || "") }];
|
||||
return content.map((item) => (item.type === "text" ? { text: item.text } : toGeminiImagePart(item.image_url.url)));
|
||||
return content.map((item) => {
|
||||
if (item.type === "text") return { text: item.text };
|
||||
if (item.type === "image_url") return toGeminiFilePart(item.image_url.url, "image/png");
|
||||
return toGeminiFilePart(item.file_url.url, item.file_url.mimeType);
|
||||
});
|
||||
}
|
||||
|
||||
function toGeminiImagePart(url: string): GeminiPart {
|
||||
function toGeminiFilePart(url: string, mimeType: string): GeminiPart {
|
||||
const match = url.match(/^data:([^;,]+);base64,(.+)$/);
|
||||
if (match) return { inlineData: { mimeType: match[1], data: match[2] } };
|
||||
return { fileData: { fileUri: url, mimeType: "image/png" } };
|
||||
return { fileData: { fileUri: url, mimeType } };
|
||||
}
|
||||
|
||||
function geminiTextContent(content: ResponseMessageContent) {
|
||||
if (!Array.isArray(content)) return String(content || "");
|
||||
return content.map((item) => (item.type === "text" ? item.text : item.image_url.url)).join("\n");
|
||||
return content.map((item) => item.type === "text" ? item.text : item.type === "image_url" ? item.image_url.url : `${item.file_url.name}: ${item.file_url.url}`).join("\n");
|
||||
}
|
||||
|
||||
function jsonObject(value: string): Record<string, unknown> {
|
||||
@@ -782,7 +808,7 @@ async function requestGeminiImages(config: AiConfig, prompt: string, references:
|
||||
async function requestGeminiImagesOnce(config: AiConfig, prompt: string, references: ReferenceImage[], options?: RequestOptions) {
|
||||
const parts: GeminiPart[] = [{ text: prompt }];
|
||||
for (const image of references) {
|
||||
parts.push(toGeminiImagePart(await imageToDataUrl(image)));
|
||||
parts.push(toGeminiFilePart(await imageToDataUrl(image), image.type || "image/png"));
|
||||
}
|
||||
const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config));
|
||||
const response = await axios.post<GeminiPayload>(
|
||||
|
||||
@@ -623,6 +623,7 @@ function normalizeVideoSize(value: string) {
|
||||
function normalizeVideoResolution(value: string) {
|
||||
if (value === "low") return "480p";
|
||||
if (value === "auto" || value === "high" || value === "medium") return "720p";
|
||||
if (value.toLowerCase() === "2k") return "1440p";
|
||||
if (value.toLowerCase() === "4k") return "2160p";
|
||||
const resolution = value.replace(/p$/i, "") || "720";
|
||||
return `${resolution}p`;
|
||||
|
||||
@@ -765,6 +765,7 @@
|
||||
--prompt-panel-label-fs: var(--fs-label); /* 11px 标签 */
|
||||
--settings-model-option-min-height: 76px;
|
||||
--prompt-panel-heading-fs: var(--fs-heading); /* 16px 区标题 */
|
||||
--creation-media-pending-max-width: 320px;
|
||||
|
||||
/* 进度条(对应 #98 决策3 任务进度)*/
|
||||
--progress-bar-height: 6px;
|
||||
@@ -3183,23 +3184,6 @@
|
||||
html:not(.dark) .creation-chat-control:hover,
|
||||
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { border-color: rgba(20, 26, 35, .2) !important; background: #e9edf1 !important; }
|
||||
|
||||
.creation-chat-submit {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: #303540;
|
||||
color: #f1f3f5;
|
||||
transition: background-color 140ms ease, color 140ms ease, transform 140ms var(--motion-ease-out);
|
||||
}
|
||||
.creation-chat-submit:hover:not(:disabled) { background: #f3f4f6; color: #121417; transform: translateY(-1px); }
|
||||
.creation-chat-submit:active:not(:disabled) { transform: scale(.96); }
|
||||
.creation-chat-submit:disabled { cursor: not-allowed; background: #282c33; color: #5c6370; }
|
||||
.creation-chat-submit.is-stopping { background: #bd4949; color: white; }
|
||||
|
||||
.creation-control-popover-surface {
|
||||
border: 0 !important;
|
||||
border-radius: 12px !important;
|
||||
@@ -3299,7 +3283,6 @@
|
||||
.creation-chat-controls { max-width: calc(100% - 42px); gap: 5px; }
|
||||
.creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 34px !important; padding: 0 8px !important; font-size: 11px; }
|
||||
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(152px, 48vw) !important; }
|
||||
.creation-chat-submit { width: 36px; height: 36px; }
|
||||
.creation-thread-stage { padding-top: 28px; }
|
||||
.creation-assistant-message { margin-bottom: 32px; }
|
||||
.creation-message-content, .creation-message-error { padding-left: 0; }
|
||||
@@ -6714,6 +6697,7 @@ html:not(.dark) .creation-chat-reference:hover:not(:disabled) { background: rgba
|
||||
.creation-chat-controls {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
@@ -6763,26 +6747,6 @@ html:not(.dark) .creation-chat-control.is-mode,
|
||||
html:not(.dark) .creation-chat-control:hover,
|
||||
html:not(.dark) .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(29, 33, 37, .06) !important; color: var(--creation-text) !important; }
|
||||
|
||||
.creation-chat-submit {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: #f3f4f5;
|
||||
color: #1b1d21;
|
||||
cursor: pointer;
|
||||
transition: transform 140ms var(--motion-ease-out), background-color 140ms ease;
|
||||
}
|
||||
|
||||
.creation-chat-submit:hover:not(:disabled) { background: #fff; transform: translateY(-1px); }
|
||||
.creation-chat-submit:disabled { background: #292d34; color: #606771; cursor: not-allowed; }
|
||||
.creation-chat-submit.is-stopping { background: #d06060; color: #fff; }
|
||||
html:not(.dark) .creation-chat-submit { background: #202328; color: #fff; }
|
||||
html:not(.dark) .creation-chat-submit:hover:not(:disabled) { background: #2d3139; }
|
||||
|
||||
.creation-control-popover-surface,
|
||||
.creation-model-picker-surface {
|
||||
overflow: hidden;
|
||||
@@ -6892,7 +6856,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
|
||||
.creation-empty-workspace { width: calc(100% - 28px); padding-bottom: 8vh; }
|
||||
.creation-chat-writing-surface, .creation-chat-composer.is-empty .creation-chat-writing-surface { grid-template-columns: 30px minmax(0, 1fr); gap: 8px; padding: 14px 12px 5px; }
|
||||
.creation-chat-dock { align-items: flex-end; padding: 4px 7px 8px 8px; }
|
||||
.creation-chat-controls { max-width: calc(100% - 38px); }
|
||||
.creation-chat-controls { max-width: none; }
|
||||
.creation-chat-control, .creation-chat-composer .creation-model-picker.canvas-composer-model-picker { height: 29px !important; padding: 0 6px !important; font-size: 10px; }
|
||||
.creation-chat-composer .creation-model-picker.canvas-composer-model-picker { width: min(136px, 42vw) !important; }
|
||||
.creation-thread-stage, .creation-thread-composer { width: calc(100% - 28px); }
|
||||
@@ -6901,8 +6865,7 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.creation-empty-composer,
|
||||
.creation-chat-reference,
|
||||
.creation-chat-submit { animation: none; transition: none; }
|
||||
.creation-chat-reference { animation: none; transition: none; }
|
||||
}
|
||||
|
||||
/* 创作入口与空间导航在同一块画布上,避免视觉断层。 */
|
||||
@@ -10373,3 +10336,322 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou
|
||||
vertical-align: -0.08em;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* ===== 创作页 · 连续对话与媒体状态 ===== */
|
||||
.creation-home .creation-thread-workbench {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.creation-home .creation-thread-toolbar {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
}
|
||||
|
||||
.creation-home .creation-thread-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.creation-home .creation-view-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--creation-surface-muted);
|
||||
padding: var(--space-1);
|
||||
}
|
||||
|
||||
.creation-home .creation-view-switch button {
|
||||
display: inline-flex;
|
||||
min-height: var(--space-8);
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--creation-muted);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--fs-caption);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--motion-state) ease, color var(--motion-state) ease;
|
||||
}
|
||||
|
||||
.creation-home .creation-view-switch button[aria-pressed="true"] {
|
||||
background: var(--creation-surface-selected);
|
||||
color: var(--creation-text);
|
||||
}
|
||||
|
||||
.creation-home .creation-view-switch button:focus-visible {
|
||||
outline: 2px solid var(--creation-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.creation-home .creation-view-switch svg { width: var(--space-4); height: var(--space-4); }
|
||||
|
||||
.creation-home .storyboard-workbench-bar-actions .creation-view-switch { margin-right: var(--space-2); }
|
||||
|
||||
.creation-home .creation-thread-stage {
|
||||
width: min(900px, calc(100% - 56px));
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) 0 var(--space-6);
|
||||
}
|
||||
|
||||
.creation-home .creation-thread-composer {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
bottom: auto;
|
||||
width: min(900px, calc(100% - 56px));
|
||||
margin: 0 auto;
|
||||
padding: var(--space-2) 0 var(--space-5);
|
||||
background: var(--creation-bg);
|
||||
}
|
||||
|
||||
.creation-home .creation-user-message {
|
||||
display: flex;
|
||||
width: min(680px, 86%);
|
||||
max-width: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
margin: 0 0 var(--space-10) auto;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--creation-text);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
html:not(.dark) .creation-home .creation-user-message { background: transparent; }
|
||||
|
||||
.creation-home .creation-user-message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--creation-faint);
|
||||
font-size: var(--fs-label);
|
||||
}
|
||||
|
||||
.creation-home .creation-user-message-meta time,
|
||||
.creation-home .creation-message-heading time { color: var(--creation-faint); font-size: var(--fs-micro); }
|
||||
|
||||
.creation-home .creation-user-message-copy {
|
||||
display: grid;
|
||||
width: var(--space-5);
|
||||
height: var(--space-5);
|
||||
flex: 0 0 var(--space-5);
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-xs);
|
||||
background: transparent;
|
||||
color: var(--creation-faint);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
opacity: .66;
|
||||
transition: background-color var(--motion-state) ease, color var(--motion-state) ease, opacity var(--motion-state) ease;
|
||||
}
|
||||
|
||||
.creation-home .creation-user-message-copy:hover {
|
||||
background: var(--creation-surface-hover);
|
||||
color: var(--creation-text);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.creation-home .creation-user-message-copy:focus-visible { outline: 2px solid var(--creation-accent); outline-offset: 1px; }
|
||||
.creation-home .creation-user-message-copy svg { width: var(--space-3); height: var(--space-3); }
|
||||
|
||||
.creation-home .creation-user-message-copy-wrap {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.creation-home .creation-user-message-copy-wrap p {
|
||||
margin: 0;
|
||||
color: var(--creation-text);
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.75;
|
||||
text-align: right;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.creation-home .creation-assistant-message {
|
||||
width: min(760px, 100%);
|
||||
max-width: none;
|
||||
margin: 0 0 var(--space-10);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.creation-home .creation-message-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.creation-home .creation-message-heading strong { color: var(--creation-text); font-size: var(--fs-body); font-weight: 620; }
|
||||
.creation-home .creation-message-progress-copy { color: var(--creation-muted); font-size: var(--fs-body); }
|
||||
|
||||
.creation-home .creation-message-content {
|
||||
max-width: 720px;
|
||||
padding: var(--space-3) 0 0 var(--space-8);
|
||||
}
|
||||
|
||||
.creation-home .creation-media-pending {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(var(--creation-media-pending-max-width), calc(100% - var(--space-8)));
|
||||
min-height: 0;
|
||||
isolation: isolate;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
margin: var(--space-3) 0 0 var(--space-8);
|
||||
border: 0;
|
||||
border-radius: var(--r-lg);
|
||||
background: linear-gradient(115deg, color-mix(in srgb, var(--status-loading) 16%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 7%, var(--creation-surface)), color-mix(in srgb, var(--status-loading) 28%, var(--creation-surface-muted)), color-mix(in srgb, var(--creation-text) 10%, var(--creation-surface)));
|
||||
background-size: 280% 280%;
|
||||
padding: 0;
|
||||
animation: creation-media-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.creation-home .creation-media-pending::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
background: linear-gradient(105deg, transparent 12%, color-mix(in srgb, var(--status-loading) 24%, transparent) 48%, color-mix(in srgb, var(--creation-text) 12%, transparent) 58%, transparent 88%);
|
||||
content: "";
|
||||
opacity: .38;
|
||||
transform: translateX(-38%);
|
||||
animation: creation-media-breathe-layer 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.creation-home .creation-media-pending-icon {
|
||||
display: grid;
|
||||
width: var(--space-12);
|
||||
height: var(--space-12);
|
||||
place-items: center;
|
||||
border-radius: var(--r-md);
|
||||
background: color-mix(in srgb, var(--creation-surface) 72%, transparent);
|
||||
color: var(--creation-text);
|
||||
animation: creation-media-icon-breathe 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.creation-home .creation-media-pending-icon svg { width: var(--space-5); height: var(--space-5); }
|
||||
|
||||
.creation-home .creation-media-pending > button {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
bottom: var(--space-3);
|
||||
min-height: var(--space-8);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--creation-surface) 78%, transparent);
|
||||
color: var(--creation-muted);
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--fs-label);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creation-home .creation-media-result { max-width: 680px; padding: var(--space-3) 0 0 var(--space-8); }
|
||||
.creation-home .creation-image-result-grid { display: flex; max-width: 680px; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.creation-home .creation-image-result { width: min(320px, 100%); aspect-ratio: auto; background: var(--creation-surface-muted); }
|
||||
.creation-home .creation-image-result img { display: block; width: 100%; height: auto; max-height: 360px; object-fit: contain; }
|
||||
.creation-home .creation-video-result { width: min(680px, 100%); }
|
||||
.creation-home .creation-video-result video { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
.creation-home .creation-media-error {
|
||||
display: flex;
|
||||
width: min(680px, calc(100% - var(--space-8)));
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin: var(--space-3) 0 0 var(--space-8);
|
||||
color: var(--status-error);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.creation-home .creation-media-error button {
|
||||
display: inline-flex;
|
||||
min-height: var(--space-8);
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--status-error) 10%, transparent);
|
||||
color: inherit;
|
||||
padding: 0 var(--space-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creation-home .creation-media-error svg { width: var(--space-4); height: var(--space-4); }
|
||||
|
||||
.creation-home .creation-chat-attachment-preview.is-file {
|
||||
width: min(220px, 100%);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.creation-home .creation-chat-file-icon {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--creation-muted);
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.creation-home .creation-chat-file-icon > svg { width: var(--space-4); height: var(--space-4); flex: 0 0 auto; }
|
||||
.creation-home .creation-chat-file-icon em { overflow: hidden; font-size: var(--fs-label); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.creation-home .creation-user-message-attachments button.is-file {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--creation-surface-muted);
|
||||
color: var(--creation-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.creation-home .creation-user-message-attachments button.is-file > svg { width: var(--space-4); height: var(--space-4); }
|
||||
|
||||
@keyframes creation-media-breathe {
|
||||
0%, 100% { background-position: 0% 50%; filter: brightness(.9) saturate(.82); }
|
||||
50% { background-position: 100% 50%; filter: brightness(1.12) saturate(1.18); }
|
||||
}
|
||||
|
||||
@keyframes creation-media-breathe-layer {
|
||||
0%, 100% { opacity: .24; transform: translateX(-38%) scale(1); }
|
||||
50% { opacity: .72; transform: translateX(38%) scale(1.04); }
|
||||
}
|
||||
|
||||
@keyframes creation-media-icon-breathe {
|
||||
0%, 100% { opacity: .58; transform: scale(.94); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.creation-home .creation-media-pending,
|
||||
.creation-home .creation-media-pending::before,
|
||||
.creation-home .creation-media-pending-icon { animation: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.creation-home .creation-thread-toolbar { align-items: flex-end; padding: var(--space-3); }
|
||||
.creation-home .creation-view-switch button { padding: 0 var(--space-2); }
|
||||
.creation-home .creation-thread-stage,
|
||||
.creation-home .creation-thread-composer { width: calc(100% - 28px); }
|
||||
.creation-home .creation-thread-stage { padding-top: var(--space-5); }
|
||||
.creation-home .creation-user-message { width: 92%; max-width: none; }
|
||||
.creation-home .creation-message-content,
|
||||
.creation-home .creation-media-result { padding-left: 0; }
|
||||
.creation-home .creation-media-pending { width: min(var(--creation-media-pending-max-width), 100%); margin-left: 0; }
|
||||
.creation-home .creation-media-error { width: 100%; margin-left: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user