初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

@@ -0,0 +1,20 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useDragScroll } from "./useDragScroll";
describe("useDragScroll", () => {
it("返回 ref 和事件 handlers", () => {
const { result } = renderHook(() => useDragScroll<HTMLDivElement>());
expect(result.current.ref).toBeDefined();
expect(result.current.handlers.onPointerDown).toBeInstanceOf(Function);
expect(result.current.handlers.onPointerMove).toBeInstanceOf(Function);
expect(result.current.handlers.onPointerUp).toBeInstanceOf(Function);
expect(result.current.handlers.onPointerLeave).toBeInstanceOf(Function);
});
it("初始 dragRef.moved 为 false", () => {
const { result } = renderHook(() => useDragScroll<HTMLDivElement>());
expect(result.current.dragRef.current?.moved).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useCallback, useRef } from "react";
import type { PointerEvent, RefObject } from "react";
interface DragState {
active: boolean;
startX: number;
left: number;
/** 本次拖拽是否发生过明显位移,用于区分"拖动"与"点击" */
moved: boolean;
}
/**
* 指针拖拽横向滚动(drag-to-scroll)。
* 用原生 scrollLeft + pointer capture,惯性自然、触控友好;
* 暴露 moved 标志,供子节点判断是否抑制 click。
*/
export function useDragScroll<T extends HTMLElement>() {
const ref = useRef<T>(null);
const drag = useRef<DragState>({
active: false,
startX: 0,
left: 0,
moved: false,
});
const onPointerDown = useCallback((e: PointerEvent<T>) => {
const el = ref.current;
if (!el) return;
drag.current = {
active: true,
startX: e.clientX,
left: el.scrollLeft,
moved: false,
};
el.setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: PointerEvent<T>) => {
if (!drag.current.active) return;
const el = ref.current;
if (!el) return;
const dx = e.clientX - drag.current.startX;
if (Math.abs(dx) > 4) drag.current.moved = true;
el.scrollLeft = drag.current.left - dx;
}, []);
const end = useCallback(() => {
drag.current.active = false;
}, []);
return {
ref,
dragRef: drag as RefObject<DragState>,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp: end,
onPointerLeave: end,
},
};
}
@@ -0,0 +1,57 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { EGG_VERSES, pickEggVerse, useEasterEgg } from "./useEasterEgg";
describe("useEasterEgg", () => {
it("连点 5 次触发 onReveal", () => {
const onReveal = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal }));
for (let i = 0; i < 5; i++) act(() => result.current.onClick());
expect(onReveal).toHaveBeenCalledTimes(1);
});
it("不足 5 次不触发 onReveal", () => {
const onReveal = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal }));
for (let i = 0; i < 4; i++) act(() => result.current.onClick());
expect(onReveal).not.toHaveBeenCalled();
});
it("连点 9 次触发 onAscend(九枢升格)", () => {
const onAscend = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onAscend }));
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
expect(onAscend).toHaveBeenCalledTimes(1);
});
it("双层彩蛋:5 次弹谶语、9 次升格,各触发一次", () => {
const onReveal = vi.fn();
const onAscend = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal, onAscend }));
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
expect(onReveal).toHaveBeenCalledTimes(1);
expect(onAscend).toHaveBeenCalledTimes(1);
});
it("升格后归零,可再次触发双层", () => {
const onReveal = vi.fn();
const onAscend = vi.fn();
const { result } = renderHook(() => useEasterEgg({ onReveal, onAscend }));
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
expect(onReveal).toHaveBeenCalledTimes(2);
expect(onAscend).toHaveBeenCalledTimes(2);
});
it("pickEggVerse 返回谶语池中一条", () => {
expect(EGG_VERSES).toContain(pickEggVerse());
});
it("未传回调时达阈值不抛错", () => {
const { result } = renderHook(() => useEasterEgg());
expect(() => {
for (let i = 0; i < 9; i++) act(() => result.current.onClick());
}).not.toThrow();
});
});
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useCallback, useRef } from "react";
/** 镜启彩蛋谶语(设计稿 eggVerses 同源) */
export const EGG_VERSES = [
"镜中有镜,心外无心。",
"汝来看此镜时,此镜亦照汝。",
"一念起,万镜生。",
"镜非鉴物,乃鉴心也。",
"幻由心生,镜随心转。",
"九枢归一,一归于何处?",
"铜镜本无字,照尽往来人。",
"且向镜中看,那个是谁?",
] as const;
/** 随机取一条谶语 */
export function pickEggVerse(): string {
return EGG_VERSES[Math.floor(Math.random() * EGG_VERSES.length)];
}
/** 镜启阈值:连点 5 次弹随机谶语(设计稿同源) */
const REVEAL_THRESHOLD = 5;
/** 九枢升格阈值:继续连点至 9 次(九枢圆满)升格全屏「九枢既明」 */
const ASCEND_THRESHOLD = 9;
/** 两次点击间隔超过此值则重新计数 */
const RESET_MS = 1200;
export interface EasterEggHandlers {
/** 5 次:弹随机谶语 + 铜镜辉光(常规彩蛋,计数不归零、继续累计) */
onReveal?: () => void;
/** 9 次:九枢圆满升格全屏(稀有彩蛋,触发后归零) */
onAscend?: () => void;
}
/**
* 镜启彩蛋(双层):
* - RESET_MS 内连点 5 次 → onReveal(弹随机谶语 + 铜镜辉光),计数不归零;
* - 继续连点至 9 次 → onAscend(九枢升格全屏),触发后归零。
* 间隔超时则计数归零。
*/
export function useEasterEgg({ onReveal, onAscend }: EasterEggHandlers = {}) {
const countRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const onClick = useCallback(() => {
countRef.current += 1;
if (timerRef.current) clearTimeout(timerRef.current);
const c = countRef.current;
if (c >= ASCEND_THRESHOLD) {
countRef.current = 0;
onAscend?.();
return;
}
if (c === REVEAL_THRESHOLD) {
onReveal?.();
}
timerRef.current = setTimeout(() => {
countRef.current = 0;
}, RESET_MS);
}, [onReveal, onAscend]);
return { onClick };
}
@@ -0,0 +1,32 @@
"use client";
/**
* useGlobalEventStream:订阅全局一炁事件流的 React Hook。
*
* 连接后端 GET /api/v1/events 端点,接收 proactive.session 等全局广播事件。
* 组件挂载时建立连接,卸载时自动关闭。
* 与 useRunStream 独立——此 Hook 不依赖 runId,只要组件存活就保持连接。
*/
import { useEffect, useRef } from "react";
import {
createGlobalEventStream,
type RunStreamHandlers,
} from "@/lib/sse-client";
export function useGlobalEventStream(handlers: RunStreamHandlers): void {
const handlersRef = useRef(handlers);
useEffect(() => {
handlersRef.current = handlers;
});
useEffect(() => {
const controller = createGlobalEventStream({
onEvent: (event) => handlersRef.current.onEvent?.(event),
onOpen: () => handlersRef.current.onOpen?.(),
onError: () => handlersRef.current.onError?.(),
});
return () => controller.close();
}, []);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* usePermission — 权限检查的 hook 版本。
*
* 供无法使用 <PermissionGuard> 的场景(如条件渲染、菜单禁用态)。
*/
import { useMemo } from "react";
import { useAuthStore } from "@/lib/stores/auth-store";
import {
can as canCheck,
canAll as canAllCheck,
canAny as canAnyCheck,
type Permission,
} from "@/components/shared/PermissionGuard";
interface UsePermissionReturn {
/** 是否拥有指定权限 */
can: (permission: Permission) => boolean;
/** 是否拥有所有指定权限 */
canAll: (permissions: Permission[]) => boolean;
/** 是否拥有任意指定权限 */
canAny: (permissions: Permission[]) => boolean;
/** 用户当前权限列表 */
permissions: string[];
/** 是否已认证 */
isAuthenticated: boolean;
}
export function usePermission(): UsePermissionReturn {
const userPermissions = useAuthStore(
(s) => (s.user?.permissions as string[] | undefined) ?? [],
);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return useMemo(
() => ({
can: (permission: Permission) => canCheck(permission, userPermissions),
canAll: (perms: Permission[]) => canAllCheck(perms, userPermissions),
canAny: (perms: Permission[]) => canAnyCheck(perms, userPermissions),
permissions: userPermissions,
isAuthenticated,
}),
[userPermissions, isAuthenticated],
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
/**
* useRunStream:订阅 Run 事件流的 React Hook。
*
* 仅当 runId 存在时建立 SSE 连接,runId 变化或组件卸载时自动关闭。
* 本 Hook 处理由用户发送消息触发的实时订阅,不属于「页面初始数据请求」。
* options(thinking / effort)透传给底层,供 Mock 流决定是否前置推送思考摘要
* 及其详尽程度。
*/
import { useEffect, useRef } from "react";
import { createRunStream, type RunStreamHandlers } from "@/lib/sse-client";
import type { ThinkingEffort } from "@/types/thinking";
export interface RunStreamOptions {
/** 是否开启思考模式(仅 Mock 流消费,决定是否推送 reasoning 事件) */
thinking?: boolean;
/** 思考力度(决定思考摘要的详尽程度与流式粒度) */
effort?: ThinkingEffort;
/** Mock 模式下的人物名(决定回复风格),透传给 createRunStream */
characterName?: string;
}
export function useRunStream(
runId: string | null | undefined,
handlers: RunStreamHandlers,
options?: RunStreamOptions,
): void {
// 用 ref 持有最新回调与选项,避免 runId 不变时因引用变化反复重连。
// 在 effect 中同步 ref,禁止在 render 阶段写入 ref(react-hooks/refs)。
const handlersRef = useRef(handlers);
const optionsRef = useRef(options);
useEffect(() => {
handlersRef.current = handlers;
optionsRef.current = options;
});
useEffect(() => {
if (!runId) return;
const controller = createRunStream(
runId,
{
onEvent: (event) => handlersRef.current.onEvent?.(event),
onOpen: () => handlersRef.current.onOpen?.(),
onError: () => handlersRef.current.onError?.(),
},
optionsRef.current,
);
return () => controller.close();
}, [runId]);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useCallback, useState } from "react";
import { safetyApi, toolsApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { ToolResultPayload } from "@/types/api";
/**
* 工具调用状态机:idle → running →(completed | confirming | error)。
* confirming 时交由确认卡片 approve/deny;approve 后回到 running 并落 completed。
* 错误统一经 describeError 归一为可展示文案。
*/
export type ToolInvokeState =
| { phase: "idle" }
| { phase: "running"; tool: string }
| {
phase: "confirming";
tool: string;
confirmation_id: string;
risk_level: string;
reason: string;
}
| { phase: "completed"; result: ToolResultPayload }
| { phase: "error"; message: string };
export interface InvokeContext {
run_id?: string;
character_id?: string;
trace_id?: string;
}
export function useToolInvoke() {
const [state, setState] = useState<ToolInvokeState>({ phase: "idle" });
const [busy, setBusy] = useState(false);
const invoke = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx?: InvokeContext,
) => {
setBusy(true);
setState({ phase: "running", tool });
try {
const res = await toolsApi.invokeTool(tool, { input, ...ctx });
if (res.status === "completed") {
setState({ phase: "completed", result: res.result });
return res.result;
}
setState({
phase: "confirming",
tool,
confirmation_id: res.confirmation_id,
risk_level: res.risk_level,
reason: res.reason,
});
return null;
} catch (e) {
setState({ phase: "error", message: describeError(e) });
throw e;
} finally {
setBusy(false);
}
},
[],
);
const approve = useCallback(async () => {
if (state.phase !== "confirming") return;
const confirmationId = state.confirmation_id;
setBusy(true);
try {
const res = await safetyApi.approveConfirmation(confirmationId);
if (res.status === "completed") {
setState({ phase: "completed", result: res.result });
}
} catch (e) {
setState({ phase: "error", message: describeError(e) });
throw e;
} finally {
setBusy(false);
}
}, [state]);
const deny = useCallback(async () => {
if (state.phase !== "confirming") return;
const confirmationId = state.confirmation_id;
setBusy(true);
try {
await safetyApi.denyConfirmation(confirmationId);
setState({ phase: "idle" });
} catch (e) {
setState({ phase: "error", message: describeError(e) });
} finally {
setBusy(false);
}
}, [state]);
const reset = useCallback(() => setState({ phase: "idle" }), []);
return { state, busy, invoke, approve, deny, reset };
}
+129
View File
@@ -0,0 +1,129 @@
"use client";
/**
* useToolStream:流式工具调用状态机。
*
* 通过 invokeToolStream(fetch + ReadableStream)订阅 SSE,实时把 stdout/stderr 增量
* 解码后 append 到 stdout/stderr 状态,供组件即时渲染。高风险工具经
* tool.requires_confirmation 进入 confirming 态,confirm 后以 confirmed=true 重跑。
* 支持 AbortController 中止(cancel)。组件卸载时应调用 cancel/reset。
*/
import { useCallback, useRef, useState } from "react";
import { invokeToolStream } from "@/lib/api/tools";
import { describeError } from "@/lib/utils/error";
import type { ToolStreamDone } from "@/types/api";
export type ToolStreamState =
| { phase: "idle" }
| { phase: "streaming"; tool: string }
| { phase: "completed"; done: ToolStreamDone }
| {
phase: "confirming";
tool: string;
risk_level: string;
reason: string;
}
| { phase: "error"; message: string };
export interface InvokeContext {
run_id?: string;
character_id?: string;
trace_id?: string;
}
export function useToolStream() {
const [state, setState] = useState<ToolStreamState>({ phase: "idle" });
const [stdout, setStdout] = useState("");
const [stderr, setStderr] = useState("");
const [busy, setBusy] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const pendingRef = useRef<{
tool: string;
input: Record<string, unknown>;
ctx?: InvokeContext;
} | null>(null);
const runOnce = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx: InvokeContext | undefined,
confirmed: boolean,
) => {
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setStdout("");
setStderr("");
setBusy(true);
setState({ phase: "streaming", tool });
try {
await invokeToolStream(
tool,
{ input, confirmed, ...ctx },
{
onStdout: (data) =>
setStdout((prev) => prev + decodeB64UrlNoPad(data)),
onStderr: (data) =>
setStderr((prev) => prev + decodeB64UrlNoPad(data)),
onCompleted: (done) => setState({ phase: "completed", done }),
onRequiresConfirmation: (risk_level, reason) =>
setState({ phase: "confirming", tool, risk_level, reason }),
onFailed: (code, message) =>
setState({ phase: "error", message: `${code}: ${message}` }),
},
ctrl.signal,
);
} catch (e) {
if (ctrl.signal.aborted) {
setState({ phase: "idle" });
} else {
setState({ phase: "error", message: describeError(e) });
}
} finally {
setBusy(false);
}
},
[],
);
const run = useCallback(
async (
tool: string,
input: Record<string, unknown>,
ctx?: InvokeContext,
) => {
pendingRef.current = { tool, input, ctx };
await runOnce(tool, input, ctx, false);
},
[runOnce],
);
const confirm = useCallback(async () => {
const pending = pendingRef.current;
if (!pending) return;
await runOnce(pending.tool, pending.input, pending.ctx, true);
}, [runOnce]);
const cancel = useCallback(() => {
abortRef.current?.abort();
}, []);
const reset = useCallback(() => {
setState({ phase: "idle" });
setStdout("");
setStderr("");
}, []);
return { state, stdout, stderr, busy, run, confirm, cancel, reset };
}
/** 解码 base64(urlsafe-no-pad) → UTF-8 字符串(命令输出可能含多字节)。 */
function decodeB64UrlNoPad(b64: string): string {
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
const binary = atob(std + "=".repeat((4 - (std.length % 4)) % 4));
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}
@@ -0,0 +1,117 @@
/**
* useVoiceInput 测试 — mock MediaRecorder 与 ASR client。
*/
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useVoiceInput } from "./useVoiceInput";
vi.mock("@/lib/api/audio", () => ({
transcribeAudio: vi.fn().mockResolvedValue({ text: "你好世界" }),
}));
import { transcribeAudio } from "@/lib/api/audio";
/** 最小 MediaRecorder stub:start/stop 并触发 ondataavailable / onstop。 */
class RecorderStub {
state = "inactive";
ondataavailable: ((e: { data: Blob }) => void) | null = null;
onstop: (() => void) | null = null;
start() {
this.state = "recording";
}
stop() {
this.state = "inactive";
this.ondataavailable?.({ data: new Blob([new Uint8Array([1, 2, 3])]) });
this.onstop?.();
}
}
describe("useVoiceInput", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("toggle 录音并转写后回调 onResult", async () => {
const onResult = vi.fn();
const onError = vi.fn();
const stopTracks = vi.fn();
const getTracks = () =>
[{ stop: stopTracks }] as unknown as MediaStreamTrack[];
Object.defineProperty(globalThis.navigator, "mediaDevices", {
value: {
getUserMedia: vi.fn().mockResolvedValue({ getTracks }),
},
configurable: true,
});
// 以 function 构造器作为 MediaRecorder,返回 stub 实例
function MR(this: unknown) {
return new RecorderStub();
}
Object.defineProperty(globalThis, "MediaRecorder", {
value: MR,
configurable: true,
});
Object.defineProperty(globalThis.MediaRecorder, "isTypeSupported", {
value: () => true,
configurable: true,
});
const { result } = renderHook(() =>
useVoiceInput({ language: "zh", onResult, onError }),
);
expect(result.current.status).toBe("idle");
await act(async () => {
await result.current.toggle();
});
expect(result.current.status).toBe("recording");
await act(async () => {
await result.current.toggle();
});
await waitFor(() => {
expect(result.current.status).toBe("idle");
});
expect(transcribeAudio).toHaveBeenCalledOnce();
expect(transcribeAudio).toHaveBeenCalledWith(
expect.any(Blob),
expect.stringMatching(/^voice-\d+\.webm$/),
"zh",
expect.any(AbortSignal),
"funasr-sensevoice",
);
expect(onResult).toHaveBeenCalledWith("你好世界");
expect(onError).not.toHaveBeenCalled();
expect(stopTracks).toHaveBeenCalled();
});
it("不支持 MediaRecorder 时触发 onError", async () => {
const onResult = vi.fn();
const onError = vi.fn();
Object.defineProperty(globalThis.navigator, "mediaDevices", {
value: { getUserMedia: vi.fn() },
configurable: true,
});
// 设为 undefined 模拟不支持
Object.defineProperty(globalThis, "MediaRecorder", {
value: undefined,
configurable: true,
});
const { result } = renderHook(() =>
useVoiceInput({ language: "zh", onResult, onError }),
);
await act(async () => {
await result.current.start();
});
expect(onError).toHaveBeenCalledWith("当前浏览器不支持 MediaRecorder。");
expect(onResult).not.toHaveBeenCalled();
});
});
+204
View File
@@ -0,0 +1,204 @@
/**
* useVoiceInput — 麦克风语音录入 hook。
*
* 流程:申请麦克风权限 → MediaRecorder 采集音频 → 停止后产出 Blob →
* 调用后端 ASR(OpenAI Whisper 兼容)转写为文本。组件层只关心三态:
* idle / recording / transcribing,以及最终文本与错误。
*
* 后端 ASR 引擎懒加载:首次请求可能返回 503(model_loading),调用方应
* 提示用户稍后重试(见 yuanqi/src/endpoints/asr.py)。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { transcribeAudio } from "@/lib/api/audio";
export type VoiceInputStatus = "idle" | "recording" | "transcribing";
export interface UseVoiceInputOptions {
/** 转写语言提示(ISO-639-1),默认 "zh" */
language?: string;
/** ASR 模型逻辑名,默认 "funasr-sensevoice"(本地 yuanqi 引擎) */
model?: string;
/** 转写成功回调(在组件内更新输入框,避免 effect 内 setState) */
onResult?: (text: string) => void;
/** 错误回调 */
onError?: (message: string) => void;
}
export interface UseVoiceInputReturn {
/** 当前状态 */
status: VoiceInputStatus;
/** 是否正在录音或转写 */
busy: boolean;
/** 开始录音 */
start: () => Promise<void>;
/** 停止录音并触发转写 */
stop: () => Promise<void>;
/** 切换录音状态 */
toggle: () => Promise<void>;
}
/** 选择一个浏览器支持的录音 MIME 类型,优先 webm/opus。 */
function pickMimeType(): string {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4",
"audio/ogg;codecs=opus",
];
if (typeof MediaRecorder === "undefined") return "";
for (const t of candidates) {
if (MediaRecorder.isTypeSupported(t)) return t;
}
return "";
}
export function useVoiceInput(
options: UseVoiceInputOptions = {},
): UseVoiceInputReturn {
const { language = "zh", model = "funasr-sensevoice" } = options;
const onResultRef = useRef(options.onResult);
const onErrorRef = useRef(options.onError);
// 保持回调为最新引用,但不参与录音流程的依赖
useEffect(() => {
onResultRef.current = options.onResult;
onErrorRef.current = options.onError;
});
const [status, setStatus] = useState<VoiceInputStatus>("idle");
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const mimeRef = useRef<string>("");
const abortRef = useRef<AbortController | null>(null);
/** 释放麦克风轨道与 recorder 引用。 */
const release = useCallback(() => {
if (recorderRef.current && recorderRef.current.state !== "inactive") {
try {
recorderRef.current.stop();
} catch {
/* 忽略 */
}
}
recorderRef.current = null;
if (streamRef.current) {
for (const track of streamRef.current.getTracks()) track.stop();
}
streamRef.current = null;
chunksRef.current = [];
}, []);
// 卸载时清理
useEffect(() => () => release(), [release]);
const start = useCallback(async () => {
if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia
) {
onErrorRef.current?.("当前环境不支持麦克风录音。");
return;
}
if (typeof MediaRecorder === "undefined") {
onErrorRef.current?.("当前浏览器不支持 MediaRecorder。");
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mime = pickMimeType();
mimeRef.current = mime;
const recorder = mime
? new MediaRecorder(stream, { mimeType: mime })
: new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
};
recorderRef.current = recorder;
recorder.start();
setStatus("recording");
} catch (err) {
release();
const name = (err as DOMException)?.name;
let msg: string;
if (name === "NotAllowedError" || name === "SecurityError") {
msg = "麦克风权限被拒绝,请在浏览器设置中允许后重试。";
} else if (name === "NotFoundError") {
msg = "未检测到可用的麦克风设备。";
} else {
msg = "无法启动录音:" + ((err as Error).message ?? String(err));
}
onErrorRef.current?.(msg);
setStatus("idle");
}
}, [release]);
const stop = useCallback(async () => {
const recorder = recorderRef.current;
if (!recorder || recorder.state === "inactive") {
setStatus("idle");
return;
}
/** 录音停止后产出的 Blob。 */
const finalize = (): Promise<Blob> =>
new Promise((resolve) => {
recorder.onstop = () => {
const mime = mimeRef.current || "audio/webm";
resolve(new Blob(chunksRef.current, { type: mime }));
};
recorder.stop();
});
setStatus("transcribing");
try {
const blob = await finalize();
release();
const ext = mimeToExtension(blob.type);
const controller = new AbortController();
abortRef.current = controller;
const result = await transcribeAudio(
blob,
`voice-${Date.now()}.${ext}`,
language,
controller.signal,
model,
);
const text = result.text ?? "";
if (text) onResultRef.current?.(text);
} catch (err) {
if ((err as Error).name === "AbortError") return;
const msg = (err as Error).message ?? "语音转写失败。";
onErrorRef.current?.(msg);
} finally {
abortRef.current = null;
setStatus("idle");
}
}, [language, model, release]);
const toggle = useCallback(async () => {
if (status === "recording") await stop();
else if (status === "idle") await start();
}, [status, start, stop]);
return {
status,
busy: status !== "idle",
start,
stop,
toggle,
};
}
/** 根据 MediaRecorder 产出的 MIME 推断文件扩展名。 */
function mimeToExtension(mime: string): string {
if (mime.includes("webm")) return "webm";
if (mime.includes("mp4")) return "m4a";
if (mime.includes("ogg")) return "ogg";
if (mime.includes("wav")) return "wav";
return "webm";
}