hiwork-core 0.0.24 → 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.
- package/cordis.patch.yml +20 -4
- package/lib/ai-router-client.js +142 -1
- package/lib/client.js +940 -3
- package/lib/index.js +1874 -92
- package/lib/knowledge-config.js +273 -0
- package/lib/pms-client.js +95 -3
- package/lib/types/ai-router-client.d.ts +61 -0
- package/lib/types/client/hero-greeting.d.ts +0 -11
- package/lib/types/client/styles.d.ts +28 -0
- package/lib/types/client/types.d.ts +13 -1
- package/lib/types/client/voice/MicButton.d.ts +24 -0
- package/lib/types/client/voice/VoiceOverlay.d.ts +37 -0
- package/lib/types/client/voice/composer.d.ts +98 -0
- package/lib/types/client/voice/entry.d.ts +46 -0
- package/lib/types/client/voice/recording.d.ts +57 -0
- package/lib/types/client/voice/voice-data.d.ts +101 -0
- package/lib/types/client/voice/voice-styles.d.ts +9 -0
- package/lib/types/index.d.ts +3 -1
- package/lib/types/knowledge-config.d.ts +115 -0
- package/lib/types/pms-client.d.ts +41 -0
- package/lib/types/voice-rpc.d.ts +133 -0
- package/lib/types/web-search.d.ts +123 -0
- package/lib/voice-rpc.js +211 -0
- package/lib/web-search.js +259 -0
- package/package.json +5 -3
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 知识库配置下发的只读 host 服务(`hiworkKnowledge`)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:HiWork 给员工接了一套知识库(WeKnora)。规范是**员工零操作**——知识库的
|
|
5
|
+
* 服务地址、凭据、问答 Agent ID、chat 模型 ID **全部由网关下发**,插件的设置页
|
|
6
|
+
* 不得让员工填任何技术字段。本模块是这条链中间的那一环:
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* hiwork-admin GET /api/me/knowledge-config ← 网关侧(纯读:无 DB、无上游调用)
|
|
10
|
+
* ↑ 本文件(hiwork-core,用登录会话调它)
|
|
11
|
+
* hiwork-knowledge ctx.get('hiworkKnowledge').config() ← 插件侧
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* 形状来源(逐字对照):`src/memory-config.ts`(同族先例,同样是「网关配置下发的只读
|
|
15
|
+
* 面」)。与它的差异**只有三处**:
|
|
16
|
+
* ① 字段集多 `agentId` / `chatModelId`(问答链路需要);
|
|
17
|
+
* ② 必填判定只看 `base_url` + `api_key`:**缺 agentId / chatModelId 不阻止下发**
|
|
18
|
+
* (返回 `''`,检索与浏览仍可用),只有两个必填项缺失才判整份不可用;
|
|
19
|
+
* ③ 状态码映射里**保留** `504 → unreachable`,但它是**防御性分类**:本端点不打上游,
|
|
20
|
+
* 网关不会回 504(网关侧 spec §3 D1/D5),保留这一档是为了将来换实现时不会把
|
|
21
|
+
* 「上游不可达」误读成 `error`。
|
|
22
|
+
*
|
|
23
|
+
* 不变量(与 memory 同口径,不得放宽):
|
|
24
|
+
* - **只读、失败不抛异常**:一律返回 `{ ok: false, reason, message }`,reason 四取值
|
|
25
|
+
* `not-configured` / `unauthenticated` / `unreachable` / `error`。失败是**数据**而不是
|
|
26
|
+
* 异常:插件侧按 `ok` 判断,绝不会因为宿主的一次网络抖动把异常漏进会话。
|
|
27
|
+
* - **字段名分层只在本文件做**:网关那层是 **snake_case**(`base_url` / `api_key` /
|
|
28
|
+
* `agent_id` / `chat_model_id` / `tenant_id`,跨仓冻结契约),本文件把它转成 camelCase
|
|
29
|
+
* 的 {@link HiworkKnowledgeConfigView} 再对外暴露——插件侧**永远不感知上游命名**,
|
|
30
|
+
* 本文件是全仓**唯一**做这层转换的地方。
|
|
31
|
+
* - **凭据只在内存里活**:`apiKey` 不写 settings、不写任何 storage、不写任何缓存文件
|
|
32
|
+
* (与 `src/memory-config.ts` / `src/office-credentials.ts` 同口径),也**不进任何日志**
|
|
33
|
+
* (含调试日志与错误日志)——本模块只记事件与异常**类型名**,绝不记字段值。
|
|
34
|
+
* - **5 分钟内存缓存**:命中即不出网;`force: true` 绕过缓存(设置页「重新获取配置」与
|
|
35
|
+
* 401 自愈走这条)。缓存与**会话 cookie 绑定**:换了登录账号(登出再登入)时旧缓存
|
|
36
|
+
* 直接失效,绝不把上一个员工的配置发给下一个员工(粘错 = 串号)。
|
|
37
|
+
* - **失败不进缓存**:拉不到时下一次调用重新出网——否则一次网关抖动会把员工锁在
|
|
38
|
+
* 「知识库不可用」里整整一个 TTL。
|
|
39
|
+
* - **message 是给非技术员工看的中文**:不含凭据、不含 URL 原文、不含 HTTP 状态码。
|
|
40
|
+
*
|
|
41
|
+
* 服务名逐字 `hiworkKnowledge`(插件侧 spec §3 D1 对既有 spec 的一处口径修正:旧文档写的是
|
|
42
|
+
* 「Knowledge + Credentials」的拼接名,本模块下发的**不只是凭据**(还有 baseUrl、Agent ID、
|
|
43
|
+
* chat 模型 ID),故不叫 Credentials;源码里不得出现那个旧名,V3 用 grep 守着)。
|
|
44
|
+
*
|
|
45
|
+
* 本模块不 import cordis 运行时:provide 走结构化的 {@link KnowledgeConfigContext} 端口,
|
|
46
|
+
* 调用方(index.ts)传真实 ctx;单测直接伪造。
|
|
47
|
+
*/
|
|
48
|
+
import { PKG_VERSION } from "./version.js";
|
|
49
|
+
/** cordis 服务名(hiwork-knowledge 的 Host 半边按此名 `ctx.get` 探测)。 */
|
|
50
|
+
export const HIWORK_KNOWLEDGE_SERVICE = 'hiworkKnowledge';
|
|
51
|
+
/** 网关侧端点(跨仓冻结;与 `/api/me/memory-config` 同一条会话鉴权)。 */
|
|
52
|
+
export const ME_KNOWLEDGE_CONFIG_PATH = '/api/me/knowledge-config';
|
|
53
|
+
/** 成功结果的缓存 TTL:5 分钟。 */
|
|
54
|
+
export const KNOWLEDGE_CONFIG_TTL_MS = 5 * 60 * 1000;
|
|
55
|
+
/** 出网超时(与 `AiRouterClient` 的缺省一致)。 */
|
|
56
|
+
export const KNOWLEDGE_CONFIG_TIMEOUT_MS = 10_000;
|
|
57
|
+
/**
|
|
58
|
+
* 失败文案(给非技术员工看的中文;不含凭据、URL 原文、HTTP 状态码)。
|
|
59
|
+
* 四句与插件侧 spec §4.1 / §5 逐字一致,设置页直接渲染,不得改造。
|
|
60
|
+
*/
|
|
61
|
+
const MESSAGE_NOT_CONFIGURED = '管理员还没开启知识库,请联系管理员开通';
|
|
62
|
+
const MESSAGE_UNAUTHENTICATED = '登录状态已过期,请重新登录';
|
|
63
|
+
const MESSAGE_UNREACHABLE = '知识库服务暂时不可用,请稍后重试';
|
|
64
|
+
const MESSAGE_GENERIC = '知识库配置暂时无法获取,请稍后重试';
|
|
65
|
+
/**
|
|
66
|
+
* 按 reason 取文案。
|
|
67
|
+
* 注:`gateway()` 返回 undefined(还没登录完 / baseURL 未配置)与网络不通归同一档
|
|
68
|
+
* `unreachable`,共用同一句——本 spec 只冻结了四句文案,不另造第五句。
|
|
69
|
+
*/
|
|
70
|
+
function messageFor(reason) {
|
|
71
|
+
switch (reason) {
|
|
72
|
+
case 'not-configured': return MESSAGE_NOT_CONFIGURED;
|
|
73
|
+
case 'unauthenticated': return MESSAGE_UNAUTHENTICATED;
|
|
74
|
+
case 'unreachable': return MESSAGE_UNREACHABLE;
|
|
75
|
+
case 'error': return MESSAGE_GENERIC;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 失败视图(**只带 reason 与 message**,绝不带半个字段)。 */
|
|
79
|
+
function failure(reason, message) {
|
|
80
|
+
return { ok: false, reason, message: message ?? messageFor(reason) };
|
|
81
|
+
}
|
|
82
|
+
/** 裁掉两侧空白;非字符串或空白串一律视同缺省。 */
|
|
83
|
+
function textOrUndefined(value) {
|
|
84
|
+
if (typeof value !== 'string')
|
|
85
|
+
return undefined;
|
|
86
|
+
const text = value.trim();
|
|
87
|
+
return text === '' ? undefined : text;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* snake_case → camelCase 投影(**全仓唯一做这层转换的地方**)。
|
|
91
|
+
*
|
|
92
|
+
* 两个必需字段(`base_url` / `api_key`)任一缺失即返回 undefined:网关 200 却缺字段 =
|
|
93
|
+
* 契约破坏,宁可报「暂时无法获取」,也绝不把半份配置交给终端(半份配置的下场是插件拿
|
|
94
|
+
* 空地址去打请求,错误信息离真正的原因十万八千里)。
|
|
95
|
+
*
|
|
96
|
+
* 三个**非必需**字段(`agent_id` / `chat_model_id` / `tenant_id`)缺失 / 空白 / 形状非法
|
|
97
|
+
* 一律落成 `''`,**整份仍算下发成功**——检索与浏览不依赖它们,只有问答需要;用「问答
|
|
98
|
+
* 用不了」冒充「知识库不可用」会把员工引到错误的方向(spec §4.1 投影规则逐字)。
|
|
99
|
+
*
|
|
100
|
+
* 同时宽容接受 camelCase 别名(`baseUrl` / `apiKey` / `agentId` / `chatModelId` /
|
|
101
|
+
* `tenantId`):两侧落地顺序不同也不炸。**写出永远是 camelCase 视图**——插件侧不感知
|
|
102
|
+
* 任何一种上游命名。
|
|
103
|
+
*/
|
|
104
|
+
function projectKnowledgeConfig(body) {
|
|
105
|
+
const baseUrl = textOrUndefined(body['base_url'] ?? body['baseUrl']);
|
|
106
|
+
const apiKey = textOrUndefined(body['api_key'] ?? body['apiKey']);
|
|
107
|
+
if (baseUrl === undefined || apiKey === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
baseUrl,
|
|
112
|
+
apiKey,
|
|
113
|
+
agentId: textOrUndefined(body['agent_id'] ?? body['agentId']) ?? '',
|
|
114
|
+
chatModelId: textOrUndefined(body['chat_model_id'] ?? body['chatModelId']) ?? '',
|
|
115
|
+
tenantId: textOrUndefined(body['tenant_id'] ?? body['tenantId']) ?? '',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** 异常类型名(日志只记类型,不记消息,避免把凭据/URL 带进日志)。 */
|
|
119
|
+
function errorName(error) {
|
|
120
|
+
return error instanceof Error ? error.name : typeof error;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 把「会抛错的网关实现」的稳定 code 映射成 reason。
|
|
124
|
+
*
|
|
125
|
+
* 本模块自带的出网端口({@link createKnowledgeConfigGateway})**从不抛错**,这一支是为了
|
|
126
|
+
* 容忍接线侧传进来的其它实现(例如将来把本端点加进 `AiRouterClient` 后直接复用
|
|
127
|
+
* `gatewayFor(baseURL)`):按结构化 `code` 字段判别,不 import 对方的类,也不读 message
|
|
128
|
+
* (message 里可能带 URL 原文)。
|
|
129
|
+
*/
|
|
130
|
+
function reasonOfErrorCode(error) {
|
|
131
|
+
const code = error?.code;
|
|
132
|
+
if (typeof code !== 'string')
|
|
133
|
+
return undefined;
|
|
134
|
+
switch (code) {
|
|
135
|
+
case 'SESSION_EXPIRED': return 'unauthenticated';
|
|
136
|
+
case 'KNOWLEDGE_NOT_CONFIGURED': return 'not-configured';
|
|
137
|
+
case 'KNOWLEDGE_UNREACHABLE':
|
|
138
|
+
case 'GATEWAY_UNREACHABLE': return 'unreachable';
|
|
139
|
+
default: return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** 非 200 状态码 → reason(逐字对应契约:401 / 503 / 504 各有确定语义)。 */
|
|
143
|
+
function reasonOfStatus(status) {
|
|
144
|
+
if (status === 401)
|
|
145
|
+
return 'unauthenticated';
|
|
146
|
+
// 网关 503 `KNOWLEDGE_NOT_CONFIGURED`:管理员还没开启知识库(部署状态,不是故障)。
|
|
147
|
+
if (status === 503)
|
|
148
|
+
return 'not-configured';
|
|
149
|
+
// 504:**防御性分类**——本端点不打上游,网关不会回 504;保留它是为了将来换实现时
|
|
150
|
+
// 不会把「上游不可达」读成 error(语义上 504 就是「够不着」)。
|
|
151
|
+
if (status === 504)
|
|
152
|
+
return 'unreachable';
|
|
153
|
+
return 'error';
|
|
154
|
+
}
|
|
155
|
+
/** 视图投影 + 5 分钟内存缓存(无状态依赖,只有一条内存缓存)。 */
|
|
156
|
+
class KnowledgeConfigProvider {
|
|
157
|
+
deps;
|
|
158
|
+
cached;
|
|
159
|
+
constructor(deps) {
|
|
160
|
+
this.deps = deps;
|
|
161
|
+
}
|
|
162
|
+
async config(opts) {
|
|
163
|
+
const force = opts?.force === true;
|
|
164
|
+
let sessionCookie;
|
|
165
|
+
try {
|
|
166
|
+
sessionCookie = await this.deps.resolveSession();
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// 凭据服务读取失败是宿主故障,不是「没登录」:如实报 error,不诱导用户反复重登。
|
|
170
|
+
this.deps.log?.(`hiwork-core: 知识库配置获取失败(${errorName(error)})`);
|
|
171
|
+
return failure('error');
|
|
172
|
+
}
|
|
173
|
+
if (sessionCookie === undefined || sessionCookie === '')
|
|
174
|
+
return failure('unauthenticated');
|
|
175
|
+
if (!force) {
|
|
176
|
+
const hit = this.cached;
|
|
177
|
+
if (hit !== undefined && hit.sessionCookie === sessionCookie && this.now() - hit.at < KNOWLEDGE_CONFIG_TTL_MS) {
|
|
178
|
+
return hit.view;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const gateway = this.deps.gateway();
|
|
182
|
+
// 没有网关地址 = 还没完成登录(baseURL 是登录时写下的),与网络不通归同一档。
|
|
183
|
+
if (gateway === undefined)
|
|
184
|
+
return failure('unreachable');
|
|
185
|
+
let outcome;
|
|
186
|
+
try {
|
|
187
|
+
outcome = await gateway.getMyKnowledgeConfig(sessionCookie);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const mapped = reasonOfErrorCode(error);
|
|
191
|
+
if (mapped !== undefined)
|
|
192
|
+
return failure(mapped);
|
|
193
|
+
this.deps.log?.(`hiwork-core: 知识库配置获取失败(${errorName(error)})`);
|
|
194
|
+
return failure('error');
|
|
195
|
+
}
|
|
196
|
+
if (!outcome.ok) {
|
|
197
|
+
if (outcome.kind === 'network')
|
|
198
|
+
return failure('unreachable');
|
|
199
|
+
if (outcome.kind === 'http')
|
|
200
|
+
return failure(reasonOfStatus(outcome.status));
|
|
201
|
+
return failure('error');
|
|
202
|
+
}
|
|
203
|
+
const view = projectKnowledgeConfig(outcome.body);
|
|
204
|
+
if (view === undefined) {
|
|
205
|
+
// 只记事件与字段名,不记任何值(响应体里带凭据)。
|
|
206
|
+
this.deps.log?.('hiwork-core: 网关返回的 knowledge-config 不完整(缺 base_url / api_key 之一)');
|
|
207
|
+
return failure('error');
|
|
208
|
+
}
|
|
209
|
+
// 只缓存成功结果:拉不到时下一次调用重新出网(一次抖动不该锁住一个 TTL)。
|
|
210
|
+
this.cached = { sessionCookie, view, at: this.now() };
|
|
211
|
+
return view;
|
|
212
|
+
}
|
|
213
|
+
now() {
|
|
214
|
+
return (this.deps.now ?? Date.now)();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 提供 host 侧服务 `hiworkKnowledge`;返回 disposer(交给 `ctx.effect` 管理生命周期,
|
|
219
|
+
* 插件卸载时服务随 fiber 一并注销)。
|
|
220
|
+
*/
|
|
221
|
+
export function provideKnowledgeConfig(ctx, deps) {
|
|
222
|
+
const service = new KnowledgeConfigProvider(deps);
|
|
223
|
+
const dispose = ctx.provide(HIWORK_KNOWLEDGE_SERVICE, service);
|
|
224
|
+
return typeof dispose === 'function' ? dispose : () => { };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 本端点的出网实现(形状与 `AiRouterClient` 的 `request()` 同款:user-agent 带包版本、
|
|
228
|
+
* 会话 cookie 走 `cookie` 头、超时用 `AbortSignal.timeout`)。
|
|
229
|
+
*
|
|
230
|
+
* **不新造鉴权**:只复用 Host 侧已有的登录会话 cookie(由 `resolveSession` 从凭据库取),
|
|
231
|
+
* 没有任何密钥/令牌在这一层生成或保存。
|
|
232
|
+
*
|
|
233
|
+
* **永不抛异常、绝不回显响应原文**:失败收敛成 {@link KnowledgeConfigOutcome},日志与
|
|
234
|
+
* 异常里都不会出现 URL、状态码或响应体——响应体里带的是知识库凭据。
|
|
235
|
+
*
|
|
236
|
+
* `fetchImpl` / `timeoutMs` 仅用于测试注入。
|
|
237
|
+
*/
|
|
238
|
+
export function createKnowledgeConfigGateway(baseURL, opts = {}) {
|
|
239
|
+
const root = baseURL.replace(/\/+$/, '');
|
|
240
|
+
const timeoutMs = opts.timeoutMs ?? KNOWLEDGE_CONFIG_TIMEOUT_MS;
|
|
241
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
242
|
+
return {
|
|
243
|
+
async getMyKnowledgeConfig(sessionCookie) {
|
|
244
|
+
try {
|
|
245
|
+
const res = await doFetch(`${root}${ME_KNOWLEDGE_CONFIG_PATH}`, {
|
|
246
|
+
method: 'GET',
|
|
247
|
+
headers: {
|
|
248
|
+
'user-agent': `hiwork-core/${PKG_VERSION}`,
|
|
249
|
+
cookie: sessionCookie,
|
|
250
|
+
},
|
|
251
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
252
|
+
});
|
|
253
|
+
if (!res.ok)
|
|
254
|
+
return { ok: false, kind: 'http', status: res.status };
|
|
255
|
+
let body;
|
|
256
|
+
try {
|
|
257
|
+
body = await res.json();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// 非 JSON 响应:只报「不可解析」,不把响应片段带进任何返回值或日志。
|
|
261
|
+
return { ok: false, kind: 'invalid' };
|
|
262
|
+
}
|
|
263
|
+
if (body === null || typeof body !== 'object')
|
|
264
|
+
return { ok: false, kind: 'invalid' };
|
|
265
|
+
return { ok: true, body };
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// 超时(AbortSignal.timeout → TimeoutError)/ 连接失败 / DNS:一律按不可达。
|
|
269
|
+
return { ok: false, kind: 'network' };
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
package/lib/pms-client.js
CHANGED
|
@@ -395,6 +395,10 @@ export class PmsClient {
|
|
|
395
395
|
* 必须与其余调用逐字相同**(spec §7:统一 10s、令牌只在有身份时发),所以两条路共用这一处,
|
|
396
396
|
* 而不是各抄一份、将来各自漂移。
|
|
397
397
|
*
|
|
398
|
+
* **重定向自己跟**(`redirect: 'manual'` + {@link redirectTargetOf}):`fetch` 的默认 `follow` 会把
|
|
399
|
+
* 重定向后的请求按 fetch 语义**把 POST 改成 GET**,于是请求落在一个只认 POST 的映射上、拿到一个
|
|
400
|
+
* 「200 + 业务错误信封」,看上去像上游坏了。判据与修法见 {@link PMS_MAX_REDIRECTS}。
|
|
401
|
+
*
|
|
398
402
|
* @param url - 相对路径(拼 {@link PmsClient.baseURL})或**绝对 URL**(原样使用)。
|
|
399
403
|
* @param opts - 方法 / 可选 JSON 体 / 可选 Bearer 令牌。
|
|
400
404
|
* @returns 原始 `Response`:状态码由调用方判定,本方法不把任何状态码当成错误。
|
|
@@ -413,14 +417,25 @@ export class PmsClient {
|
|
|
413
417
|
const init = {
|
|
414
418
|
method: opts.method,
|
|
415
419
|
headers,
|
|
416
|
-
|
|
420
|
+
// 手动跟随:默认的 `follow` 会把重定向后的 POST 降级成 GET(见本方法的抬头)。
|
|
421
|
+
redirect: 'manual',
|
|
417
422
|
};
|
|
418
423
|
if (opts.body !== undefined)
|
|
419
424
|
init.body = JSON.stringify(opts.body);
|
|
420
|
-
|
|
425
|
+
// 截止时刻是**整条链**的预算(10s 不是「每跳各给 10s」)。
|
|
426
|
+
const deadline = Date.now() + this.timeoutMs;
|
|
427
|
+
let target = isAbsoluteUrl(url) ? url : `${this.baseURL}${url}`;
|
|
428
|
+
let response = await fetchWithinDeadline(target, init, deadline);
|
|
429
|
+
for (let hop = 0;; hop += 1) {
|
|
430
|
+
const next = redirectTargetOf(target, response, hop);
|
|
431
|
+
if (next === undefined)
|
|
432
|
+
return response;
|
|
433
|
+
target = next;
|
|
434
|
+
response = await fetchWithinDeadline(target, init, deadline);
|
|
435
|
+
}
|
|
421
436
|
}
|
|
422
437
|
catch {
|
|
423
|
-
// DNS / 连接失败 / TLS / 超时(AbortSignal.timeout
|
|
438
|
+
// DNS / 连接失败 / TLS / 超时(AbortSignal.timeout)/ 超过跳数 —— 一律「上游不可用」,
|
|
424
439
|
// 且**不**把底层异常信息带出去(它可能含内网地址)。
|
|
425
440
|
throw new PmsError('UPSTREAM_UNAVAILABLE', PMS_GENERIC_FAILURE_MESSAGE);
|
|
426
441
|
}
|
|
@@ -486,6 +501,83 @@ function isRecord(value) {
|
|
|
486
501
|
function isAbsoluteUrl(url) {
|
|
487
502
|
return /^https?:\/\//i.test(url);
|
|
488
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* 最多跟随的**重定向跳数**(超过即放弃,把最后那个 3xx 原样交给调用方 → 上游不可用)。
|
|
506
|
+
*
|
|
507
|
+
* 为什么要有这个上限:重定向跟随是本次加固里唯一一处「会替调用方多发一次请求」的地方,
|
|
508
|
+
* 上限定小了(2 跳)足以覆盖真实形态(`http://` 登记 → nginx 302 → `https://` 一跳;
|
|
509
|
+
* 见过的最多是「域名 → 规范域名 → 真实地址」两跳),定大了只会在配错时把断言连打出去。
|
|
510
|
+
*/
|
|
511
|
+
export const PMS_MAX_REDIRECTS = 2;
|
|
512
|
+
/**
|
|
513
|
+
* 会「原样重发」的重定向状态码:301 / 302 / 307 / 308。
|
|
514
|
+
*
|
|
515
|
+
* **303 刻意不在其中**:按 RFC 与 fetch 语义,303 的语义就是「用 GET 去取那个地址」——把它也变成
|
|
516
|
+
* 「保持 POST 重发」等于替服务端改主意。本层宁可把 303 原样交出去(调用方按上游不可用收敛),
|
|
517
|
+
* 也不猜服务端想要哪一种。
|
|
518
|
+
*/
|
|
519
|
+
const METHOD_PRESERVING_REDIRECTS = new Set([301, 302, 307, 308]);
|
|
520
|
+
/**
|
|
521
|
+
* 决定这一跳要不要跟、跟去哪。`undefined` = **到此为止**(把当前这份 `Response` 交给调用方)。
|
|
522
|
+
*
|
|
523
|
+
* 背景(生产实测的故障):平台把服务登记成 `http://pms.hivery.cn/mcp/credential-exchange`,
|
|
524
|
+
* nginx 回 302 到 https,而 `fetch` 默认跟随时会按 fetch 语义**把 POST 改成 GET**——于是请求落在
|
|
525
|
+
* 一个只认 `@PostMapping` 的映射上,应用回「200 + `{"code":500}`」信封,桌面端只看到「待办暂时
|
|
526
|
+
* 拉不到」。所以这里手动跟随,并**保持方法与请求体**。
|
|
527
|
+
*
|
|
528
|
+
* 三条闸(都是「宁可不跟」的方向,因为跟着走的那个请求体里装的是**凭据**:mint 的 body 是 120 秒断言):
|
|
529
|
+
* - **状态码**:只有 {@link METHOD_PRESERVING_REDIRECTS} 里的码才跟;
|
|
530
|
+
* - **主机名必须相同**(端口与 scheme 可变,见下):跨主机名跟随等于把断言发给第三方;
|
|
531
|
+
* - **禁止 https → http 降级**(http → https 升级允许):凭据不该因为一次重定向而落到明文上。
|
|
532
|
+
*
|
|
533
|
+
* 端口为什么允许变:真实拓扑里「对外 443 / 内网 8080」是常态,主机名才是「我是不是在跟同一个部署
|
|
534
|
+
* 说话」的边界;scheme 从 http 升到 https(本次的真实形态)跨端口更常见。
|
|
535
|
+
*
|
|
536
|
+
* @param current - 本次请求实际打的地址(绝对 URL)。
|
|
537
|
+
* @param response - 它的响应(只读 `status` 与 `location`)。
|
|
538
|
+
* @param hop - 已经跟随过几跳(从 0 起)。
|
|
539
|
+
* @returns 下一跳的绝对地址;不跟时 `undefined`。
|
|
540
|
+
*/
|
|
541
|
+
export function redirectTargetOf(current, response, hop) {
|
|
542
|
+
if (!METHOD_PRESERVING_REDIRECTS.has(response.status))
|
|
543
|
+
return undefined;
|
|
544
|
+
if (hop >= PMS_MAX_REDIRECTS)
|
|
545
|
+
return undefined;
|
|
546
|
+
const location = response.headers.get('location');
|
|
547
|
+
if (location === null || location.trim() === '')
|
|
548
|
+
return undefined;
|
|
549
|
+
let from;
|
|
550
|
+
let next;
|
|
551
|
+
try {
|
|
552
|
+
from = new URL(current);
|
|
553
|
+
// 相对 `Location` 按当前地址解析(HTTP 允许)。
|
|
554
|
+
next = new URL(location.trim(), current);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
if (next.protocol !== 'https:' && next.protocol !== from.protocol)
|
|
560
|
+
return undefined;
|
|
561
|
+
if (next.hostname !== from.hostname)
|
|
562
|
+
return undefined;
|
|
563
|
+
return next.toString();
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* 一次 `fetch`,但超时按**整条链共用的截止时刻**算(含重定向后的那些跳)。
|
|
567
|
+
*
|
|
568
|
+
* 为什么不是每跳各给 {@link PmsClientOptions.timeoutMs}:spec §7 冻结的是「统一 10s 超时」,
|
|
569
|
+
* 而一次取数最多可能走三跳;每跳各给 10s 会把面板的转圈时间变成 30s。
|
|
570
|
+
*
|
|
571
|
+
* @param target - 绝对地址。
|
|
572
|
+
* @param init - 方法 / 头 / 体(**同一个 init 对象复用于每一跳**,方法与体因此天然一致)。
|
|
573
|
+
* @param deadline - 整条链的截止时刻(epoch ms)。
|
|
574
|
+
*/
|
|
575
|
+
async function fetchWithinDeadline(target, init, deadline) {
|
|
576
|
+
const remaining = deadline - Date.now();
|
|
577
|
+
if (remaining <= 0)
|
|
578
|
+
throw new Error('pms request deadline exceeded');
|
|
579
|
+
return await fetch(target, { ...init, signal: AbortSignal.timeout(remaining) });
|
|
580
|
+
}
|
|
489
581
|
/**
|
|
490
582
|
* 兑换响应里的 `expiresAt`(spec §4.1b 的 ISO 串)**原样透传**;缺失 / 不是能解析的时间串时退回兜底时刻。
|
|
491
583
|
*
|
|
@@ -286,6 +286,23 @@ export interface GatewayRelayContent {
|
|
|
286
286
|
revision: number;
|
|
287
287
|
bytes?: Uint8Array;
|
|
288
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* GET /api/me/voice-config 响应。
|
|
291
|
+
*
|
|
292
|
+
* 网关线上的字段是 snake_case `max_audio_seconds`,**在这一层就转成 camelCase**:
|
|
293
|
+
* Host 频道、浏览器半边与测试都只认 `maxDurationSeconds` 一种写法,免得同一个概念
|
|
294
|
+
* 在链路里换两次名字。
|
|
295
|
+
*/
|
|
296
|
+
export interface GatewayVoiceConfig {
|
|
297
|
+
/** 本部署 + 本账号是否可用语音识别(管理员开关)。 */
|
|
298
|
+
enabled: boolean;
|
|
299
|
+
/** 单次录音时长上限(秒):录音端据此自动停录,别让用户录到被网关 413 拒掉。 */
|
|
300
|
+
maxDurationSeconds: number;
|
|
301
|
+
}
|
|
302
|
+
/** POST /api/me/voice-transcribe 响应:识别出的文本(可能为空串,即「没听到人话」)。 */
|
|
303
|
+
export interface GatewayVoiceTranscription {
|
|
304
|
+
text: string;
|
|
305
|
+
}
|
|
289
306
|
/**
|
|
290
307
|
* POST /api/me/mcp-assertions 响应:一张 **120 秒**的身份断言(RS256 JWT)+
|
|
291
308
|
* 去哪换令牌。断言本身不是凭据(换不成令牌就是废纸),所以它可以出现在这里;
|
|
@@ -627,6 +644,50 @@ export declare class AiRouterClient implements GatewayClient {
|
|
|
627
644
|
* 那里才决定「这一份算不算成功拉到的清单」。
|
|
628
645
|
*/
|
|
629
646
|
getMyWorkbenchApps(sessionCookie: string): Promise<GatewayWorkbenchAppsView>;
|
|
647
|
+
/**
|
|
648
|
+
* GET /api/me/voice-config:当前账号的语音识别开关与录音时长上限。
|
|
649
|
+
*
|
|
650
|
+
* 与每日一句的 `getMyDailyQuote` 同款,**这两个方法不在 {@link GatewayClient} 接口上**:
|
|
651
|
+
* 那个接口是编排层的端口,「语音识别」与它无关,加进去只会逼着每处编排层假网关
|
|
652
|
+
* (tests/orchestration.spec.ts 里一堆)都补两个用不到的桩。频道侧改用结构子集
|
|
653
|
+
* `VoiceGatewayPort`(见 `src/voice-rpc.ts`),与 usage / daily 三处一致。
|
|
654
|
+
*
|
|
655
|
+
* 与 {@link getMyMcpAssignments} 同样的理由**不复用 `getMeJson`**:那条路径把 403 映射成
|
|
656
|
+
* `NO_KEY_ASSIGNED`(「尚未被分配虚拟 KEY」),而本端点的鉴权只有会话——一个还没被分配
|
|
657
|
+
* Key 的账号照样应该有语音识别(分能力与分 Key 是两件事)。沿用它的文案会把「没有 Key」
|
|
658
|
+
* 错报成原因。
|
|
659
|
+
*
|
|
660
|
+
* 本端点契约**恒 200**(开关开与关都是 200 + `enabled` 布尔),因此除 401 以外没有任何
|
|
661
|
+
* 需要单独解释的拒绝语义:403 / 404 / 5xx / 反代错误页一律收敛成 `GATEWAY_UNREACHABLE`。
|
|
662
|
+
*
|
|
663
|
+
* 形状收敛(**本层负责**,消费方拿到的永远是一个合法的 {@link GatewayVoiceConfig}):
|
|
664
|
+
* 网关线上是 snake_case `max_audio_seconds`,这里转 camelCase;字段缺失 / 类型不符
|
|
665
|
+
* 按「禁用 + 60 秒」处理——配置读不出来时话筒本就该消失,用一个 undefined 让消费方
|
|
666
|
+
* 去猜是把形状问题推给下游。真正的「读不到就回落」再往上一层(`src/voice-rpc.ts`)。
|
|
667
|
+
*/
|
|
668
|
+
getMyVoiceConfig(sessionCookie: string): Promise<GatewayVoiceConfig>;
|
|
669
|
+
/**
|
|
670
|
+
* POST /api/me/voice-transcribe:把一段录音(base64)交给网关转发上游做识别。
|
|
671
|
+
*
|
|
672
|
+
* 状态码映射与服务端 handler 一一对应,**不许合并**:
|
|
673
|
+
* - 401 = 会话过期(唯一一条能让人做点什么——重新登录——的拒绝);
|
|
674
|
+
* - 503 = 网关自己发的「功能不可用」,再按响应体 `code` 细分:
|
|
675
|
+
* `VOICE_NOT_CONFIGURED`(部署没配上游)/ `VOICE_DISABLED`(管理员把开关关了)。
|
|
676
|
+
* 两者给的提示不同(该找管理员配服务 vs 该找管理员开开关)。
|
|
677
|
+
* ⚠️ 响应体没有 `code`(非 JSON / 反代错误页)也归 `VOICE_DISABLED`——这条路由的
|
|
678
|
+
* 503 语义就是「没开」,而**上游故障走 502**(见下),不会伪装成 503;
|
|
679
|
+
* - 429 / 413 / 400 = 配额 / 超长 / 请求体不合法,三种不同的事,分开映射(合并后
|
|
680
|
+
* 客户端只会看到一句「识别失败」,413 的人不知道该录短一点);
|
|
681
|
+
* - 其余(含 502 = 上游出错)→ `VOICE_UPSTREAM`。
|
|
682
|
+
*
|
|
683
|
+
* message 一律取网关响应体里的 `message`(网关侧是冻结的中文文案,会直接展示给用户);
|
|
684
|
+
* 响应体不是合法 JSON / 缺 message 时退回本地兜底文案。**绝不把上游 / 网关原文回显**:
|
|
685
|
+
* 读取响应体走 {@link readVoiceError}(解析失败即空对象),与 `parseMeJson` 不回显原文同源。
|
|
686
|
+
*
|
|
687
|
+
* 超时**单独放宽到 60s**(见 {@link VOICE_TRANSCRIBE_TIMEOUT_MS}):这条要上传录音 +
|
|
688
|
+
* 等上游识别,构造时的 10s 默认值在这里是误报源。
|
|
689
|
+
*/
|
|
690
|
+
transcribeVoice(sessionCookie: string, audioBase64: string): Promise<GatewayVoiceTranscription>;
|
|
630
691
|
/**
|
|
631
692
|
* /api/me 自助端点的 GET 公共路径:网络异常 → GATEWAY_UNREACHABLE,
|
|
632
693
|
* 401/403/400 映射为稳定 code,响应体非 JSON 时不把原文带进异常。
|
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hero 问候语轮换(每日卡片的同页组件,自 hiwork-dsh-ui 静态版演化):
|
|
3
|
-
* 官方口号「探索未至之境 / Into the Unknown」替换为「HiWork,+ 候选池随机一条」,
|
|
4
|
-
* 前缀固定保留,候选每次进入 hero 页时随机切换(排除上一条,不重复);
|
|
5
|
-
* 同时隐藏鱼形品牌标记与「预览版」徽标。
|
|
6
|
-
*
|
|
7
|
-
* 实现:宿主标题节点用 CSS 归零隐藏,问候语是插件自有(data-hiwork-hero-greeting
|
|
8
|
-
* 标记)的相邻 span,由观察器幂等插入——不搬移/不改写宿主节点,宿主重建 hero 时自动
|
|
9
|
-
* 重挂并换下一条,非 hero 阶段自动清残留。样式 id 改用 hiwork 前缀:本模块已是
|
|
10
|
-
* dsh-ui 静态 ::after 版的超集,两版并存会双份文案。
|
|
11
|
-
*/
|
|
12
1
|
/** 问候候选池(前缀 HiWork,之后的部分),措辞逐字保留产品定稿。 */
|
|
13
2
|
export declare const HERO_GREETINGS: readonly string[];
|
|
14
3
|
/** 固定前缀:所有候选共用,轮换的只是后半句。 */
|
|
@@ -130,3 +130,31 @@ export declare const brandCss: string;
|
|
|
130
130
|
* 由 client 入口 apply() 注入一次(带 id 防重)。
|
|
131
131
|
*/
|
|
132
132
|
export declare const interactionCss: string;
|
|
133
|
+
/**
|
|
134
|
+
* 手写注入的 <style> 必须带 DSH 的归属标记(`data-plugin`)。
|
|
135
|
+
*
|
|
136
|
+
* DSH 的 client module system 用这个属性记账:每装载(materialize)一个插件 bundle,
|
|
137
|
+
* 就把文档里**所有没有** `data-plugin` 的 <style> 记到当时正在装载的那个插件名下
|
|
138
|
+
* (`claimStyles`,`dsh-client-modules/src/client/system.ts`);插件被替换、重建或停用
|
|
139
|
+
* 时按 `removeOwnedStyles(id)`(同包 `entry-lifecycle.ts`)删掉「记在它名下」的全部
|
|
140
|
+
* <style>。裸注入的样式会被随后第一个装载的插件顺手认领,再随它被替换/停用而**连带
|
|
141
|
+
* 删除**,而本插件此刻并没有重新 apply —— 表现就是这部分样式静默消失、只有刷新页面
|
|
142
|
+
* 才恢复(2026-09-23 实测:hiwork-knowledge 重建后,本插件两个样式表被一起删掉,实测
|
|
143
|
+
* 归属曾被记到 `@deepseek-ai/dsh-client-ui-deliverables` 名下)。
|
|
144
|
+
* 标记形态与官方 client bundle 预设一致(`tsdown.client.ts` 的 styleInjectionModule)。
|
|
145
|
+
*/
|
|
146
|
+
export declare const CORE_STYLE_OWNER = "hiwork-core";
|
|
147
|
+
/** 给本插件注入的 <style> 打归属标记;`cssTagId` 沿用官方预设的 `包名/文件名` 形态。 */
|
|
148
|
+
export declare function ownStyleTag(style: HTMLStyleElement, cssTagId: string): void;
|
|
149
|
+
/**
|
|
150
|
+
* 语音输入(话筒按钮 + 录音浮层)的样式,随本插件一起分发。
|
|
151
|
+
*
|
|
152
|
+
* 类名统一 `hiwv-` 前缀,**不引用宿主内部 CSS 变量**:这些浮层元素是 `document.body`
|
|
153
|
+
* 直挂的,宿主 DOM 结构/变量名跨版本不稳,故自带中性配色 + `prefers-color-scheme` 自适应。
|
|
154
|
+
* 设置分区(`.hiwv-settings*` / `.hiwv-field` / `.hiwv-help`)**不存在**:配置面在网关
|
|
155
|
+
* (管理端「语音识别设置」),桌面端没有任何语音相关的设置项。
|
|
156
|
+
*
|
|
157
|
+
* 注入走 `voice/voice-styles.ts` 的 `ensureVoiceStyle`,**必须**经 {@link ownStyleTag}
|
|
158
|
+
* 打归属标记(见上方事故记录):裸注入会被随后装载的插件认领并连带删除。
|
|
159
|
+
*/
|
|
160
|
+
export declare const voiceCss: string;
|
|
@@ -31,7 +31,19 @@ export type LoginErrorCode = 'AUTH_FAILED' | 'RATE_LIMITED' | 'GATEWAY_UNREACHAB
|
|
|
31
31
|
* 收敛成 `GATEWAY_UNREACHABLE`;工作台的 404 是一个明确的功能状态(spec §10 的降级矩阵),
|
|
32
32
|
* 消费方据此显示「本部署尚未启用工作台」而不是报错。别顺手把两边对齐。
|
|
33
33
|
*/
|
|
34
|
-
| 'WORKBENCH_DISABLED'
|
|
34
|
+
| 'WORKBENCH_DISABLED'
|
|
35
|
+
/** 本部署未启用语音识别(管理员把开关关了):识别时 503(或 503 无 body code)。 */
|
|
36
|
+
| 'VOICE_DISABLED'
|
|
37
|
+
/** 本部署未配置语音识别上游服务(网关侧没有可用 ASR 配置):识别时 503。 */
|
|
38
|
+
| 'VOICE_NOT_CONFIGURED'
|
|
39
|
+
/** 单用户识别频率超限:429。 */
|
|
40
|
+
| 'VOICE_LIMIT_REACHED'
|
|
41
|
+
/** 录音超过网关允许的大小 / 时长上限:413。 */
|
|
42
|
+
| 'VOICE_AUDIO_TOO_LARGE'
|
|
43
|
+
/** 录音数据不合法(base64 损坏 / 请求体越界):400。 */
|
|
44
|
+
| 'VOICE_BAD_REQUEST'
|
|
45
|
+
/** 上游识别服务出错或网关 502 之类的转发失败:其余非 2xx(含 502)。 */
|
|
46
|
+
| 'VOICE_UPSTREAM';
|
|
35
47
|
/** 一个稳定的、可安全展示的登录/刷新错误。 */
|
|
36
48
|
export interface LoginError {
|
|
37
49
|
code: LoginErrorCode;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输入框工具栏的话筒按钮(`conversation.input.right` 槽)。
|
|
3
|
+
*
|
|
4
|
+
* 该槽渲染在发送区右组、**模型选择器之前**(宿主 InputBar 的顺序:right → model →
|
|
5
|
+
* stop/send),正是需求指定的落点。按钮只负责「打开浮层」,识别与插入都在浮层里。
|
|
6
|
+
*
|
|
7
|
+
* 一条约定:**不按「本机有没有配豆包密钥」改 tooltip**——D1 之后配置面整体废除,
|
|
8
|
+
* 能不能用由 `status` 端点说了算,而 `enabled !== true` 时本按钮根本不会被注册
|
|
9
|
+
* (见 `voice/entry.ts` 的门禁)。
|
|
10
|
+
*/
|
|
11
|
+
import { type ReactElement } from 'react';
|
|
12
|
+
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
13
|
+
/** 组件注入面:打开录音浮层(会话 id 由槽位在渲染时注入,见 entry)。 */
|
|
14
|
+
export interface MicButtonFace {
|
|
15
|
+
/** 打开浮层;`sessionId` 是槽位注入的会话 id(拿不到为 undefined,插入时退到当前会话)。 */
|
|
16
|
+
open: (sessionId?: string) => void;
|
|
17
|
+
}
|
|
18
|
+
/** 槽位组件 props:框架标准件 + 注入面(`conversation.input.right` 是 session 作用域 list 槽)。 */
|
|
19
|
+
export type MicButtonProps = PropsRuntime<'conversation.input.right'> & InjectFace<MicButtonFace>;
|
|
20
|
+
/**
|
|
21
|
+
* 话筒按钮;宿主通过 inject 合成 `open`。
|
|
22
|
+
* @param props - 槽位 props(只消费注入面的 `open`)。
|
|
23
|
+
*/
|
|
24
|
+
export declare function MicButton({ open }: MicButtonProps): ReactElement;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 录音浮层:点击话筒后弹出的「正在录音」交互。
|
|
3
|
+
*
|
|
4
|
+
* 需求约束:
|
|
5
|
+
* - 组件**不能太小**(面板最小 420px、居中、带遮罩);
|
|
6
|
+
* - 必须支持**取消**(ESC / 取消按钮 / 点遮罩),取消即丢弃录音、不写输入框;
|
|
7
|
+
* - 「完成」后上传识别,识别文本**填进输入框草稿,不自动发送**;
|
|
8
|
+
* - 没听清 / 失败时给出原因与出口(再录一次 / 重试识别 / 复制文本),绝不静默。
|
|
9
|
+
*
|
|
10
|
+
* 两条与网关中继相配的约定(设计稿 §5.4 冻结):
|
|
11
|
+
* 1. **时长上限来自 `status` RPC**(`{enabled, maxDurationSeconds}`,见 `voice-data.ts`),
|
|
12
|
+
* 不来自任何本机配置——D1 之后没有「本机密钥配置」这回事了;
|
|
13
|
+
* 2. **失败文案按网关 code 映射**({@link voiceErrorMessageOf}),不把 Host 的
|
|
14
|
+
* `message` 直出;「重试识别」只在可能自愈的 code 上出现(`status.enabled !== true`
|
|
15
|
+
* 与网关不可达走同一句「连接语音服务失败,请稍后重试」,本组件只是兜底,
|
|
16
|
+
* 正常情况下这种状态压根不会注册话筒按钮)。
|
|
17
|
+
*/
|
|
18
|
+
import { type ReactElement } from 'react';
|
|
19
|
+
import type { ComposerInsertResult } from './composer.ts';
|
|
20
|
+
import { type VoiceRuntime } from './voice-data.ts';
|
|
21
|
+
/** 浮层属性(由 `voice/entry.ts` 的浮层控制器组装)。 */
|
|
22
|
+
export interface VoiceOverlayProps {
|
|
23
|
+
/** 取数面:`status`(时长上限 + 能不能用)与 `transcribe`(WAV base64 → 文本)。 */
|
|
24
|
+
runtime: VoiceRuntime;
|
|
25
|
+
/** 关闭浮层(取消或完成后由组件内部调用;React root 的卸载由控制器负责)。 */
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
/** 把识别文本写进会话输入框草稿(entry 用 `composer.ts` 的 `insertDraftText` 组装)。 */
|
|
28
|
+
insertDraft: (text: string) => ComposerInsertResult;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* RMS → 显示电平。语音的裸 RMS 通常只有 0.02~0.2,直接进 `8 + level*46` 的像素映射
|
|
32
|
+
* 几乎看不出跳动(0.1 的响度只比安静高 4px);放大 6 倍再钳到 1,正常说话就能打到
|
|
33
|
+
* 半高以上。导出供测试锁定映射。
|
|
34
|
+
*/
|
|
35
|
+
export declare function rmsToLevel(rms: number): number;
|
|
36
|
+
/** 录音浮层组件(由 `voice/entry.ts` 的 overlay 控制器挂载/卸载)。 */
|
|
37
|
+
export declare function VoiceOverlay(props: VoiceOverlayProps): ReactElement;
|