feat: optimize playground media references
This commit is contained in:
parent
5868dd7e68
commit
0cd4e6fed1
@ -1478,7 +1478,7 @@ func isVideoResolution(modelType string, value string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isVideoModelType(modelType string) bool {
|
func isVideoModelType(modelType string) bool {
|
||||||
return modelType == "video_generate" || modelType == "text_to_video" || modelType == "image_to_video" || modelType == "video_edit" || modelType == "omni_video" || modelType == "omni"
|
return modelType == "video_generate" || modelType == "text_to_video" || modelType == "image_to_video" || modelType == "video_edit" || modelType == "video_reference" || modelType == "video_first_last_frame" || modelType == "omni_video" || modelType == "omni"
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneMap(values map[string]any) map[string]any {
|
func cloneMap(values map[string]any) map[string]any {
|
||||||
|
|||||||
@ -687,7 +687,7 @@ func requestedModelTypeFromBody(body map[string]any) string {
|
|||||||
|
|
||||||
func isKnownModelType(value string) bool {
|
func isKnownModelType(value string) bool {
|
||||||
switch value {
|
switch value {
|
||||||
case "text_generate", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "omni_video", "omni":
|
case "text_generate", "image_generate", "image_edit", "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "omni_video", "omni":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -668,8 +668,12 @@ export async function createVideoGenerationTask(
|
|||||||
audio?: boolean;
|
audio?: boolean;
|
||||||
audioUrl?: string | string[];
|
audioUrl?: string | string[];
|
||||||
audio_url?: string | string[];
|
audio_url?: string | string[];
|
||||||
|
capabilityType?: string;
|
||||||
content?: Array<Record<string, unknown>>;
|
content?: Array<Record<string, unknown>>;
|
||||||
|
firstFrame?: string;
|
||||||
|
first_frame?: string;
|
||||||
model: string;
|
model: string;
|
||||||
|
model_type?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
@ -681,6 +685,8 @@ export async function createVideoGenerationTask(
|
|||||||
image_url?: string | string[];
|
image_url?: string | string[];
|
||||||
imageUrls?: string[];
|
imageUrls?: string[];
|
||||||
image_urls?: string[];
|
image_urls?: string[];
|
||||||
|
lastFrame?: string;
|
||||||
|
last_frame?: string;
|
||||||
n?: number;
|
n?: number;
|
||||||
output_audio?: boolean;
|
output_audio?: boolean;
|
||||||
referenceAudio?: string | string[];
|
referenceAudio?: string | string[];
|
||||||
|
|||||||
@ -19,10 +19,15 @@ import { code } from '@streamdown/code';
|
|||||||
import { math } from '@streamdown/math';
|
import { math } from '@streamdown/math';
|
||||||
import { mermaid } from '@streamdown/mermaid';
|
import { mermaid } from '@streamdown/mermaid';
|
||||||
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||||
import { Bot, ChevronDown, FileText, Image as ImageIcon, LoaderCircle, MessageSquarePlus, Music2, Paperclip, Send, Sparkles, Video, X } from 'lucide-react';
|
import { Bot, ChevronDown, FileText, Image as ImageIcon, LoaderCircle, MessageSquarePlus, Music2, Paperclip, Plus, Repeat2, Send, Sparkles, Video, X } from 'lucide-react';
|
||||||
import { Badge, Button, Select, Textarea } from '../components/ui';
|
import { Badge, Button, Select, Textarea } from '../components/ui';
|
||||||
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending, uploadFileToStorage } from '../api';
|
import { GatewayApiError, createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, streamChatCompletionText, taskIsPending, uploadFileToStorage } from '../api';
|
||||||
import type { PlaygroundMode } from '../types';
|
import type { PlaygroundMode } from '../types';
|
||||||
|
import {
|
||||||
|
PlaygroundPromptMentionInput,
|
||||||
|
removeInvalidPlaygroundResourceTokens,
|
||||||
|
replacePlaygroundResourceTokens,
|
||||||
|
} from './playground-prompt-mention';
|
||||||
import {
|
import {
|
||||||
defaultMediaGenerationSettings,
|
defaultMediaGenerationSettings,
|
||||||
deriveMediaModelCapabilities,
|
deriveMediaModelCapabilities,
|
||||||
@ -59,6 +64,7 @@ interface ModelOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
|
type PlaygroundUploadKind = 'audio' | 'file' | 'image' | 'video';
|
||||||
|
type PlaygroundUploadRole = 'first_frame' | 'last_frame';
|
||||||
|
|
||||||
interface PlaygroundUpload {
|
interface PlaygroundUpload {
|
||||||
contentType: string;
|
contentType: string;
|
||||||
@ -66,6 +72,7 @@ interface PlaygroundUpload {
|
|||||||
kind: PlaygroundUploadKind;
|
kind: PlaygroundUploadKind;
|
||||||
name: string;
|
name: string;
|
||||||
raw: Record<string, unknown>;
|
raw: Record<string, unknown>;
|
||||||
|
role?: PlaygroundUploadRole;
|
||||||
size: number;
|
size: number;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
@ -95,6 +102,7 @@ const quickPrompts: Record<PlaygroundMode, string[]> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mediaUploadAccept = 'image/*,video/*,audio/*';
|
const mediaUploadAccept = 'image/*,video/*,audio/*';
|
||||||
|
const imageOnlyUploadAccept = 'image/*';
|
||||||
const chatUploadAccept = [
|
const chatUploadAccept = [
|
||||||
mediaUploadAccept,
|
mediaUploadAccept,
|
||||||
'.csv',
|
'.csv',
|
||||||
@ -167,7 +175,9 @@ export function PlaygroundPage(props: {
|
|||||||
const pendingMediaModelRef = useRef('');
|
const pendingMediaModelRef = useRef('');
|
||||||
const resumedTaskIdsRef = useRef(new Set<string>());
|
const resumedTaskIdsRef = useRef(new Set<string>());
|
||||||
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
const activeMode = useMemo(() => modeOptions.find((item) => item.value === props.mode) ?? modeOptions[0], [props.mode]);
|
||||||
|
const mediaUploadAcceptValue = mediaUploadAcceptForMode(props.mode, videoMode);
|
||||||
const effectiveImageHasReference = imageHasReference || (props.mode === 'image' && mediaUploads.some((item) => item.kind === 'image'));
|
const effectiveImageHasReference = imageHasReference || (props.mode === 'image' && mediaUploads.some((item) => item.kind === 'image'));
|
||||||
|
const mediaUploadSignature = useMemo(() => mediaUploads.map((item) => `${item.id}:${item.kind}:${item.role ?? ''}`).join('|'), [mediaUploads]);
|
||||||
const modelOptions = useMemo(
|
const modelOptions = useMemo(
|
||||||
() => buildModelOptions(filterModelsForMode(props.models, props.mode, effectiveImageHasReference, videoMode)),
|
() => buildModelOptions(filterModelsForMode(props.models, props.mode, effectiveImageHasReference, videoMode)),
|
||||||
[effectiveImageHasReference, props.mode, props.models, videoMode],
|
[effectiveImageHasReference, props.mode, props.models, videoMode],
|
||||||
@ -215,7 +225,31 @@ export function PlaygroundPage(props: {
|
|||||||
writeStoredMediaRuns(mediaRuns);
|
writeStoredMediaRuns(mediaRuns);
|
||||||
}, [mediaRuns]);
|
}, [mediaRuns]);
|
||||||
|
|
||||||
async function uploadMediaFiles(files: File[]) {
|
useEffect(() => {
|
||||||
|
if (props.mode === 'chat') return;
|
||||||
|
setPrompt((current) => removeInvalidPlaygroundResourceTokens(current, mediaUploads));
|
||||||
|
}, [mediaUploadSignature, props.mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.mode === 'image') {
|
||||||
|
setMediaUploads((current) => current.some((item) => item.kind !== 'image') ? current.filter((item) => item.kind === 'image') : current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.mode === 'video' && videoMode === 'first_last_frame') {
|
||||||
|
setMediaUploads((current) => normalizeFirstLastFrameUploads(current));
|
||||||
|
}
|
||||||
|
}, [props.mode, videoMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.mode !== 'video') return;
|
||||||
|
setVideoMode((current) => {
|
||||||
|
if (mediaUploads.length > 0 && current === 'text_to_video') return 'omni_reference';
|
||||||
|
if (mediaUploads.length === 0 && current === 'omni_reference') return 'text_to_video';
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}, [mediaUploads.length, props.mode]);
|
||||||
|
|
||||||
|
async function uploadMediaFiles(files: File[], targetRole?: PlaygroundUploadRole) {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
const credential = activeApiKeySecret || props.token;
|
const credential = activeApiKeySecret || props.token;
|
||||||
if (!props.token) {
|
if (!props.token) {
|
||||||
@ -231,15 +265,19 @@ export function PlaygroundPage(props: {
|
|||||||
try {
|
try {
|
||||||
const { items, warnings } = await uploadPlaygroundFiles(credential, files, {
|
const { items, warnings } = await uploadPlaygroundFiles(credential, files, {
|
||||||
allowFiles: false,
|
allowFiles: false,
|
||||||
|
allowedKinds: allowedMediaUploadKinds(props.mode, videoMode),
|
||||||
source: `ai-gateway-playground-${props.mode}`,
|
source: `ai-gateway-playground-${props.mode}`,
|
||||||
});
|
});
|
||||||
if (items.length) {
|
if (items.length) {
|
||||||
setMediaUploads((current) => [...current, ...items]);
|
setMediaUploads((current) => mergeMediaUploadsForMode(current, items, props.mode, videoMode, targetRole));
|
||||||
if (props.mode === 'image' && items.some((item) => item.kind === 'image')) {
|
if (props.mode === 'image' && items.some((item) => item.kind === 'image')) {
|
||||||
setImageHasReference(true);
|
setImageHasReference(true);
|
||||||
}
|
}
|
||||||
|
if (props.mode === 'video' && videoMode === 'text_to_video') {
|
||||||
|
setVideoMode('omni_reference');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setMediaUploadMessage(warnings[0] ?? (items.length ? `已上传 ${items.length} 个参考素材。` : ''));
|
setMediaUploadMessage(warnings[0] ?? '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMediaUploadMessage(err instanceof Error ? err.message : '文件上传失败');
|
setMediaUploadMessage(err instanceof Error ? err.message : '文件上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
@ -305,7 +343,8 @@ export function PlaygroundPage(props: {
|
|||||||
|
|
||||||
const localId = newLocalId();
|
const localId = newLocalId();
|
||||||
const runUploads = overrides ? [] : mediaUploads;
|
const runUploads = overrides ? [] : mediaUploads;
|
||||||
const modelLabel = modelOptions.find((item) => item.value === runModel)?.label ?? runModel;
|
const runModelOption = modelOptions.find((item) => item.value === runModel);
|
||||||
|
const modelLabel = runModelOption?.label ?? runModel;
|
||||||
const run: MediaGenerationRun = {
|
const run: MediaGenerationRun = {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
localId,
|
localId,
|
||||||
@ -320,11 +359,13 @@ export function PlaygroundPage(props: {
|
|||||||
setMediaRuns((current) => [...current, run]);
|
setMediaRuns((current) => [...current, run]);
|
||||||
setMediaMessage('');
|
setMediaMessage('');
|
||||||
try {
|
try {
|
||||||
const uploadPayload = mediaUploadRequestPayload(runUploads, runMode);
|
const requestPrompt = replacePlaygroundResourceTokens(trimmedPrompt, runUploads, runMode);
|
||||||
|
const uploadPayload = mediaUploadRequestPayload(runUploads, runMode, videoMode);
|
||||||
const requestPayload = {
|
const requestPayload = {
|
||||||
model: runModel,
|
model: runModel,
|
||||||
prompt: promptWithUploadSummary(trimmedPrompt, runUploads),
|
prompt: requestPrompt,
|
||||||
...mediaRequestPayload(runSettings, runMode),
|
...mediaRequestPayload(runSettings, runMode),
|
||||||
|
...videoModeRequestPayload(runMode, videoMode, runUploads, runModelOption),
|
||||||
...uploadPayload,
|
...uploadPayload,
|
||||||
};
|
};
|
||||||
const response = runMode === 'video'
|
const response = runMode === 'video'
|
||||||
@ -415,7 +456,7 @@ export function PlaygroundPage(props: {
|
|||||||
imageHasReference={effectiveImageHasReference}
|
imageHasReference={effectiveImageHasReference}
|
||||||
mediaSettings={mediaSettings}
|
mediaSettings={mediaSettings}
|
||||||
mediaCapabilities={mediaCapabilities}
|
mediaCapabilities={mediaCapabilities}
|
||||||
uploadAccept={mediaUploadAccept}
|
uploadAccept={mediaUploadAcceptValue}
|
||||||
uploadMessage={mediaUploadMessage}
|
uploadMessage={mediaUploadMessage}
|
||||||
uploads={mediaUploads}
|
uploads={mediaUploads}
|
||||||
uploading={mediaUploading}
|
uploading={mediaUploading}
|
||||||
@ -434,8 +475,9 @@ export function PlaygroundPage(props: {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})}
|
})}
|
||||||
|
onSwapFrameUploads={() => setMediaUploads((current) => swapFirstLastFrameUploads(current))}
|
||||||
onSubmit={() => void submitMediaTask()}
|
onSubmit={() => void submitMediaTask()}
|
||||||
onUploadFiles={(files) => void uploadMediaFiles(files)}
|
onUploadFiles={(files, targetRole) => void uploadMediaFiles(files, targetRole)}
|
||||||
onVideoModeChange={setVideoMode}
|
onVideoModeChange={setVideoMode}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -1120,34 +1162,64 @@ function Composer(props: {
|
|||||||
onModelChange: (value: string) => void;
|
onModelChange: (value: string) => void;
|
||||||
onPromptChange: (value: string) => void;
|
onPromptChange: (value: string) => void;
|
||||||
onRemoveUpload?: (id: string) => void;
|
onRemoveUpload?: (id: string) => void;
|
||||||
|
onSwapFrameUploads?: () => void;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
onUploadFiles?: (files: File[]) => void;
|
onUploadFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||||
onVideoModeChange?: (value: VideoCreateMode) => void;
|
onVideoModeChange?: (value: VideoCreateMode) => void;
|
||||||
}) {
|
}) {
|
||||||
const quickItems = quickPrompts[props.mode];
|
const quickItems = quickPrompts[props.mode];
|
||||||
const apiKeyNotice = props.apiKeys && props.apiKeySecretsById ? apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById) : '';
|
const apiKeyNotice = props.apiKeys && props.apiKeySecretsById ? apiKeyNoticeText(props.apiKeys, props.apiKeySecretsById) : '';
|
||||||
|
const hasMediaReferencePicker = props.mode !== 'chat' && Boolean(props.onUploadFiles);
|
||||||
|
const mediaReferenceMessage = hasMediaReferencePicker
|
||||||
|
? props.uploadMessage || mediaUploadSummaryMessage(props.uploads ?? [], props.mode, props.videoMode ?? 'text_to_video')
|
||||||
|
: props.uploadMessage;
|
||||||
return (
|
return (
|
||||||
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
|
<div className={props.compact ? 'playgroundComposer compact' : 'playgroundComposer'}>
|
||||||
<div className="composerBody">
|
<div className={hasMediaReferencePicker ? 'composerBody composerBodyWithReferences' : 'composerBody'} data-frame-mode={props.mode === 'video' && props.videoMode === 'first_last_frame'}>
|
||||||
<ComposerUploadButton
|
{hasMediaReferencePicker ? (
|
||||||
accept={props.uploadAccept ?? mediaUploadAccept}
|
<MediaReferencePicker
|
||||||
active={Boolean(props.uploads?.length) || props.imageHasReference === true}
|
accept={props.uploadAccept ?? mediaUploadAccept}
|
||||||
disabled={!props.onUploadFiles}
|
mode={props.mode as Exclude<PlaygroundMode, 'chat'>}
|
||||||
uploading={props.uploading}
|
|
||||||
onFiles={props.onUploadFiles}
|
|
||||||
/>
|
|
||||||
<div className="composerInputStack">
|
|
||||||
<Textarea
|
|
||||||
size={props.compact ? 'sm' : 'md'}
|
|
||||||
value={props.prompt}
|
|
||||||
placeholder={placeholderByMode[props.mode]}
|
|
||||||
onChange={(event) => props.onPromptChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
<UploadAttachmentList
|
|
||||||
message={props.uploadMessage}
|
|
||||||
uploads={props.uploads ?? []}
|
uploads={props.uploads ?? []}
|
||||||
|
uploading={props.uploading}
|
||||||
|
videoMode={props.videoMode ?? 'text_to_video'}
|
||||||
|
onFiles={props.onUploadFiles}
|
||||||
onRemove={props.onRemoveUpload}
|
onRemove={props.onRemoveUpload}
|
||||||
|
onSwapFrames={props.onSwapFrameUploads}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<ComposerUploadButton
|
||||||
|
accept={props.uploadAccept ?? mediaUploadAccept}
|
||||||
|
active={Boolean(props.uploads?.length) || props.imageHasReference === true}
|
||||||
|
disabled={!props.onUploadFiles}
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onUploadFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="composerInputStack">
|
||||||
|
{hasMediaReferencePicker ? (
|
||||||
|
<PlaygroundPromptMentionInput
|
||||||
|
value={props.prompt}
|
||||||
|
placeholder={mediaPromptPlaceholder(props.mode)}
|
||||||
|
uploads={props.uploads ?? []}
|
||||||
|
onChange={props.onPromptChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Textarea
|
||||||
|
size={props.compact ? 'sm' : 'md'}
|
||||||
|
value={props.prompt}
|
||||||
|
placeholder={placeholderByMode[props.mode]}
|
||||||
|
onChange={(event) => props.onPromptChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!hasMediaReferencePicker && (
|
||||||
|
<UploadAttachmentList
|
||||||
|
message={props.uploadMessage}
|
||||||
|
uploads={props.uploads ?? []}
|
||||||
|
onRemove={props.onRemoveUpload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasMediaReferencePicker && mediaReferenceMessage && <div className="composerUploadMessage">{mediaReferenceMessage}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="composerFooter">
|
<div className="composerFooter">
|
||||||
@ -1235,6 +1307,260 @@ function ComposerUploadButton(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MediaReferencePicker(props: {
|
||||||
|
accept: string;
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
|
videoMode: VideoCreateMode;
|
||||||
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
onSwapFrames?: () => void;
|
||||||
|
}) {
|
||||||
|
if (props.mode === 'video' && props.videoMode === 'first_last_frame') {
|
||||||
|
return (
|
||||||
|
<FirstLastFramePicker
|
||||||
|
accept={props.accept}
|
||||||
|
uploads={props.uploads}
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
onSwapFrames={props.onSwapFrames}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<StackedReferencePicker
|
||||||
|
accept={props.accept}
|
||||||
|
uploads={props.uploads}
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackedReferencePicker(props: {
|
||||||
|
accept: string;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
|
onFiles?: (files: File[]) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [hoveredId, setHoveredId] = useState('');
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const hoveredUpload = props.uploads.find((item) => item.id === hoveredId);
|
||||||
|
const disabled = props.uploading || !props.onFiles;
|
||||||
|
const uploadCardIndex = props.uploads.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mediaReferenceStack"
|
||||||
|
data-expanded={expanded}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setExpanded(false);
|
||||||
|
setHoveredId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hoveredUpload && <div className="mediaReferenceTooltip">{hoveredUpload.name}</div>}
|
||||||
|
<div
|
||||||
|
className="mediaReferenceStackCards"
|
||||||
|
data-empty={!props.uploads.length}
|
||||||
|
style={{ '--reference-count': Math.max(1, props.uploads.length + 1) } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
{props.uploads.map((item, index) => (
|
||||||
|
<div
|
||||||
|
className="mediaReferenceCard"
|
||||||
|
data-hovered={hoveredId === item.id}
|
||||||
|
data-kind={item.kind}
|
||||||
|
key={item.id}
|
||||||
|
style={referenceCardStyle(index)}
|
||||||
|
title={item.name}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setExpanded(true);
|
||||||
|
setHoveredId(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReferencePreview item={item} />
|
||||||
|
{item.kind === 'video' && <span className="mediaReferenceDuration">视频</span>}
|
||||||
|
{props.onRemove && hoveredId === item.id && (
|
||||||
|
<button type="button" className="mediaReferenceRemove" aria-label={`删除 ${item.name}`} onClick={() => props.onRemove?.(item.id)}>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mediaReferenceCard mediaReferenceUploadCard"
|
||||||
|
aria-label="追加参考资源"
|
||||||
|
data-has-uploads={props.uploads.length > 0}
|
||||||
|
data-uploading={Boolean(props.uploading)}
|
||||||
|
disabled={disabled}
|
||||||
|
style={referenceCardStyle(uploadCardIndex)}
|
||||||
|
title="追加参考资源"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onMouseEnter={() => setHoveredId('')}
|
||||||
|
>
|
||||||
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={18} /> : <Plus size={20} />}
|
||||||
|
<span>参考内容</span>
|
||||||
|
</button>
|
||||||
|
{props.uploads.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mediaReferenceAdd"
|
||||||
|
aria-label="追加参考资源"
|
||||||
|
data-uploading={Boolean(props.uploading)}
|
||||||
|
disabled={disabled}
|
||||||
|
title="追加参考资源"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setExpanded(false);
|
||||||
|
setHoveredId('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.uploading ? <LoaderCircle className="composerUploadSpinner" size={15} /> : <Plus size={17} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.currentTarget.files ?? []);
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
props.onFiles?.(files);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceTiltValues = [-7, 6, -3, 8, -5, 4, -8, 5];
|
||||||
|
const referenceXValues = [0, -5, 6, -3, 4, -6, 3, -4];
|
||||||
|
const referenceYValues = [0, 3, -1, 4, 1, 5, 2, -2];
|
||||||
|
|
||||||
|
function referenceCardStyle(index: number) {
|
||||||
|
const valueIndex = index % referenceTiltValues.length;
|
||||||
|
return {
|
||||||
|
'--reference-index': index,
|
||||||
|
'--reference-tilt': `${referenceTiltValues[valueIndex]}deg`,
|
||||||
|
'--reference-x': `${referenceXValues[valueIndex]}px`,
|
||||||
|
'--reference-y': `${referenceYValues[valueIndex]}px`,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FirstLastFramePicker(props: {
|
||||||
|
accept: string;
|
||||||
|
uploads: PlaygroundUpload[];
|
||||||
|
uploading?: boolean;
|
||||||
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
onSwapFrames?: () => void;
|
||||||
|
}) {
|
||||||
|
const first = frameUploadByRole(props.uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(props.uploads, 'last_frame');
|
||||||
|
const canSwap = Boolean(first && last);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="firstLastFramePicker">
|
||||||
|
<FrameSlot
|
||||||
|
accept={props.accept}
|
||||||
|
item={first}
|
||||||
|
label="首帧"
|
||||||
|
role="first_frame"
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
/>
|
||||||
|
<button type="button" className="frameSwapButton" aria-label="交换首尾帧" disabled={!canSwap} onClick={props.onSwapFrames}>
|
||||||
|
<Repeat2 size={19} />
|
||||||
|
</button>
|
||||||
|
<FrameSlot
|
||||||
|
accept={props.accept}
|
||||||
|
item={last}
|
||||||
|
label="尾帧"
|
||||||
|
role="last_frame"
|
||||||
|
uploading={props.uploading}
|
||||||
|
onFiles={props.onFiles}
|
||||||
|
onRemove={props.onRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FrameSlot(props: {
|
||||||
|
accept: string;
|
||||||
|
item?: PlaygroundUpload;
|
||||||
|
label: string;
|
||||||
|
role: PlaygroundUploadRole;
|
||||||
|
uploading?: boolean;
|
||||||
|
onFiles?: (files: File[], targetRole?: PlaygroundUploadRole) => void;
|
||||||
|
onRemove?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const disabled = props.uploading || !props.onFiles;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="frameSlot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="frameSlotButton"
|
||||||
|
data-filled={Boolean(props.item)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={props.item?.name ?? props.label}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{props.item ? <ReferencePreview item={props.item} /> : <Plus size={20} />}
|
||||||
|
<span>{props.label}</span>
|
||||||
|
</button>
|
||||||
|
{props.item && props.onRemove && (
|
||||||
|
<button type="button" className="frameSlotRemove" aria-label={`删除 ${props.label}`} onClick={() => props.onRemove?.(props.item!.id)}>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept={props.accept}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.currentTarget.files ?? []);
|
||||||
|
event.currentTarget.value = '';
|
||||||
|
props.onFiles?.(files, props.role);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferencePreview(props: { item: PlaygroundUpload }) {
|
||||||
|
if (props.item.kind === 'image') {
|
||||||
|
return <img src={props.item.url} alt="" draggable={false} />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'video') {
|
||||||
|
return <video src={props.item.url} muted playsInline preload="metadata" />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'audio') {
|
||||||
|
return (
|
||||||
|
<span className="mediaReferencePlaceholder">
|
||||||
|
<Music2 size={18} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="mediaReferencePlaceholder">
|
||||||
|
<FileText size={18} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UploadAttachmentList(props: {
|
function UploadAttachmentList(props: {
|
||||||
message?: string;
|
message?: string;
|
||||||
uploads: PlaygroundUpload[];
|
uploads: PlaygroundUpload[];
|
||||||
@ -1301,16 +1627,17 @@ function ApiKeySelect(props: {
|
|||||||
async function uploadPlaygroundFiles(
|
async function uploadPlaygroundFiles(
|
||||||
token: string,
|
token: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
options: { allowFiles: boolean; source: string },
|
options: { allowFiles: boolean; allowedKinds?: PlaygroundUploadKind[]; source: string },
|
||||||
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
|
): Promise<{ items: PlaygroundUpload[]; warnings: string[] }> {
|
||||||
|
const allowedKinds = options.allowedKinds ?? (options.allowFiles ? ['audio', 'file', 'image', 'video'] : ['audio', 'image', 'video']);
|
||||||
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
|
const accepted: Array<{ file: File; kind: PlaygroundUploadKind }> = [];
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
const kind = acceptedUploadKind(file, options.allowFiles);
|
const kind = acceptedUploadKind(file, options.allowFiles);
|
||||||
if (!kind) {
|
if (!kind || !allowedKinds.includes(kind)) {
|
||||||
warnings.push(options.allowFiles
|
warnings.push(options.allowFiles
|
||||||
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
|
? `已跳过 ${file.name},聊天仅支持图片、视频、音频和常见文档。`
|
||||||
: `已跳过 ${file.name},当前场景仅支持图片、视频和音频。`);
|
: `已跳过 ${file.name},当前场景仅支持${allowedUploadKindLabel(allowedKinds)}。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
accepted.push({ file, kind });
|
accepted.push({ file, kind });
|
||||||
@ -1399,7 +1726,7 @@ function promptWithUploadSummary(prompt: string, uploads: PlaygroundUpload[]) {
|
|||||||
return `${prompt}\n\n参考附件:\n${lines.join('\n')}`;
|
return `${prompt}\n\n参考附件:\n${lines.join('\n')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
|
function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>, videoMode: VideoCreateMode) {
|
||||||
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
|
const images = uploads.filter((item) => item.kind === 'image').map((item) => item.url);
|
||||||
const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
|
const videos = uploads.filter((item) => item.kind === 'video').map((item) => item.url);
|
||||||
const audios = uploads.filter((item) => item.kind === 'audio').map((item) => item.url);
|
const audios = uploads.filter((item) => item.kind === 'audio').map((item) => item.url);
|
||||||
@ -1411,19 +1738,25 @@ function mediaUploadRequestPayload(uploads: PlaygroundUpload[], mode: Exclude<Pl
|
|||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
if (videoMode === 'first_last_frame') {
|
||||||
|
const first = frameUploadByRole(uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(uploads, 'last_frame');
|
||||||
|
if (first) {
|
||||||
|
payload.first_frame = first.url;
|
||||||
|
}
|
||||||
|
if (last) {
|
||||||
|
payload.last_frame = last.url;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
if (images.length) {
|
if (images.length) {
|
||||||
payload.image = singleOrMany(images);
|
|
||||||
payload.image_url = images[0];
|
|
||||||
payload.images = images;
|
|
||||||
payload.reference_image = singleOrMany(images);
|
payload.reference_image = singleOrMany(images);
|
||||||
}
|
}
|
||||||
if (videos.length) {
|
if (videos.length) {
|
||||||
payload.reference_video = singleOrMany(videos);
|
payload.reference_video = singleOrMany(videos);
|
||||||
payload.video_url = videos[0];
|
|
||||||
}
|
}
|
||||||
if (audios.length) {
|
if (audios.length) {
|
||||||
payload.reference_audio = singleOrMany(audios);
|
payload.reference_audio = singleOrMany(audios);
|
||||||
payload.audio_url = audios[0];
|
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
@ -1439,6 +1772,11 @@ function uploadKindLabel(kind: PlaygroundUploadKind) {
|
|||||||
return '文件';
|
return '文件';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowedUploadKindLabel(kinds: PlaygroundUploadKind[]) {
|
||||||
|
const labels = kinds.map(uploadKindLabel);
|
||||||
|
return labels.length ? labels.join('、') : '当前文件类型';
|
||||||
|
}
|
||||||
|
|
||||||
function formatFileSize(size: number) {
|
function formatFileSize(size: number) {
|
||||||
if (!Number.isFinite(size) || size <= 0) return '';
|
if (!Number.isFinite(size) || size <= 0) return '';
|
||||||
if (size < 1024) return `${size} B`;
|
if (size < 1024) return `${size} B`;
|
||||||
@ -1446,6 +1784,155 @@ function formatFileSize(size: number) {
|
|||||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mediaPromptPlaceholder(mode: PlaygroundMode) {
|
||||||
|
if (mode === 'image') return '输入画面描述,可用 @ 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
|
||||||
|
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 引用图片、视频或音频资源...';
|
||||||
|
return placeholderByMode.chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaUploadAcceptForMode(mode: PlaygroundMode, videoMode: VideoCreateMode) {
|
||||||
|
if (mode === 'image') return imageOnlyUploadAccept;
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') return imageOnlyUploadAccept;
|
||||||
|
return mediaUploadAccept;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedMediaUploadKinds(mode: PlaygroundMode, videoMode: VideoCreateMode): PlaygroundUploadKind[] {
|
||||||
|
if (mode === 'image') return ['image'];
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') return ['image'];
|
||||||
|
if (mode === 'video') return ['audio', 'image', 'video'];
|
||||||
|
return ['audio', 'file', 'image', 'video'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaUploadSummaryMessage(uploads: PlaygroundUpload[], mode: PlaygroundMode, videoMode: VideoCreateMode) {
|
||||||
|
if (!uploads.length) return '';
|
||||||
|
const images = uploads.filter((item) => item.kind === 'image').length;
|
||||||
|
const videos = uploads.filter((item) => item.kind === 'video').length;
|
||||||
|
const audios = uploads.filter((item) => item.kind === 'audio').length;
|
||||||
|
const files = uploads.filter((item) => item.kind === 'file').length;
|
||||||
|
if (mode === 'image') {
|
||||||
|
return `已上传 ${images} 张参考图。`;
|
||||||
|
}
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') {
|
||||||
|
const first = frameUploadByRole(uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(uploads, 'last_frame');
|
||||||
|
if (first && last) return '已上传首帧、尾帧参考图。';
|
||||||
|
if (first) return '已上传首帧参考图。';
|
||||||
|
if (last) return '已上传尾帧参考图。';
|
||||||
|
return `已上传 ${images} 张首尾帧参考图。`;
|
||||||
|
}
|
||||||
|
const parts = [
|
||||||
|
images ? `${images} 张图片` : '',
|
||||||
|
videos ? `${videos} 个视频` : '',
|
||||||
|
audios ? `${audios} 段音频` : '',
|
||||||
|
files ? `${files} 个文件` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length ? `已上传 ${parts.join('、')}。` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMediaUploadsForMode(
|
||||||
|
current: PlaygroundUpload[],
|
||||||
|
items: PlaygroundUpload[],
|
||||||
|
mode: PlaygroundMode,
|
||||||
|
videoMode: VideoCreateMode,
|
||||||
|
targetRole?: PlaygroundUploadRole,
|
||||||
|
) {
|
||||||
|
if (mode === 'image') {
|
||||||
|
return [...current.filter((item) => item.kind === 'image'), ...items.filter((item) => item.kind === 'image')];
|
||||||
|
}
|
||||||
|
if (mode === 'video' && videoMode === 'first_last_frame') {
|
||||||
|
return mergeFirstLastFrameUploads(current, items, targetRole);
|
||||||
|
}
|
||||||
|
return [...current, ...items.filter((item) => item.kind === 'image' || item.kind === 'video' || item.kind === 'audio')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
||||||
|
const images = uploads.filter((item) => item.kind === 'image');
|
||||||
|
if (!images.length) return uploads.length ? [] : uploads;
|
||||||
|
const first = frameUploadByRole(images, 'first_frame') ?? images[0];
|
||||||
|
const last = frameUploadByRole(images, 'last_frame') ?? images.find((item) => item.id !== first?.id);
|
||||||
|
const next: PlaygroundUpload[] = [];
|
||||||
|
if (first) next.push({ ...first, role: 'first_frame' });
|
||||||
|
if (last) next.push({ ...last, role: 'last_frame' });
|
||||||
|
return uploadListsEqual(uploads, next) ? uploads : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeFirstLastFrameUploads(current: PlaygroundUpload[], items: PlaygroundUpload[], targetRole?: PlaygroundUploadRole) {
|
||||||
|
const incoming = items.filter((item) => item.kind === 'image');
|
||||||
|
let next = normalizeFirstLastFrameUploads(current);
|
||||||
|
if (!incoming.length) return next;
|
||||||
|
const assignUpload = (item: PlaygroundUpload, role: PlaygroundUploadRole) => {
|
||||||
|
next = next.filter((upload) => upload.role !== role);
|
||||||
|
next.push({ ...item, role });
|
||||||
|
};
|
||||||
|
if (targetRole) {
|
||||||
|
assignUpload(incoming[0]!, targetRole);
|
||||||
|
const oppositeRole: PlaygroundUploadRole = targetRole === 'first_frame' ? 'last_frame' : 'first_frame';
|
||||||
|
incoming.slice(1).forEach((item) => {
|
||||||
|
if (!frameUploadByRole(next, oppositeRole)) assignUpload(item, oppositeRole);
|
||||||
|
});
|
||||||
|
return sortFrameUploads(next);
|
||||||
|
}
|
||||||
|
incoming.forEach((item) => {
|
||||||
|
if (!frameUploadByRole(next, 'first_frame')) {
|
||||||
|
assignUpload(item, 'first_frame');
|
||||||
|
} else if (!frameUploadByRole(next, 'last_frame')) {
|
||||||
|
assignUpload(item, 'last_frame');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return sortFrameUploads(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapFirstLastFrameUploads(uploads: PlaygroundUpload[]) {
|
||||||
|
return sortFrameUploads(uploads.map((item) => {
|
||||||
|
if (item.role === 'first_frame') return { ...item, role: 'last_frame' as const };
|
||||||
|
if (item.role === 'last_frame') return { ...item, role: 'first_frame' as const };
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortFrameUploads(uploads: PlaygroundUpload[]) {
|
||||||
|
const first = frameUploadByRole(uploads, 'first_frame');
|
||||||
|
const last = frameUploadByRole(uploads, 'last_frame');
|
||||||
|
return [first, last].filter((item): item is PlaygroundUpload => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameUploadByRole(uploads: PlaygroundUpload[], role: PlaygroundUploadRole) {
|
||||||
|
return uploads.find((item) => item.role === role);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadListsEqual(left: PlaygroundUpload[], right: PlaygroundUpload[]) {
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
return left.every((item, index) => {
|
||||||
|
const next = right[index];
|
||||||
|
return next && item.id === next.id && item.role === next.role && item.kind === next.kind;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoModeRequestPayload(
|
||||||
|
mode: Exclude<PlaygroundMode, 'chat'>,
|
||||||
|
videoMode: VideoCreateMode,
|
||||||
|
uploads: PlaygroundUpload[],
|
||||||
|
modelOption?: ModelOption,
|
||||||
|
) {
|
||||||
|
if (mode !== 'video') return {};
|
||||||
|
const modelTypes = new Set(modelOption?.models.flatMap((model) => model.modelType) ?? []);
|
||||||
|
if (videoMode === 'first_last_frame') {
|
||||||
|
const modelType = modelTypes.has('video_first_last_frame') ? 'video_first_last_frame' : 'image_to_video';
|
||||||
|
return { capabilityType: modelType, model_type: modelType };
|
||||||
|
}
|
||||||
|
if (videoMode === 'omni_reference' || uploads.length > 0) {
|
||||||
|
const modelType = modelTypes.has('omni_video')
|
||||||
|
? 'omni_video'
|
||||||
|
: modelTypes.has('video_reference')
|
||||||
|
? 'video_reference'
|
||||||
|
: modelTypes.has('image_to_video')
|
||||||
|
? 'image_to_video'
|
||||||
|
: 'video_generate';
|
||||||
|
return { capabilityType: modelType, model_type: modelType };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
||||||
if (mode === 'chat') {
|
if (mode === 'chat') {
|
||||||
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
|
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
|
||||||
@ -1455,7 +1942,7 @@ function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasR
|
|||||||
return filterWithFallback(models, [...preferredTypes, 'image']);
|
return filterWithFallback(models, [...preferredTypes, 'image']);
|
||||||
}
|
}
|
||||||
const videoTypesByMode: Record<VideoCreateMode, string[]> = {
|
const videoTypesByMode: Record<VideoCreateMode, string[]> = {
|
||||||
first_last_frame: ['image_to_video', 'video_first_last_frame', 'video_generate'],
|
first_last_frame: ['video_first_last_frame', 'image_to_video', 'video_generate'],
|
||||||
omni_reference: ['omni_video', 'video_reference', 'video_generate'],
|
omni_reference: ['omni_video', 'video_reference', 'video_generate'],
|
||||||
text_to_video: ['text_to_video', 'video_generate'],
|
text_to_video: ['text_to_video', 'video_generate'],
|
||||||
};
|
};
|
||||||
|
|||||||
554
apps/web/src/pages/playground-prompt-mention.tsx
Normal file
554
apps/web/src/pages/playground-prompt-mention.tsx
Normal file
@ -0,0 +1,554 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
|
import { FileText, Music2, Video } from 'lucide-react';
|
||||||
|
|
||||||
|
export type PlaygroundMentionUploadKind = 'audio' | 'file' | 'image' | 'video';
|
||||||
|
|
||||||
|
export interface PlaygroundMentionUpload {
|
||||||
|
id: string;
|
||||||
|
kind: PlaygroundMentionUploadKind;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceTokenPrefix = '<<<playground-resource:';
|
||||||
|
const resourceTokenSuffix = '>>>';
|
||||||
|
const resourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
|
||||||
|
const resourceTokenWithSpacePattern = /<<<playground-resource:([^>]+)>>>\s?/g;
|
||||||
|
|
||||||
|
export function buildPlaygroundResourceToken(id: string) {
|
||||||
|
return `${resourceTokenPrefix}${id}${resourceTokenSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeInvalidPlaygroundResourceTokens(raw: string, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
if (!raw.includes(resourceTokenPrefix)) return raw;
|
||||||
|
const validIds = new Set(uploads.map((item) => item.id));
|
||||||
|
return raw.replace(resourceTokenWithSpacePattern, (full, id: string) => validIds.has(id) ? full : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replacePlaygroundResourceTokens(
|
||||||
|
raw: string,
|
||||||
|
uploads: PlaygroundMentionUpload[],
|
||||||
|
mode: 'image' | 'video',
|
||||||
|
) {
|
||||||
|
if (!raw.includes(resourceTokenPrefix)) return raw;
|
||||||
|
const byId = new Map(uploads.map((item) => [item.id, item]));
|
||||||
|
return raw.replace(resourceTokenPattern, (full, id: string) => {
|
||||||
|
const item = byId.get(id);
|
||||||
|
if (!item) return '';
|
||||||
|
return resourcePromptLabel(item, uploads, mode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaygroundPromptMentionInput(props: {
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
uploads: PlaygroundMentionUpload[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const editableRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const blurTimerRef = useRef<number | undefined>(undefined);
|
||||||
|
const [text, setText] = useState(props.value);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
|
const [mentionAtIndex, setMentionAtIndex] = useState(-1);
|
||||||
|
const [mentionSearch, setMentionSearch] = useState('');
|
||||||
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, placement: 'bottom' as 'bottom' | 'top' });
|
||||||
|
const showPlaceholder = text.trim().length === 0;
|
||||||
|
|
||||||
|
const uploadSignature = useMemo(
|
||||||
|
() => props.uploads.map((item) => `${item.id}:${item.kind}:${item.name}:${item.url}`).join('|'),
|
||||||
|
[props.uploads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mentionItems = useMemo(() => props.uploads.map((item, index) => ({
|
||||||
|
item,
|
||||||
|
label: mentionDisplayLabel(item, props.uploads),
|
||||||
|
searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} ${index + 1}`.toLowerCase(),
|
||||||
|
token: buildPlaygroundResourceToken(item.id),
|
||||||
|
})), [props.uploads]);
|
||||||
|
|
||||||
|
const filteredMentionItems = useMemo(() => {
|
||||||
|
const keyword = mentionSearch.trim().toLowerCase();
|
||||||
|
if (!keyword) return mentionItems;
|
||||||
|
return mentionItems.filter((item) => item.searchText.includes(keyword));
|
||||||
|
}, [mentionItems, mentionSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.value === text) return;
|
||||||
|
setText(props.value);
|
||||||
|
if (!focused) {
|
||||||
|
requestAnimationFrame(() => renderToEditable(props.value));
|
||||||
|
}
|
||||||
|
}, [focused, props.value, text]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleaned = removeInvalidPlaygroundResourceTokens(text, props.uploads);
|
||||||
|
if (cleaned !== text) {
|
||||||
|
setText(cleaned);
|
||||||
|
props.onChange(cleaned);
|
||||||
|
requestAnimationFrame(() => renderToEditable(cleaned));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!focused) {
|
||||||
|
requestAnimationFrame(() => renderToEditable(text));
|
||||||
|
}
|
||||||
|
}, [uploadSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => renderToEditable(text));
|
||||||
|
return () => {
|
||||||
|
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateMentionDropdownPosition = useCallback(() => {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
const root = editable?.parentElement;
|
||||||
|
if (!editable || !root) return;
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const caretRect = getCaretClientRect(editable) ?? editable.getBoundingClientRect();
|
||||||
|
const dropdown = dropdownRef.current;
|
||||||
|
const viewportPadding = 8;
|
||||||
|
const gap = 6;
|
||||||
|
const dropdownWidth = Math.min(dropdown?.offsetWidth ?? 320, Math.max(180, window.innerWidth - viewportPadding * 2));
|
||||||
|
const dropdownHeight = Math.min(dropdown?.offsetHeight ?? 220, Math.max(120, window.innerHeight - viewportPadding * 2));
|
||||||
|
const minLeft = viewportPadding - rootRect.left;
|
||||||
|
const maxLeft = window.innerWidth - viewportPadding - dropdownWidth - rootRect.left;
|
||||||
|
const left = clamp(caretRect.left - rootRect.left, minLeft, Math.max(minLeft, maxLeft));
|
||||||
|
const belowTop = caretRect.bottom + gap;
|
||||||
|
const aboveTop = caretRect.top - dropdownHeight - gap;
|
||||||
|
const shouldOpenAbove = belowTop + dropdownHeight > window.innerHeight - viewportPadding && aboveTop >= viewportPadding;
|
||||||
|
const viewportTop = shouldOpenAbove
|
||||||
|
? Math.max(viewportPadding, aboveTop)
|
||||||
|
: Math.min(belowTop, window.innerHeight - viewportPadding - dropdownHeight);
|
||||||
|
setDropdownPosition({
|
||||||
|
top: viewportTop - rootRect.top,
|
||||||
|
left,
|
||||||
|
placement: shouldOpenAbove ? 'top' : 'bottom',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mentionOpen) return;
|
||||||
|
const frame = window.requestAnimationFrame(updateMentionDropdownPosition);
|
||||||
|
const handleViewportChange = () => updateMentionDropdownPosition();
|
||||||
|
window.addEventListener('resize', handleViewportChange);
|
||||||
|
window.addEventListener('scroll', handleViewportChange, true);
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(frame);
|
||||||
|
window.removeEventListener('resize', handleViewportChange);
|
||||||
|
window.removeEventListener('scroll', handleViewportChange, true);
|
||||||
|
};
|
||||||
|
}, [filteredMentionItems.length, mentionOpen, mentionSearch, updateMentionDropdownPosition]);
|
||||||
|
|
||||||
|
function renderToEditable(nextText = text, caret?: number) {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
if (!editable) return;
|
||||||
|
editable.innerHTML = textToHtml(nextText, props.uploads);
|
||||||
|
if (typeof caret === 'number') {
|
||||||
|
editable.focus();
|
||||||
|
setCaretOffset(editable, caret);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitFromEditable(shouldInspectMention: boolean, event?: InputEvent) {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
if (!editable || props.disabled || isComposing) return;
|
||||||
|
const nextText = serializeEditableToPlainText(editable);
|
||||||
|
setText(nextText);
|
||||||
|
props.onChange(nextText);
|
||||||
|
if (!shouldInspectMention) return;
|
||||||
|
inspectMentionTrigger(nextText, getCaretOffset(editable), event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectMentionTrigger(nextText: string, caret: number, event?: InputEvent) {
|
||||||
|
const beforeCaret = nextText.slice(0, caret);
|
||||||
|
const atIndex = Math.max(beforeCaret.lastIndexOf('@'), beforeCaret.lastIndexOf('@'));
|
||||||
|
if (atIndex < 0) {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const search = beforeCaret.slice(atIndex + 1);
|
||||||
|
if (/\s/.test(search)) {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inputData = event?.data ?? '';
|
||||||
|
if (!mentionOpen && search.length > 0 && inputData !== '@' && inputData !== '@') {
|
||||||
|
closeMention();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMentionAtIndex(atIndex);
|
||||||
|
setMentionSearch(search);
|
||||||
|
setMentionOpen(true);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
requestAnimationFrame(updateMentionDropdownPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMention() {
|
||||||
|
setMentionOpen(false);
|
||||||
|
setMentionSearch('');
|
||||||
|
setMentionAtIndex(-1);
|
||||||
|
setHighlightIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMention(token: string) {
|
||||||
|
const editable = editableRef.current;
|
||||||
|
if (!editable || mentionAtIndex < 0) return;
|
||||||
|
const currentText = serializeEditableToPlainText(editable);
|
||||||
|
const replaceEnd = Math.max(mentionAtIndex + 1, mentionAtIndex + 1 + mentionSearch.length);
|
||||||
|
const before = currentText.slice(0, mentionAtIndex);
|
||||||
|
const after = currentText.slice(replaceEnd);
|
||||||
|
const nextText = `${before}${token} ${after}`;
|
||||||
|
const nextCaret = before.length + token.length + 1;
|
||||||
|
setText(nextText);
|
||||||
|
props.onChange(nextText);
|
||||||
|
closeMention();
|
||||||
|
requestAnimationFrame(() => renderToEditable(nextText, nextCaret));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (props.disabled) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mentionOpen || !filteredMentionItems.length) return;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightIndex((current) => (current + 1) % filteredMentionItems.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setHighlightIndex((current) => (current - 1 + filteredMentionItems.length) % filteredMentionItems.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const item = filteredMentionItems[Math.min(highlightIndex, filteredMentionItems.length - 1)];
|
||||||
|
if (item) applyMention(item.token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeMention();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="promptMentionInput">
|
||||||
|
<div
|
||||||
|
ref={editableRef}
|
||||||
|
className="promptMentionEditable"
|
||||||
|
contentEditable={!props.disabled}
|
||||||
|
role="textbox"
|
||||||
|
aria-label={props.placeholder}
|
||||||
|
aria-multiline="true"
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onBlur={() => {
|
||||||
|
blurTimerRef.current = window.setTimeout(() => {
|
||||||
|
setFocused(false);
|
||||||
|
closeMention();
|
||||||
|
commitFromEditable(false);
|
||||||
|
}, 120);
|
||||||
|
}}
|
||||||
|
onCompositionEnd={() => {
|
||||||
|
setIsComposing(false);
|
||||||
|
requestAnimationFrame(() => commitFromEditable(true));
|
||||||
|
}}
|
||||||
|
onCompositionStart={() => setIsComposing(true)}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current);
|
||||||
|
}}
|
||||||
|
onInput={(event) => commitFromEditable(true, event.nativeEvent instanceof InputEvent ? event.nativeEvent : undefined)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={(event) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const plainText = event.clipboardData.getData('text/plain');
|
||||||
|
document.execCommand('insertText', false, plainText);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showPlaceholder && <div className="promptMentionPlaceholder" aria-hidden="true">{props.placeholder}</div>}
|
||||||
|
{mentionOpen && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="promptMentionDropdown"
|
||||||
|
data-placement={dropdownPosition.placement}
|
||||||
|
style={{ left: dropdownPosition.left, top: dropdownPosition.top }}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
|
{filteredMentionItems.length ? (
|
||||||
|
filteredMentionItems.map((candidate, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="promptMentionItem"
|
||||||
|
data-active={index === highlightIndex}
|
||||||
|
key={candidate.item.id}
|
||||||
|
onMouseEnter={() => setHighlightIndex(index)}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
applyMention(candidate.token);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MentionThumb item={candidate.item} />
|
||||||
|
<span>
|
||||||
|
<strong>{candidate.label}</strong>
|
||||||
|
<small>{candidate.item.name}</small>
|
||||||
|
</span>
|
||||||
|
<em>{uploadKindChinese(candidate.item.kind)}</em>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="promptMentionEmpty">暂无可引用资源</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MentionThumb(props: { item: PlaygroundMentionUpload }) {
|
||||||
|
if (props.item.kind === 'image') {
|
||||||
|
return <img className="promptMentionThumb" src={props.item.url} alt="" draggable={false} />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'video') {
|
||||||
|
return <video className="promptMentionThumb" src={props.item.url} muted playsInline preload="metadata" />;
|
||||||
|
}
|
||||||
|
if (props.item.kind === 'audio') {
|
||||||
|
return <Music2 className="promptMentionThumbIcon" size={16} />;
|
||||||
|
}
|
||||||
|
return <FileText className="promptMentionThumbIcon" size={16} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToHtml(raw: string, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
const uploadById = new Map(uploads.map((item) => [item.id, item]));
|
||||||
|
const parts: string[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
const re = new RegExp(resourceTokenPattern);
|
||||||
|
while ((match = re.exec(raw)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(escapeHtml(raw.slice(lastIndex, match.index)));
|
||||||
|
}
|
||||||
|
const token = match[0] ?? '';
|
||||||
|
const id = match[1] ?? '';
|
||||||
|
const item = uploadById.get(id);
|
||||||
|
parts.push(item ? mentionChipHtml(token, item, uploads) : '');
|
||||||
|
lastIndex = match.index + token.length;
|
||||||
|
}
|
||||||
|
if (lastIndex < raw.length) {
|
||||||
|
parts.push(escapeHtml(raw.slice(lastIndex)));
|
||||||
|
}
|
||||||
|
return parts.join('').replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionChipHtml(token: string, item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
const label = mentionDisplayLabel(item, uploads);
|
||||||
|
const thumb = item.kind === 'image'
|
||||||
|
? `<img class="promptMentionChipThumb" src="${escapeAttr(item.url)}" alt="" draggable="false">`
|
||||||
|
: item.kind === 'video'
|
||||||
|
? `<video class="promptMentionChipThumb" src="${escapeAttr(item.url)}" muted preload="metadata" playsinline></video>`
|
||||||
|
: `<span class="promptMentionChipThumb promptMentionChipThumbPlaceholder">${escapeHtml(uploadKindShort(item.kind))}</span>`;
|
||||||
|
return `<span contenteditable="false" class="promptMentionChip" data-token="${escapeAttr(token)}">${thumb}<span>${escapeHtml(label)}</span></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaretClientRect(editable: HTMLElement) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return null;
|
||||||
|
const originalRange = selection.getRangeAt(0);
|
||||||
|
if (!editable.contains(originalRange.startContainer)) return null;
|
||||||
|
const collapsedRange = originalRange.cloneRange();
|
||||||
|
collapsedRange.collapse(true);
|
||||||
|
let rect = collapsedRange.getBoundingClientRect();
|
||||||
|
if (rect.width || rect.height) return rect;
|
||||||
|
const marker = document.createElement('span');
|
||||||
|
marker.textContent = '\u200b';
|
||||||
|
const restoreRange = originalRange.cloneRange();
|
||||||
|
collapsedRange.insertNode(marker);
|
||||||
|
rect = marker.getBoundingClientRect();
|
||||||
|
marker.remove();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(restoreRange);
|
||||||
|
return rect.width || rect.height ? rect : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeEditableToPlainText(editable: HTMLElement) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const walk = (node: Node) => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
parts.push(node.textContent ?? '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const token = element.getAttribute('data-token');
|
||||||
|
if (token) {
|
||||||
|
parts.push(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'BR') {
|
||||||
|
parts.push('\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'DIV' && parts.length && !parts[parts.length - 1]?.endsWith('\n')) {
|
||||||
|
parts.push('\n');
|
||||||
|
}
|
||||||
|
element.childNodes.forEach(walk);
|
||||||
|
};
|
||||||
|
editable.childNodes.forEach(walk);
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCaretOffset(editable: HTMLElement) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return 0;
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
let offset = 0;
|
||||||
|
const walk = (node: Node): boolean => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const textLength = (node.textContent ?? '').length;
|
||||||
|
if (node === range.startContainer) {
|
||||||
|
offset += range.startOffset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += textLength;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const token = element.getAttribute('data-token');
|
||||||
|
if (token) {
|
||||||
|
if (node === range.startContainer || element.contains(range.startContainer)) {
|
||||||
|
offset += range.startOffset > 0 ? token.length : 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += token.length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'BR') {
|
||||||
|
offset += 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
if (walk(child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const child of Array.from(editable.childNodes)) {
|
||||||
|
if (walk(child)) break;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaretOffset(editable: HTMLElement, targetOffset: number) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
let offset = 0;
|
||||||
|
let targetNode: Node | null = null;
|
||||||
|
let targetNodeOffset = 0;
|
||||||
|
const walk = (node: Node): boolean => {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const textLength = (node.textContent ?? '').length;
|
||||||
|
if (offset + textLength >= targetOffset) {
|
||||||
|
targetNode = node;
|
||||||
|
targetNodeOffset = Math.max(0, targetOffset - offset);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += textLength;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return false;
|
||||||
|
const element = node as HTMLElement;
|
||||||
|
const token = element.getAttribute('data-token');
|
||||||
|
if (token) {
|
||||||
|
if (offset + token.length >= targetOffset) {
|
||||||
|
targetNode = node;
|
||||||
|
targetNodeOffset = targetOffset <= offset ? 0 : 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += token.length;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (element.tagName === 'BR') {
|
||||||
|
if (offset + 1 >= targetOffset) {
|
||||||
|
targetNode = node;
|
||||||
|
targetNodeOffset = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
offset += 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const child of Array.from(element.childNodes)) {
|
||||||
|
if (walk(child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const child of Array.from(editable.childNodes)) {
|
||||||
|
if (walk(child)) break;
|
||||||
|
}
|
||||||
|
if (!targetNode) {
|
||||||
|
targetNode = editable;
|
||||||
|
targetNodeOffset = editable.childNodes.length;
|
||||||
|
}
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(targetNode, targetNode.nodeType === Node.TEXT_NODE ? targetNodeOffset : Math.min(targetNodeOffset, targetNode.childNodes.length));
|
||||||
|
range.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionDisplayLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
return `@${uploadKindChinese(item.kind)} ${uploadKindIndex(item, uploads)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourcePromptLabel(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[], mode: 'image' | 'video') {
|
||||||
|
const kind = mode === 'image' ? 'image' : uploadKindEnglish(item.kind);
|
||||||
|
return `${kind} ${uploadKindIndex(item, uploads)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindIndex(item: PlaygroundMentionUpload, uploads: PlaygroundMentionUpload[]) {
|
||||||
|
const sameKind = uploads.filter((upload) => upload.kind === item.kind);
|
||||||
|
return Math.max(1, sameKind.findIndex((upload) => upload.id === item.id) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindChinese(kind: PlaygroundMentionUploadKind) {
|
||||||
|
if (kind === 'image') return '图像';
|
||||||
|
if (kind === 'video') return '视频';
|
||||||
|
if (kind === 'audio') return '音频';
|
||||||
|
return '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindEnglish(kind: PlaygroundMentionUploadKind) {
|
||||||
|
if (kind === 'image') return 'image';
|
||||||
|
if (kind === 'video') return 'video';
|
||||||
|
if (kind === 'audio') return 'audio';
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadKindShort(kind: PlaygroundMentionUploadKind) {
|
||||||
|
if (kind === 'image') return '图';
|
||||||
|
if (kind === 'video') return '视';
|
||||||
|
if (kind === 'audio') return '音';
|
||||||
|
return '文';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value: string) {
|
||||||
|
return escapeHtml(value).replace(/"/g, '"');
|
||||||
|
}
|
||||||
@ -139,6 +139,442 @@
|
|||||||
line-height: var(--line-height-relaxed);
|
line-height: var(--line-height-relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composerBodyWithReferences {
|
||||||
|
grid-template-columns: 120px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composerBodyWithReferences[data-frame-mode="true"] {
|
||||||
|
grid-template-columns: 250px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack {
|
||||||
|
position: relative;
|
||||||
|
width: 108px;
|
||||||
|
min-height: 106px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack[data-expanded="true"] {
|
||||||
|
z-index: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceTooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 14;
|
||||||
|
top: -42px;
|
||||||
|
left: 0;
|
||||||
|
max-width: min(640px, 54vw);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #20252b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 12px 32px rgba(24, 24, 27, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStackCards {
|
||||||
|
position: relative;
|
||||||
|
width: 108px;
|
||||||
|
min-height: 104px;
|
||||||
|
transition: width 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack[data-expanded="true"] .mediaReferenceStackCards {
|
||||||
|
width: min(calc(var(--reference-count) * 52px + 18px), calc(100vw - 96px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceCard {
|
||||||
|
position: absolute;
|
||||||
|
display: grid;
|
||||||
|
width: 58px;
|
||||||
|
height: 76px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: visible;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-soft);
|
||||||
|
box-shadow: 0 10px 24px rgba(24, 24, 27, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceCard {
|
||||||
|
left: calc(24px + var(--reference-x, 0px));
|
||||||
|
top: calc(7px + var(--reference-y, 0px));
|
||||||
|
transform: rotate(var(--reference-tilt, -6deg));
|
||||||
|
transition: left 160ms ease, top 160ms ease, transform 160ms ease, z-index 150ms ease, box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack[data-expanded="true"] .mediaReferenceCard {
|
||||||
|
left: calc(2px + var(--reference-index) * 52px);
|
||||||
|
top: 8px;
|
||||||
|
transform: rotate(var(--reference-tilt, -4deg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceCard[data-hovered="true"] {
|
||||||
|
z-index: 6;
|
||||||
|
transform: translateY(-8px) scale(1.04) rotate(0deg);
|
||||||
|
box-shadow: 0 16px 36px rgba(24, 24, 27, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceCard img,
|
||||||
|
.mediaReferenceCard video,
|
||||||
|
.frameSlotButton video,
|
||||||
|
.frameSlotButton img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferencePlaceholder {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceDuration {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(24, 24, 27, 0.66);
|
||||||
|
color: #fff;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceRemove,
|
||||||
|
.frameSlotRemove {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 7;
|
||||||
|
display: grid;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #20252b;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 6px 16px rgba(24, 24, 27, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceRemove {
|
||||||
|
top: -13px;
|
||||||
|
right: -13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceUploadCard {
|
||||||
|
border-style: dashed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceUploadCard span {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
color: inherit;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack:not([data-expanded="true"]) .mediaReferenceUploadCard[data-has-uploads="true"] {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack[data-expanded="true"] .mediaReferenceUploadCard,
|
||||||
|
.mediaReferenceUploadCard[data-has-uploads="false"] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceUploadCard:not(:disabled):hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceUploadCard:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack:not([data-expanded="true"]) .mediaReferenceUploadCard[data-has-uploads="true"]:disabled {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceAdd {
|
||||||
|
position: absolute;
|
||||||
|
left: 74px;
|
||||||
|
top: 61px;
|
||||||
|
z-index: 12;
|
||||||
|
display: grid;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-normal);
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 8px 20px rgba(24, 24, 27, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceStack[data-expanded="true"] .mediaReferenceAdd {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: scale(0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceAdd:not(:disabled):hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mediaReferenceAdd:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.firstLastFramePicker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 86px 34px 86px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSlot {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSlotButton {
|
||||||
|
display: grid;
|
||||||
|
width: 78px;
|
||||||
|
height: 104px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-soft);
|
||||||
|
transform: rotate(-7deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSlot:nth-child(3) .frameSlotButton {
|
||||||
|
transform: rotate(6deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSlotButton[data-filled="true"] {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSlotButton span {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
color: inherit;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSlotButton[data-filled="true"] span {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(24, 24, 27, 0.62);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSlotRemove {
|
||||||
|
top: -8px;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSwapButton {
|
||||||
|
display: grid;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSwapButton:not(:disabled):hover {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameSwapButton:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionInput {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionEditable {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 88px;
|
||||||
|
max-height: 190px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-normal);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionPlaceholder {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
pointer-events: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playgroundComposer.compact .promptMentionEditable {
|
||||||
|
min-height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionChip {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: min(260px, 80%);
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0 4px 2px 0;
|
||||||
|
padding: 2px 6px 2px 2px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
line-height: 1.55;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionChipThumb {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-width: 22px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 5px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionChipThumbPlaceholder {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionDropdown {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
width: min(320px, calc(100vw - 24px));
|
||||||
|
max-height: 286px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 18px 42px rgba(24, 24, 27, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionItem {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 54px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-normal);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionItem:hover,
|
||||||
|
.promptMentionItem[data-active="true"] {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionThumb,
|
||||||
|
.promptMentionThumbIcon {
|
||||||
|
display: grid;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--primary);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionItem span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionItem strong,
|
||||||
|
.promptMentionItem small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionItem strong {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionItem small {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionItem em {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promptMentionEmpty {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes composer-upload-spin {
|
@keyframes composer-upload-spin {
|
||||||
to {
|
to {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user