Merge dev into master
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m59s

This commit is contained in:
zyc 2026-05-25 18:35:27 +08:00
commit f7e2fb8ae2
7 changed files with 247 additions and 53 deletions

View File

@ -1,4 +1,5 @@
import logging import logging
import re
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, parser_classes from rest_framework.decorators import api_view, permission_classes, parser_classes
@ -90,6 +91,17 @@ def _format_prompt_for_ark(prompt, label_placeholders):
return result return result
_ORPHAN_MATERIAL_MENTION_RE = re.compile(r'@(?:图片|视频|音频|素材)[^\s,.!?;:)]*')
def _find_orphan_material_mention(prompt, references):
"""Return a material @mention in prompt when no reference payload arrived."""
if not prompt or references:
return None
match = _ORPHAN_MATERIAL_MENTION_RE.search(prompt)
return match.group(0) if match else None
def _get_token_price(config, model, has_video_ref, resolution): def _get_token_price(config, model, has_video_ref, resolution):
"""根据模型、是否含视频、分辨率选择单价。 """根据模型、是否含视频、分辨率选择单价。
@ -226,9 +238,22 @@ def video_generate_view(request):
aspect_ratio = serializer.validated_data['aspect_ratio'] aspect_ratio = serializer.validated_data['aspect_ratio']
# serializer 已设 default='720p' + choices 约束validated_data 必有合法值 # serializer 已设 default='720p' + choices 约束validated_data 必有合法值
resolution = serializer.validated_data['resolution'] resolution = serializer.validated_data['resolution']
references = serializer.validated_data.get('references', [])
search_mode = request.data.get('search_mode', 'off') search_mode = request.data.get('search_mode', 'off')
seed = _safe_int(request.data.get('seed', -1), -1) seed = _safe_int(request.data.get('seed', -1), -1)
orphan_mention = _find_orphan_material_mention(prompt, references)
if orphan_mention:
logger.warning(
'Blocked generate with material @mention but empty references (user=%s prompt=%s)',
user.id,
prompt[:120],
)
return Response({
'error': 'missing_references',
'message': f'{orphan_mention} 对应的内容为空',
}, status=status.HTTP_400_BAD_REQUEST)
# 1080P 仅 Seedance 2.0 支持Fast 不支持 # 1080P 仅 Seedance 2.0 支持Fast 不支持
if resolution == '1080p' and model == 'seedance_2.0_fast': if resolution == '1080p' and model == 'seedance_2.0_fast':
return Response({ return Response({
@ -238,7 +263,6 @@ def video_generate_view(request):
# ── 预估 token 和费用 ── # ── 预估 token 和费用 ──
config = QuotaConfig.objects.get_or_create(pk=1)[0] config = QuotaConfig.objects.get_or_create(pk=1)[0]
references = request.data.get('references', [])
w, h = get_resolution(aspect_ratio, resolution) w, h = get_resolution(aspect_ratio, resolution)
has_video_ref = _has_video_reference(references) has_video_ref = _has_video_reference(references)
input_video_dur = _sum_video_duration(references) if has_video_ref else 0 input_video_dur = _sum_video_duration(references) if has_video_ref else 0
@ -335,9 +359,12 @@ def video_generate_view(request):
}, status=status.HTTP_429_TOO_MANY_REQUESTS) }, status=status.HTTP_429_TOO_MANY_REQUESTS)
# 构建参考素材 # 构建参考素材
references = request.data.get('references', []) # 直接上传的参考图片最多 9 张;素材库 asset:// 引用不计入该上传槽位限制。
# 火山限制最多 9 张参考图片 image_count = sum(
image_count = sum(1 for r in references if r.get('type', 'image') == 'image') 1 for r in references
if r.get('type', 'image') == 'image'
and not str(r.get('url', '')).startswith('asset://')
)
if image_count > 9: if image_count > 9:
return Response({ return Response({
'error': 'too_many_references', 'error': 'too_many_references',

View File

@ -146,6 +146,16 @@ class TestVideoGenerateArkPrompt(TestCase):
'references': references, 'references': references,
}, format='json') }, format='json')
@mock.patch('apps.generation.views.create_task')
def test_reject_material_mention_without_references(self, mock_create_task):
"""事故回归prompt 含 @图片 但 references 为空时,不能创建 refs=0 脏任务。"""
resp = self._post_generate('@图片1 走过来', [])
self.assertEqual(resp.status_code, 400, resp.content)
self.assertEqual(resp.json().get('error'), 'missing_references')
self.assertEqual(resp.json().get('message'), '@图片1 对应的内容为空')
self.assertFalse(mock_create_task.called)
self.assertFalse(GenerationRecord.objects.filter(user=self.user).exists())
@mock.patch('apps.generation.tasks.poll_video_task') @mock.patch('apps.generation.tasks.poll_video_task')
@mock.patch('apps.generation.views.create_task') @mock.patch('apps.generation.views.create_task')
def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll): def test_view_converts_prompt_for_local_assets(self, mock_create_task, mock_poll):

