feat(web): improve playground media resource prompts

This commit is contained in:
wangbo 2026-05-14 01:17:59 +08:00
parent cdf469eccf
commit 170fd8655c
4 changed files with 763 additions and 41 deletions

View File

@ -2,10 +2,11 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { ChevronDown, MessageSquarePlus, Send, Sparkles } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, taskIsPending } from '../api';
import { createImageEditTask, createImageGenerationTask, createVideoGenerationTask, pollTaskUntilSettled, resolveApiAssetUrl, taskIsPending } from '../api';
import type { PlaygroundMode } from '../types';
import {
PlaygroundPromptMentionInput,
buildPlaygroundResourceToken,
removeInvalidPlaygroundResourceTokens,
replacePlaygroundResourceTokens,
} from './playground-prompt-mention';
@ -240,6 +241,8 @@ export function PlaygroundPage(props: {
mode?: Exclude<PlaygroundMode, 'chat'>;
prompt?: string;
settings?: MediaGenerationSettings;
uploads?: PlaygroundUpload[];
videoMode?: VideoCreateMode;
}) {
const runMode = overrides?.mode ?? props.mode;
if (runMode === 'chat') return;
@ -266,7 +269,8 @@ export function PlaygroundPage(props: {
}
const localId = newLocalId();
const runUploads = overrides ? [] : mediaUploads;
const runUploads = sanitizeMediaRunUploads(overrides?.uploads ?? mediaUploads);
const runVideoMode = overrides?.videoMode ?? videoMode;
const runModelOption = modelOptions.find((item) => item.value === runModel);
const modelLabel = runModelOption?.label ?? runModel;
const run: MediaGenerationRun = {
@ -278,6 +282,8 @@ export function PlaygroundPage(props: {
prompt: trimmedPrompt,
settings: runSettings,
status: 'submitting',
uploads: runUploads,
videoMode: runMode === 'video' ? runVideoMode : undefined,
};
setMediaRuns((current) => [...current, run]);
@ -288,7 +294,7 @@ export function PlaygroundPage(props: {
if (runMode === 'video') {
response = await createVideoGenerationTask(credential, {
model: runModel,
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, runUploads, videoMode),
content: sharedVideoGenerationContentFromPromptAndUploads(requestPrompt, runUploads, runVideoMode),
...mediaRequestPayload(runSettings, 'video'),
});
} else {
@ -353,8 +359,15 @@ export function PlaygroundPage(props: {
}
function editMediaRun(run: MediaGenerationRun) {
setPrompt(run.prompt);
const runUploads = editableMediaRunUploads(run);
const editablePrompt = editableMediaRunPrompt(run, runUploads);
setPrompt(editablePrompt);
setMediaSettings(run.settings);
setMediaUploads(runUploads);
setImageHasReference(run.mode === 'image' && runUploads.some((item) => item.kind === 'image'));
if (run.mode === 'video') {
setVideoMode(run.videoMode ?? inferVideoModeFromUploads(runUploads));
}
selectMediaRunModel(run);
if (props.mode !== run.mode) {
props.onModeChange(run.mode);
@ -363,15 +376,23 @@ export function PlaygroundPage(props: {
}
function rerunMediaRun(run: MediaGenerationRun) {
setPrompt(run.prompt);
const runUploads = editableMediaRunUploads(run);
const editablePrompt = editableMediaRunPrompt(run, runUploads);
const runVideoMode = run.videoMode ?? inferVideoModeFromUploads(runUploads);
setPrompt(editablePrompt);
setMediaSettings(run.settings);
setMediaUploads(runUploads);
setImageHasReference(run.mode === 'image' && runUploads.some((item) => item.kind === 'image'));
if (run.mode === 'video') {
setVideoMode(runVideoMode);
}
const runModel = selectMediaRunModel(run);
if (props.mode !== run.mode) {
props.onModeChange(run.mode);
setMediaMessage('已切换到对应模式并带入模型和参数,请确认后再次生成。');
return;
}
void submitMediaTask({ mode: run.mode, model: runModel, prompt: run.prompt, settings: run.settings });
void submitMediaTask({ mode: run.mode, model: runModel, prompt: editablePrompt, settings: run.settings, uploads: runUploads, videoMode: runVideoMode });
}
const mediaComposer = props.mode === 'chat' ? null : (
@ -676,8 +697,8 @@ function Composer(props: {
}
function mediaPromptPlaceholder(mode: PlaygroundMode) {
if (mode === 'image') return '输入画面描述,可用 @ 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 引用图片、视频或音频资源...';
if (mode === 'image') return '输入画面描述,可用 @ 或 @资产 快速引用图片资源,例如:让 @图像 1 保持人物一致...';
if (mode === 'video') return '输入镜头、运动和风格,可用 @ 或 @资产 引用图片、视频或音频资源...';
return placeholderByMode.chat;
}
@ -771,6 +792,211 @@ function updateMediaRun(runs: MediaGenerationRun[], localId: string, patch: Part
return runs.map((run) => run.localId === localId ? { ...run, ...patch } : run);
}
const playgroundResourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
function editableMediaRunUploads(run: MediaGenerationRun): PlaygroundUpload[] {
const storedUploads = sanitizeMediaRunUploads(run.uploads ?? []);
if (storedUploads.length) return storedUploads;
return mediaRunUploadsFromTaskRequest(run.task?.request, run.mode);
}
function editableMediaRunPrompt(run: MediaGenerationRun, uploads: PlaygroundUpload[]) {
return restorePromptResourceTokens(run.prompt, uploads, run.mode);
}
function restorePromptResourceTokens(raw: string, uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
if (!uploads.length) return raw;
if (raw.includes('<<<playground-resource:')) {
return restoreSerializedPromptResourceTokens(raw, uploads);
}
return restorePromptLabelsToResourceTokens(raw, uploads, mode);
}
function restoreSerializedPromptResourceTokens(raw: string, uploads: PlaygroundUpload[]) {
const byId = new Map(uploads.map((item) => [item.id, item]));
const usedIds = new Set<string>();
let fallbackIndex = 0;
return raw.replace(playgroundResourceTokenPattern, (full, id: string) => {
let item = byId.get(id);
if (item) {
usedIds.add(item.id);
} else {
while (fallbackIndex < uploads.length && usedIds.has(uploads[fallbackIndex]!.id)) {
fallbackIndex += 1;
}
item = uploads[fallbackIndex];
fallbackIndex += 1;
if (item) usedIds.add(item.id);
}
return item ? buildPlaygroundResourceToken(item.id) : full;
});
}
function restorePromptLabelsToResourceTokens(raw: string, uploads: PlaygroundUpload[], mode: Exclude<PlaygroundMode, 'chat'>) {
return uploads.reduce((text, item) => {
const index = uploadKindIndex(item, uploads);
return promptResourceRestorePatterns(item.kind, index, mode).reduce(
(current, pattern) => current.replace(pattern, buildPlaygroundResourceToken(item.id)),
text,
);
}, raw);
}
function promptResourceRestorePatterns(kind: PlaygroundUpload['kind'], index: number, mode: Exclude<PlaygroundMode, 'chat'>) {
const englishKind = mode === 'image' ? 'image' : uploadKindEnglish(kind);
const patterns = [
new RegExp(`\\b${englishKind}\\s+${index}\\b`, 'g'),
];
if (kind === 'image') {
patterns.push(new RegExp(`@(?:图像|图片)\\s*${index}(?!\\d)`, 'g'));
} else {
patterns.push(new RegExp(`@${uploadKindTitle(kind)}\\s*${index}(?!\\d)`, 'g'));
}
patterns.push(new RegExp(`@(?:素材|资产)\\s*${index}(?!\\d)`, 'g'));
return patterns;
}
function mediaRunUploadsFromTaskRequest(request: unknown, mode: Exclude<PlaygroundMode, 'chat'>): PlaygroundUpload[] {
const record = recordFromUnknown(request);
if (!record) return [];
const uploads: PlaygroundUpload[] = [];
if (Array.isArray(record.content)) {
record.content.forEach((item) => {
const part = recordFromUnknown(item);
if (!part) return;
appendUploadFromContentPart(uploads, part);
});
}
if (mode === 'image') {
appendImageUploadsFromRequestValue(uploads, record.image);
appendImageUploadsFromRequestValue(uploads, record.images);
appendImageUploadsFromRequestValue(uploads, record.input_image);
appendImageUploadsFromRequestValue(uploads, record.input_images);
}
return dedupeMediaRunUploads(uploads);
}
function appendUploadFromContentPart(uploads: PlaygroundUpload[], part: Record<string, unknown>) {
const role = part.role === 'first_frame' || part.role === 'last_frame' ? part.role : undefined;
const type = stringFromUnknown(part.type);
if (type === 'image_url') {
appendMediaRunUploadUrl(uploads, 'image', firstString(nestedString(part.image_url, 'url'), part.url), role);
return;
}
if (type === 'video_url') {
appendMediaRunUploadUrl(uploads, 'video', firstString(nestedString(part.video_url, 'url'), part.url), role);
return;
}
if (type === 'audio_url') {
appendMediaRunUploadUrl(uploads, 'audio', firstString(nestedString(part.audio_url, 'url'), part.url), role);
}
}
function appendImageUploadsFromRequestValue(uploads: PlaygroundUpload[], value: unknown) {
if (!value) return;
if (typeof value === 'string') {
appendMediaRunUploadUrl(uploads, 'image', value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => appendImageUploadsFromRequestValue(uploads, item));
return;
}
const record = recordFromUnknown(value);
if (!record) return;
appendMediaRunUploadUrl(uploads, 'image', firstString(record.url, nestedString(record.image_url, 'url'), record.image_url, record.imageUrl, record.path));
}
function appendMediaRunUploadUrl(uploads: PlaygroundUpload[], kind: PlaygroundUpload['kind'], value: unknown, role?: PlaygroundUpload['role']) {
const rawUrl = stringFromUnknown(value);
if (!rawUrl) return;
const url = resolveApiAssetUrl(rawUrl);
uploads.push({
contentType: '',
id: `${kind}-${uploads.length}-${url}`,
kind,
name: `${uploadKindTitle(kind)} ${uploads.filter((item) => item.kind === kind).length + 1}`,
raw: {},
role,
size: 0,
url,
});
}
function dedupeMediaRunUploads(uploads: PlaygroundUpload[]) {
const seen = new Set<string>();
return uploads.filter((item) => {
const key = `${item.kind}:${item.url}:${item.role ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function sanitizeMediaRunUploads(uploads: unknown[]): PlaygroundUpload[] {
return uploads
.map((item, index) => sanitizeMediaRunUpload(item, index))
.filter((item): item is PlaygroundUpload => Boolean(item));
}
function sanitizeMediaRunUpload(value: unknown, index: number): PlaygroundUpload | undefined {
const record = recordFromUnknown(value);
if (!record) return undefined;
const url = stringFromUnknown(record.url);
const kind = uploadKindFromUnknown(record.kind);
if (!url || !kind) return undefined;
const size = numericFromUnknown(record.size);
return {
contentType: stringFromUnknown(record.contentType),
id: stringFromUnknown(record.id) || `${kind}-${index}-${url}`,
kind,
name: stringFromUnknown(record.name) || `${uploadKindTitle(kind)} ${index + 1}`,
raw: recordFromUnknown(record.raw) ?? {},
role: record.role === 'first_frame' || record.role === 'last_frame' ? record.role : undefined,
size: size && size > 0 ? Math.round(size) : 0,
url,
};
}
function numericFromUnknown(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function uploadKindFromUnknown(value: unknown): PlaygroundUpload['kind'] | undefined {
if (value === 'audio' || value === 'file' || value === 'image' || value === 'video') return value;
return undefined;
}
function uploadKindTitle(kind: PlaygroundUpload['kind']) {
if (kind === 'image') return '图像';
if (kind === 'video') return '视频';
if (kind === 'audio') return '音频';
return '文件';
}
function uploadKindEnglish(kind: PlaygroundUpload['kind']) {
if (kind === 'image') return 'image';
if (kind === 'video') return 'video';
if (kind === 'audio') return 'audio';
return 'file';
}
function uploadKindIndex(item: PlaygroundUpload, uploads: PlaygroundUpload[]) {
const sameKind = uploads.filter((upload) => upload.kind === item.kind);
return Math.max(1, sameKind.findIndex((upload) => upload.id === item.id) + 1);
}
function inferVideoModeFromUploads(uploads: PlaygroundUpload[]): VideoCreateMode {
if (uploads.some((item) => item.role === 'first_frame' || item.role === 'last_frame')) return 'first_last_frame';
if (uploads.length > 0) return 'omni_reference';
return 'text_to_video';
}
function readStoredMediaRuns(): MediaGenerationRun[] {
if (typeof window === 'undefined') return [];
try {
@ -796,7 +1022,9 @@ function writeStoredMediaRuns(runs: MediaGenerationRun[]) {
window.localStorage.removeItem(MEDIA_RUNS_STORAGE_KEY);
return;
}
const storedRuns = sortMediaRunsByCreatedAt(runs).slice(-MEDIA_RUNS_STORAGE_LIMIT);
const storedRuns = sortMediaRunsByCreatedAt(runs)
.slice(-MEDIA_RUNS_STORAGE_LIMIT)
.map((run) => ({ ...run, uploads: sanitizeMediaRunUploads(run.uploads ?? []) }));
window.localStorage.setItem(MEDIA_RUNS_STORAGE_KEY, JSON.stringify({
runs: storedRuns,
version: 1,
@ -823,6 +1051,7 @@ function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun
status = 'failed';
error = error || '任务提交已中断,请重新生成。';
}
const uploads = sanitizeMediaRunUploads(Array.isArray(record.uploads) ? record.uploads : []);
return {
createdAt,
error,
@ -834,6 +1063,8 @@ function mediaRunFromStorage(value: unknown, index: number): MediaGenerationRun
settings: mediaSettingsFromStorage(record.settings),
status,
task,
uploads,
videoMode: mode === 'video' ? videoModeFromStorage(record.videoMode, uploads) : undefined,
};
}
@ -858,6 +1089,11 @@ function mediaSettingsFromStorage(value: unknown): MediaGenerationSettings {
};
}
function videoModeFromStorage(value: unknown, uploads: PlaygroundUpload[]): VideoCreateMode {
if (value === 'text_to_video' || value === 'first_last_frame' || value === 'omni_reference') return value;
return inferVideoModeFromUploads(uploads);
}
function countPresetFromStorage(value: unknown, fallback: MediaGenerationSettings['countPreset']) {
if (value === 'custom') return value;
const numeric = Number(value);
@ -880,6 +1116,11 @@ function stringFromUnknown(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function nestedString(value: unknown, key: string) {
const record = recordFromUnknown(value);
return record ? stringFromUnknown(record[key]) : '';
}
function dateStringFromUnknown(value: unknown) {
const raw = stringFromUnknown(value);
if (!raw) return undefined;

View File

@ -5,16 +5,19 @@ import Slider from 'antd/es/slider';
import {
Download,
Edit3,
FileText,
Image as ImageIcon,
Images,
Link2,
LoaderCircle,
Music2,
Sparkles,
Square,
} from 'lucide-react';
import { resolveApiAssetUrl } from '../api';
import { Button, Input, Popover, PopoverContent, PopoverTrigger } from '../components/ui';
import type { PlaygroundMode } from '../types';
import type { PlaygroundUpload, PlaygroundUploadKind, PlaygroundVideoCreateMode } from './playground-upload';
export type MediaOutputMode = 'single' | 'group';
export type MediaCountPreset = 1 | 2 | 3 | 4 | 'custom';
@ -46,6 +49,8 @@ export interface MediaGenerationRun {
settings: MediaGenerationSettings;
status: GatewayTask['status'] | 'submitting';
task?: GatewayTask;
uploads?: PlaygroundUpload[];
videoMode?: PlaygroundVideoCreateMode;
}
export function gatewayTaskErrorText(task: GatewayTask | undefined, fallback = '任务失败') {
@ -513,20 +518,40 @@ function MediaTaskCard(props: {
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
const errorText = mediaRunErrorText(props.run);
const references = mediaReferenceItems(props.run);
const promptParts = promptDisplayParts(props.run.prompt, references);
const taskMeta = mediaTaskMetaText(props.run);
return (
<article className="mediaTaskItem" data-status={props.run.status}>
<header className="mediaTaskHeader">
<div>
<p>
<span>{props.run.prompt}</span>
<small>{props.run.mode === 'video' ? '视频' : '图片'} {props.run.modelLabel} {props.run.settings.aspectRatio} {props.run.settings.resolution}</small>
</p>
<time dateTime={props.run.createdAt}>{formatRunTime(props.run.createdAt)}</time>
<div className="mediaTaskHeaderMain">
{references.length > 0 && <MediaTaskReferenceStack references={references} />}
<div className="mediaTaskHeaderContent">
<div className="mediaTaskMetaLine">
<Sparkles size={16} />
<time dateTime={props.run.createdAt}>{formatRunDateTime(props.run.createdAt)}</time>
<span aria-hidden="true">|</span>
<strong>{props.run.modelLabel}</strong>
<span>{props.run.mode === 'video' ? '视频生成' : references.length ? '图像编辑' : '图像生成'}</span>
<span aria-hidden="true">|</span>
<span>{taskMeta}</span>
</div>
<div className="mediaTaskPromptLine">
<span className="mediaTaskPromptText">{promptParts}</span>
</div>
</div>
</div>
<span className="mediaTaskStatus" data-status={props.run.status}>{status}</span>
</header>
{errorText && (
<div className="mediaTaskError">
<strong></strong>
<span>{errorText}</span>
</div>
)}
<div className="mediaPreviewStage" data-count={expectedCount}>
{backdropItem && <img aria-hidden="true" className="mediaPreviewBackdrop" src={backdropItem.src} alt="" />}
<div className="mediaGrid" data-count={expectedCount} style={style}>
@ -543,12 +568,6 @@ function MediaTaskCard(props: {
</div>
</div>
{errorText && (
<div className="mediaTaskError">
<strong></strong>
<span>{errorText}</span>
</div>
)}
<footer className="mediaTaskActions">
{items[0] ? (
<Button asChild size="sm" variant="secondary">
@ -580,6 +599,57 @@ function MediaTaskCard(props: {
);
}
function MediaTaskReferenceStack(props: { references: PlaygroundUpload[] }) {
const visibleReferences = props.references.slice(0, 8);
const overflowCount = props.references.length - visibleReferences.length;
return (
<span
className="mediaTaskReferenceStack"
style={{ '--reference-count': Math.max(1, visibleReferences.length) } as CSSProperties}
title={props.references.map((item) => item.name).join('\n')}
>
{visibleReferences.map((item, index) => (
<span
className="mediaTaskReferenceCard"
data-kind={item.kind}
key={`${item.id}-${index}`}
style={taskReferenceCardStyle(index)}
>
<MediaTaskReferencePreview item={item} />
<small>{referenceKindLabel(item.kind)}</small>
</span>
))}
{overflowCount > 0 && (
<span className="mediaTaskReferenceOverflow">+{overflowCount}</span>
)}
</span>
);
}
function MediaTaskReferencePreview(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 <Music2 size={16} />;
}
return <FileText size={16} />;
}
function PromptResourceTag(props: { item: PlaygroundUpload; references: PlaygroundUpload[] }) {
return (
<span className="mediaPromptResourceTag" contentEditable={false}>
<span className="mediaPromptResourceThumb">
<MediaTaskReferencePreview item={props.item} />
</span>
<span>{mediaReferenceMentionLabel(props.item, props.references)}</span>
</span>
);
}
function MediaTile(props: {
expectedCount: number;
index: number;
@ -628,6 +698,193 @@ function mediaRunErrorText(run: MediaGenerationRun) {
return gatewayTaskErrorText(run.task, '') || run.error || '';
}
const mediaResourceTokenPattern = /<<<playground-resource:([^>]+)>>>/g;
const taskReferenceTiltValues = [-10, 8, -5, 9, -7, 5, -8, 6];
const taskReferenceYValues = [0, 3, -1, 2, -2, 4, 1, -3];
function promptDisplayParts(raw: string, references: PlaygroundUpload[]): ReactNode[] {
if (!raw.includes('<<<playground-resource:')) return [raw];
const parts: ReactNode[] = [];
const byId = new Map(references.map((item) => [item.id, item]));
const usedIds = new Set<string>();
let fallbackIndex = 0;
let lastIndex = 0;
let match: RegExpExecArray | null;
const re = new RegExp(mediaResourceTokenPattern);
while ((match = re.exec(raw)) !== null) {
if (match.index > lastIndex) {
parts.push(raw.slice(lastIndex, match.index));
}
const id = match[1] ?? '';
let item = byId.get(id);
if (item) {
usedIds.add(item.id);
} else {
while (fallbackIndex < references.length && usedIds.has(references[fallbackIndex]!.id)) {
fallbackIndex += 1;
}
item = references[fallbackIndex];
fallbackIndex += 1;
if (item) usedIds.add(item.id);
}
if (item) {
parts.push(<PromptResourceTag item={item} references={references} key={`${match.index}-${item.id}`} />);
} else {
parts.push('@资产');
}
lastIndex = match.index + (match[0]?.length ?? 0);
}
if (lastIndex < raw.length) {
parts.push(raw.slice(lastIndex));
}
return parts;
}
function taskReferenceCardStyle(index: number) {
const valueIndex = index % taskReferenceTiltValues.length;
return {
'--reference-index': index,
'--reference-tilt': `${taskReferenceTiltValues[valueIndex]}deg`,
'--reference-y': `${taskReferenceYValues[valueIndex]}px`,
} as CSSProperties;
}
function mediaReferenceMentionLabel(item: PlaygroundUpload, references: PlaygroundUpload[]) {
return `@${promptReferenceKindLabel(item.kind)}${referenceKindIndex(item, references)}`;
}
function referenceKindIndex(item: PlaygroundUpload, references: PlaygroundUpload[]) {
const sameKind = references.filter((reference) => reference.kind === item.kind);
return Math.max(1, sameKind.findIndex((reference) => reference.id === item.id) + 1);
}
function mediaReferenceItems(run: MediaGenerationRun): PlaygroundUpload[] {
const uploads = normalizeReferenceUploads(run.uploads);
if (uploads.length) return uploads;
return referencesFromTaskRequest(run.task?.request, run.mode);
}
function normalizeReferenceUploads(value: unknown): PlaygroundUpload[] {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => normalizeReferenceUpload(item, index))
.filter((item): item is PlaygroundUpload => Boolean(item));
}
function normalizeReferenceUpload(value: unknown, index: number): PlaygroundUpload | undefined {
const record = recordFromUnknown(value);
if (!record) return undefined;
const url = stringFromUnknown(record.url);
const kind = referenceKindFromUnknown(record.kind, url);
if (!url || !kind) return undefined;
const size = numberFromUnknown(record.size);
return {
contentType: stringFromUnknown(record.contentType),
id: stringFromUnknown(record.id) || `${kind}-${index}-${url}`,
kind,
name: stringFromUnknown(record.name) || `${referenceKindLabel(kind)} ${index + 1}`,
raw: recordFromUnknown(record.raw) ?? {},
role: record.role === 'first_frame' || record.role === 'last_frame' ? record.role : undefined,
size: size && size > 0 ? Math.round(size) : 0,
url,
};
}
function referencesFromTaskRequest(request: unknown, mode: Exclude<PlaygroundMode, 'chat'>): PlaygroundUpload[] {
const record = recordFromUnknown(request);
if (!record) return [];
const references: PlaygroundUpload[] = [];
if (Array.isArray(record.content)) {
record.content.forEach((item) => {
const content = recordFromUnknown(item);
if (!content) return;
appendReferenceFromContentPart(references, content);
});
}
if (mode === 'image') {
appendImageReferencesFromValue(references, record.image);
appendImageReferencesFromValue(references, record.images);
appendImageReferencesFromValue(references, record.input_image);
appendImageReferencesFromValue(references, record.input_images);
}
return dedupeReferenceUploads(references);
}
function appendReferenceFromContentPart(references: PlaygroundUpload[], part: Record<string, unknown>) {
const type = stringFromUnknown(part.type);
if (type === 'image_url') {
appendReferenceUrl(references, 'image', firstString(nestedString(part.image_url, 'url'), part.url));
return;
}
if (type === 'video_url') {
appendReferenceUrl(references, 'video', firstString(nestedString(part.video_url, 'url'), part.url));
return;
}
if (type === 'audio_url') {
appendReferenceUrl(references, 'audio', firstString(nestedString(part.audio_url, 'url'), part.url));
}
}
function appendImageReferencesFromValue(references: PlaygroundUpload[], value: unknown) {
if (!value) return;
if (typeof value === 'string') {
appendReferenceUrl(references, 'image', value);
return;
}
if (Array.isArray(value)) {
value.forEach((item) => appendImageReferencesFromValue(references, item));
return;
}
const record = recordFromUnknown(value);
if (!record) return;
appendReferenceUrl(references, 'image', firstString(record.url, nestedString(record.image_url, 'url'), record.image_url, record.imageUrl, record.path));
}
function appendReferenceUrl(references: PlaygroundUpload[], kind: PlaygroundUploadKind, rawUrl: unknown) {
const url = stringFromUnknown(rawUrl);
if (!url) return;
const resolvedUrl = resolveApiAssetUrl(url);
references.push({
contentType: '',
id: `${kind}-${references.length}-${resolvedUrl}`,
kind,
name: `${referenceKindLabel(kind)} ${references.filter((item) => item.kind === kind).length + 1}`,
raw: {},
size: 0,
url: resolvedUrl,
});
}
function dedupeReferenceUploads(references: PlaygroundUpload[]) {
const seen = new Set<string>();
return references.filter((item) => {
const key = `${item.kind}:${item.url}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function referenceKindFromUnknown(value: unknown, url: string): PlaygroundUploadKind | undefined {
if (value === 'image' || value === 'video' || value === 'audio' || value === 'file') return value;
if (/\.(png|jpe?g|webp|gif|bmp|avif|svg)(\?|#|$)/i.test(url)) return 'image';
if (/\.(mp4|mov|webm|m4v|avi|mkv)(\?|#|$)/i.test(url)) return 'video';
if (/\.(mp3|m4a|wav|aac|flac|ogg|opus)(\?|#|$)/i.test(url)) return 'audio';
return undefined;
}
function referenceKindLabel(kind: PlaygroundUploadKind) {
if (kind === 'image') return '图像';
if (kind === 'video') return '视频';
if (kind === 'audio') return '音频';
return '文件';
}
function promptReferenceKindLabel(kind: PlaygroundUploadKind) {
if (kind === 'image') return '图片';
return referenceKindLabel(kind);
}
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
const record = recordFromUnknown(entry);
if (!record) return undefined;
@ -660,8 +917,23 @@ function mediaStatusText(run: MediaGenerationRun) {
return run.status;
}
function formatRunTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
function formatRunDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const pad = (item: number) => String(item).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function mediaTaskMetaText(run: MediaGenerationRun) {
const items = [run.settings.aspectRatio, run.settings.resolution];
if (run.mode === 'video') {
items.push(`${run.settings.durationSeconds}s`);
if (run.settings.outputAudio) items.push('有声音');
} else {
const count = mediaOutputCount(run.settings);
if (count > 1) items.push(`${count}`);
}
return items.filter(Boolean).join(' | ');
}
function cssAspectRatio(settings: MediaGenerationSettings) {

View File

@ -39,6 +39,28 @@ export function replacePlaygroundResourceTokens(
});
}
export function replacePlaygroundResourceTokensForDisplay(raw: string, uploads: PlaygroundMentionUpload[]) {
if (!raw.includes(resourceTokenPrefix)) return raw;
const byId = new Map(uploads.map((item) => [item.id, item]));
const usedIds = new Set<string>();
let fallbackIndex = 0;
return raw.replace(resourceTokenPattern, (full, id: string) => {
let item = byId.get(id);
if (item) {
usedIds.add(item.id);
} else {
while (fallbackIndex < uploads.length && usedIds.has(uploads[fallbackIndex]!.id)) {
fallbackIndex += 1;
}
item = uploads[fallbackIndex];
fallbackIndex += 1;
if (item) usedIds.add(item.id);
}
if (!item) return '@资产';
return mentionDisplayLabel(item, uploads);
});
}
export function PlaygroundPromptMentionInput(props: {
disabled?: boolean;
placeholder: string;
@ -68,7 +90,7 @@ export function PlaygroundPromptMentionInput(props: {
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(),
searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} 资产 素材 resource asset ${index + 1}`.toLowerCase(),
token: buildPlaygroundResourceToken(item.id),
})), [props.uploads]);
@ -85,22 +107,26 @@ export function PlaygroundPromptMentionInput(props: {
}
setText(props.value);
setHasEditableContent(promptTextHasContent(props.value));
if (!focused) {
if (!isComposingRef.current) {
requestAnimationFrame(() => renderToEditable(props.value));
}
}, [focused, props.value, text]);
useEffect(() => {
const cleaned = removeInvalidPlaygroundResourceTokens(text, props.uploads);
if (cleaned !== text) {
const cleaned = removeInvalidPlaygroundResourceTokens(props.value, props.uploads);
if (cleaned !== props.value) {
setText(cleaned);
setHasEditableContent(promptTextHasContent(cleaned));
props.onChange(cleaned);
requestAnimationFrame(() => renderToEditable(cleaned));
return;
}
if (props.value !== text) {
setText(props.value);
setHasEditableContent(promptTextHasContent(props.value));
}
if (!focused) {
requestAnimationFrame(() => renderToEditable(text));
requestAnimationFrame(() => renderToEditable(props.value));
}
}, [uploadSignature]);

View File

@ -1831,37 +1831,220 @@
.mediaTaskHeader {
display: flex;
align-items: flex-end;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
gap: 16px;
}
.mediaTaskHeader > div {
display: grid;
gap: 4px;
.mediaTaskHeaderMain {
display: flex;
flex: 1 1 auto;
align-items: flex-end;
gap: 14px;
min-width: 0;
}
.mediaTaskHeader p {
.mediaTaskHeaderContent {
display: grid;
flex: 1 1 auto;
gap: 10px;
min-width: 0;
}
.mediaTaskMetaLine {
display: flex;
min-width: 0;
align-items: baseline;
gap: 9px;
flex-wrap: wrap;
align-items: center;
gap: 8px;
color: #7a8794;
font-size: var(--font-size-sm);
line-height: 1.35;
}
.mediaTaskHeader p span {
color: var(--text-strong);
font-size: var(--font-size-lg);
.mediaTaskMetaLine svg {
flex: 0 0 auto;
color: #607080;
}
.mediaTaskMetaLine time,
.mediaTaskMetaLine strong,
.mediaTaskMetaLine span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mediaTaskMetaLine strong {
color: var(--text-normal);
font-weight: var(--font-weight-medium);
}
.mediaTaskHeader small,
.mediaTaskPromptLine {
position: relative;
min-width: 0;
}
.mediaTaskPromptText {
display: block;
min-width: 0;
color: var(--text-strong);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: 1.65;
max-height: 4.95em;
overflow: hidden;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.mediaTaskPromptLine:hover {
z-index: 13;
}
.mediaTaskPromptLine:hover .mediaTaskPromptText {
max-height: none;
overflow: visible;
}
.mediaPromptResourceTag {
display: inline-flex;
max-width: min(220px, 86vw);
align-items: center;
gap: 6px;
margin: 0 5px 3px;
padding: 2px 8px 2px 4px;
border-radius: 7px;
background: #438ce9;
color: #fff;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-regular);
line-height: 1.25;
vertical-align: baseline;
}
.mediaPromptResourceThumb {
display: inline-grid;
width: 24px;
height: 24px;
min-width: 24px;
place-items: center;
overflow: hidden;
border-radius: 6px;
background: rgba(255, 255, 255, 0.18);
}
.mediaPromptResourceThumb img,
.mediaPromptResourceThumb video {
width: 100%;
height: 100%;
object-fit: cover;
}
.mediaPromptResourceThumb svg {
width: 15px;
height: 15px;
}
.mediaTaskHeader time {
color: #7a8794;
font-size: var(--font-size-sm);
}
.mediaTaskReferenceStack {
--task-reference-width: 54px;
--task-reference-height: 72px;
position: relative;
display: inline-block;
flex: 0 0 auto;
width: var(--task-reference-width);
height: var(--task-reference-height);
margin: 2px 2px 2px 0;
vertical-align: middle;
transition: width 180ms ease;
}
.mediaTaskReferenceStack:hover {
z-index: 14;
width: min(calc(var(--reference-count) * 58px), calc(100vw - 96px));
}
.mediaTaskReferenceCard {
position: absolute;
left: 0;
top: calc(var(--reference-y, 0px));
display: grid;
width: var(--task-reference-width);
height: var(--task-reference-height);
place-items: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.86);
border-radius: 7px;
background: var(--surface-muted);
color: var(--primary);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.2);
transform: rotate(var(--reference-tilt, -8deg));
transition: left 180ms ease, top 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.mediaTaskReferenceStack:hover .mediaTaskReferenceCard {
left: calc(var(--reference-index) * 58px);
top: 0;
transform: rotate(var(--reference-tilt, -5deg));
}
.mediaTaskReferenceCard:hover {
z-index: 6;
transform: translateY(-5px) scale(1.04) rotate(0deg) !important;
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.24);
}
.mediaTaskReferenceCard img,
.mediaTaskReferenceCard video {
width: 100%;
height: 100%;
object-fit: cover;
}
.mediaTaskReferenceCard svg {
width: 17px;
height: 17px;
}
.mediaTaskReferenceCard small {
position: absolute;
left: 4px;
bottom: 4px;
padding: 1px 4px;
border-radius: 999px;
background: rgba(24, 24, 27, 0.68);
color: #fff;
font-size: 10px;
line-height: 1.25;
}
.mediaTaskReferenceCard[data-kind="audio"],
.mediaTaskReferenceCard[data-kind="file"] {
background: linear-gradient(135deg, #f7fbff, #e7edf5);
}
.mediaTaskReferenceOverflow {
position: absolute;
right: -9px;
bottom: -7px;
z-index: 8;
display: inline-grid;
min-width: 22px;
height: 22px;
place-items: center;
padding: 0 5px;
border-radius: 999px;
background: var(--text-strong);
color: var(--surface);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
}
.mediaTaskStatus {
flex: 0 0 auto;
min-height: 28px;