hiwork-core 0.0.24 → 0.0.30

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,98 @@
1
+ /**
2
+ * 把一段文本放进**某个会话的输入框**(卡片上的「追问」、页面上的「带进聊天」)。
3
+ *
4
+ * 为什么不是 `conversation.send(text)`:那会立刻排一轮对话发出去,用户没有反悔余地;
5
+ * 而且 `send()` 是**按调用方作用域寻址**的,本插件的 ctx 是 root 作用域,直接调会抛
6
+ * `conversation.send requires a session scope`。放进草稿既符合「带进聊天」的语义,
7
+ * 也把「发不发、要不要改」留给用户。
8
+ *
9
+ * 三个必须照抄的宿主细节(都来自 `hiwork-capabilities/src/client/composer.ts` 的实测结论):
10
+ * 1. **`actx.get('conversation')` 而不是 `actx.conversation`**:后者是属性访问,会撞上
11
+ * cordis 的注入门禁(`cannot get property "conversation" without inject`),而本插件
12
+ * 的顶层 `inject` 里没有 `conversation`(也不该有——那是可选能力);
13
+ * 2. **`sessions.scope(id)` 失败时退到 `sessions.binding(id).ctx`**:两条路在不同宿主
14
+ * 版本上互为兜底,且都要 try/catch——读属性本身就可能抛;
15
+ * 3. **读快照与插入之间不得有 await**:`draftRev` 是 CAS 票据,中间让出一次事件循环,
16
+ * 草稿被别人改过就会必然被拒。所以本模块整体是**同步**的。
17
+ *
18
+ * 本文件零依赖:bundle 必须自包含。
19
+ */
20
+ /** 草稿里的引用 chip 占位(结构契约)。 */
21
+ export interface ComposerOccurrence {
22
+ readonly offset: number;
23
+ readonly length: number;
24
+ }
25
+ /** 输入机状态里本模块用到的部分(结构契约)。 */
26
+ export interface ComposerInputState {
27
+ /** 剪贴板投影:chip 展开成各自的 `clipboardText`。 */
28
+ readonly draft: string;
29
+ /** 单调递增的编辑器版本号,插入时用作 CAS 票据。 */
30
+ readonly draftRev: number;
31
+ /**
32
+ * 提交相位;旧宿主可能没有这个字段,缺省视为可写。
33
+ *
34
+ * 写成 `| undefined`(而不是裸 `?`)是本仓库 tsconfig 的 `exactOptionalPropertyTypes`
35
+ * 要求:裸可选成员不能显式传 `undefined`,而调用方/测试需要能这么写。
36
+ */
37
+ readonly phase?: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | undefined;
38
+ /** 草稿里的引用 chip,按 offset 升序。 */
39
+ readonly occurrences?: readonly ComposerOccurrence[] | undefined;
40
+ }
41
+ /** 会话作用域的输入机面(`conversation.input.for(actx)` 的返回值)。 */
42
+ export interface ComposerInsertionTarget {
43
+ readonly state: {
44
+ getSnapshot(): ComposerInputState;
45
+ };
46
+ /** 把普通文本替换进 pick-time span;旧宿主可能没有这个方法。 */
47
+ insertText?(text: string, span: {
48
+ start: number;
49
+ end: number;
50
+ draftRev: number;
51
+ }, keepCompleting?: boolean): boolean;
52
+ }
53
+ /** `actx.get('conversation')` 的最小结构面。 */
54
+ export interface ConversationAccess {
55
+ readonly input: {
56
+ for(actx: unknown): ComposerInsertionTarget | undefined;
57
+ };
58
+ }
59
+ /** `ctx.sessions` 的最小面(`scope` 与 `binding` 互为兜底)。 */
60
+ export interface SessionScopeAccess {
61
+ scope?(id: string): unknown;
62
+ binding?(id: string): {
63
+ ctx?: unknown;
64
+ } | undefined;
65
+ list?: {
66
+ getSnapshot(): {
67
+ readonly current?: string | undefined;
68
+ };
69
+ };
70
+ }
71
+ /** 插入结果。失败一律**返回原因**,不抛、不静默——静默会让按钮看起来是坏的。 */
72
+ export type ComposerInsertResult = {
73
+ readonly ok: true;
74
+ } | {
75
+ readonly ok: false;
76
+ readonly reason: 'no-target' | 'busy' | 'rejected';
77
+ };
78
+ /**
79
+ * 探测草稿在**坐标投影**里的末尾偏移。
80
+ *
81
+ * 用 `occurrences` 而不是 `draft.length`:chip 在坐标里只占 1 个位置,而 `draft` 里是
82
+ * 展开后的全文。拿错投影会被宿主直接拒掉。
83
+ * @param state - 输入机快照。
84
+ * @returns 探测坐标里的草稿末尾偏移。
85
+ */
86
+ export declare function detectTextLength(state: ComposerInputState): number;
87
+ /**
88
+ * 把文本追加进某个会话输入框的草稿末尾。
89
+ *
90
+ * 落点选**末尾**:`/名字 ` 这类文本由宿主自己的触发器敲在光标处,我们追加在末尾不会
91
+ * 抢走用户已经写了一半的话;中间同样不得有 await(见文件头第 3 点)。
92
+ *
93
+ * @param sessions - `ctx.sessions`;缺失时直接判定没有目标。
94
+ * @param text - 要追加的文本。
95
+ * @param sessionId - 槽位注入的会话 id;缺省退到 `sessions.list.current`。
96
+ * @returns 成功或失败原因。
97
+ */
98
+ export declare function insertDraftText(sessions: SessionScopeAccess | undefined, text: string, sessionId?: string): ComposerInsertResult;
@@ -0,0 +1,72 @@
1
+ import { type SessionScopeAccess } from './composer.ts';
2
+ import { type VoiceRpcPort } from './voice-data.ts';
3
+ /** 话筒按钮落在输入框工具栏右组(渲染在模型选择器之前);必须与 package.json 的
4
+ * `dsh.client.inject` 里声明的 `@deepseek-ai/dsh-client-ui-conversation` 对齐。 */
5
+ export declare const VOICE_INPUT_SLOT = "conversation.input.right";
6
+ /** 槽位注册 id(稳定值,供宿主与测试定位)。 */
7
+ export declare const VOICE_INPUT_SLOT_ID = "hiwork-core-voice";
8
+ /** 槽位排序权重(list 槽按 order 升序渲染在右组里)。 */
9
+ export declare const VOICE_INPUT_SLOT_ORDER = 10;
10
+ /** 浮层控制器的容器类名(`document.body` 直挂的那层 div;无样式依赖,仅便于排查)。 */
11
+ export declare const VOICE_ROOT_CLASS = "hiwv-root";
12
+ /**
13
+ * 门禁复问的节拍(毫秒):装配后的第 1..n 次复问间隔,**走完就停**(等 `retryNow`)。
14
+ *
15
+ * 首步短(3s)是刻意的:最常见的场景是「应用刚起来、网关会话还没热」。之后拉长只是
16
+ * 为了兜住网关短时不通。**不能无限轮询**——每次复问都是 Host 打网关的一次真 HTTP
17
+ * (`getMyVoiceConfig`),而「本部署没开语音」是完全正常的状态,无限轮询等于给网关
18
+ * 加一份与用户无关的常驻负载。
19
+ */
20
+ export declare const VOICE_GATE_RETRY_DELAYS_MS: readonly number[];
21
+ /** 门禁复问的定时器面(单测注入假实现——真定时器会让用例要么等 4 分钟、要么靠假时钟)。 */
22
+ export interface VoiceGateTimer {
23
+ /** `ms` 毫秒后调 `fn`;返回取消函数(已取消 / 已触发后重复调用是空操作)。 */
24
+ after(ms: number, fn: () => void): () => void;
25
+ }
26
+ /** 话筒按钮的注入面:`open` 已经绑好会话 id,组件只需调用它。 */
27
+ export interface VoiceMicInject {
28
+ open: (sessionId?: string) => void;
29
+ }
30
+ /** 安装依赖(由 `src/client/index.ts` 组装;测试用假实现注入)。 */
31
+ export interface VoiceEntryDeps {
32
+ /** 宿主 loopback RPC(`ctx.connection.rpc`)。 */
33
+ rpc: VoiceRpcPort;
34
+ /**
35
+ * **现取**会话服务(`() => ctx.get('sessions')`);缺失时草稿插入一律判定为「没有打开的会话」。
36
+ *
37
+ * 必须是取用函数而不是服务本身:`sessions` 不在插件的 `inject` 列表里(那是可选能力),
38
+ * 实测**装配期它还没上线**——`ctx.get('sessions')` 返回 `undefined`,而服务稍后才注册。
39
+ * 装配期快照会把 `undefined` 冻进浮层控制器,于是每次录音都在填草稿那步失败并报
40
+ * 「没有找到打开的会话输入框」(真机复现:装配期 `undefined`、点击时已就绪)。现取即可。
41
+ */
42
+ resolveSessions: () => SessionScopeAccess | undefined;
43
+ /** 注册话筒按钮:index.ts 用 `ctx.slots.inject` + `ctx.slots.register` 实现,测试用 spy。 */
44
+ registerMic: (inject: VoiceMicInject) => void;
45
+ /** 归属文档;缺省取全局 `document`(Node 环境为 undefined 时不挂浮层)。 */
46
+ document?: Document | undefined;
47
+ /** 安装作废信号(插件卸载 / HMR 重建时 abort):作废后绝不注册话筒按钮。 */
48
+ signal?: AbortSignal | undefined;
49
+ /** 复问定时器面;缺省用真定时器(测试注入假实现,见 tests/voice-overlay.spec.tsx)。 */
50
+ timer?: VoiceGateTimer | undefined;
51
+ }
52
+ /** 安装结果句柄:`enabled` 是 status 门禁的**当前**结论,`dispose` 关闭浮层并停掉复问。 */
53
+ export interface VoiceEntryHandle {
54
+ /** 取值器而非快照:后台复问成功后由 false 变 true(调用方按需现读)。 */
55
+ readonly enabled: boolean;
56
+ /**
57
+ * 立刻复问一次 status,并**重新起一轮**复问表。给「登录 / 换账号 / 换 Key」这类外部
58
+ * 事件用:它们能让门禁由关变开,而复问表只覆盖装配后的几分钟。已启用或已作废时是
59
+ * 空操作(幂等)。
60
+ */
61
+ retryNow(): void;
62
+ dispose(): void;
63
+ }
64
+ /**
65
+ * 安装语音输入:注入样式 → 问 status → 可用才注册话筒按钮;问不上按表复问。
66
+ *
67
+ * **首个 status 出结果就 resolve**(不等复问表走完):调用方拿到的是一份可用的句柄,
68
+ * 复问在后台继续。等表走完再 resolve 会把调用方吊住最长达 4.7 分钟,插件装载也会跟着卡。
69
+ * @param deps - 安装依赖。
70
+ * @returns 安装句柄(`enabled` 是**当时**的门禁结论,复问成功后会变,见该字段说明)。
71
+ */
72
+ export declare function installVoiceInput(deps: VoiceEntryDeps): Promise<VoiceEntryHandle>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * 浏览器侧麦克风采集与重采样(改编自 dsh 实验区 client-ui-voice-input 的 audio.ts,
3
+ * 采集/重采样/编码逻辑保持同构,去掉了对其 Remote 面的耦合)。
4
+ *
5
+ * 管线:getUserMedia → MediaRecorder(整段) → decodeAudioData →
6
+ * OfflineAudioContext 重采样 16k 单声道 → PCM16 WAV → base64。
7
+ * 豆包「录音文件识别极速版」吃整段音频,所以这里录完再一次性编码,不做流式。
8
+ */
9
+ /** 采集失败种类:消息由 UI 层展示(本模块只带 kind)。 */
10
+ export declare class RecordingError extends Error {
11
+ readonly kind: 'unavailable' | 'permission' | 'empty' | 'cancelled' | 'interrupted';
12
+ constructor(kind: 'unavailable' | 'permission' | 'empty' | 'cancelled' | 'interrupted');
13
+ }
14
+ /**
15
+ * 把单声道浮点采样编码成 PCM16 WAV(16 kHz、单声道、16 bit、little-endian)。
16
+ * @param samples - 重采样后的 16k 单声道采样。
17
+ */
18
+ export declare function encodeWave(samples: Float32Array): Uint8Array<ArrayBuffer>;
19
+ /** WAV 字节 → base64(无 data URL 前缀;RPC JSON 载荷用)。 */
20
+ export declare function audioBase64(bytes: Uint8Array): string;
21
+ /**
22
+ * 判断本模块产出的 WAV(16k 单声道 PCM16)是否**接近全静音**。
23
+ *
24
+ * macOS 在 TCC 层拒掉麦克风权限时不报错,而是给一条内容全零的流——录音「成功」、
25
+ * 上传「成功」,服务端只能回「没有识别到有效语音」,用户被误导去查网络/密钥。
26
+ * 停录后就地检查,能把「系统权限被拒」和「真的没说话」在客户端分开。
27
+ * @param threshold - 采样绝对阈值(0~32767),低于它视为静音。
28
+ */
29
+ export declare function isSilentWav(bytes: Uint8Array, threshold?: number): boolean;
30
+ /** 一次麦克风采集会话;含「授权弹窗还没落定用户就点了取消」的竞态处理。 */
31
+ export declare class Recording {
32
+ private readonly onDispose;
33
+ private stream;
34
+ private recorder;
35
+ private context;
36
+ private analyser;
37
+ private samples;
38
+ private chunks;
39
+ private readonly lifetime;
40
+ private disposal;
41
+ constructor(onDispose: () => void);
42
+ /**
43
+ * 申请麦克风并开始录音。
44
+ * @param onError - 采集途中的失败回调(在异步资源释放完成前触发)。
45
+ */
46
+ start(onError?: (error: RecordingError) => void): Promise<void>;
47
+ /** 读实时麦克风电平(RMS,0~1 粗略值);不在采集态时恒为 0。 */
48
+ amplitude(): number;
49
+ /**
50
+ * 结束录音并重采样成 WAV。
51
+ * @param maxDurationSeconds - 截掉超出上限的部分(定时器漂移保护)。
52
+ */
53
+ stop(maxDurationSeconds: number): Promise<Uint8Array<ArrayBuffer>>;
54
+ /** 释放采集资源并作废未完成的授权竞态;重复调用安全。 */
55
+ dispose(): Promise<void>;
56
+ private release;
57
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 语音输入的客户端取数层:经 `/hiwork-voice` loopback RPC 问**网关中继**两件事——
3
+ * 「这台机器能不能用语音输入、单次最长多久」(`status`)与「把这段 WAV 转成文字」
4
+ * (`transcribe`)。
5
+ *
6
+ * 铁律(对齐 `usage-data.ts` / `daily-data.ts`):
7
+ * - 客户端**不持有**任何豆包凭据,也**不直接 fetch 火山**——录音以 WAV base64 经 RPC 送
8
+ * Host,由 Host 上传识别;网关侧的会话 cookie 同样只活在 Host 半边;
9
+ * - 信封 `{ok:true,value}` / `{ok:false,error:{code,message,details}}`:成功解出 value,
10
+ * 失败抛 {@link VoiceRpcError},**code 原样保留**——浮层文案按 code 映射
11
+ * ({@link voiceErrorMessageOf}),不去猜 message 里的措辞;
12
+ * - 形状不合法(不是信封、字段类型不对)一律抛 `internal`,绝不把未知结构当数据渲染。
13
+ *
14
+ * client bundle 纯度:本文件不 import 任何 `@deepseek-ai/*` 运行时值,也不 import Host 代码
15
+ * (`src/voice-rpc.ts` 的频道/端点/载荷形状在这里按结构重声明,字段名与冻结契约一致)。
16
+ */
17
+ /** RPC 频道;必须与 Host `src/voice-rpc.ts` 的频道常量一致(由测试锁定)。 */
18
+ export declare const VOICE_CHANNEL = "/hiwork-voice";
19
+ /** 端点白名单;必须与 Host 的 `VOICE_RPC_ENDPOINTS` 一致(由测试锁定)。 */
20
+ export declare const VOICE_ENDPOINTS: readonly ["status", "transcribe"];
21
+ export type VoiceEndpoint = (typeof VOICE_ENDPOINTS)[number];
22
+ /**
23
+ * 单次时长上限的缺省值(秒)。
24
+ *
25
+ * 冻结契约:Host 在 `status` 失败时也回 `{enabled:false, maxDurationSeconds:60}`,
26
+ * 所以这个值只在**形状非法**时兜底——浮层绝不能因为一个坏字段就没有上限。
27
+ */
28
+ export declare const VOICE_DEFAULT_MAX_DURATION_SECONDS = 60;
29
+ /** 浮层错误文案(**逐字冻结**,见迁移 spec 的 code → 文案映射表)。 */
30
+ export declare const VOICE_ERROR_TEXT_SESSION_EXPIRED = "\u767B\u5F55\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u540E\u518D\u8BD5";
31
+ export declare const VOICE_ERROR_TEXT_LIMIT_REACHED = "\u4ECA\u5929\u7684\u8BED\u97F3\u8BC6\u522B\u6B21\u6570\u5DF2\u7528\u5B8C\uFF0C\u660E\u5929\u518D\u6765";
32
+ export declare const VOICE_ERROR_TEXT_AUDIO_TOO_LARGE = "\u8FD9\u6BB5\u5F55\u97F3\u592A\u957F\u4E86\uFF0C\u8BF7\u5206\u6BB5\u8BF4\u5B8C";
33
+ export declare const VOICE_ERROR_TEXT_UPSTREAM = "\u8BED\u97F3\u8BC6\u522B\u670D\u52A1\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
34
+ /** 其余 code 与**网关不可达**共用的一句(`enabled:false` 也走它,见下方 status 契约)。 */
35
+ export declare const VOICE_ERROR_TEXT_UNREACHABLE = "\u8FDE\u63A5\u8BED\u97F3\u670D\u52A1\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
36
+ /** 信封不是信封 / value 形状非法时的哨兵消息(用户可读的兜底在映射层)。 */
37
+ export declare const VOICE_INVALID_RESPONSE_TEXT = "\u8BED\u97F3\u670D\u52A1\u8FD4\u56DE\u4E86\u65E0\u6548\u54CD\u5E94\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
38
+ /** 宿主 loopback RPC 调用面(结构兼容 `ctx.connection.rpc`)。 */
39
+ export interface VoiceRpcPort {
40
+ call(channel: string, endpoint: string, payload: unknown, signal?: AbortSignal): Promise<unknown>;
41
+ }
42
+ /** 带稳定 code 的语音 RPC 错误(`message` 由 Host 消毒,可直接展示,但浮层走 code 映射)。 */
43
+ export declare class VoiceRpcError extends Error {
44
+ readonly code: string;
45
+ constructor(code: string, message: string);
46
+ }
47
+ /**
48
+ * 解开 DSH Connection 的结果信封。
49
+ * 宿主返回 `{ok:true,value}` / `{ok:false,error:{code,message,details}}`;形状不合法时抛
50
+ * `internal`,绝不把未知结构当数据渲染。
51
+ * @param value - `rpc.call` 的原始返回值。
52
+ * @returns 成功分支的 value。
53
+ */
54
+ export declare function unwrapVoiceResult<T>(value: unknown): T;
55
+ /** `status` 端点视图:网关中继能不能用 + 单次录音时长上限。 */
56
+ export interface VoiceStatusView {
57
+ /** `false`(含网关不可达)时**不渲染话筒按钮**——见 `voice/entry.ts` 的门禁。 */
58
+ readonly enabled: boolean;
59
+ readonly maxDurationSeconds: number;
60
+ }
61
+ /** `transcribe` 端点视图;`text` 可能是空串(没听清),由调用方决定怎么提示。 */
62
+ export interface VoiceTranscriptView {
63
+ readonly text: string;
64
+ }
65
+ /** 组件消费的最小取数面(便于视图与测试注入假实现)。 */
66
+ export interface VoiceRuntime {
67
+ /** 取网关中继状态;形状非法按 `internal` 抛错(调用方各自降级)。 */
68
+ status(): Promise<VoiceStatusView>;
69
+ /** 送一段 WAV base64 转写;`signal` 取消时透传 AbortError。 */
70
+ transcribe(audioBase64: string, signal?: AbortSignal): Promise<VoiceTranscriptView>;
71
+ }
72
+ /**
73
+ * 归一 `status` 响应:`enabled` 只认**严格 true**(缺省 / 非布尔一律视为不可用),
74
+ * `maxDurationSeconds` 只认有限正数,否则回落到 {@link VOICE_DEFAULT_MAX_DURATION_SECONDS}。
75
+ * 形状完全不是对象时抛 `internal`。
76
+ * @param value - 已解信封的 value。
77
+ */
78
+ export declare function parseVoiceStatus(value: unknown): VoiceStatusView;
79
+ /** 归一 `transcribe` 响应:`text` 必填字符串(可为空串),否则 `internal`。 */
80
+ export declare function parseVoiceTranscript(value: unknown): VoiceTranscriptView;
81
+ /**
82
+ * 网关 code → 浮层文案(**逐字冻结**)。
83
+ *
84
+ * `SESSION_EXPIRED`(登录过期)、`VOICE_LIMIT_REACHED`(当日次数用尽)、
85
+ * `VOICE_AUDIO_TOO_LARGE`(单段太长)、`VOICE_UPSTREAM`(上游挂了)各自一句;
86
+ * **其余一切 code 以及网关不可达**共用「连接语音服务失败,请稍后重试」——
87
+ * 用户不需要知道是哪一层断的,只需要知道可以再试。
88
+ * @param error - 任意异常(非 {@link VoiceRpcError} 一律归入兜底文案)。
89
+ */
90
+ export declare function voiceErrorMessageOf(error: unknown): string;
91
+ /**
92
+ * 这次失败是否值得保留录音并给「重试识别」:只有可能自愈的那几类才值得
93
+ * (登录过期 / 用量上限 / 音频超长 / 参数错 / 已取消——重试必然再失败一次)。
94
+ * @param error - transcribe 抛出的异常。
95
+ */
96
+ export declare function voiceErrorRetryable(error: unknown): boolean;
97
+ /**
98
+ * 创建语音输入运行时。
99
+ * @param rpc - 宿主 loopback RPC(`ctx.connection.rpc`)。
100
+ */
101
+ export declare function createVoiceRuntime(rpc: VoiceRpcPort): VoiceRuntime;
@@ -0,0 +1,9 @@
1
+ /** 注入 <style> 的固定 id,防止重复注入(HMR 后也只剩一份)。 */
2
+ export declare const VOICE_STYLE_ID = "hiwork-core-voice-styles";
3
+ /** 归属标记里的 css 标识,沿用官方客户端预设的 `包名/文件名` 形态。 */
4
+ export declare const VOICE_STYLE_TAG_ID = "hiwork-core/voice.css";
5
+ /**
6
+ * 注入语音输入样式;幂等(同 id 已存在则原样返回,不带标记的旧节点补上标记)。
7
+ * @param doc - 目标文档(测试可传仿真 DOM)。
8
+ */
9
+ export declare function ensureVoiceStyle(doc: Document): void;
@@ -12,7 +12,9 @@ import { type CredentialsPort, type DefaultModelPort, type AiRouterStatePort } f
12
12
  export declare const name = "hiwork-core";
13
13
  /**
14
14
  * 必需服务:`connection` 承载设置页「用量与成本」的 loopback RPC 频道
15
- * (`/hiwork-usage`,见 `src/usage-rpc.ts`);其余三项是既有登录 / 供给依赖。
15
+ * (`/hiwork-usage`,见 `src/usage-rpc.ts`);`web` 承载联网搜索 provider 的注册
16
+ * (`ctx.web.registerSearchProvider`,见 `src/web-search.ts`;dsh-base 基座先于本
17
+ * 插件挂载 dsh-web);其余三项是既有登录 / 供给依赖。
16
18
  */
17
19
  export declare const inject: string[];
18
20
  /** 插件配置 + 运行时状态字段(全部可选;写操作按此 schema 校验)。 */
@@ -0,0 +1,115 @@
1
+ /** cordis 服务名(hiwork-knowledge 的 Host 半边按此名 `ctx.get` 探测)。 */
2
+ export declare const HIWORK_KNOWLEDGE_SERVICE = "hiworkKnowledge";
3
+ /** 网关侧端点(跨仓冻结;与 `/api/me/memory-config` 同一条会话鉴权)。 */
4
+ export declare const ME_KNOWLEDGE_CONFIG_PATH = "/api/me/knowledge-config";
5
+ /** 成功结果的缓存 TTL:5 分钟。 */
6
+ export declare const KNOWLEDGE_CONFIG_TTL_MS: number;
7
+ /** 出网超时(与 `AiRouterClient` 的缺省一致)。 */
8
+ export declare const KNOWLEDGE_CONFIG_TIMEOUT_MS = 10000;
9
+ /**
10
+ * 失败原因(稳定枚举,消费方据此决定提示与自愈策略):
11
+ * - `not-configured`:管理员还没开启知识库(网关 503 `KNOWLEDGE_NOT_CONFIGURED`);
12
+ * - `unauthenticated`:没有可用会话(未登录 / 会话已失效);
13
+ * - `unreachable`:超时、网络不通、网关地址未配置(以及防御性的 504);
14
+ * - `error`:其余非 200、响应不符合契约、宿主内部故障等。
15
+ */
16
+ export type HiworkKnowledgeFailureReason = 'not-configured' | 'unauthenticated' | 'unreachable' | 'error';
17
+ /**
18
+ * 对外视图(camelCase,插件侧唯一接口面):
19
+ * 成功时 `ok: true` 且带 `baseUrl` / `apiKey` / `agentId` / `chatModelId` / `tenantId`
20
+ * (后三个是**永远存在**的字符串,没有就是 `''` —— 插件侧不必再判 undefined);
21
+ * 失败时 `ok: false` 且带 `reason` / `message`,**不带任何半个字段**。
22
+ */
23
+ export interface HiworkKnowledgeConfigView {
24
+ ok: boolean;
25
+ baseUrl?: string;
26
+ apiKey?: string;
27
+ agentId?: string;
28
+ chatModelId?: string;
29
+ tenantId?: string;
30
+ reason?: HiworkKnowledgeFailureReason;
31
+ message?: string;
32
+ }
33
+ /** 本服务对宿主的可调用面(只读、永不抛异常)。 */
34
+ export interface HiworkKnowledgeService {
35
+ /** 取当前登录员工的知识库配置;force=true 绕过缓存(设置页「重新获取配置」与 401 自愈)。 */
36
+ config(opts?: {
37
+ force?: boolean;
38
+ }): Promise<HiworkKnowledgeConfigView>;
39
+ }
40
+ /**
41
+ * 网关侧原始响应(**snake_case**,跨仓冻结契约;本文件是唯一读它的地方)。
42
+ * 仅供文档与类型自证——真正的读取走宽容投影 {@link projectKnowledgeConfig}。
43
+ */
44
+ export interface GatewayKnowledgeConfig {
45
+ base_url: string;
46
+ api_key: string;
47
+ /** 缺失 / 空 = `''`(**不阻止下发**:检索与浏览仍可用,只是问答不可用)。 */
48
+ agent_id?: string;
49
+ chat_model_id?: string;
50
+ tenant_id?: string;
51
+ }
52
+ /**
53
+ * 传输层结果:**失败是数据而不是异常**(本模块不依赖调用方捕获异常来判失败)。
54
+ * - `kind: 'http'`:拿到了非 200 状态码;
55
+ * - `kind: 'network'`:超时 / 连接失败 / DNS / 请求被中断;
56
+ * - `kind: 'invalid'`:200 但响应体不是可用的 JSON 对象(契约破坏)。
57
+ */
58
+ export type KnowledgeConfigOutcome = {
59
+ readonly ok: true;
60
+ readonly body: unknown;
61
+ } | {
62
+ readonly ok: false;
63
+ readonly kind: 'http';
64
+ readonly status: number;
65
+ } | {
66
+ readonly ok: false;
67
+ readonly kind: 'network';
68
+ } | {
69
+ readonly ok: false;
70
+ readonly kind: 'invalid';
71
+ };
72
+ /** 网关客户端的 knowledge-config 子集:只声明本模块真正调用的方法(便于单测打桩计数)。 */
73
+ export interface KnowledgeConfigGatewayPort {
74
+ /** 调 `GET /api/me/knowledge-config`,返回 snake_case 原始响应或一个稳定失败原因。 */
75
+ getMyKnowledgeConfig(sessionCookie: string): Promise<KnowledgeConfigOutcome>;
76
+ }
77
+ /** Host 侧取数依赖:会话解析 + 当前 baseURL 下的网关出网端口。 */
78
+ export interface KnowledgeConfigDeps {
79
+ /**
80
+ * 解析网关会话 cookie。未登录 / 会话缺失返回 undefined。
81
+ * **返回值只在本模块内部使用**(缓存判等 + 出网),绝不出现在视图与日志里。
82
+ */
83
+ resolveSession(): Promise<string | undefined>;
84
+ /** 当前 baseURL 对应的网关出网端口;baseURL 未配置时返回 undefined。 */
85
+ gateway(): KnowledgeConfigGatewayPort | undefined;
86
+ /** Host 日志出口(只记事件与异常类型名,绝不记凭据 / URL / 响应内容)。 */
87
+ log?: ((message: string) => void) | undefined;
88
+ /** 时钟注入(测试用);缺省 `Date.now`。 */
89
+ now?: (() => number) | undefined;
90
+ }
91
+ /** `ctx.provide` 的最小端口(结构兼容 cordis Context,便于单测伪造)。 */
92
+ export interface KnowledgeConfigContext {
93
+ provide(name: string, value?: unknown): unknown;
94
+ }
95
+ /**
96
+ * 提供 host 侧服务 `hiworkKnowledge`;返回 disposer(交给 `ctx.effect` 管理生命周期,
97
+ * 插件卸载时服务随 fiber 一并注销)。
98
+ */
99
+ export declare function provideKnowledgeConfig(ctx: KnowledgeConfigContext, deps: KnowledgeConfigDeps): () => void;
100
+ /**
101
+ * 本端点的出网实现(形状与 `AiRouterClient` 的 `request()` 同款:user-agent 带包版本、
102
+ * 会话 cookie 走 `cookie` 头、超时用 `AbortSignal.timeout`)。
103
+ *
104
+ * **不新造鉴权**:只复用 Host 侧已有的登录会话 cookie(由 `resolveSession` 从凭据库取),
105
+ * 没有任何密钥/令牌在这一层生成或保存。
106
+ *
107
+ * **永不抛异常、绝不回显响应原文**:失败收敛成 {@link KnowledgeConfigOutcome},日志与
108
+ * 异常里都不会出现 URL、状态码或响应体——响应体里带的是知识库凭据。
109
+ *
110
+ * `fetchImpl` / `timeoutMs` 仅用于测试注入。
111
+ */
112
+ export declare function createKnowledgeConfigGateway(baseURL: string, opts?: {
113
+ timeoutMs?: number;
114
+ fetchImpl?: typeof fetch;
115
+ }): KnowledgeConfigGatewayPort;
@@ -299,6 +299,10 @@ export declare class PmsClient {
299
299
  * 必须与其余调用逐字相同**(spec §7:统一 10s、令牌只在有身份时发),所以两条路共用这一处,
300
300
  * 而不是各抄一份、将来各自漂移。
301
301
  *
302
+ * **重定向自己跟**(`redirect: 'manual'` + {@link redirectTargetOf}):`fetch` 的默认 `follow` 会把
303
+ * 重定向后的请求按 fetch 语义**把 POST 改成 GET**,于是请求落在一个只认 POST 的映射上、拿到一个
304
+ * 「200 + 业务错误信封」,看上去像上游坏了。判据与修法见 {@link PMS_MAX_REDIRECTS}。
305
+ *
302
306
  * @param url - 相对路径(拼 {@link PmsClient.baseURL})或**绝对 URL**(原样使用)。
303
307
  * @param opts - 方法 / 可选 JSON 体 / 可选 Bearer 令牌。
304
308
  * @returns 原始 `Response`:状态码由调用方判定,本方法不把任何状态码当成错误。
@@ -315,3 +319,40 @@ export declare class PmsClient {
315
319
  */
316
320
  private call;
317
321
  }
322
+ /**
323
+ * 最多跟随的**重定向跳数**(超过即放弃,把最后那个 3xx 原样交给调用方 → 上游不可用)。
324
+ *
325
+ * 为什么要有这个上限:重定向跟随是本次加固里唯一一处「会替调用方多发一次请求」的地方,
326
+ * 上限定小了(2 跳)足以覆盖真实形态(`http://` 登记 → nginx 302 → `https://` 一跳;
327
+ * 见过的最多是「域名 → 规范域名 → 真实地址」两跳),定大了只会在配错时把断言连打出去。
328
+ */
329
+ export declare const PMS_MAX_REDIRECTS = 2;
330
+ /** {@link redirectTargetOf} 只看这两个字段,避免把整个 `Response` 绑进签名(便于单测与复用)。 */
331
+ export interface RedirectSource {
332
+ readonly status: number;
333
+ readonly headers: {
334
+ get(name: string): string | null;
335
+ };
336
+ }
337
+ /**
338
+ * 决定这一跳要不要跟、跟去哪。`undefined` = **到此为止**(把当前这份 `Response` 交给调用方)。
339
+ *
340
+ * 背景(生产实测的故障):平台把服务登记成 `http://pms.hivery.cn/mcp/credential-exchange`,
341
+ * nginx 回 302 到 https,而 `fetch` 默认跟随时会按 fetch 语义**把 POST 改成 GET**——于是请求落在
342
+ * 一个只认 `@PostMapping` 的映射上,应用回「200 + `{"code":500}`」信封,桌面端只看到「待办暂时
343
+ * 拉不到」。所以这里手动跟随,并**保持方法与请求体**。
344
+ *
345
+ * 三条闸(都是「宁可不跟」的方向,因为跟着走的那个请求体里装的是**凭据**:mint 的 body 是 120 秒断言):
346
+ * - **状态码**:只有 {@link METHOD_PRESERVING_REDIRECTS} 里的码才跟;
347
+ * - **主机名必须相同**(端口与 scheme 可变,见下):跨主机名跟随等于把断言发给第三方;
348
+ * - **禁止 https → http 降级**(http → https 升级允许):凭据不该因为一次重定向而落到明文上。
349
+ *
350
+ * 端口为什么允许变:真实拓扑里「对外 443 / 内网 8080」是常态,主机名才是「我是不是在跟同一个部署
351
+ * 说话」的边界;scheme 从 http 升到 https(本次的真实形态)跨端口更常见。
352
+ *
353
+ * @param current - 本次请求实际打的地址(绝对 URL)。
354
+ * @param response - 它的响应(只读 `status` 与 `location`)。
355
+ * @param hop - 已经跟随过几跳(从 0 起)。
356
+ * @returns 下一跳的绝对地址;不跟时 `undefined`。
357
+ */
358
+ export declare function redirectTargetOf(current: string, response: RedirectSource, hop: number): string | undefined;
@@ -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>;