hiwork-core 0.0.11 → 0.0.17

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.
@@ -1,5 +1,6 @@
1
1
  import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
2
2
  import { type AiRouterCardFace } from './controller.ts';
3
+ import { type PluginUpdatesStore } from './plugin-updates.ts';
3
4
  /**
4
5
  * 侧边栏底部「设置」上方的账号信息行:显示当前登录网关的显示名(网关 LDAP
5
6
  * 中文名,缺省回退登录账号)。「头像 + 用户名」是一个合并钮,hover 高亮与点击
@@ -9,6 +10,15 @@ import { type AiRouterCardFace } from './controller.ts';
9
10
  * 设置入口。行几何(高度/留白/字号/图标尺寸)与 ui-settings-general 的设置触发
10
11
  * 行完全一致。
11
12
  */
12
- export type UserBadgeProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<AiRouterCardFace>;
13
- /** 底部账号信息行。 */
13
+ /**
14
+ * 「插件更新待重启」的取数面(`/hiwork-plugin-updates`,见 `src/client/plugin-updates.ts`)。
15
+ *
16
+ * 单独成一格而**不是**塞进 `AiRouterCardFace`:那个面是登录卡片的控制器,与插件更新无关;
17
+ * 合成面在这里做,注册点只负责把两者拼起来(`src/client/index.ts`)。
18
+ */
19
+ export interface PluginUpdatesFace {
20
+ pluginUpdates: PluginUpdatesStore;
21
+ }
22
+ export type UserBadgeProps = PropsRuntime<'sidebar.footer.action'> & InjectFace<AiRouterCardFace & PluginUpdatesFace>;
23
+ /** 底部账号信息行(宽栏 / 折叠栏共用;插件更新提示卡片也由它渲染,见下)。 */
14
24
  export declare function UserBadge(props: UserBadgeProps): import("react").JSX.Element | null;
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `/hiwork-managed-skills` loopback RPC 的 Host 实现:管理员下发的技能清单 + 按需取回的包体字节。
3
+ *
4
+ * 契约见 ai-hub 的 spec `docs/superpowers/specs/2026-09-17-skill-distribution-design.md` §4.5
5
+ * (Host 半边),清单字段见 §4.1、包体端点见 §4.2。本文件是 `src/managed-mcp.ts` 同一副骨架的
6
+ * **第二条链**——技能比 MCP 短一整段(没有服务端调用 ⇒ 没有凭证、没有断言、没有 mint/revoke),
7
+ * 所以刻意**不把 MCP 独有的东西搬过来**:端点少一个(`assertion`),也没有任何票据。
8
+ *
9
+ * 与 MCP 的实质差异有两处(第二处见 {@link ManagedSkillsStore.bindSession},那里写了为什么**不许**
10
+ * 为了「两半对齐」改回去):
11
+ * - `package` 会把**大字节**交给客户端(ZIP 的 base64),因此它是全频道唯一的带大小闸的端点
12
+ * ——spec §8 R8 说得很清楚,这是唯一一条把大字节经 Host 搬进客户端的路径;
13
+ * - 内存清单**按会话绑定**:缓存只属于产生它的那个会话,登出 / 换账号后绝不再吐出去。
14
+ *
15
+ * 与 MCP 逐字同款的两条铁律:
16
+ * - **网关会话 cookie 绝不离开 Host**:cookie 只在 `resolveSession()` 内部出现,客户端拿到的
17
+ * 是清单与包体字节(经 loopback RPC 以 base64 过去),浏览器侧永远拿不到包体,也零 fetch;
18
+ * - **拉清单失败绝不清空上一次成功的清单**:客户端只有在拿到一份「成功拉到的清单」时才允许
19
+ * 删除本地受管技能(spec §6 的唯一安全不变量),所以失败必须返回「保留旧值 + 标注失败原因」,
20
+ * 否则一次网络抖动就会让员工端把自己装好的技能删干净。
21
+ *
22
+ * 本模块不 import cordis 运行时,只依赖结构化的 {@link ManagedSkillsRpcContext} 端口,便于单测直接伪造。
23
+ */
24
+ import { type GatewaySkillAssignmentsView, type GatewaySkillPackage } from './ai-router-client.ts';
25
+ /** RPC 频道;必须与 client 半边 `src/client/managed-skills.ts` 的 `MANAGED_SKILLS_CHANNEL` 一致(由测试锁定)。 */
26
+ export declare const MANAGED_SKILLS_RPC_CHANNEL = "/hiwork-managed-skills";
27
+ /**
28
+ * 端点白名单。
29
+ *
30
+ * `snapshot` 是「你现在有什么」(有缓存就直接给,冷启动才拉一次),`refresh` 是「现在去拉一次」,
31
+ * `package` 是「把某一条的包体给我」。分三个端点的理由与 MCP 同款:客户端的同步策略(启动 /
32
+ * 30 分钟 / 打开能力中心且 >5min stale / 手动「立即同步」)由它自己定,Host 不该替它决定
33
+ * 什么时候必须联网;而 `package` 必须**按需单独取**——同步一轮只搬「真要装/更新」的那几条,
34
+ * 一轮带全等于每次同步都把所有技能包拉一遍(spec §4.6 的 `needsPackage` 就是这个形状)。
35
+ */
36
+ export declare const MANAGED_SKILLS_RPC_ENDPOINTS: readonly ["snapshot", "refresh", "package"];
37
+ export type ManagedSkillsRpcEndpoint = (typeof MANAGED_SKILLS_RPC_ENDPOINTS)[number];
38
+ /**
39
+ * Host 侧后台预热间隔:30 分钟(与客户端同步节奏同量级)。
40
+ * 它**不是**权威节奏——只为让客户端首次 `snapshot` 不必等一次网络往返。
41
+ */
42
+ export declare const MANAGED_SKILLS_REFRESH_INTERVAL_MS: number;
43
+ /**
44
+ * 包体上限:4 MiB(spec §2 的冻结决策)。ai-hub 侧的常量叫 `MAX_MANAGED_ARCHIVE_BYTES`,
45
+ * 两仓各自定格同一个数值、不是同一份代码;这里**不是**「与客户端同值」的那类约定
46
+ * (spec §5.4 第 5 步特地警告过:别把客户端 32 MiB 的浏览器上传门槛误当成本条)。
47
+ *
48
+ * 它拦的不是「服务端超发」(服务端入库与服务时各拦一次),而是**不让超大字节经 loopback RPC
49
+ * 搬进客户端**:base64 之后约 5.3 MiB 的字符串,是这条通道上唯一一次大对象传输(spec §8 R8)。
50
+ */
51
+ export declare const MAX_MANAGED_SKILL_ARCHIVE_BYTES: number;
52
+ /** 一条分配交给客户端的形状(字段名与网关响应一致,是冻结契约的一部分,见 spec §4.1)。 */
53
+ export interface ManagedSkillAssignment {
54
+ assignmentId: string;
55
+ /** 单条版本号:管理员每次推送 +1,客户端据此判「这条是不是刚被管理员动过」。 */
56
+ revision: number;
57
+ slug: string;
58
+ displayName: string;
59
+ /**
60
+ * **服务端权威下发**的技能名:模型侧 `/<名>` 与受管目录名的唯一来源(spec §2 的决策表)。
61
+ * 客户端**不参与命名**,也**不得**替它改名——技能名是模型侧标识符,漂了就什么都对不上。
62
+ */
63
+ skillName: string;
64
+ description: string;
65
+ /** 要不要重装的**唯一**依据(spec §4.1):内容寻址,客户端不得拿 `version` 比大小。 */
66
+ contentSha256: string;
67
+ sizeBytes: number;
68
+ /**
69
+ * 只给人看的版本标签(服务端派生、库里没有这一列),空串表示没有。契约里可缺省的字符串
70
+ * 一律折成空串而不是 `?`——Host 与 client 两侧镜像同一个形状,少一个「有键 / 没键」的分支。
71
+ */
72
+ version: string;
73
+ updatedAt: string;
74
+ }
75
+ /**
76
+ * 一份清单快照。
77
+ *
78
+ * `stale` 为 true 表示「这是上一次成功的结果,最近一次拉取失败了」——调用方可以照常展示,
79
+ * 但**不得**把它用作删除本地受管技能的依据(那条路只允许走成功响应,spec §6)。
80
+ */
81
+ export interface ManagedSkillsSnapshot {
82
+ /** 清单级序号(服务端任何影响清单的写入都 +1),客户端先比它再决定要不要深比对。 */
83
+ revision: number;
84
+ /** 本次成功拉取的时刻(RFC3339)。 */
85
+ fetchedAt: string;
86
+ assignments: ManagedSkillAssignment[];
87
+ stale: boolean;
88
+ /** 最近一次拉取失败的原因(成功时缺省)。 */
89
+ lastError?: ManagedSkillsFailure;
90
+ }
91
+ /** `package` 的返回值:包体字节的 base64 + 服务端声明的身份(客户端据此校验字节,spec §4.2)。 */
92
+ export interface ManagedSkillPackage {
93
+ assignmentId: string;
94
+ skillName: string;
95
+ /** 必须等于收到字节的 `hex(sha256(...))`,否则该条**不落盘**(spec §6 的完整性那一行)。 */
96
+ contentSha256: string;
97
+ archiveBase64: string;
98
+ }
99
+ /** 客户端可安全展示的失败。 */
100
+ export interface ManagedSkillsFailure {
101
+ code: string;
102
+ message: string;
103
+ /** DSH Connection 信封要求 `details` 是对象;协议层没有额外细节时补空对象。 */
104
+ details: object;
105
+ }
106
+ /** 端点结果信封(与 DSH Connection 的 `ConnectionRpcResult` 同形)。 */
107
+ export type ManagedSkillsRpcResult<T> = {
108
+ readonly ok: true;
109
+ readonly value: T;
110
+ } | {
111
+ readonly ok: false;
112
+ readonly error: ManagedSkillsFailure;
113
+ };
114
+ /** 请求侧错误(只有 `bad-request`:未知端点 / 非法 payload)。 */
115
+ export declare class ManagedSkillsRequestError extends Error {
116
+ readonly code: 'bad-request';
117
+ constructor(code: 'bad-request', message: string);
118
+ }
119
+ /** Host 侧 RPC 依赖的最小上下文(结构兼容 cordis Context,便于单测伪造)。 */
120
+ export interface ManagedSkillsRpcConnection {
121
+ rpc: {
122
+ handle(channel: string, handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<unknown>, options: {
123
+ authority: 'loopback';
124
+ }): () => Promise<void> | void;
125
+ };
126
+ }
127
+ export interface ManagedSkillsRpcContext {
128
+ readonly connection: ManagedSkillsRpcConnection;
129
+ readonly logger: {
130
+ warn(message: string): void;
131
+ };
132
+ }
133
+ /** 网关客户端的技能子集:只声明本频道真正调用的方法(结构兼容 `GatewayClient`)。 */
134
+ export interface ManagedSkillsGatewayPort {
135
+ getMySkillAssignments(sessionCookie: string): Promise<GatewaySkillAssignmentsView>;
136
+ getMySkillPackage(sessionCookie: string, assignmentId: string): Promise<GatewaySkillPackage>;
137
+ }
138
+ /** Host 侧取数依赖:会话解析 + 当前 baseURL 下的网关客户端。 */
139
+ export interface ManagedSkillsRpcDeps {
140
+ /**
141
+ * 解析网关会话 cookie(`AI_ROUTER_SESSION`)。未登录 / 会话缺失返回 undefined。
142
+ * **返回值只在本模块内部使用,绝不出现在 RPC 响应里。**
143
+ */
144
+ resolveSession(): Promise<string | undefined>;
145
+ /** 当前 baseURL 对应的网关客户端;baseURL 未配置时返回 undefined。 */
146
+ gateway(): ManagedSkillsGatewayPort | undefined;
147
+ /** Host 日志出口(只记事件与错误类型,不记会话值 / 包体)。 */
148
+ log?: ((message: string) => void) | undefined;
149
+ }
150
+ /** 通用失败文案:不暴露任何网关细节。 */
151
+ export declare const MANAGED_SKILLS_GENERIC_FAILURE_MESSAGE = "\u6280\u80FD\u6E05\u5355\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
152
+ /** 未登录 / 会话失效的统一文案(与用量、每日一句、MCP 清单几个频道同口径)。 */
153
+ export declare const MANAGED_SKILLS_SESSION_EXPIRED_MESSAGE = "\u767B\u5F55\u72B6\u6001\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
154
+ /** 收到超上限包体时的失败 code(服务端超发 / 契约被改大时才会走到)。 */
155
+ export declare const MANAGED_SKILLS_PACKAGE_TOO_LARGE_CODE = "SKILL_PACKAGE_TOO_LARGE";
156
+ /** 超上限包体的文案:给员工一句可行动的话(找管理员),而不是一个数字。 */
157
+ export declare const MANAGED_SKILLS_PACKAGE_TOO_LARGE_MESSAGE = "\u8FD9\u4E2A\u6280\u80FD\u5305\u8D85\u8FC7\u4E86\u5927\u5C0F\u4E0A\u9650\uFF0C\u6682\u65F6\u88C5\u4E0D\u4E0A\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458";
158
+ /**
159
+ * 网关 message 消毒(与 `usage-rpc.ts` 的 `displayGatewayMessage` 同实现,MCP 那半逐字同款):
160
+ * 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的中文业务文案)
161
+ * 原样保留。code 不受影响,始终原样透传。
162
+ */
163
+ export declare function displayManagedSkillsMessage(code: string, message: unknown): string;
164
+ /** 端点入参:只有 `package` 带 `assignmentId`。 */
165
+ export interface ManagedSkillsRpcPayload {
166
+ assignmentId?: string | undefined;
167
+ }
168
+ /**
169
+ * payload 校验:`snapshot` / `refresh` 只放行空对象,`package` 只放行 `{ assignmentId }`;
170
+ * 其余字段 / 类型不符一律 `bad-request`。
171
+ */
172
+ export declare function parseManagedSkillsPayload(endpoint: ManagedSkillsRpcEndpoint, raw: unknown): ManagedSkillsRpcPayload;
173
+ /** 是否为白名单端点。 */
174
+ export declare function isManagedSkillsRpcEndpoint(endpoint: string): endpoint is ManagedSkillsRpcEndpoint;
175
+ /**
176
+ * 清单缓存:**只有成功响应才覆写**,失败只记原因;且缓存**按会话绑定**(见 {@link bindSession})。
177
+ *
178
+ * 「成功」的判据里含**响应形状**:200 但响应不是「带数组 `assignments` 的对象」按**取数失败**
179
+ * 处理(见 {@link ManagedSkillsStore.fetchOnce})——那种响应是中间层给的,不是服务端说「你没有
180
+ * 技能」,把它当成空清单会让客户端把本地受管技能全删。
181
+ *
182
+ * 进程内的(不落盘)是有意的:清单是「服务端此刻认什么」的投影,跨重启的旧清单没有任何价值
183
+ * ——重启后客户端会立刻同步一次,拿到新的比拿到旧的更安全;而且**绝不落盘**这条还保证了
184
+ * 「本地磁盘上不存在一份可能被误当成事实的旧清单」(受管记录归 hiwork-capabilities 的
185
+ * `hiwork_skills` 域,见 spec §4.3)。
186
+ */
187
+ export declare class ManagedSkillsStore {
188
+ private readonly deps;
189
+ private readonly now;
190
+ private cached;
191
+ private lastError;
192
+ /** 产生 `cached` 的那个会话 cookie(只在内存里)。会话绑定的全部依据就是它。 */
193
+ private cachedSession;
194
+ /**
195
+ * 在飞请求连同它所用的会话一起记账:并发合并**只允许发生在同一会话内**。
196
+ * 合并跨会话等于把 A 那次拉取的结果交给 B——`refresh` 是客户端唯一被允许用来删本地技能的那条
197
+ * 路(spec §6 的唯一安全不变量),这条缝不能留。
198
+ */
199
+ private inFlight;
200
+ constructor(deps: ManagedSkillsRpcDeps, now?: () => number);
201
+ /**
202
+ * 当前缓存(同步读,不发请求)。从没成功过返回 undefined;
203
+ * 最近一次失败会以 `stale + lastError` 标注在上一次成功的结果上。
204
+ *
205
+ * ⚠️ **本方法不做会话绑定**——它是 spec §4.5 冻结的同步读,而当前会话只能异步解析(见
206
+ * {@link peekForCurrentSession}),同步函数里根本判不了「这份缓存还算不算我的」。
207
+ * 所以模块内唯一的正确用法是:先 `await store.peekForCurrentSession()`,再(或由它内部)`peek()`。
208
+ * 直接调 `peek()` 只适合诊断与测试断言内存状态。
209
+ */
210
+ peek(): ManagedSkillsSnapshot | undefined;
211
+ /**
212
+ * 「当前会话下的缓存」= 解析当前会话 → 剪枝 → {@link peek}。
213
+ *
214
+ * 为什么比对写在**这里**而不是 `peek()` 里:`peek()` 是 spec §4.5 冻结的**同步**接口,而
215
+ * 「当前是谁」只有 `deps.resolveSession()` 异步拿得到。两者凑不到一个函数里,于是分工只能是
216
+ * 「async 的一侧先绑会话,sync 的 `peek()` 再读」。这是本文件里唯一一处 async 前置步骤,
217
+ * 端点实现(`handleManagedSkillsEndpoint`)必须走它、不许绕过。
218
+ */
219
+ peekForCurrentSession(): Promise<ManagedSkillsSnapshot | undefined>;
220
+ /**
221
+ * 会话绑定:缓存只属于产生它的那个会话;当前会话不是它(含**当前没有会话**)→ 立刻丢弃。
222
+ *
223
+ * 这是刻意与 `src/managed-mcp.ts` **不同**的一处,**不许**为了「两半对齐」改回去:
224
+ * - 技能这半的 {@link clear} 是 spec §4.5 冻结要求提供的接口,却**没有任何调用方**——端点白名单
225
+ * 只有 `snapshot`/`refresh`/`package`,客户端够不到 Host 的内存,Host 自己也没有登出钩子;
226
+ * 而「靠一个没人调的钩子保证安全」正是**已经失败过一次**的做法(MCP 那条链路里同样的
227
+ * `clear()` 也悬着),不能把安全不变量挂在它上面。
228
+ * - 绑会话**自带**:不依赖任何调用方、不依赖谁记得清缓存,且方向更严——登出后(当前会话为空)
229
+ * 与换账号后都不会再吐出上一个人的清单。更严在这里恒等于更安全。
230
+ *
231
+ * 它也**不**触碰「失败不覆写缓存」那条铁律:剪枝的触发条件是**会话变了**,不是「这一次拉取
232
+ * 失败了」。失败路径仍然原样保留本会话上一次成功的清单(`stale + lastError`)。
233
+ */
234
+ private bindSession;
235
+ /**
236
+ * 去网关拉一次清单并更新缓存。失败时抛异常,且**本会话的缓存原样保留**。
237
+ *
238
+ * 并发调用合并成一次请求:后台预热与客户端手动同步撞在一起时不必打两遍网关。
239
+ * 合并**只在同一会话内**——两个会话合并到一起,就等于把 A 的结果发给 B。
240
+ */
241
+ load(): Promise<ManagedSkillsSnapshot>;
242
+ /**
243
+ * 清空内存清单(登出 / 换账号调用)。
244
+ *
245
+ * ⚠️ **当前没有任何调用方**:这是 spec §4.5 冻结要求提供的接口(客户端半边的同名方法有调用方,
246
+ * Host 这半没有),而它的用途已经被**会话绑定**覆盖——缓存按会话剪枝,登出 / 换账号后再也吐不出
247
+ * 上一个人的清单(见 {@link bindSession})。保留它是为了不改冻结接口;真要删得先改 spec §4.5。
248
+ * 冻结接口里的成员不要悄悄删掉,哪怕它暂时是死的。
249
+ */
250
+ clear(): void;
251
+ private fetchOnce;
252
+ }
253
+ /**
254
+ * 处理一次端点调用:返回 DSH Connection 的结果信封,**永不抛异常**。
255
+ * 导出以便单测直接驱动(无需伪造 `ctx.connection`)。
256
+ */
257
+ export declare function handleManagedSkillsEndpoint(endpoint: string, rawPayload: unknown, deps: ManagedSkillsRpcDeps, store: ManagedSkillsStore, signal?: AbortSignal): Promise<ManagedSkillsRpcResult<unknown>>;
258
+ /**
259
+ * 注册 `/hiwork-managed-skills` 频道,返回异步 disposer(交给 `ctx.effect` 管理生命周期)。
260
+ */
261
+ export declare function registerManagedSkillsRpc(ctx: ManagedSkillsRpcContext, deps: ManagedSkillsRpcDeps, store: ManagedSkillsStore): () => Promise<void>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 网关集中下发的 OnlyOffice 集成凭据(host 侧 cordis 服务 `hiworkOfficeCredentials`)。
3
+ *
4
+ * 背景:hiwork-office 插件原先要求每台终端在设置里手工粘贴 Document Server 的 JWT
5
+ * 密钥——密钥靠人肉传播(聊天记录/文档里转发)、轮换无通道、谁在用无法审计
6
+ * (2026-09-16 查证:DS 侧密钥只是部署机 `.env` 里的一行 `JWT_SECRET`,没有任何管理
7
+ * 能力)。本模块把「密钥从哪来」收敛到网关:hiwork-office 的 Host 半边按登录态经本
8
+ * 服务取 `{ documentServerUrl, jwtSecret }`,终端零配置。
9
+ *
10
+ * 设计要点(与 `src/managed-mcp.ts` / `src/daily-rpc.ts` 同构):
11
+ * - **网关会话 cookie 绝不离开 Host**:只在 `resolveSession()` 内部出现;
12
+ * - **本模块不缓存**:调用方(hiwork-office)按自己的 TTL 决定何时取——密钥轮换后
13
+ * 它的下一次拉取自然拿到新值,不存在两层缓存互相打架;
14
+ * - **失败不抛异常**,返回稳定 `{ ok: false, code, message }`:message 经消毒,
15
+ * 不含 baseURL / HTTP 状态码等实现细节,可直接展示给用户;
16
+ * - 服务经 `ctx.provide` 提供给同进程的其他插件,hiwork-office 用 `ctx.get` 探测
17
+ * (老 core 上它是 undefined,插件回退本地手工配置——独立安装场景)。
18
+ *
19
+ * 这是「密钥下发」的 MVP(方案一):密钥仍随登录态落到终端,但不再靠人肉传播。
20
+ * 后续演进(方案二:网关代签)不改变本服务的**形状**——消费方拿到的仍是一份
21
+ * 可用的集成配置,只是内容可能从共享密钥换成短期凭证,两端无需改结构。
22
+ *
23
+ * 本模块不 import cordis 运行时:provide 走结构化的 {@link OfficeCredentialsContext}
24
+ * 端口,调用方(index.ts)传真实 ctx;单测直接伪造。
25
+ */
26
+ import { type GatewayOfficeConfig } from './ai-router-client.ts';
27
+ /** cordis 服务名(hiwork-office 的 Host 半边按此名探测)。 */
28
+ export declare const OFFICE_CREDENTIALS_SERVICE = "hiworkOfficeCredentials";
29
+ /** 一条可用的集成凭据。`fetchedAt` 供消费方展示「更新于 …」。 */
30
+ export interface OfficeCredentialsView {
31
+ documentServerUrl: string;
32
+ jwtSecret: string;
33
+ fetchedAt: string;
34
+ /**
35
+ * 网关侧的文档中继开关(`office.relay_enabled`)。true 时插件走中继模式
36
+ * (上传 → DS 从签名 URL 拉 → 回调落版本 → 桌面端轮询取回),不再要求
37
+ * DS 能回连用户电脑。旧版网关缺省该字段 → false(自动回落本机模式)。
38
+ */
39
+ relayEnabled: boolean;
40
+ }
41
+ /** 取数结果信封:失败是数据而不是异常(消费方据此提示用户 / 回退本地配置)。 */
42
+ export type OfficeCredentialsResult = {
43
+ readonly ok: true;
44
+ readonly value: OfficeCredentialsView;
45
+ } | {
46
+ readonly ok: false;
47
+ readonly code: string;
48
+ readonly message: string;
49
+ };
50
+ /** 本服务对宿主的可调用面。 */
51
+ export interface OfficeCredentialsService {
52
+ /** 取一份当前有效的集成凭据。永不抛异常。 */
53
+ resolve(): Promise<OfficeCredentialsResult>;
54
+ }
55
+ /** 网关客户端的 office 子集:只声明本模块真正调用的方法(结构兼容 `GatewayClient`)。 */
56
+ export interface OfficeCredentialsGatewayPort {
57
+ getMyOfficeConfig(sessionCookie: string): Promise<GatewayOfficeConfig>;
58
+ }
59
+ /** Host 侧取数依赖:会话解析 + 当前 baseURL 下的网关客户端。 */
60
+ export interface OfficeCredentialsDeps {
61
+ /**
62
+ * 解析网关会话 cookie。未登录 / 会话缺失返回 undefined。
63
+ * **返回值只在本模块内部使用,绝不出现在结果里。**
64
+ */
65
+ resolveSession(): Promise<string | undefined>;
66
+ /** 当前 baseURL 对应的网关客户端;baseURL 未配置时返回 undefined。 */
67
+ gateway(): OfficeCredentialsGatewayPort | undefined;
68
+ /** Host 日志出口(只记事件与错误类型,不记会话值 / 密钥)。 */
69
+ log?: ((message: string) => void) | undefined;
70
+ /** 时钟注入(测试用);缺省 `new Date()`。 */
71
+ now?: (() => Date) | undefined;
72
+ }
73
+ /** `ctx.provide` 的最小端口(结构兼容 cordis Context,便于单测伪造)。 */
74
+ export interface OfficeCredentialsContext {
75
+ provide(name: string, value?: unknown): unknown;
76
+ }
77
+ /** 通用失败文案:不暴露任何网关细节。 */
78
+ export declare const OFFICE_CREDENTIALS_GENERIC_FAILURE_MESSAGE = "HiWork Office \u96C6\u6210\u914D\u7F6E\u6682\u65F6\u65E0\u6CD5\u83B7\u53D6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
79
+ /** 未登录 / 会话失效的统一文案(与用量、每日一句、MCP 三个频道同口径)。 */
80
+ export declare const OFFICE_CREDENTIALS_SESSION_EXPIRED_MESSAGE = "\u767B\u5F55\u72B6\u6001\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
81
+ /**
82
+ * 网关 message 消毒(与 `usage-rpc.ts` 的 `displayGatewayMessage` 同实现):
83
+ * 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的
84
+ * 中文业务文案)原样保留。code 不受影响,始终原样透传。
85
+ */
86
+ export declare function displayOfficeGatewayMessage(code: string, message: unknown): string;
87
+ /**
88
+ * 凭据取数(无缓存;缓存策略是消费方的事)。
89
+ */
90
+ export declare class OfficeCredentialsBroker implements OfficeCredentialsService {
91
+ private readonly deps;
92
+ constructor(deps: OfficeCredentialsDeps);
93
+ resolve(): Promise<OfficeCredentialsResult>;
94
+ }
95
+ /**
96
+ * 提供 host 侧服务 `hiworkOfficeCredentials`;返回 disposer(交给 `ctx.effect` 管理
97
+ * 生命周期,插件卸载时服务随 fiber 一并注销)。
98
+ */
99
+ export declare function provideOfficeCredentials(ctx: OfficeCredentialsContext, deps: OfficeCredentialsDeps): () => void;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 文档中继的 host 侧服务 `hiworkOfficeRelay`(设计见 ai-gateway 的
3
+ * `docs/superpowers/specs/2026-09-16-office-document-relay-design.md` §5)。
4
+ *
5
+ * 为什么需要它:在线编辑要求 Document Server 主动回连文档 URL;用户走 VPN / DS 在云上时
6
+ * 这条路径不通(2026-09-16 现场:DS 拉 http://192.168.16.105:54426 超时)。中继把文档
7
+ * 暂存到 DS 能访问的服务器侧,桌面端只做出站调用——**用户侧不再需要任何入向可达性**。
8
+ *
9
+ * 与 `office-credentials.ts` 同一套形态:
10
+ * - **网关会话 cookie 绝不离开 Host**:只在 resolveSession() 内部出现;
11
+ * - 失败不抛异常,返回稳定 `{ ok, code, message }`(message 已消毒,可直接展示);
12
+ * - 服务名 `hiworkOfficeRelay`,经 ctx.provide 发布;hiwork-office 用 ctx.get 探测,
13
+ * 老 core 上没有它 → 插件回落本机模式。
14
+ *
15
+ * 本模块不 import cordis 运行时:provide 走结构化的 {@link OfficeRelayContext} 端口。
16
+ */
17
+ import { type GatewayRelayContent, type GatewayRelayDocument, type GatewayRelayStatus } from './ai-router-client.ts';
18
+ /** cordis 服务名(hiwork-office 的 Host 半边按此名探测)。 */
19
+ export declare const OFFICE_RELAY_SERVICE = "hiworkOfficeRelay";
20
+ /**
21
+ * 上传入参:文档名 + 字节(字节由插件从工作区读出)+ 打开模式。
22
+ *
23
+ * mode 会带给网关,只用于 **document key 的模式变体**:DS 按 key 复用编辑会话,
24
+ * view 与 edit 共用 key 时会互相污染(只读会话把可编辑会话顶掉)。
25
+ */
26
+ export interface OfficeRelayUploadInput {
27
+ readonly name: string;
28
+ readonly bytes: Uint8Array;
29
+ readonly mode?: 'view' | 'edit';
30
+ }
31
+ /** 上传结果:DS 侧两条签名 URL 由网关签发,插件原样放进编辑器 config。 */
32
+ export interface OfficeRelayUploadView {
33
+ readonly id: string;
34
+ readonly documentKey: string;
35
+ readonly readUrl: string;
36
+ readonly callbackUrl: string;
37
+ readonly expiresAt: string;
38
+ }
39
+ /** 取回结果:`notModified` 为真表示客户端手里的版本已是最新。 */
40
+ export interface OfficeRelayDownloadView {
41
+ readonly notModified: boolean;
42
+ readonly bytes?: Uint8Array;
43
+ }
44
+ /** 稳定信封:失败是数据而不是异常。 */
45
+ export type OfficeRelayResult<T> = {
46
+ readonly ok: true;
47
+ readonly value: T;
48
+ } | {
49
+ readonly ok: false;
50
+ readonly code: string;
51
+ readonly message: string;
52
+ };
53
+ /** 本服务对宿主的可调用面。 */
54
+ export interface OfficeRelayService {
55
+ /** 上传文档到中继。 */
56
+ upload(input: OfficeRelayUploadInput, signal?: AbortSignal): Promise<OfficeRelayResult<OfficeRelayUploadView>>;
57
+ /** 轮询状态(revision 单调递增即"有新版本")。 */
58
+ status(id: string, signal?: AbortSignal): Promise<OfficeRelayResult<GatewayRelayStatus>>;
59
+ /** 取回当前版本字节;`revision` 传客户端手里的版本可短路(204 → notModified)。 */
60
+ download(id: string, revision?: number, signal?: AbortSignal): Promise<OfficeRelayResult<OfficeRelayDownloadView>>;
61
+ /** 关闭会话(立即清理字节;幂等)。 */
62
+ close(id: string, signal?: AbortSignal): Promise<OfficeRelayResult<Record<string, never>>>;
63
+ }
64
+ /** 网关客户端的中继子集(结构兼容 `GatewayClient`)。 */
65
+ export interface OfficeRelayGatewayPort {
66
+ createOfficeRelayDocument(sessionCookie: string, name: string, bytes: Uint8Array, mode?: 'view' | 'edit'): Promise<GatewayRelayDocument>;
67
+ getOfficeRelayStatus(sessionCookie: string, id: string): Promise<GatewayRelayStatus>;
68
+ getOfficeRelayContent(sessionCookie: string, id: string, revision?: number): Promise<GatewayRelayContent>;
69
+ closeOfficeRelayDocument(sessionCookie: string, id: string): Promise<void>;
70
+ }
71
+ /** Host 侧依赖:会话解析 + 当前 baseURL 下的网关客户端。 */
72
+ export interface OfficeRelayDeps {
73
+ /** 解析网关会话 cookie;未登录返回 undefined。**只在本模块内部使用。** */
74
+ resolveSession(): Promise<string | undefined>;
75
+ /** 当前 baseURL 对应的网关客户端;baseURL 未配置时返回 undefined。 */
76
+ gateway(): OfficeRelayGatewayPort | undefined;
77
+ /** Host 日志出口(只记事件与错误类型,不记文档名/内容)。 */
78
+ log?: ((message: string) => void) | undefined;
79
+ }
80
+ /** `ctx.provide` 的最小端口。 */
81
+ export interface OfficeRelayContext {
82
+ provide(name: string, value?: unknown): unknown;
83
+ }
84
+ /** 通用失败文案:不暴露任何网关细节。 */
85
+ export declare const OFFICE_RELAY_GENERIC_FAILURE_MESSAGE = "\u6587\u6863\u4E2D\u7EE7\u6682\u65F6\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
86
+ /** 未登录 / 会话失效的统一文案(与其余频道同口径)。 */
87
+ export declare const OFFICE_RELAY_SESSION_EXPIRED_MESSAGE = "\u767B\u5F55\u72B6\u6001\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55";
88
+ /**
89
+ * 网关 message 消毒(与 `office-credentials.ts` 同实现,各频道自带一份)。
90
+ */
91
+ export declare function displayOfficeRelayMessage(code: string, message: unknown): string;
92
+ /** 凭据取数(无缓存;缓存策略是消费方的事)。 */
93
+ export declare class OfficeRelayBroker implements OfficeRelayService {
94
+ private readonly deps;
95
+ constructor(deps: OfficeRelayDeps);
96
+ private withSession;
97
+ upload(input: OfficeRelayUploadInput, signal?: AbortSignal): Promise<OfficeRelayResult<OfficeRelayUploadView>>;
98
+ status(id: string, _signal?: AbortSignal): Promise<OfficeRelayResult<GatewayRelayStatus>>;
99
+ download(id: string, revision?: number, _signal?: AbortSignal): Promise<OfficeRelayResult<OfficeRelayDownloadView>>;
100
+ close(id: string, _signal?: AbortSignal): Promise<OfficeRelayResult<Record<string, never>>>;
101
+ }
102
+ /**
103
+ * 提供 host 侧服务 `hiworkOfficeRelay`;返回 disposer(交给 `ctx.effect`)。
104
+ */
105
+ export declare function provideOfficeRelay(ctx: OfficeRelayContext, deps: OfficeRelayDeps): () => void;