hiwork-core 0.0.22 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `/hiwork-voice` loopback RPC 的 Host 实现(浏览器侧话筒录音 → 识别文本的唯一入口)。
3
+ *
4
+ * 设计要点(与 `src/daily-rpc.ts` / `src/usage-rpc.ts` 完全同构):
5
+ * - 只注册一个 channel,endpoint 白名单在 {@link VOICE_RPC_ENDPOINTS},未知 endpoint
6
+ * 直接 `bad-request`;
7
+ * - payload 严格校验:`status` 只接受空对象,`transcribe` 只接受 `{ audioBase64: 非空字符串 }`,
8
+ * 任何其它字段 / 类型不符一律 `bad-request`;
9
+ * - **网关会话 cookie 绝不离开 Host**:调用方只拿到端点结果,cookie 只在
10
+ * `resolveSession()` 内部出现;
11
+ * - 网关 `GatewayError` 的稳定 code 原样透传,但 message 先经
12
+ * {@link displayVoiceGatewayMessage} 消毒——baseURL / HTTP 状态码这类实现细节不进 UI;
13
+ * - 其余异常收敛为通用失败(code `internal`),Host 只记日志、不把堆栈或原始消息
14
+ * 交给客户端。
15
+ *
16
+ * 两处**与 daily / usage 不同**的语义,都写在这里免得后来的人「对齐」掉:
17
+ * 1. `status` 端点的失败**不是错误信封**——网关故障时回落
18
+ * `{enabled:false, maxDurationSeconds:60}`({@link VOICE_FALLBACK_CONFIG})。
19
+ * 这条端点的调用频率是「页面挂载一次」,对用户而言它只有一个含义:话筒该不该出现。
20
+ * 网关挂了就报一个红字,只会让一个本来就没有的功能显得像坏了。
21
+ * 2. `transcribe` 的入参是**一段 base64 录音**(全链路唯一的大对象,与技能包体同级),
22
+ * 所以它的载荷校验只走白名单 + 非空,不在 Host 侧做 base64 解码或大小判断——
23
+ * 那两件事分别由浏览器半边与网关负责(网关超限回 413)。
24
+ *
25
+ * 消毒逻辑是**同构复制**而非复用 usage/daily 的导出:兜底文案是用量域 / 每日一句域的,
26
+ * 复用会让语音识别的失败吐出别的域的文案;且本仓库的频道模块本就各自自带一份同实现,
27
+ * 保持模块自包含。
28
+ *
29
+ * 本模块不 import cordis 运行时,只依赖结构化的 {@link VoiceRpcContext} 端口,
30
+ * 便于单测直接伪造。
31
+ */
32
+ import { type GatewayVoiceConfig, type GatewayVoiceTranscription } from './ai-router-client.ts';
33
+ /** RPC 频道;必须与 client 半边 `src/client/voice/voice-data.ts` 的 `VOICE_CHANNEL` 一致(由测试锁定)。 */
34
+ export declare const VOICE_RPC_CHANNEL = "/hiwork-voice";
35
+ /** 端点白名单:`status`(配置)与 `transcribe`(识别)。 */
36
+ export declare const VOICE_RPC_ENDPOINTS: readonly ["status", "transcribe"];
37
+ export type VoiceRpcEndpoint = (typeof VOICE_RPC_ENDPOINTS)[number];
38
+ /** 端点入参:`status` 无入参;`transcribe` 必带非空 `audioBase64`。 */
39
+ export interface VoiceRpcPayload {
40
+ audioBase64?: string | undefined;
41
+ }
42
+ /** 客户端可安全展示的失败。 */
43
+ export interface VoiceRpcFailure {
44
+ code: string;
45
+ message: string;
46
+ /** DSH Connection 信封要求 `details` 是对象;协议层没有额外细节时补空对象。 */
47
+ details: object;
48
+ }
49
+ /** 端点结果信封(与 DSH Connection 的 `ConnectionRpcResult` 同形)。 */
50
+ export type VoiceRpcResult<T> = {
51
+ readonly ok: true;
52
+ readonly value: T;
53
+ } | {
54
+ readonly ok: false;
55
+ readonly error: VoiceRpcFailure;
56
+ };
57
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
58
+ export declare class VoiceRequestError extends Error {
59
+ readonly code: 'bad-request';
60
+ constructor(code: 'bad-request', message: string);
61
+ }
62
+ /** Host 侧 RPC 依赖的最小上下文(结构兼容 cordis Context,便于单测伪造)。 */
63
+ export interface VoiceRpcConnection {
64
+ rpc: {
65
+ handle(channel: string, handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<unknown>, options: {
66
+ authority: 'loopback';
67
+ }): () => Promise<void> | void;
68
+ };
69
+ }
70
+ export interface VoiceRpcContext {
71
+ readonly connection: VoiceRpcConnection;
72
+ readonly logger: {
73
+ warn(message: string): void;
74
+ };
75
+ }
76
+ /**
77
+ * 网关客户端的语音子集:只声明本频道真正调用的方法(结构兼容 `GatewayClient` 实例),
78
+ * 单测因此不需要伪造整个客户端。
79
+ */
80
+ export interface VoiceGatewayPort {
81
+ getMyVoiceConfig(sessionCookie: string): Promise<GatewayVoiceConfig>;
82
+ transcribeVoice(sessionCookie: string, audioBase64: string): Promise<GatewayVoiceTranscription>;
83
+ }
84
+ /** Host 侧取数依赖:会话解析 + 当前 baseURL 下的网关客户端。 */
85
+ export interface VoiceRpcDeps {
86
+ /**
87
+ * 解析网关会话 cookie。未登录 / 会话缺失返回 undefined。
88
+ * **返回值只在本模块内部使用,绝不出现在 RPC 响应里。**
89
+ */
90
+ resolveSession(): Promise<string | undefined>;
91
+ /** 当前 baseURL 对应的网关客户端;baseURL 未配置时返回 undefined。 */
92
+ gateway(): VoiceGatewayPort | undefined;
93
+ /** Host 日志出口(只记事件与错误类型,不记会话值、不记录音内容)。 */
94
+ log?: ((message: string) => void) | undefined;
95
+ }
96
+ /** 通用失败文案:不暴露任何网关细节。 */
97
+ export declare const VOICE_GENERIC_FAILURE_MESSAGE = "\u8BED\u97F3\u8BC6\u522B\u6682\u65F6\u65E0\u6CD5\u4F7F\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
98
+ /** 未登录 / 会话失效的统一文案(与用量频道的 `SESSION_EXPIRED` 口径一致)。 */
99
+ export declare const VOICE_SESSION_EXPIRED_MESSAGE = "\u767B\u5F55\u72B6\u6001\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
100
+ /**
101
+ * `status` 端点读不到配置时的回落值(**成功信封**,见模块头注释第 1 条):
102
+ * 话筒消失,而不是弹一条用户看不懂的错误。
103
+ */
104
+ export declare const VOICE_FALLBACK_CONFIG: GatewayVoiceConfig;
105
+ /**
106
+ * 网关 message 消毒(与 `daily-rpc.ts` 的 `displayDailyGatewayMessage` 同实现):
107
+ * 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的
108
+ * 中文业务文案)原样保留。code 不受影响,始终原样透传。
109
+ */
110
+ export declare function displayVoiceGatewayMessage(code: string, message: unknown): string;
111
+ /**
112
+ * payload 校验:`status` 只放行空对象,`transcribe` 只放行 `{ audioBase64: 非空字符串 }`
113
+ * (纯空白视为空——它不可能是合法录音);其余字段 / 类型不符一律 `bad-request`。
114
+ *
115
+ * **不在这里解析 / 校验 base64 本身**:解码与大小上限分别由浏览器半边与网关负责
116
+ * (网关超限回 413,映射在 `AiRouterClient.transcribeVoice`),Host 侧只做协议形状。
117
+ */
118
+ export declare function parseVoicePayload(endpoint: VoiceRpcEndpoint, raw: unknown): VoiceRpcPayload;
119
+ /** 是否为白名单端点。 */
120
+ export declare function isVoiceRpcEndpoint(endpoint: string): endpoint is VoiceRpcEndpoint;
121
+ /**
122
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
123
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
124
+ *
125
+ * `status` 走 {@link readVoiceConfig} 的**降级**分支:网关故障 → 成功信封里的
126
+ * `enabled:false`。「没有会话」「网关地址未配置」两条**不是**降级——那时还没有任何
127
+ * 功能状态可言(页面本身在登录门后),照常回错误信封。
128
+ */
129
+ export declare function handleVoiceEndpoint(endpoint: string, rawPayload: unknown, deps: VoiceRpcDeps, signal?: AbortSignal): Promise<VoiceRpcResult<unknown>>;
130
+ /**
131
+ * 注册 `/hiwork-voice` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
132
+ */
133
+ export declare function registerVoiceRpc(ctx: VoiceRpcContext, deps: VoiceRpcDeps): () => Promise<void>;
@@ -0,0 +1,123 @@
1
+ import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web';
2
+ /** 本 provider 注册进 `ctx.web` 用的稳定 id(cordis.patch.yml 的 searchProvider pin 与它逐字一致)。 */
3
+ export declare const HIWORK_SEARCH_PROVIDER_ID = "hiwork-managed-search";
4
+ /** 网关侧端点(跨仓冻结;会话鉴权与 `/api/me/memory-config` 同一条)。 */
5
+ export declare const ME_WEB_SEARCH_PATH = "/api/me/web-search";
6
+ /** 出网超时。网关侧自持 10s 上游预算;这里取 12s 留出网关排队的余量,仍远小于 tool-web 的 60s 工具预算。 */
7
+ export declare const WEB_SEARCH_TIMEOUT_MS = 12000;
8
+ /**
9
+ * 网关侧原始响应(**snake_case**,跨仓冻结契约;本文件是唯一读它的地方)。
10
+ * 仅供类型自证 —— 真正的读取走宽容投影 {@link projectWebSearch}。
11
+ */
12
+ export interface GatewayWebSearchResponse {
13
+ query: string;
14
+ /** 产出结果的服务商(排障线索;本模块不按它分支 —— 换服务商正是要隐藏的变化)。 */
15
+ provider: string;
16
+ results: GatewayWebSearchResultItem[];
17
+ }
18
+ export interface GatewayWebSearchResultItem {
19
+ title?: string;
20
+ url: string;
21
+ snippet?: string;
22
+ content?: string;
23
+ published_at?: string;
24
+ }
25
+ /**
26
+ * 传输层结果:失败是数据而不是异常(gateway 端口**从不抛错**)。
27
+ * - `kind: 'http'`:拿到了非 2xx;`code` 是网关冻结错误码(body 里有就带上,
28
+ * 供 provider 分「关闭 / 未配置 / 超限」三档);
29
+ * - `kind: 'aborted'`:调用方取消(seam 的取消契约要求映射回 `WEB_ABORTED`);
30
+ * - `kind: 'network'`:超时 / 连接失败 / DNS;
31
+ * - `kind: 'invalid'`:2xx 但响应体不是可用的 JSON 对象(契约破坏)。
32
+ */
33
+ export type WebSearchOutcome = {
34
+ readonly ok: true;
35
+ readonly body: unknown;
36
+ } | {
37
+ readonly ok: false;
38
+ readonly kind: 'http';
39
+ readonly status: number;
40
+ readonly code?: string;
41
+ } | {
42
+ readonly ok: false;
43
+ readonly kind: 'aborted';
44
+ } | {
45
+ readonly ok: false;
46
+ readonly kind: 'network';
47
+ } | {
48
+ readonly ok: false;
49
+ readonly kind: 'invalid';
50
+ };
51
+ /** 网关客户端的 web-search 子集:只声明本模块真正调用的方法(便于单测打桩)。 */
52
+ export interface WebSearchGatewayPort {
53
+ searchWeb(sessionCookie: string, body: {
54
+ query: string;
55
+ max_results?: number;
56
+ }, signal?: AbortSignal): Promise<WebSearchOutcome>;
57
+ }
58
+ /** Host 侧依赖:会话解析 + 当前 baseURL 下的网关出网端口。 */
59
+ export interface WebSearchProviderDeps {
60
+ /**
61
+ * 解析网关会话 cookie。未登录 / 会话缺失返回 undefined。
62
+ * **返回值只在本模块内部用于出网**,绝不进日志与错误消息。
63
+ */
64
+ resolveSession(): Promise<string | undefined>;
65
+ /** 当前 baseURL 对应的网关出网端口;baseURL 未配置时返回 undefined。 */
66
+ gateway(): WebSearchGatewayPort | undefined;
67
+ /** Host 日志出口(只记事件与异常类型名,不记 query / URL / 响应内容)。 */
68
+ log?: ((message: string) => void) | undefined;
69
+ }
70
+ /**
71
+ * HiWork 受管搜索 provider。`available()` 恒 true(同步、无网络 —— DSH 契约):
72
+ * 会话是否可用要到 `search()` 时才知道,那时按 {@link WebError} 的稳定 code 上报,
73
+ * 而不是假装「不可用」让 seam 报一个笼统的 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`。
74
+ */
75
+ export declare class HiworkSearchProvider implements WebSearchProvider {
76
+ private readonly deps;
77
+ readonly id = "hiwork-managed-search";
78
+ constructor(deps: WebSearchProviderDeps);
79
+ available(): boolean;
80
+ search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;
81
+ }
82
+ /** 未登录 / 会话失效(网关 401,或本地就没有会话)。 */
83
+ export declare const HIWORK_SEARCH_UNAUTHENTICATED = "HIWORK_SEARCH_UNAUTHENTICATED";
84
+ /** 管理员还没开启联网搜索(网关 503 SEARCH_DISABLED)。 */
85
+ export declare const HIWORK_SEARCH_DISABLED = "HIWORK_SEARCH_DISABLED";
86
+ /** 网关没配上游(网关 503 SEARCH_NOT_CONFIGURED;找运维,不是员工能解决的)。 */
87
+ export declare const HIWORK_SEARCH_NOT_CONFIGURED = "HIWORK_SEARCH_NOT_CONFIGURED";
88
+ /** 当日次数用完(网关 429 SEARCH_LIMIT_EXCEEDED)。 */
89
+ export declare const HIWORK_SEARCH_LIMIT = "HIWORK_SEARCH_LIMIT";
90
+ /** 网关不可达 / 超时 / 宿主故障(可重试)。 */
91
+ export declare const HIWORK_SEARCH_UNREACHABLE = "HIWORK_SEARCH_UNREACHABLE";
92
+ /** 网关代打上游失败(网关 502 SEARCH_UPSTREAM_ERROR;可重试)。 */
93
+ export declare const HIWORK_SEARCH_UPSTREAM = "HIWORK_SEARCH_UPSTREAM";
94
+ /**
95
+ * 响应体 → 归一化结果。
96
+ *
97
+ * `results` 缺失或不是数组返回 undefined(契约破坏,调用方报 provider error);
98
+ * 单条缺 `url` 的**丢弃**(没法被引用),缺 title/snippet/published_at 的保留其余
99
+ * 字段(DSH 侧全是可选语义)。`content`(上游网页原文片段)在 snippet 缺席时兜底 ——
100
+ * 两个都是正文片段,给模型看比给一个空引用强。
101
+ */
102
+ export declare function projectWebSearch(body: Record<string, unknown>): WebSearchResult | undefined;
103
+ /**
104
+ * 本端点的出网实现(形状与 `createKnowledgeConfigGateway` 同款:user-agent 带包版本、
105
+ * 会话 cookie 走 `cookie` 头、外部 signal 与超时经 `AbortSignal.any` 合并)。
106
+ *
107
+ * **永不抛异常**:失败收敛成 {@link WebSearchOutcome}。取消(外部 signal 先触发)单独
108
+ * 分档 —— seam 的取消契约要求它以 `WEB_ABORTED` 上抛,不能被折进「网络故障」。
109
+ *
110
+ * `fetchImpl` / `timeoutMs` 仅用于测试注入。
111
+ */
112
+ export declare function createWebSearchGateway(baseURL: string, opts?: {
113
+ timeoutMs?: number;
114
+ fetchImpl?: typeof fetch;
115
+ }): WebSearchGatewayPort;
116
+ /**
117
+ * 把 provider 注册进 `ctx.web`。注册即挂 fiber(disposer 由 dsh-web 自己挂,见
118
+ * `WebRuntime.registerProvider`),与 web-search-exa 插件的 apply 同款。
119
+ * 依赖 `web` 服务在 inject 里声明(dsh-base 基座先于本插件挂载 dsh-web)。
120
+ */
121
+ export declare function registerManagedSearchProvider(web: {
122
+ registerSearchProvider(provider: WebSearchProvider): () => void;
123
+ }, deps: WebSearchProviderDeps): () => void;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `/hiwork-voice` loopback RPC 的 Host 实现(浏览器侧话筒录音 → 识别文本的唯一入口)。
3
+ *
4
+ * 设计要点(与 `src/daily-rpc.ts` / `src/usage-rpc.ts` 完全同构):
5
+ * - 只注册一个 channel,endpoint 白名单在 {@link VOICE_RPC_ENDPOINTS},未知 endpoint
6
+ * 直接 `bad-request`;
7
+ * - payload 严格校验:`status` 只接受空对象,`transcribe` 只接受 `{ audioBase64: 非空字符串 }`,
8
+ * 任何其它字段 / 类型不符一律 `bad-request`;
9
+ * - **网关会话 cookie 绝不离开 Host**:调用方只拿到端点结果,cookie 只在
10
+ * `resolveSession()` 内部出现;
11
+ * - 网关 `GatewayError` 的稳定 code 原样透传,但 message 先经
12
+ * {@link displayVoiceGatewayMessage} 消毒——baseURL / HTTP 状态码这类实现细节不进 UI;
13
+ * - 其余异常收敛为通用失败(code `internal`),Host 只记日志、不把堆栈或原始消息
14
+ * 交给客户端。
15
+ *
16
+ * 两处**与 daily / usage 不同**的语义,都写在这里免得后来的人「对齐」掉:
17
+ * 1. `status` 端点的失败**不是错误信封**——网关故障时回落
18
+ * `{enabled:false, maxDurationSeconds:60}`({@link VOICE_FALLBACK_CONFIG})。
19
+ * 这条端点的调用频率是「页面挂载一次」,对用户而言它只有一个含义:话筒该不该出现。
20
+ * 网关挂了就报一个红字,只会让一个本来就没有的功能显得像坏了。
21
+ * 2. `transcribe` 的入参是**一段 base64 录音**(全链路唯一的大对象,与技能包体同级),
22
+ * 所以它的载荷校验只走白名单 + 非空,不在 Host 侧做 base64 解码或大小判断——
23
+ * 那两件事分别由浏览器半边与网关负责(网关超限回 413)。
24
+ *
25
+ * 消毒逻辑是**同构复制**而非复用 usage/daily 的导出:兜底文案是用量域 / 每日一句域的,
26
+ * 复用会让语音识别的失败吐出别的域的文案;且本仓库的频道模块本就各自自带一份同实现,
27
+ * 保持模块自包含。
28
+ *
29
+ * 本模块不 import cordis 运行时,只依赖结构化的 {@link VoiceRpcContext} 端口,
30
+ * 便于单测直接伪造。
31
+ */
32
+ import { GatewayError, } from "./ai-router-client.js";
33
+ /** RPC 频道;必须与 client 半边 `src/client/voice/voice-data.ts` 的 `VOICE_CHANNEL` 一致(由测试锁定)。 */
34
+ export const VOICE_RPC_CHANNEL = '/hiwork-voice';
35
+ /** 端点白名单:`status`(配置)与 `transcribe`(识别)。 */
36
+ export const VOICE_RPC_ENDPOINTS = ['status', 'transcribe'];
37
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
38
+ export class VoiceRequestError extends Error {
39
+ code;
40
+ constructor(code, message) {
41
+ super(message);
42
+ this.code = code;
43
+ this.name = 'VoiceRequestError';
44
+ }
45
+ }
46
+ /** 各稳定错误码的用户可见文案(网关 message 含实现细节时用它兜底)。 */
47
+ const VOICE_MESSAGE_FALLBACK = {
48
+ SESSION_EXPIRED: '登录状态已过期,请重新登录',
49
+ GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
50
+ VOICE_DISABLED: '本部署未启用语音识别,请联系管理员',
51
+ VOICE_NOT_CONFIGURED: '本部署尚未配置语音识别服务,请联系管理员',
52
+ VOICE_LIMIT_REACHED: '语音识别请求过于频繁,请稍后再试',
53
+ VOICE_AUDIO_TOO_LARGE: '录音过长,请缩短后重试',
54
+ VOICE_BAD_REQUEST: '录音数据不合法,请重新录制',
55
+ VOICE_UPSTREAM: '语音识别服务暂时不可用,请稍后重试',
56
+ RATE_LIMITED: '请求过于频繁,请稍后再试',
57
+ AUTH_FAILED: '登录状态异常,请重新登录',
58
+ };
59
+ /** 通用失败文案:不暴露任何网关细节。 */
60
+ export const VOICE_GENERIC_FAILURE_MESSAGE = '语音识别暂时无法使用,请稍后重试';
61
+ /** 未登录 / 会话失效的统一文案(与用量频道的 `SESSION_EXPIRED` 口径一致)。 */
62
+ export const VOICE_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
63
+ /**
64
+ * `status` 端点读不到配置时的回落值(**成功信封**,见模块头注释第 1 条):
65
+ * 话筒消失,而不是弹一条用户看不懂的错误。
66
+ */
67
+ export const VOICE_FALLBACK_CONFIG = { enabled: false, maxDurationSeconds: 60 };
68
+ /**
69
+ * 网关 message 消毒(与 `daily-rpc.ts` 的 `displayDailyGatewayMessage` 同实现):
70
+ * 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的
71
+ * 中文业务文案)原样保留。code 不受影响,始终原样透传。
72
+ */
73
+ export function displayVoiceGatewayMessage(code, message) {
74
+ const text = typeof message === 'string' ? message.trim() : '';
75
+ if (text === '')
76
+ return VOICE_MESSAGE_FALLBACK[code] ?? VOICE_GENERIC_FAILURE_MESSAGE;
77
+ const leaksImplementationDetail = /https?:\/\//i.test(text)
78
+ || /\bHTTP\s*\d{3}\b/i.test(text)
79
+ || /\bbaseURL\b/i.test(text);
80
+ if (leaksImplementationDetail)
81
+ return VOICE_MESSAGE_FALLBACK[code] ?? VOICE_GENERIC_FAILURE_MESSAGE;
82
+ return text;
83
+ }
84
+ /**
85
+ * payload 校验:`status` 只放行空对象,`transcribe` 只放行 `{ audioBase64: 非空字符串 }`
86
+ * (纯空白视为空——它不可能是合法录音);其余字段 / 类型不符一律 `bad-request`。
87
+ *
88
+ * **不在这里解析 / 校验 base64 本身**:解码与大小上限分别由浏览器半边与网关负责
89
+ * (网关超限回 413,映射在 `AiRouterClient.transcribeVoice`),Host 侧只做协议形状。
90
+ */
91
+ export function parseVoicePayload(endpoint, raw) {
92
+ if (raw === undefined || raw === null) {
93
+ if (endpoint === 'transcribe')
94
+ throw new VoiceRequestError('bad-request', '语音识别请求缺少 audioBase64。');
95
+ return {};
96
+ }
97
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
98
+ throw new VoiceRequestError('bad-request', '语音识别请求参数不合法。');
99
+ }
100
+ const record = raw;
101
+ const keys = Object.keys(record);
102
+ if (endpoint === 'status') {
103
+ // status 无任何入参:出现任何字段都是客户端实现缺陷
104
+ if (keys.length > 0)
105
+ throw new VoiceRequestError('bad-request', `语音识别请求包含未知字段 '${keys[0]}'。`);
106
+ return {};
107
+ }
108
+ for (const key of keys) {
109
+ if (key !== 'audioBase64')
110
+ throw new VoiceRequestError('bad-request', `语音识别请求包含未知字段 '${key}'。`);
111
+ }
112
+ const rawAudio = record['audioBase64'];
113
+ if (typeof rawAudio !== 'string' || rawAudio.trim() === '') {
114
+ throw new VoiceRequestError('bad-request', '语音识别请求的 audioBase64 必须是非空字符串。');
115
+ }
116
+ return { audioBase64: rawAudio };
117
+ }
118
+ /** 是否为白名单端点。 */
119
+ export function isVoiceRpcEndpoint(endpoint) {
120
+ return VOICE_RPC_ENDPOINTS.includes(endpoint);
121
+ }
122
+ /** 取消判定(用函数包一层,避免 TS 把属性窄化固化到 `await` 之后)。 */
123
+ function isAborted(signal) {
124
+ return signal?.aborted === true;
125
+ }
126
+ /**
127
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
128
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
129
+ *
130
+ * `status` 走 {@link readVoiceConfig} 的**降级**分支:网关故障 → 成功信封里的
131
+ * `enabled:false`。「没有会话」「网关地址未配置」两条**不是**降级——那时还没有任何
132
+ * 功能状态可言(页面本身在登录门后),照常回错误信封。
133
+ */
134
+ export async function handleVoiceEndpoint(endpoint, rawPayload, deps, signal) {
135
+ try {
136
+ if (isAborted(signal))
137
+ return failure('cancelled', '语音识别请求已取消。');
138
+ if (!isVoiceRpcEndpoint(endpoint)) {
139
+ throw new VoiceRequestError('bad-request', `未知的语音识别端点 '${endpoint}'`);
140
+ }
141
+ const payload = parseVoicePayload(endpoint, rawPayload);
142
+ const sessionCookie = await deps.resolveSession();
143
+ if (sessionCookie === undefined || sessionCookie === '') {
144
+ return failure('SESSION_EXPIRED', VOICE_SESSION_EXPIRED_MESSAGE);
145
+ }
146
+ const gateway = deps.gateway();
147
+ if (gateway === undefined) {
148
+ return failure('GATEWAY_UNREACHABLE', '网关地址未配置,请先在「网关模型」里完成登录');
149
+ }
150
+ if (isAborted(signal))
151
+ return failure('cancelled', '语音识别请求已取消。');
152
+ if (endpoint === 'status')
153
+ return { ok: true, value: await readVoiceConfig(gateway, sessionCookie, deps.log) };
154
+ return { ok: true, value: await transcribe(gateway, sessionCookie, payload) };
155
+ }
156
+ catch (error) {
157
+ if (error instanceof VoiceRequestError)
158
+ return failure(error.code, error.message);
159
+ if (error instanceof GatewayError) {
160
+ // code 原样透传(稳定契约),message 消毒后再给客户端。
161
+ return failure(error.code, displayVoiceGatewayMessage(error.code, error.message));
162
+ }
163
+ deps.log?.(`hiwork-core: 语音识别 RPC '${endpoint}' 失败(${error instanceof Error ? error.name : typeof error})`);
164
+ return failure('internal', VOICE_GENERIC_FAILURE_MESSAGE);
165
+ }
166
+ }
167
+ /**
168
+ * `status` 端点取数:**读不到配置就让话筒消失**(返回 {@link VOICE_FALLBACK_CONFIG}),
169
+ * 而不是回错误信封。
170
+ *
171
+ * 这条端点会在页面挂载时被调一次,它的唯一用途是「要不要显示话筒」;网关故障、
172
+ * 会话在途中过期、响应形状不对……对用户而言都只有同一个结论:现在用不了。
173
+ * 抛错误信封会让一个**默认不存在**的功能冒出红字。所以这里把全部异常咽成禁用配置,
174
+ * 只留一条 Host 日志(日志里也不带 cookie / 响应原文)。
175
+ */
176
+ async function readVoiceConfig(gateway, sessionCookie, log) {
177
+ try {
178
+ const config = await gateway.getMyVoiceConfig(sessionCookie);
179
+ // 字段名 / 形状已在网关客户端层收敛(snake_case → camelCase + 缺省兜底),
180
+ // 这里只把「真值才算开」钉死:undefined / 非布尔一律当关。
181
+ return {
182
+ enabled: config.enabled === true,
183
+ maxDurationSeconds: config.maxDurationSeconds > 0 ? config.maxDurationSeconds : VOICE_FALLBACK_CONFIG.maxDurationSeconds,
184
+ };
185
+ }
186
+ catch (error) {
187
+ log?.(`hiwork-core: 语音配置读取失败,按禁用回落(${error instanceof Error ? error.name : typeof error})`);
188
+ return { ...VOICE_FALLBACK_CONFIG };
189
+ }
190
+ }
191
+ /** `transcribe` 端点:只回 `{ text }`(网关响应的其余字段不进频道)。 */
192
+ async function transcribe(gateway, sessionCookie, payload) {
193
+ const audioBase64 = payload.audioBase64;
194
+ // parseVoicePayload 已保证 transcribe 必带非空 audioBase64;这一支是类型层兜底,
195
+ // 真走到这里也只抛 VoiceRequestError(由上层信封收敛,不会逃逸)。
196
+ if (audioBase64 === undefined)
197
+ throw new VoiceRequestError('bad-request', '语音识别请求缺少 audioBase64。');
198
+ const result = await gateway.transcribeVoice(sessionCookie, audioBase64);
199
+ return { text: result.text };
200
+ }
201
+ function failure(code, message) {
202
+ return { ok: false, error: { code, message, details: {} } };
203
+ }
204
+ /**
205
+ * 注册 `/hiwork-voice` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
206
+ */
207
+ export function registerVoiceRpc(ctx, deps) {
208
+ const handler = (endpoint, payload, signal) => handleVoiceEndpoint(endpoint, payload, deps, signal);
209
+ const remove = ctx.connection.rpc.handle(VOICE_RPC_CHANNEL, handler, { authority: 'loopback' });
210
+ return async () => { await remove(); };
211
+ }