feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user