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.
package/README.md CHANGED
@@ -130,6 +130,12 @@ ctx.inject(['hiworkFeatureCenter'], (scope) => {
130
130
  - [src/client/feature-center.ts](src/client/feature-center.ts):侧栏菜单注入 + 中央区 takeover。
131
131
  - [src/client/contracts.ts](src/client/contracts.ts):业务插件可见的 client 面与服务名。
132
132
  - [src/orchestration.ts](src/orchestration.ts):登录后模型供给编排(拉 Key、写路由、热重载)。
133
+ - [src/office-credentials.ts](src/office-credentials.ts):OnlyOffice 集成凭据的 host 侧服务
134
+ (`hiworkOfficeCredentials`)——hiwork-office 按登录态经它取 Document Server 地址与共享
135
+ JWT 密钥(网关 `GET /api/me/office-config`),终端零配置;网关会话 cookie 不出 Host。
136
+ - [src/office-relay.ts](src/office-relay.ts):文档中继的 host 侧服务(`hiworkOfficeRelay`)——
137
+ 跨 NAT 的在线编辑:插件把文档暂存到 DS 能访问的服务器侧(上传/轮询/取回/关闭),
138
+ 用户侧不再需要入向可达性;同样只在 Host 持 cookie、失败用稳定信封。
133
139
  - [src/default-model.ts](src/default-model.ts):供给成功后校正 `agent-default-model`(默认模型落点)。
134
140
  - [src/client/login-gate.tsx](src/client/login-gate.tsx):全屏登录门禁页。
135
141
  - [src/client/section.tsx](src/client/section.tsx):设置页「AI Router 网关」卡片。
@@ -21,6 +21,41 @@ const ME_DAILY_QUOTE_RANDOM_PATH = '/api/me/daily-quote/random';
21
21
  */
22
22
  const ME_MCP_ASSIGNMENTS_PATH = '/api/me/mcp-assignments';
23
23
  const ME_MCP_ASSERTIONS_PATH = '/api/me/mcp-assertions';
24
+ /**
25
+ * 技能分发的自助端点(管理员在 ai-hub 登记技能、下发给全员或某人后,员工端从这里拉清单与包体)。
26
+ * 与 MCP 那两条同款:都在网关 `/api/me` 会话组下,鉴权是 `SessionAuth`(只读 Cookie)。
27
+ *
28
+ * 契约见 ai-hub 的 spec `docs/superpowers/specs/2026-09-17-skill-distribution-design.md`
29
+ * §4.1(清单)/ §4.2(包体)。技能链路比 MCP 短一整段:**没有任何凭据**——清单里只有元数据,
30
+ * 包体是纯文件字节,不存在换令牌那一跳,所以也没有断言 / JWKS 相关端点。
31
+ *
32
+ * 清单与包体分成两条路径是刻意的(§4.1):清单必须小到能每 30 分钟拉一次、能随手打进日志比对,
33
+ * 包体则只在真要装/更新时才取(`package` 是唯一搬大字节的那条,见 src/managed-skills.ts)。
34
+ */
35
+ const ME_SKILL_ASSIGNMENTS_PATH = '/api/me/skill-assignments';
36
+ const ME_SKILL_PACKAGES_PATH = '/api/me/skill-packages';
37
+ /**
38
+ * 工作台应用清单的自助端点:管理员在网关管理端登记并分配给当前账号的应用,一条 GET 全量下发。
39
+ *
40
+ * 契约见 spec `docs/superpowers/specs/2026-09-17-hiwork-workbench-design.md` §4.1(本仓是消费方,
41
+ * 权威定义在 ai-gateway 侧)。与 MCP / 技能那两组同款:网关 `/api/me` 会话组、`SessionAuth`
42
+ * 只读 Cookie、响应里**没有任何令牌**(应用后端怎么鉴权由网关代理时附身份头决定,客户端不见凭证)。
43
+ *
44
+ * 与前面几组不同的一处:**本端点会回 404**——网关没配置工作台时这条路由压根不注册
45
+ * (spec §10 的降级矩阵),所以 404 在 {@link AiRouterClient.getMyWorkbenchApps} 里有独立语义。
46
+ */
47
+ const ME_WORKBENCH_APPS_PATH = '/api/me/workbench-apps';
48
+ /**
49
+ * 文档办公(OnlyOffice)集成下发:网关集中持有共享 JWT 密钥,员工端经登录态拉取。
50
+ * 与 MCP 清单同理,本端点只看会话——没有被分配虚拟 Key 的账号照样拿得到。
51
+ */
52
+ const ME_OFFICE_CONFIG_PATH = '/api/me/office-config';
53
+ /**
54
+ * 文档中继(跨 NAT 的在线编辑):桌面端上传 / 轮询 / 取回 / 关闭。
55
+ * 四条都在网关 `/api/me` 会话组下;DS 侧的那两条(拉文档、回调)由网关直接对 DS 签发,
56
+ * 桌面端永远不接触。
57
+ */
58
+ const ME_OFFICE_RELAY_DOCUMENTS_PATH = '/api/me/office-relay/documents';
24
59
  /** 授权请求默认值:桌面端 client_id 与自定义协议回调地址(网关侧精确匹配)。 */
25
60
  const DEFAULT_CLIENT_ID = 'hiwork-desktop';
26
61
  const DEFAULT_REDIRECT_URI = 'hiwork://auth/callback';
@@ -233,6 +268,109 @@ export class AiRouterClient {
233
268
  throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
234
269
  return await parseMeJson(res);
235
270
  }
271
+ /**
272
+ * POST /api/me/office-relay/documents:上传文档字节到中继。
273
+ *
274
+ * 文件名走 `X-Office-Document-Name` 头(HTTP 头只能带 ASCII,用 encodeURIComponent
275
+ * 编码;网关侧 PathUnescape 解码)。状态码映射成稳定 code,供插件层给用户文案。
276
+ */
277
+ async createOfficeRelayDocument(sessionCookie, name, bytes, mode) {
278
+ const res = await this.requestOrUnreachable(ME_OFFICE_RELAY_DOCUMENTS_PATH, {
279
+ method: 'POST',
280
+ sessionCookie,
281
+ rawBody: bytes,
282
+ headers: {
283
+ 'content-type': 'application/octet-stream',
284
+ 'x-office-document-name': encodeURIComponent(name),
285
+ // 打开模式只影响网关侧的 document key 变体(view / edit 不能共用 DS 会话)
286
+ ...(mode === undefined ? {} : { 'x-office-document-mode': mode }),
287
+ },
288
+ });
289
+ if (res.status === 401)
290
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
291
+ if (res.status === 404) {
292
+ throw new GatewayError('RELAY_DISABLED', '本部署未启用文档中继,请联系管理员', 404);
293
+ }
294
+ if (res.status === 413)
295
+ throw new GatewayError('RELAY_TOO_LARGE', '文档超过在线编辑的大小上限', 413);
296
+ if (res.status === 415)
297
+ throw new GatewayError('RELAY_EXTENSION', '该文档类型不支持在线编辑', 415);
298
+ if (res.status === 429)
299
+ throw new GatewayError('RELAY_QUOTA', '同时打开的编辑会话过多,请稍后再试', 429);
300
+ if (!res.ok)
301
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
302
+ return await parseMeJson(res);
303
+ }
304
+ /** GET /api/me/office-relay/documents/:id:轮询状态。 */
305
+ async getOfficeRelayStatus(sessionCookie, id) {
306
+ const res = await this.requestOrUnreachable(`${ME_OFFICE_RELAY_DOCUMENTS_PATH}/${encodeURIComponent(id)}`, {
307
+ method: 'GET',
308
+ sessionCookie,
309
+ });
310
+ if (res.status === 401)
311
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
312
+ if (res.status === 404)
313
+ throw new GatewayError('RELAY_DISABLED', '本部署未启用文档中继,请联系管理员', 404);
314
+ if (res.status === 410)
315
+ throw new GatewayError('RELAY_EXPIRED', '编辑会话已过期,请重新打开文档', 410);
316
+ if (!res.ok)
317
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
318
+ return await parseMeJson(res);
319
+ }
320
+ /**
321
+ * GET /api/me/office-relay/documents/:id/content[?v=N]:取回当前版本字节。
322
+ * 204 表示"你手里的版本已是最新"(不是错误)。
323
+ */
324
+ async getOfficeRelayContent(sessionCookie, id, revision) {
325
+ const query = revision === undefined ? '' : `?v=${revision}`;
326
+ const res = await this.requestOrUnreachable(`${ME_OFFICE_RELAY_DOCUMENTS_PATH}/${encodeURIComponent(id)}/content${query}`, { method: 'GET', sessionCookie });
327
+ if (res.status === 204) {
328
+ return { notModified: true, revision: revision ?? 0 };
329
+ }
330
+ if (res.status === 401)
331
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
332
+ if (res.status === 404)
333
+ throw new GatewayError('RELAY_DISABLED', '本部署未启用文档中继,请联系管理员', 404);
334
+ if (res.status === 410)
335
+ throw new GatewayError('RELAY_EXPIRED', '编辑会话已过期,请重新打开文档', 410);
336
+ if (!res.ok)
337
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
338
+ const bytes = new Uint8Array(await res.arrayBuffer());
339
+ // 版本号以轮询到的状态为准;这里回 -1 让调用方用 status 的值覆盖
340
+ return { notModified: false, revision: -1, bytes };
341
+ }
342
+ /** DELETE /api/me/office-relay/documents/:id:关闭会话(幂等)。 */
343
+ async closeOfficeRelayDocument(sessionCookie, id) {
344
+ const res = await this.requestOrUnreachable(`${ME_OFFICE_RELAY_DOCUMENTS_PATH}/${encodeURIComponent(id)}`, {
345
+ method: 'DELETE',
346
+ sessionCookie,
347
+ });
348
+ if (res.status === 204 || res.status === 404 || res.status === 410)
349
+ return; // 幂等:没了也算关掉
350
+ if (res.status === 401)
351
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
352
+ if (!res.ok)
353
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
354
+ }
355
+ /**
356
+ * GET /api/me/office-config:本部署 OnlyOffice 集成(Document Server 地址 + 共享 JWT 密钥)。
357
+ *
358
+ * 与 {@link getMyMcpAssignments} 同样的理由不复用 `getMeJson`:那条路径把 403 映射成
359
+ * `NO_KEY_ASSIGNED`,而本端点只看会话(分集成配置与分 Key 是两件事)。503 是
360
+ * 「部署未配置」的稳定信号,单独映射——消费方(hiwork-office)据此提示联系管理员
361
+ * 并回退本地手工配置,而不是报一句含糊的「网关不可达」。
362
+ */
363
+ async getMyOfficeConfig(sessionCookie) {
364
+ const res = await this.requestOrUnreachable(ME_OFFICE_CONFIG_PATH, { method: 'GET', sessionCookie });
365
+ if (res.status === 401)
366
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
367
+ if (res.status === 503) {
368
+ throw new GatewayError('OFFICE_NOT_CONFIGURED', '本部署尚未配置 HiWork Office 文档办公集成,请联系管理员', 503);
369
+ }
370
+ if (!res.ok)
371
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
372
+ return await parseMeJson(res);
373
+ }
236
374
  /**
237
375
  * POST /api/me/mcp-assertions:为某条分配签一张 120 秒断言。
238
376
  *
@@ -263,6 +401,118 @@ export class AiRouterClient {
263
401
  throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
264
402
  return await parseMeJson(res);
265
403
  }
404
+ /**
405
+ * GET /api/me/skill-assignments:管理员下发给当前账号的技能清单(spec §4.1)。
406
+ *
407
+ * 与 {@link getMyMcpAssignments} 同样的理由不复用 `getMeJson`:那条路径把 403 映射成
408
+ * `NO_KEY_ASSIGNED`(「尚未被分配虚拟 KEY」),而本端点的鉴权只有会话——一个还没被分配 Key
409
+ * 的账号照样应该有技能清单(分技能与分 Key 是两件事)。
410
+ *
411
+ * **这条路径上只有 401 有独立语义,其余全部收敛成 `GATEWAY_UNREACHABLE`**,理由逐条:
412
+ * - 401 = 会话过期/缺失(`middleware` 的会话鉴权)——唯一一条服务端**真的会为这条路由**发出的
413
+ * 拒绝,也是唯一一条能让人做点什么(重新登录)的;
414
+ * - 403 不会出现:这条路由的鉴权只有会话,没有「有没有被分配虚拟 KEY」那一层(正因为如此,
415
+ * 这里才不能复用 `getMeJson`,那个函数会把 403 读成「没分配 KEY」);
416
+ * - 404 不是这条路由的响应形状:清单**永远**是 200 + 一份数组,某条技能被停用 / 被撤销是
417
+ * 「不在数组里」,不是 404(`MyAssignments` 逐条过滤,不会为单条抛错)。真收到 404 只可能是
418
+ * 「路径压根没被路由」——老网关没有这条路由的时候就是这个样子,而它与反代配错无法区分,
419
+ * 所以诚实读法是网关不可达,不是「管理员把技能全撤了」;
420
+ * - 503 不会出现:员工端这条路由**无条件注册**,服务端没有任何「技能下发未启用」的闸
421
+ * (`getMySkillPackage` 那条 503 也一样没有生产者,见那里的注释),整份响应由 service
422
+ * 直接产出;
423
+ * - 其余(5xx / 反代错误页 / 维护页)同理:没有可解释的拒绝语义,收敛成网关不可达。
424
+ *
425
+ * 形状校验(`assignments` 必须是数组)**不在这里**:本方法只做传输与状态码映射,把 JSON 直接
426
+ * 断言成契约类型。真正的形状判据在消费端 `/hiwork-managed-skills` 的 Host 半边
427
+ * (`src/managed-skills.ts` 的 `fetchOnce`)——那里才决定「这一份算不算成功拉到的清单」。
428
+ */
429
+ async getMySkillAssignments(sessionCookie) {
430
+ const res = await this.requestOrUnreachable(ME_SKILL_ASSIGNMENTS_PATH, { method: 'GET', sessionCookie });
431
+ if (res.status === 401)
432
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
433
+ // 403 / 404 / 503 / 5xx / 反代错误页一律「网关不可达」:见方法注释里的逐条理由。
434
+ if (!res.ok)
435
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
436
+ return await parseMeJson(res);
437
+ }
438
+ /**
439
+ * GET /api/me/skill-packages/:assignmentId:取一条分配的包体字节(spec §4.2)。
440
+ *
441
+ * **这条路径是「两个端口」里的另一条**,它才有独立的拒绝语义(与服务端 handler 一一对应):
442
+ * - 404 = 这条分配不在这个账号名下(已被撤销 / 从未属于它,`ErrSkillAssignmentNotForUser`
443
+ * ——技能被停用 / 分配被停用也走这一条,服务端刻意不区分),
444
+ * - 503 = 部署未启用技能下发(**保留的防御性映射:当前没有生产者**,见下)。
445
+ * 两者必须分开映射,否则客户端只会看到一句「拉包失败」。用途是**别把原因咽下去**:404 让人
446
+ * 停止重试、把原因显示出来(「这条分配已失效,稍后同步会重新核对」),503 让人知道该找管理员。
447
+ * ⚠️ 事实核对(spec §4.5):`SKILL_DISTRIBUTION_DISABLED` 全仓**只有本方法这一个生产方**
448
+ * (本方法里 503 那一支),而 ai-hub 目前**没有任何**会产生这个 503 的代码——员工端两条路由在
449
+ * `cmd/server/main.go` 里无条件注册,技能 / 分配被停用在员工端走 404
450
+ * (`ErrSkillAssignmentNotForUser`)。映射保留是**防御性 + 与 MCP 同形**(那里
451
+ * `MCP_ASSERTION_DISABLED` 真有生产者:`me_mcp_handler.go` 的 503),别拿它当「会发生的分支」
452
+ * 去设计行为。
453
+ * ⚠️ **这两条都只是「失败的原因」,不是删除本地受管技能的依据**:删除的唯一权柄是「成功拉到的
454
+ * 清单」(spec §6 的唯一安全不变量),由 Host 的清单端点产出。本文件里没有任何一处据 404 或
455
+ * `contentSha256` 去删本地技能(全仓 `SKILL_ASSIGNMENT_GONE` 只有生产方、类型联合、文案表与
456
+ * 测试,没有消费方)。
457
+ * 404 而不泄露「这条分配是否存在」是服务端的选择(§4.2 明确要 404 不要 403)。
458
+ *
459
+ * 本调用方从不发 `If-None-Match`(§4.2 说 304 那条是给管理端与 curl 排障用的),所以 304
460
+ * 不在这条路径上;真收到(反代插话)会落进下面那条 → 网关不可达。
461
+ *
462
+ * 包体**只在本函数里短暂存在**:交给 Host 侧频道(src/managed-skills.ts)后立即 base64
463
+ * 出去,网关会话 cookie 全程不出 Host,浏览器侧永远拿不到字节。
464
+ */
465
+ async getMySkillPackage(sessionCookie, assignmentId) {
466
+ const res = await this.requestOrUnreachable(`${ME_SKILL_PACKAGES_PATH}/${encodeURIComponent(assignmentId)}`, { method: 'GET', sessionCookie });
467
+ if (res.status === 401)
468
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
469
+ // 这条分配不在这个账号名下(已被撤销 / 从未属于它):客户端据此停止重试,并把这句话显示出来。
470
+ // 它**不是**删除本地受管技能的依据(见方法注释末段)。
471
+ if (res.status === 404) {
472
+ throw new GatewayError('SKILL_ASSIGNMENT_GONE', '这条分配已失效,稍后同步会重新核对', 404);
473
+ }
474
+ // 部署未启用技能下发:与 MCP 的 503 同款单独映射,别混进「网关不可达」。
475
+ // ⚠️ 当前没有生产者(见方法注释末段的事实核对):这一支是保留下来的防御性映射。
476
+ if (res.status === 503) {
477
+ throw new GatewayError('SKILL_DISTRIBUTION_DISABLED', '本部署未启用技能下发,请联系管理员', 503);
478
+ }
479
+ if (!res.ok)
480
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
481
+ return {
482
+ skillName: res.headers.get('x-hiwork-skill-name') ?? '',
483
+ contentSha256: res.headers.get('x-hiwork-content-sha256') ?? '',
484
+ bytes: new Uint8Array(await res.arrayBuffer()),
485
+ };
486
+ }
487
+ /**
488
+ * GET /api/me/workbench-apps:管理员登记并分配给当前账号的工作台应用清单(spec §4.1)。
489
+ *
490
+ * 与技能清单同款**不复用 `getMeJson`**:那条路径把 403 映射成 `NO_KEY_ASSIGNED`(「尚未被分配
491
+ * 虚拟 KEY」),而本端点的鉴权只有会话——一个还没被分配 Key 的账号照样应该有工作台。
492
+ *
493
+ * 状态码映射(**与技能清单有一处刻意的不同**,理由在下面):
494
+ * - 401 = 会话过期/缺失:唯一一条能让人做点什么(重新登录)的拒绝;
495
+ * - **404 = 本部署未启用工作台**:这条路由在网关侧是「有配置才有」的(spec §10 的降级矩阵:
496
+ * 「网关未配置 workbench → 回 404」),因此 404 不是「应用清单为空」也不是网络故障,
497
+ * 而是一个可展示的功能状态——客户端据此显示「本部署尚未启用工作台」而不是报错。
498
+ * ⚠️ 技能清单那半**刻意**没有这一档(那里 404 只能是路径没被路由,收敛成网关不可达);
499
+ * 两边的差别是服务端语义的差别,不是实现风格的差别,别顺手对齐;
500
+ * - 其余(403 / 5xx / 反代错误页 / 维护页)一律 `GATEWAY_UNREACHABLE`:没有可解释的拒绝语义。
501
+ *
502
+ * 形状校验(`apps` 必须是数组)**不在这里**:本方法只做传输与状态码映射,把 JSON 直接断言成
503
+ * 契约类型;真正的形状判据在消费端 `/hiwork-workbench` 的 Host 半边(`src/workbench-bridge.ts`),
504
+ * 那里才决定「这一份算不算成功拉到的清单」。
505
+ */
506
+ async getMyWorkbenchApps(sessionCookie) {
507
+ const res = await this.requestOrUnreachable(ME_WORKBENCH_APPS_PATH, { method: 'GET', sessionCookie });
508
+ if (res.status === 401)
509
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
510
+ if (res.status === 404)
511
+ throw new GatewayError('WORKBENCH_DISABLED', '本部署未启用工作台,请联系管理员', 404);
512
+ if (!res.ok)
513
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
514
+ return await parseMeJson(res);
515
+ }
266
516
  /**
267
517
  * /api/me 自助端点的 GET 公共路径:网络异常 → GATEWAY_UNREACHABLE,
268
518
  * 401/403/400 映射为稳定 code,响应体非 JSON 时不把原文带进异常。
@@ -306,7 +556,11 @@ export class AiRouterClient {
306
556
  headers,
307
557
  signal: AbortSignal.timeout(this.timeoutMs),
308
558
  };
309
- if (opts.body !== undefined)
559
+ // Uint8Array 作为 BodyInit 需要 ArrayBuffer 视图(TS DOM 类型要求)
560
+ if (opts.rawBody !== undefined) {
561
+ init.body = opts.rawBody.buffer.slice(opts.rawBody.byteOffset, opts.rawBody.byteOffset + opts.rawBody.byteLength);
562
+ }
563
+ else if (opts.body !== undefined)
310
564
  init.body = JSON.stringify(opts.body);
311
565
  return fetch(`${this.baseURL}${path}`, init);
312
566
  }