From b2c902fa1d9ede78ffa3a921402a13fb516d888a Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 18 Aug 2026 02:51:39 +0800 Subject: [PATCH] fix: harden AI design voice lifecycle --- .../tasks/20260813-sync-push-main-9c2f71.md | 7 + src/pages/ImageCanvas/index.tsx | 2 +- tests/unit/image-canvas-page.test.tsx | 122 ++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md index 40d2319..daeddfb 100644 --- a/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md +++ b/.project-docs/30-worklog/tasks/20260813-sync-push-main-9c2f71.md @@ -473,6 +473,8 @@ Gate result: - The merge keeps AI Design's Enter/Shift+Enter/IME composer contract and E2E Enter coverage, enables Works Speech voice input with 16 kHz mono WAV conversion, authentication retry, context-lifecycle cleanup, and an always-available stop control while other composer operations are busy. - No canonical architecture, domain-rule, or decision promotion is required; the source task records no promotion candidate. Real microphone/Works-account smoke remains an external release validation item. - This resumption is local-only. It does not fetch or push the remote; the existing Git credential follow-up remains unchanged. +- The final staged-merge review initially returned `FAIL` on two lifecycle/test gaps: a MediaRecorder-constructor failure could leave an acquired track live, and the 401 refresh/retry path lacked Canvas-specific regression coverage. The stream is now registered before recorder construction, and focused tests cover both constructor failure cleanup and stale-token → refreshed-token retry. +- The corrected merge is `7fe5103f43830e26f29693c61c177faca0812b85`, with `31fb4ff9e146304afcb5123a2dc3c0fb1b9bf5e1` as first parent and `1ba5b9cc84347bf37cf8a18148a1dcc6b88b3ad2` as second parent. The source task record is absent from `main`. ### 2026-08-18 AI Design Video Download Extension Integration Resume @@ -640,6 +642,11 @@ Gate result: - Post-review Electron E2E selection — 3/3 passed. - Final context-compaction integration re-review — `PASS`; no remaining P0-P3 findings. The reviewer confirmed polling-idle completion precedes queue release, cold hydration requires a matching current running identity, `main` is clean, and merge/fix/documentation topology is correct. +- 2026-08-18 AI Design merged-main focused Vitest — 46/46 passed, including 401 refresh/retry and MediaRecorder-constructor track cleanup regressions. +- 2026-08-18 AI Design merged-main full Vitest with `--maxWorkers=4` — 176 files / 2120 tests passed. An unconstrained concurrent run had one known `opencode-manager` port-release timeout; its isolated file rerun passed 41/41. +- 2026-08-18 AI Design merged-main `pnpm run typecheck`, scoped ESLint, and `pnpm run build:vite` — passed; only existing mixed-import and chunk-size warnings remain. +- 2026-08-18 AI Design merged-main `check_project_docs.py`, integration `check_doc_drift.py`, task registry doctor, staged/unstaged whitespace checks — passed. + - 2026-08-18 video-download merged-main route regression — 1 file / 14 tests passed; `.mp4`, `.mov`, and `.webm` preserve their extensions and non-media MIME responses remain rejected. - 2026-08-18 video-download merged-main focused selection — 3 files / 72 tests passed; typecheck and scoped ESLint for the route/test files passed. - Merge topology and `git diff --check` passed for `31fb4ff`; the source task record is absent from `main`. The integration worktree retains unrelated staged AI Design input changes, so no clean-worktree claim is made. diff --git a/src/pages/ImageCanvas/index.tsx b/src/pages/ImageCanvas/index.tsx index cdb4992..67ba57a 100644 --- a/src/pages/ImageCanvas/index.tsx +++ b/src/pages/ImageCanvas/index.tsx @@ -1426,6 +1426,7 @@ export function ImageCanvas() { for (const track of stream.getTracks()) track.stop(); return; } + voiceStreamRef.current = stream; const preferredMimeType = window.MediaRecorder.isTypeSupported?.('audio/webm') ? 'audio/webm' : ''; @@ -1433,7 +1434,6 @@ export function ImageCanvas() { stream, preferredMimeType ? { mimeType: preferredMimeType } : undefined, ); - voiceStreamRef.current = stream; voiceChunksRef.current = []; voiceRecorderRef.current = recorder; recorder.ondataavailable = (event) => { diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index b64d752..b2714ed 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -3,6 +3,7 @@ import { MemoryRouter } from 'react-router-dom'; import { toast } from 'sonner'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ImageWorkspaceApiError } from '@/lib/image-workspace'; +import { WorksSquareApiError } from '@/lib/works-square'; import { ImageCanvas } from '@/pages/ImageCanvas'; import { useAuthStore } from '@/stores/auth'; import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum'; @@ -991,6 +992,127 @@ describe('ImageCanvas Workspace-first design experience', () => { }); }); + it('refreshes the Works Speech token once after a 401 and keeps the refreshed result', async () => { + installMockVoiceWavTranscoder(); + const getValidAccessToken = vi.fn().mockResolvedValue('stale-token'); + const refreshSession = vi.fn().mockResolvedValue('fresh-token'); + transcribeWorksSpeechMock + .mockRejectedValueOnce(new WorksSquareApiError('token expired', 401)) + .mockResolvedValueOnce({ text: '刷新后的语音内容', model: 'gpt-4o-mini-transcribe' }); + const stopTrack = vi.fn(); + const getUserMedia = vi.fn().mockResolvedValue({ + getTracks: () => [{ stop: stopTrack }], + }); + class MockMediaRecorder { + static isTypeSupported(): boolean { return true; } + ondataavailable: ((event: { data: Blob }) => void) | null = null; + onstop: (() => void) | null = null; + state: RecordingState = 'inactive'; + mimeType = 'audio/webm'; + start(): void { this.state = 'recording'; } + stop(): void { + this.state = 'inactive'; + this.ondataavailable?.({ data: new Blob(['audio bytes'], { type: 'audio/webm' }) }); + this.onstop?.(); + } + } + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia }, + }); + Object.defineProperty(window, 'MediaRecorder', { + configurable: true, + value: MockMediaRecorder, + }); + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + accessToken: 'stale-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + legacyRefreshToken: null, + getValidAccessToken, + refreshSession, + user: { + username: 'designer', + userId: '42', + tenantId: null, + deptId: null, + authorities: [], + }, + }); + + render(); + await screen.findByTestId('image-workspace-conversation'); + const composer = screen.getByLabelText('设计需求'); + fireEvent.click(screen.getByRole('button', { name: '语音输入' })); + await screen.findByRole('button', { name: '停止语音输入' }); + fireEvent.click(screen.getByRole('button', { name: '停止语音输入' })); + + await waitFor(() => expect(transcribeWorksSpeechMock).toHaveBeenCalledTimes(2)); + expect(transcribeWorksSpeechMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ accessToken: 'stale-token' }), + ); + expect(transcribeWorksSpeechMock.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ accessToken: 'fresh-token' }), + ); + expect(refreshSession).toHaveBeenCalledOnce(); + await waitFor(() => expect(composer).toHaveValue('刷新后的语音内容')); + expect(stopTrack).toHaveBeenCalledOnce(); + }); + + it('releases the acquired stream when MediaRecorder construction fails', async () => { + const toastError = vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id'); + const stopTrack = vi.fn(); + const getUserMedia = vi.fn().mockResolvedValue({ + getTracks: () => [{ stop: stopTrack }], + }); + class FailingMediaRecorder { + static isTypeSupported(): boolean { return true; } + constructor() { throw new Error('recorder construction failed'); } + } + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia }, + }); + Object.defineProperty(window, 'MediaRecorder', { + configurable: true, + value: FailingMediaRecorder, + }); + useAuthStore.setState({ + initialized: true, + loading: false, + error: null, + accessToken: 'access-token', + tokenType: 'Bearer', + expiresAt: Date.now() + 60_000, + lastActiveAt: Date.now(), + canRefresh: true, + legacyRefreshToken: null, + user: { + username: 'designer', + userId: '42', + tenantId: null, + deptId: null, + authorities: [], + }, + }); + + render(); + await screen.findByTestId('image-workspace-conversation'); + fireEvent.click(screen.getByRole('button', { name: '语音输入' })); + + await waitFor(() => expect(getUserMedia).toHaveBeenCalledWith({ audio: true })); + await waitFor(() => expect(stopTrack).toHaveBeenCalledOnce()); + expect(toastError).toHaveBeenCalledWith('无法开始录音', { + description: 'recorder construction failed', + }); + toastError.mockRestore(); + }); + it('discards a late transcription after Conversation switch', async () => { installMockVoiceWavTranscoder(); const transcription = deferred<{ text: string; model: string }>();