feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useAgentSpeechSynthesis } from "./use-agent-voice";
import { setSelectedSpeechVoice } from "../speech-voices";
class MockAudio {
static instances: MockAudio[] = [];
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
play = vi.fn(async () => undefined);
pause = vi.fn();
load = vi.fn();
removeAttribute = vi.fn();
constructor() { MockAudio.instances.push(this); }
}
const fetchMock = vi.fn<typeof fetch>();
const audioResponse = () => new Response(new Blob(["audio"], { type: "audio/mpeg" }));
const errorResponse = () => new Response(JSON.stringify({ error: "语音生成失败" }), { status: 502 });
beforeEach(() => {
setSelectedSpeechVoice("");
MockAudio.instances = [];
fetchMock.mockReset();
vi.stubGlobal("Audio", MockAudio);
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:audio");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
it("keeps one chosen voice for all chunks, then applies a changed choice to the next playback", async () => {
fetchMock.mockImplementation(async () => audioResponse());
setSelectedSpeechVoice("zh-CN-YunyangNeural");
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis());
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。${"丙".repeat(300)}。`));
setSelectedSpeechVoice("zh-CN-XiaoyiNeural");
await act(async () => { MockAudio.instances[0].onended?.(); });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
expect(fetchMock.mock.calls.map(([, options]) => JSON.parse(String(options?.body)).voice))
.toEqual(["zh-CN-YunyangNeural", "zh-CN-YunyangNeural", "zh-CN-YunyangNeural"]);
await act(() => result.current.speak("new", "Hello"));
expect(JSON.parse(String(fetchMock.mock.lastCall?.[1]?.body)).voice).toBe("zh-CN-XiaoyiNeural");
unmount();
});
it("reports synthesis failure and allows a successful retry", async () => {
const onError = vi.fn();
fetchMock.mockResolvedValueOnce(errorResponse()).mockResolvedValueOnce(audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", "你好"));
expect(onError).toHaveBeenCalledWith("语音生成失败");
expect(result.current.speechState).toBe("idle");
await act(() => result.current.speak("message", "你好"));
expect(result.current.speechState).toBe("playing");
unmount();
});
it("handles failure of a later chunk without leaving playback loading", async () => {
const onError = vi.fn();
fetchMock.mockResolvedValueOnce(audioResponse()).mockImplementation(async () => errorResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。`));
await act(async () => { MockAudio.instances[0].onended?.(); });
await waitFor(() => expect(onError).toHaveBeenCalledWith("语音生成失败"));
expect(result.current.speechState).toBe("idle");
expect(result.current.speakingMessageId).toBeNull();
unmount();
});
it("ignores a late failure belonging to a replaced playback", async () => {
const onError = vi.fn();
let failOld!: (reason: Error) => void;
fetchMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { failOld = reject; }))
.mockResolvedValueOnce(audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
let oldPlayback!: Promise<void>;
act(() => { oldPlayback = result.current.speak("old", "旧回复"); });
await act(() => result.current.speak("new", "新回复"));
await act(async () => { failOld(new Error("old request failed")); await oldPlayback; });
expect(result.current.speechState).toBe("playing");
expect(result.current.speakingMessageId).toBe("new");
expect(onError).not.toHaveBeenCalled();
unmount();
});
it("reports media and resume failures and releases playback", async () => {
const onError = vi.fn();
fetchMock.mockImplementation(async () => audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", "你好"));
act(() => { MockAudio.instances[0].onerror?.(); });
expect(onError).toHaveBeenLastCalledWith("音频播放失败,请重试");
expect(result.current.speechState).toBe("idle");
await act(() => result.current.speak("message", "你好"));
act(() => result.current.pause());
MockAudio.instances[1].play.mockRejectedValueOnce(new DOMException("blocked", "NotAllowedError"));
await act(async () => result.current.resume());
expect(onError).toHaveBeenLastCalledWith("浏览器阻止了语音播放,请再次点击播放");
expect(result.current.speechState).toBe("idle");
unmount();
});
it("cancels a pending request silently when stopped", async () => {
const onError = vi.fn();
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
}));
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
let playback!: Promise<void>;
act(() => { playback = result.current.speak("message", "你好"); });
await act(async () => { result.current.stop(); await playback; });
expect(onError).not.toHaveBeenCalled();
expect(result.current.speechState).toBe("idle");
unmount();
});
+36 -24
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { getSelectedSpeechVoice } from "../speech-voices";
import {
splitSpeechTextIntoChunks,
type AgentSpeakOptions,
@@ -54,7 +55,11 @@ function getServerBrowserCapability() {
return false;
}
export function useAgentSpeechSynthesis() {
export function useAgentSpeechSynthesis(onError?: (message: string) => void) {
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
const [speechState, setSpeechState] = useState<AgentSpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -64,6 +69,7 @@ export function useAgentSpeechSynthesis() {
const chunkUrlCacheRef = useRef<Map<number, string>>(new Map());
const chunkPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
const chunksRef = useRef<string[]>([]);
const playbackVoiceRef = useRef("");
const currentChunkIndexRef = useRef(0);
const playbackTokenRef = useRef(0);
const activeMessageIdRef = useRef<string | null>(null);
@@ -108,6 +114,19 @@ export function useAgentSpeechSynthesis() {
activeMessageIdRef.current = null;
}, [detachAudio]);
const handlePlaybackError = useCallback((error: unknown, playbackToken: number) => {
if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
if (error instanceof DOMException && error.name === "AbortError") return;
const message = error instanceof DOMException && error.name === "NotAllowedError"
? "浏览器阻止了语音播放,请再次点击播放"
: error instanceof Error ? error.message : "音频播放失败,请重试";
onErrorRef.current?.(message);
}, [releaseAudio]);
const fetchChunkAudio = useCallback((chunkIndex: number, playbackToken: number) => {
const cachedUrl = chunkUrlCacheRef.current.get(chunkIndex);
if (cachedUrl) return Promise.resolve(cachedUrl);
@@ -123,7 +142,7 @@ export function useAgentSpeechSynthesis() {
const promise = fetch("/api/tts/edge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
body: JSON.stringify({ text, ...(playbackVoiceRef.current ? { voice: playbackVoiceRef.current } : {}) }),
signal: controller.signal
})
.then(async (response) => {
@@ -144,7 +163,7 @@ export function useAgentSpeechSynthesis() {
})
.finally(() => {
fetchControllersRef.current.delete(controller);
chunkPromisesRef.current.delete(chunkIndex);
if (playbackToken === playbackTokenRef.current) chunkPromisesRef.current.delete(chunkIndex);
});
chunkPromisesRef.current.set(chunkIndex, promise);
@@ -179,21 +198,18 @@ export function useAgentSpeechSynthesis() {
return;
}
currentChunkIndexRef.current = nextChunkIndex;
void playChunkRef.current(nextChunkIndex, playbackToken);
void playChunkRef.current(nextChunkIndex, playbackToken)
.catch((error: unknown) => handlePlaybackError(error, playbackToken));
};
audio.onerror = () => {
if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
handlePlaybackError(new Error("音频播放失败,请重试"), playbackToken);
};
await audio.play();
if (playbackToken !== playbackTokenRef.current) return;
setSpeechState("playing");
prefetchChunk(chunkIndex + 1, playbackToken);
}, [detachAudio, fetchChunkAudio, prefetchChunk, releaseAudio]);
}, [detachAudio, fetchChunkAudio, handlePlaybackError, prefetchChunk, releaseAudio]);
useEffect(() => {
playChunkRef.current = playChunk;
@@ -211,6 +227,7 @@ export function useAgentSpeechSynthesis() {
playbackTokenRef.current = playbackToken;
releaseAudio();
chunksRef.current = chunks;
playbackVoiceRef.current = getSelectedSpeechVoice();
activeMessageIdRef.current = messageId;
setSpeakingMessageId(messageId);
setSpeechState("loading");
@@ -218,12 +235,9 @@ export function useAgentSpeechSynthesis() {
try {
await playChunk(0, playbackToken);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
handlePlaybackError(error, playbackToken);
}
}, [isSupported, playChunk, releaseAudio]);
}, [isSupported, playChunk, releaseAudio, handlePlaybackError]);
const pause = useCallback(() => {
if (!audioRef.current || speechState !== "playing") return;
@@ -234,15 +248,13 @@ export function useAgentSpeechSynthesis() {
const resume = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
setSpeakingMessageId(activeMessageIdRef.current);
setSpeechState("playing");
void audio.play().catch(() => {
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
});
}, [releaseAudio]);
const playbackToken = playbackTokenRef.current;
void audio.play().then(() => {
if (playbackToken !== playbackTokenRef.current) return;
setSpeakingMessageId(activeMessageIdRef.current);
setSpeechState("playing");
}).catch((error: unknown) => handlePlaybackError(error, playbackToken));
}, [handlePlaybackError]);
const stop = useCallback(() => {
playbackTokenRef.current += 1;