View File

@ -416,14 +416,15 @@ export function PromptInput() {
}, [extractText]); }, [extractText]);
const insertAssetMention = useCallback((asset: AssetSearchResult) => { const insertAssetMention = useCallback((asset: AssetSearchResult) => {
// Instant check: count limit // Instant check: count limit. Image assets from the library do not consume
// the 9 direct-upload image slots.
const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } }; const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } };
const refs = useInputBarStore.getState().references; const refs = useInputBarStore.getState().references;
const refCounts = { image: 0, video: 0, audio: 0 }; const refCounts = { image: 0, video: 0, audio: 0 };
refs.forEach((r) => refCounts[r.type]++); refs.forEach((r) => refCounts[r.type]++);
const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image'; const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image';
const maxMap = { image: 9, video: 3, audio: 3 }; const maxMap = { video: 3, audio: 3 };
if (refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) { if (typeKey !== 'image' && refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片'; const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片';
showToast(`${typeLabel}已达上限`); showToast(`${typeLabel}已达上限`);
return; return;
@ -715,12 +716,16 @@ export function PromptInput() {
}} }}
> >
<div className={styles.mentionThumb}> <div className={styles.mentionThumb}>
{ref.type === 'video' ? ( {ref.uploading ? (
<span style={{ fontSize: 12 }}>...</span>
) : ref.type === 'video' && ref.previewUrl ? (
<video src={rewriteTosUrl(ref.previewUrl)} muted className={styles.thumbMedia} /> <video src={rewriteTosUrl(ref.previewUrl)} muted className={styles.thumbMedia} />
) : ref.type === 'audio' ? ( ) : ref.type === 'audio' ? (
<span style={{ fontSize: 16 }}>{'\u266B'}</span> <span style={{ fontSize: 16 }}>{'\u266B'}</span>
) : ( ) : ref.previewUrl ? (
<img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} /> <img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
) : (
<span style={{ fontSize: 12 }}>?</span>
)} )}
</div> </div>
<span className={styles.mentionLabel}>{ref.label}</span> <span className={styles.mentionLabel}>{ref.label}</span>

View File

@ -282,6 +282,16 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.uploadPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-overlay-soft);
color: var(--color-text-secondary);
}
/* Upload status overlay */ /* Upload status overlay */
.uploadOverlay { .uploadOverlay {
position: absolute; position: absolute;

View File

@ -142,28 +142,29 @@ export function UniversalUpload() {
}} }}
> >
<div className={styles.thumbInner}> <div className={styles.thumbInner}>
{ref.type === 'video' ? ( {ref.uploading ? (
<div className={styles.uploadPlaceholder}>
<Spinner />
</div>
) : ref.uploadError ? (
<div
className={`${styles.uploadPlaceholder} ${styles.uploadError}`}
onClick={(e) => { e.stopPropagation(); retryUpload(ref.id); }}
title="点击重试"
>
<ErrorIcon />
</div>
) : ref.type === 'video' && ref.previewUrl ? (
<video src={rewriteTosUrl(ref.previewUrl)} className={styles.thumbMedia} muted /> <video src={rewriteTosUrl(ref.previewUrl)} className={styles.thumbMedia} muted />
) : ref.type === 'audio' ? ( ) : ref.type === 'audio' ? (
<div className={styles.audioPlaceholder}> <div className={styles.audioPlaceholder}>
<AudioIcon /> <AudioIcon />
</div> </div>
) : ( ) : ref.previewUrl ? (
<img src={tosThumb(ref.previewUrl, 200)} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} /> <img src={tosThumb(ref.previewUrl, 200)} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
)} ) : (
{/* Upload status overlay */} <div className={styles.audioPlaceholder}>
{ref.uploading && ( <AudioIcon />
<div className={styles.uploadOverlay}>
<Spinner />
</div>
)}
{ref.uploadError && (
<div
className={`${styles.uploadOverlay} ${styles.uploadError}`}
onClick={(e) => { e.stopPropagation(); retryUpload(ref.id); }}
title="点击重试"
>
<ErrorIcon />
</div> </div>
)} )}
<div <div

View File

