diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 043e5f953..da4941b38 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -110,6 +110,11 @@ export function useDirectProjectChatController({ */ const [attachmentsImporting, setAttachmentsImporting] = useState(false); const attachmentsImportingCountRef = useRef(0); + /** + * 已经在飞、还没落成正文芯片的附件条数:并发导入各算各的预算时,这些名额必须先占住, + * 否则两次并发导入会拿着同一份草稿附件数各自放行,插进正文的芯片就超过上限。 + */ + const attachmentsPendingCountRef = useRef(0); const [queuedTurns, setQueuedTurns] = useState([]); const queuedTurnsRef = useRef([]); queuedTurnsRef.current = queuedTurns; @@ -255,6 +260,9 @@ export function useDirectProjectChatController({ * * 控制器不再持有待发附件列表:本轮附件只有「正文芯片」一份事实源, * 所以这里不做任何「提交时再拼一次」的事。 + * + * 上限必须是硬上限:草稿里的附件芯片数 + 这次与其它并发导入已经放行、还没落地的条数 + * 一起算预算,放行的那部分在 `finally` 里还回去。 */ async function uploadFiles( files: readonly File[], @@ -267,7 +275,12 @@ export function useDirectProjectChatController({ } const accepted = files.slice( 0, - Math.max(MAX_CHAT_COMPOSER_ATTACHMENTS - draftAttachmentCount, 0), + Math.max( + MAX_CHAT_COMPOSER_ATTACHMENTS - + draftAttachmentCount - + attachmentsPendingCountRef.current, + 0, + ), ); if (accepted.length === 0) { setAttachmentNotice( @@ -275,6 +288,7 @@ export function useDirectProjectChatController({ ); return []; } + attachmentsPendingCountRef.current += accepted.length; attachmentsImportingCountRef.current += 1; setAttachmentsImporting(true); setAttachmentNotice('正在上传文件'); @@ -310,6 +324,7 @@ export function useDirectProjectChatController({ } return []; } finally { + attachmentsPendingCountRef.current -= accepted.length; attachmentsImportingCountRef.current -= 1; if (attachmentsImportingCountRef.current <= 0) { attachmentsImportingCountRef.current = 0; diff --git a/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx new file mode 100644 index 000000000..9698af429 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/chatComposerAttachmentCap.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + MAX_CHAT_COMPOSER_ATTACHMENTS, + useDirectProjectChatController, +} from '../src/view/project-development/chat/controller/useDirectProjectChatController'; +import type { DirectCodexTurnAttachment } from '../src/view/project-development/chat/conversation/directCodexTurnAttachments'; + +// jsdom 的 Blob/File 没实现 `arrayBuffer`,而附件导入链路要按字节读文件。 +if (typeof Blob.prototype.arrayBuffer !== 'function') { + Blob.prototype.arrayBuffer = () => + Promise.resolve(new Uint8Array([120]).buffer); +} + +function files(count: number, prefix: string) { + return Array.from( + { length: count }, + (_, index) => + new File(['x'], `${prefix}-${index}.png`, { type: 'image/png' }), + ); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + window.__TAURI__ = undefined; +}); + +describe('聊天输入盒的附件上限', () => { + it('并发导入也守得住上限:在飞的条数先占名额', async () => { + let uploadSeq = 0; + const invoke = vi.fn(async (command: string, args?: unknown) => { + if (command !== 'upload_local_asset') { + throw new Error(`unexpected invoke ${command}`); + } + const fileName = (args as { fileName?: string }).fileName ?? 'file'; + return { localPath: `assets/upload-${(uploadSeq += 1)}-${fileName}` }; + }); + window.__TAURI__ = { core: { invoke: invoke as never } }; + + const { result } = renderHook(() => + useDirectProjectChatController({ + assets: [], + enabled: false, + ensureConversationReadAllowed: async () => true, + ensureConversationWriteAllowed: async () => true, + onRuntimeError: () => undefined, + projectId: 'project-1', + projectPath: 'C:/project', + refreshManifest: () => undefined, + }), + ); + + // 两次并发导入各带 5 个文件:第一次占住 5 个名额,第二次只剩 3 个名额。 + const imported: DirectCodexTurnAttachment[][] = [[], []]; + await act(async () => { + const first = result.current.uploadFiles(files(5, 'first'), 0); + const second = result.current.uploadFiles(files(5, 'second'), 0); + [imported[0], imported[1]] = await Promise.all([first, second]); + }); + + await waitFor(() => { + expect(result.current.attachmentsImporting).toBe(false); + }); + expect(imported[0]).toHaveLength(5); + expect(imported[1]).toHaveLength(3); + expect(imported[0].length + imported[1].length).toBe( + MAX_CHAT_COMPOSER_ATTACHMENTS, + ); + }); +});