fix: harden AI design voice lifecycle
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
### 2026-08-18 AI Design Video Download Extension Integration Resume
|
||||||
|
|
||||||
@@ -640,6 +642,11 @@ Gate result:
|
|||||||
- Post-review Electron E2E selection — 3/3 passed.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -1426,6 +1426,7 @@ export function ImageCanvas() {
|
|||||||
for (const track of stream.getTracks()) track.stop();
|
for (const track of stream.getTracks()) track.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
voiceStreamRef.current = stream;
|
||||||
const preferredMimeType = window.MediaRecorder.isTypeSupported?.('audio/webm')
|
const preferredMimeType = window.MediaRecorder.isTypeSupported?.('audio/webm')
|
||||||
? 'audio/webm'
|
? 'audio/webm'
|
||||||
: '';
|
: '';
|
||||||
@@ -1433,7 +1434,6 @@ export function ImageCanvas() {
|
|||||||
stream,
|
stream,
|
||||||
preferredMimeType ? { mimeType: preferredMimeType } : undefined,
|
preferredMimeType ? { mimeType: preferredMimeType } : undefined,
|
||||||
);
|
);
|
||||||
voiceStreamRef.current = stream;
|
|
||||||
voiceChunksRef.current = [];
|
voiceChunksRef.current = [];
|
||||||
voiceRecorderRef.current = recorder;
|
voiceRecorderRef.current = recorder;
|
||||||
recorder.ondataavailable = (event) => {
|
recorder.ondataavailable = (event) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
import { ImageWorkspaceApiError } from '@/lib/image-workspace';
|
||||||
|
import { WorksSquareApiError } from '@/lib/works-square';
|
||||||
import { ImageCanvas } from '@/pages/ImageCanvas';
|
import { ImageCanvas } from '@/pages/ImageCanvas';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useImagePromptMuseumStore } from '@/stores/image-prompt-museum';
|
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(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||||
|
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(<MemoryRouter><ImageCanvas /></MemoryRouter>);
|
||||||
|
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 () => {
|
it('discards a late transcription after Conversation switch', async () => {
|
||||||
installMockVoiceWavTranscoder();
|
installMockVoiceWavTranscoder();
|
||||||
const transcription = deferred<{ text: string; model: string }>();
|
const transcription = deferred<{ text: string; model: string }>();
|
||||||
|
|||||||
Reference in New Issue
Block a user