@ -54,6 +54,68 @@ function mapProgress(backendStatus: string): number {
return 50; return 50;
} }
const ORPHAN_MATERIAL_MENTION_RE = /@(?:图片|视频|音频|素材)[^\s,.!?;:)]*/;
const RESOURCE_UPLOAD_FAILED_MESSAGE = '资源上传失败,请删除后重新上传';
function findOrphanMaterialMention(input: ReturnType<typeof useInputBarStore.getState>): string | null {
if (input.mode !== 'universal') return null;
const hasDirectRefs = input.references.length > 0;
const hasAssetMentions = (input.assetMentions || []).length > 0;
if (hasDirectRefs || hasAssetMentions) return null;
return input.prompt.match(ORPHAN_MATERIAL_MENTION_RE)?.[0] || null;
}
function isBlobUrl(url?: string): boolean {
return !!url && url.startsWith('blob:');
}
function isOnlineUrl(url?: string): boolean {
return !!url && /^https?:\/\//i.test(url);
}
function isSubmittedMaterialUrl(url?: string): boolean {
return isOnlineUrl(url) || !!url?.startsWith('asset://');
}
function hasBlobResourceInEditorHtml(editorHtml?: string): boolean {
return !!editorHtml && editorHtml.includes('blob:');
}
function hasBlobResourceInAssetMentions(assetMentions?: Record<string, unknown>[]): boolean {
return !!assetMentions?.some((mention) =>
isBlobUrl(typeof mention.thumbUrl === 'string' ? mention.thumbUrl : undefined)
);
}
function hasBlobResourceBeforeSubmit(input: ReturnType<typeof useInputBarStore.getState>): boolean {
if (input.mode !== 'universal') return false;
if (hasBlobResourceInEditorHtml(input.editorHtml)) return true;
if (hasBlobResourceInAssetMentions(input.assetMentions)) return true;
return input.references.some((ref) =>
isBlobUrl(ref.previewUrl) || isBlobUrl(ref.tosUrl)
);
}
function hasUnuploadedDirectReference(input: ReturnType<typeof useInputBarStore.getState>): boolean {
if (input.mode !== 'universal') return false;
return input.references.some((ref) => !isOnlineUrl(ref.tosUrl));
}
function hasBlobResourceInUploadedRefs(
refs: { url: string; thumb_url?: string }[],
): boolean {
return refs.some((ref) => isBlobUrl(ref.url) || isBlobUrl(ref.thumb_url));
}
function hasInvalidSubmittedMaterialUrl(
refs: { url: string; thumb_url?: string }[],
): boolean {
return refs.some((ref) =>
!isSubmittedMaterialUrl(ref.url) ||
(!!ref.thumb_url && !isOnlineUrl(ref.thumb_url))
);
}
/** Check if a URL is an asset library reference (case-insensitive protocol). */ /** Check if a URL is an asset library reference (case-insensitive protocol). */
function isAssetUrl(url: string): boolean { function isAssetUrl(url: string): boolean {
return url.startsWith('asset://') || url.startsWith('Asset://'); return url.startsWith('asset://') || url.startsWith('Asset://');
@ -314,7 +376,16 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
addTask: async () => { addTask: async () => {
const input = useInputBarStore.getState(); const input = useInputBarStore.getState();
const orphanMention = findOrphanMaterialMention(input);
if (orphanMention) {
showToast(`${orphanMention} 对应的内容为空`);
return null;
}
if (!input.canSubmit()) return null; if (!input.canSubmit()) return null;
if (hasBlobResourceBeforeSubmit(input) || hasUnuploadedDirectReference(input)) {
showToast(RESOURCE_UPLOAD_FAILED_MESSAGE);
return null;
}
// Collect files to upload (or existing TOS URLs for regeneration) // Collect files to upload (or existing TOS URLs for regeneration)
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = []; const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = [];
@ -516,6 +587,16 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
} }
} }
if (hasBlobResourceInUploadedRefs(uploadedRefs) || hasInvalidSubmittedMaterialUrl(uploadedRefs)) {
showToast(RESOURCE_UPLOAD_FAILED_MESSAGE);
set((s) => ({
tasks: s.tasks.map((t) =>
t.id === tempId ? { ...t, status: 'failed' as const, progress: 0, errorMessage: RESOURCE_UPLOAD_FAILED_MESSAGE } : t
),
}));
return null;
}
// Call generate API // Call generate API
const { data: genResult } = await videoApi.generate({ const { data: genResult } = await videoApi.generate({
prompt: input.prompt, prompt: input.prompt,

View File

@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { CreationMode, ModelOption, AspectRatio, Duration, Resolution, GenerationType, UploadedFile } from '../types'; import type { CreationMode, ModelOption, AspectRatio, Duration, Resolution, GenerationType, UploadedFile } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
import { mediaApi } from '../lib/api'; import { mediaApi, tosThumb } from '../lib/api';
import { parseAssetMentions } from '../lib/assetMentions'; import { parseAssetMentions } from '../lib/assetMentions';
let fileCounter = 0; let fileCounter = 0;
@ -65,6 +65,70 @@ const MAX_IMAGES = 9;
const MAX_VIDEOS = 3; const MAX_VIDEOS = 3;
const MAX_AUDIO = 3; const MAX_AUDIO = 3;
function isBlobUrl(url?: string): boolean {
return !!url && url.startsWith('blob:');
}
function revokePreviewUrl(url?: string) {
if (url && isBlobUrl(url)) URL.revokeObjectURL(url);
}
function syncEditorReferencePreview(editorHtml: string, refId: string, previewUrl: string): string {
if (!editorHtml) return editorHtml;
const doc = new DOMParser().parseFromString(`<div>${editorHtml}</div>`, 'text/html');
const container = doc.body.firstElementChild as HTMLElement | null;
if (!container) return editorHtml;
let changed = false;
container.querySelectorAll('[data-ref-id]').forEach((span) => {
const el = span as HTMLElement;
if (el.dataset.refId !== refId || el.dataset.refType === 'asset') return;
el.dataset.thumbUrl = previewUrl;
if (el.dataset.refType !== 'audio') {
let img = el.querySelector('img') as HTMLImageElement | null;
if (!img) {
const newImg = document.createElement('img');
newImg.className = 'mentionImg';
newImg.setAttribute('width', '16');
newImg.setAttribute('height', '16');
newImg.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
newImg.onerror = () => { newImg.style.display = 'none'; };
el.insertBefore(newImg, el.firstChild);
img = newImg;
}
img.src = tosThumb(previewUrl, 32);
}
changed = true;
});
return changed ? container.innerHTML : editorHtml;
}
function markReferenceUploadSuccess(
refs: UploadedFile[],
refId: string,
uploadedUrl: string,
blobUrlsToRevoke: Set<string>,
): UploadedFile[] {
return refs.map((ref) => {
if (ref.id !== refId) return ref;
if (isBlobUrl(ref.previewUrl)) blobUrlsToRevoke.add(ref.previewUrl);
return {
...ref,
tosUrl: uploadedUrl,
previewUrl: uploadedUrl,
uploading: false,
uploadError: false,
};
});
}
function markReferenceUploadError(refs: UploadedFile[], refId: string): UploadedFile[] {
return refs.map((ref) =>
ref.id === refId ? { ...ref, uploading: false, uploadError: true } : ref
);
}
interface InputBarState { interface InputBarState {
// Generation type // Generation type
generationType: GenerationType; generationType: GenerationType;
@ -197,13 +261,10 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prevReferences: [], prevReferences: [],
addReferences: (files) => { addReferences: (files) => {
const state = get(); const state = get();
// Count existing references by type + merge @ asset mentions // Count direct uploaded references by type. Asset library mentions do not
// consume direct upload slots.
const counts = { image: 0, video: 0, audio: 0 }; const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++; for (const ref of state.references) counts[ref.type]++;
const { counts: assetCounts } = parseAssetMentions(state.editorHtml);
counts.image += assetCounts.image;
counts.video += assetCounts.video;
counts.audio += assetCounts.audio;
// Separate images (sync) from audio/video (need async duration check) // Separate images (sync) from audio/video (need async duration check)
const imageFiles: File[] = []; const imageFiles: File[] = [];
@ -245,7 +306,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
const state = get(); const state = get();
const removedRef = state.references.find((r) => r.id === id); const removedRef = state.references.find((r) => r.id === id);
if (!removedRef) return; if (!removedRef) return;
if (removedRef.previewUrl) URL.revokeObjectURL(removedRef.previewUrl); revokePreviewUrl(removedRef.previewUrl);
// 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续) // 删除后同类型的剩余引用按顺序重新编号(即梦逻辑:保持 1/2/3 连续)
const remaining = state.references.filter((r) => r.id !== id); const remaining = state.references.filter((r) => r.id !== id);
@ -283,7 +344,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
}, },
clearReferences: () => { clearReferences: () => {
const state = get(); const state = get();
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl)); state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
set({ references: [] }); set({ references: [] });
}, },
retryUpload: (refId) => { retryUpload: (refId) => {
@ -301,7 +362,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
lastFrame: null, lastFrame: null,
setFirstFrame: (file) => { setFirstFrame: (file) => {
const state = get(); const state = get();
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl); if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
if (file) { if (file) {
fileCounter++; fileCounter++;
set({ set({
@ -319,7 +380,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
}, },
setLastFrame: (file) => { setLastFrame: (file) => {
const state = get(); const state = get();
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl); if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
if (file) { if (file) {
fileCounter++; fileCounter++;
set({ set({
@ -391,8 +452,8 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
}); });
} else { } else {
// Clear keyframe // Clear keyframe
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl); if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl); if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
set({ set({
mode, mode,
assetMentions: [], assetMentions: [],
@ -412,10 +473,10 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
reset: () => { reset: () => {
const state = get(); const state = get();
state.references.forEach((r) => URL.revokeObjectURL(r.previewUrl)); state.references.forEach((r) => revokePreviewUrl(r.previewUrl));
state.prevReferences.forEach((r) => URL.revokeObjectURL(r.previewUrl)); state.prevReferences.forEach((r) => revokePreviewUrl(r.previewUrl));
if (state.firstFrame) URL.revokeObjectURL(state.firstFrame.previewUrl); if (state.firstFrame) revokePreviewUrl(state.firstFrame.previewUrl);
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl); if (state.lastFrame) revokePreviewUrl(state.lastFrame.previewUrl);
set({ set({
mode: 'universal', mode: 'universal',
model: 'seedance_2.0', model: 'seedance_2.0',
@ -441,16 +502,17 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
/** Upload a single reference file to TOS and update the store. */ /** Upload a single reference file to TOS and update the store. */
function _uploadRef(refId: string, file: File) { function _uploadRef(refId: string, file: File) {
mediaApi.upload(file).then(({ data }) => { mediaApi.upload(file).then(({ data }) => {
const blobUrlsToRevoke = new Set<string>();
useInputBarStore.setState((s) => ({ useInputBarStore.setState((s) => ({
references: s.references.map((r) => references: markReferenceUploadSuccess(s.references, refId, data.url, blobUrlsToRevoke),
r.id === refId ? { ...r, tosUrl: data.url, uploading: false, uploadError: false } : r prevReferences: markReferenceUploadSuccess(s.prevReferences, refId, data.url, blobUrlsToRevoke),
), editorHtml: syncEditorReferencePreview(s.editorHtml, refId, data.url),
})); }));
blobUrlsToRevoke.forEach((url) => URL.revokeObjectURL(url));
}).catch(() => { }).catch(() => {
useInputBarStore.setState((s) => ({ useInputBarStore.setState((s) => ({
references: s.references.map((r) => references: markReferenceUploadError(s.references, refId),
r.id === refId ? { ...r, uploading: false, uploadError: true } : r prevReferences: markReferenceUploadError(s.prevReferences, refId),
),
})); }));
}); });
} }
@ -494,14 +556,13 @@ async function _validateAndAddImages(files: File[]) {
// Passed — add to store + upload // Passed — add to store + upload
fileCounter++; fileCounter++;
const existingSameType = state.references.filter(r => r.type === 'image').length const existingSameType = state.references.filter(r => r.type === 'image').length;
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === 'image').length;
const refId = `ref_${fileCounter}`; const refId = `ref_${fileCounter}`;
const newRef: UploadedFile = { const newRef: UploadedFile = {
id: refId, id: refId,
file, file,
type: 'image', type: 'image',
previewUrl: URL.createObjectURL(file), previewUrl: '',
label: `图片${existingSameType + 1}`, label: `图片${existingSameType + 1}`,
uploading: true, uploading: true,
}; };
@ -594,14 +655,13 @@ async function _validateAndAddMedia(files: File[]) {
// Passed all checks — add to store // Passed all checks — add to store
fileCounter++; fileCounter++;
const labelPrefix = type === 'video' ? '视频' : '音频'; const labelPrefix = type === 'video' ? '视频' : '音频';
const existingSameType = state.references.filter(r => r.type === type).length const existingSameType = state.references.filter(r => r.type === type).length;
+ (state.assetMentions || []).filter((m: Record<string, string>) => m.type === type).length;
const refId = `ref_${fileCounter}`; const refId = `ref_${fileCounter}`;
const newRef: UploadedFile = { const newRef: UploadedFile = {
id: refId, id: refId,
file, file,
type, type,
previewUrl: type === 'audio' ? '' : URL.createObjectURL(file), previewUrl: '',
label: `${labelPrefix}${existingSameType + 1}`, label: `${labelPrefix}${existingSameType + 1}`,
uploading: true, uploading: true,
duration: Math.round(dur * 10) / 10, duration: Math.round(dur * 10) / 10,