feat(multimodal): 完成阶段F运行管理与收尾验收

This commit is contained in:
2026-09-05 02:02:45 +08:00
parent 510936431a
commit 6ee6cd7d73
33 changed files with 1180 additions and 86 deletions
@@ -0,0 +1,42 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from 'vitest'
import { createMediaSubmission, mediaService, type MediaJob } from './mediaService'
afterEach(() => vi.restoreAllMocks())
it('reuses upload and job identities after lost responses, until explicitly reset', async () => {
const upload = vi.spyOn(mediaService, 'upload').mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValue({attachment_id:'uploaded'})
const create = vi.spyOn(mediaService, 'create').mockRejectedValueOnce(new Error('response lost'))
.mockResolvedValue({job_id:'same-job'} as MediaJob)
const submission = createMediaSubmission()
const file = new File(['audio'], 'lecture.wav')
const options = {local_only:true}
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
expect(await submission.submit(file, options)).toEqual({job_id:'same-job'})
expect(upload).toHaveBeenCalledTimes(2)
expect(upload.mock.calls[0][1]).toBe(upload.mock.calls[1][1])
expect(create.mock.calls[0][0]).toEqual(create.mock.calls[1][0])
submission.reset()
await submission.submit(file, options)
expect(upload.mock.calls[2][1]).not.toBe(upload.mock.calls[1][1])
expect(create.mock.calls[2][0]).not.toEqual(create.mock.calls[1][0])
})
it('freezes options across upload and treats changed options as a new request', async () => {
let release!: (value:{attachment_id:string}) => void
vi.spyOn(mediaService, 'upload').mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
.mockResolvedValue({attachment_id:'next'})
const create = vi.spyOn(mediaService, 'create').mockResolvedValue({job_id:'job'} as MediaJob)
const submission = createMediaSubmission()
const file = new File(['audio'], 'lecture.wav')
const options = {local_only:true}
const pending = submission.submit(file, options)
options.local_only = false
release({attachment_id:'first'})
await pending
expect(create.mock.calls[0][0]).toMatchObject({local_only:true})
await submission.submit(file, options)
expect(create.mock.calls[1][0]).toMatchObject({local_only:false})
})
+21 -3
View File
@@ -18,15 +18,33 @@ export const mediaService = {
revision: job.revision, text: job.text, segments: job.segments, speaker_names: job.speaker_names,
}),
revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`),
note: (id: string, title: string) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title }),
note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }),
audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`),
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
async upload(file: File) {
async upload(file: File, idempotencyKey?: string) {
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body: file,
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
})
if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败')
return await response.json() as {attachment_id: string}
},
}
// Keep one identity until the input/options change, including a lost HTTP response.
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
export function createMediaSubmission() {
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
return {
reset() { pending = null },
async submit(file: File, options: Record<string, unknown>) {
const serialized = JSON.stringify(options)
if (!pending || pending.file !== file || pending.options !== serialized) {
pending = {file, options: serialized, uploadKey: crypto.randomUUID(), jobKey: crypto.randomUUID()}
}
const current = pending
if (!current.attachmentId) current.attachmentId = (await mediaService.upload(file, current.uploadKey)).attachment_id
return mediaService.create({...JSON.parse(current.options), attachment_id: current.attachmentId, idempotency_key: current.jobKey})
},
}
}