初始化提交
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user