hiwork-core 0.0.30 → 0.0.35

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.
Files changed (38) hide show
  1. package/cordis.patch.yml +15 -1
  2. package/lib/ai-router-client.js +394 -2
  3. package/lib/client-policy.js +2 -2
  4. package/lib/client.js +2413 -439
  5. package/lib/image-rpc.js +306 -0
  6. package/lib/imagegen.js +424 -0
  7. package/lib/index.js +19212 -7694
  8. package/lib/knowledge-config.js +91 -1
  9. package/lib/provision-session.js +10 -2
  10. package/lib/tool-names.js +10 -0
  11. package/lib/tools.js +205 -0
  12. package/lib/types/ai-router-client.d.ts +255 -2
  13. package/lib/types/client/about-section.d.ts +27 -7
  14. package/lib/types/client/controller.d.ts +17 -9
  15. package/lib/types/client/feature-center.d.ts +46 -1
  16. package/lib/types/client/feature-registry.d.ts +36 -3
  17. package/lib/types/client/image-cards.d.ts +218 -0
  18. package/lib/types/client/image-styles.d.ts +9 -0
  19. package/lib/types/client/images-data.d.ts +127 -0
  20. package/lib/types/client/images-page.d.ts +97 -0
  21. package/lib/types/client/index.d.ts +0 -1
  22. package/lib/types/client/login-gate.d.ts +2 -2
  23. package/lib/types/client/logo.d.ts +7 -2
  24. package/lib/types/client/menu-preferences.d.ts +163 -0
  25. package/lib/types/client/menu-section.d.ts +5 -0
  26. package/lib/types/client/menu-store.d.ts +101 -0
  27. package/lib/types/client/styles.d.ts +16 -0
  28. package/lib/types/client/types.d.ts +9 -0
  29. package/lib/types/image-rpc.d.ts +142 -0
  30. package/lib/types/imagegen.d.ts +241 -0
  31. package/lib/types/index.d.ts +70 -8
  32. package/lib/types/knowledge-config.d.ts +50 -2
  33. package/lib/types/tool-names.d.ts +11 -0
  34. package/lib/types/tools.d.ts +80 -0
  35. package/lib/types/user-agent.d.ts +31 -0
  36. package/lib/user-agent.js +54 -0
  37. package/lib/web-search.js +2 -2
  38. package/package.json +34 -24
package/cordis.patch.yml CHANGED
@@ -23,6 +23,15 @@
23
23
  - id: web-search-deepseek
24
24
  disabled: true
25
25
 
26
+ # rc.2 的 base bundle 新增官方账号路径(「登录 DeepSeek 官方账号」入口 + 账号型 provider)。
27
+ # 与上面 llm-deepseek 同一理由:本产品只供给网关模型,官方账号入口对企业用户必然不可用,
28
+ # 一并停用(0.1.7-rc.2 升级实施期评估项,spec §2.3)。
29
+ - id: deepseek-account
30
+ disabled: true
31
+
32
+ - id: llm-deepseek-account
33
+ disabled: true
34
+
26
35
  # 把 web_search 的后端 pin 到本插件注册的 `hiwork-managed-search`。
27
36
  # ⚠️ bundle patch 对 config 是**整行替换**不是合并:基座此行的原值是
28
37
  # `{ searchProvider: deepseek-official, fetchProvider: http }`,两个 pin 必须一并重述,
@@ -41,8 +50,13 @@
41
50
  search: true
42
51
  searchTimeoutMs: 60000
43
52
 
53
+ # entry id 是 rc.2 settings 的命名空间(`ctx.settings` 的 describe/mutate 都按它寻址)。
54
+ # 用 'ai-router' 而不是包名:本插件的 settings 段落在 rc.1 时代就叫 ai-router,
55
+ # 上游启动期把旧 settings.yaml 各段按「段落名 = entry id」一次性导入 profile,
56
+ # 同名才能把存量用户的 keyId / lastLogin / loginMode 等原样搬进来。
57
+ # src/index.ts 的 NS 与 client 半边的 AI_ROUTER_NS 都是同一字面量,三处必须逐字一致。
44
58
  - insert:
45
- - id: hiwork-core
59
+ - id: ai-router
46
60
  name: 'hiwork-core'
47
61
  config:
48
62
  # 企业网关地址固定配置,不提供用户填写入口。
@@ -1,4 +1,19 @@
1
- import { PKG_VERSION } from "./version.js";
1
+ import { gatewayUserAgent } from "./user-agent.js";
2
+ /**
3
+ * 读错误响应体里的结构化 `code`(读不到 → 空串)。
4
+ *
5
+ * 只取 `code` 这一个字段:错误体里可能带 URL/内部标识,**不**把原文带进 message。
6
+ * 消费端已经拿过一次响应体,这里用 clone 读,避免影响后续 `parseMeJson`。
7
+ */
8
+ async function knowledgeErrorCodeFromBody(res) {
9
+ try {
10
+ const payload = (await res.clone().json());
11
+ return typeof payload?.code === 'string' ? payload.code : '';
12
+ }
13
+ catch {
14
+ return '';
15
+ }
16
+ }
2
17
  /** 浏览器授权登录(PKCE)端点:域名一律来自 baseURL,不硬编码。 */
3
18
  const AUTH_AUTHORIZE_PATH = '/api/auth/authorize';
4
19
  const AUTH_TOKEN_PATH = '/api/auth/token';
@@ -50,6 +65,7 @@ const ME_WORKBENCH_APPS_PATH = '/api/me/workbench-apps';
50
65
  * 与 MCP 清单同理,本端点只看会话——没有被分配虚拟 Key 的账号照样拿得到。
51
66
  */
52
67
  const ME_OFFICE_CONFIG_PATH = '/api/me/office-config';
68
+ const ME_KNOWLEDGE_TOKEN_PATH = '/api/me/knowledge-token';
53
69
  /**
54
70
  * 文档中继(跨 NAT 的在线编辑):桌面端上传 / 轮询 / 取回 / 关闭。
55
71
  * 四条都在网关 `/api/me` 会话组下;DS 侧的那两条(拉文档、回调)由网关直接对 DS 签发,
@@ -69,6 +85,93 @@ const ME_OFFICE_RELAY_DOCUMENTS_PATH = '/api/me/office-relay/documents';
69
85
  */
70
86
  const ME_VOICE_CONFIG_PATH = '/api/me/voice-config';
71
87
  const ME_VOICE_TRANSCRIBE_PATH = '/api/me/voice-transcribe';
88
+ /**
89
+ * 生图中继端点(桌面端说话/卡片触发的唯一出网点)与「我的图片」自助端点。
90
+ *
91
+ * 三条都在网关 `/api/me` 会话组下,鉴权只有 `SessionAuth`(只读 Cookie)——与语音同理,
92
+ * **一个还没被分配虚拟 Key 的账号照样应当能用生图**(分能力与分 Key 是两件事),
93
+ * 所以这些方法都不能复用 `getMeJson`(那条路径把 403 读成「没分配 KEY」)。
94
+ *
95
+ * `image-config` 恒 200(开关关也在 200 里体现成 `enabled:false`);`image-generate` 是
96
+ * 七档错误的冻结契约(见 {@link ImageErrorCode});`/api/me/images` 与
97
+ * `/api/me/images/:id/file` 是**既有**的员工自助端点(管理台「我的图片」也在用),
98
+ * 桌面端画廊页只是第二个消费方。**上游地址、模型提供方、虚拟 Key 桌面端一概不知道。**
99
+ */
100
+ const ME_IMAGE_CONFIG_PATH = '/api/me/image-config';
101
+ const ME_IMAGE_GENERATE_PATH = '/api/me/image-generate';
102
+ const ME_IMAGES_PATH = '/api/me/images';
103
+ /**
104
+ * 生图的单次请求超时(**按网关下发的预算算**,不是常量)。
105
+ *
106
+ * 生图是「数十秒 ~ 分钟级」的调用,默认 10s 会**稳定误判**成网关不可达(语音那条的
107
+ * 同一理由,只是量级更大)。真正该等多久由网关的 `image.timeout_seconds` 决定,
108
+ * 而这个值**现在随 `image-config` 下发**(见 {@link GatewayImageConfig.timeoutSeconds})。
109
+ *
110
+ * 为什么不写常量(实测踩过):原实现把网关闭口硬编码成 180s,而网关实际被管理员
111
+ * 设为 600s —— 于是每次真实生图(实测 213s / 415s)都被客户端判超时,网关却还在画。
112
+ * 与 `model` 同理:参数只在网关,客户端跟随下发。
113
+ *
114
+ * `list` / `fetch` 是普通 JSON / 二进制往返,仍用构造时的默认超时。
115
+ */
116
+ export const IMAGE_GATEWAY_TIMEOUT_MARGIN_MS = 15_000;
117
+ /**
118
+ * 读不到 `timeout_seconds` 时的兜底等待上限(仅此情形用)。
119
+ *
120
+ * 取 900s:网关侧 `image.timeout_seconds` 的合法区间是 30~600(`intRange(30, 600)`),
121
+ * 兜底必须**大于它的上界**,否则配置读不出来时又会对合法调用误判超时。
122
+ * 配置正常时**不用**这个值——用「网关预算 + 15s」,这样网关自己 504 之前客户端
123
+ * 不会先放弃(否则用户看到的是「网络错误」而不是「超时」)。
124
+ */
125
+ export const IMAGE_GENERATE_TIMEOUT_FALLBACK_MS = 900_000;
126
+ /**
127
+ * 按网关下发的单次预算算客户端等待上限。
128
+ *
129
+ * 传 `undefined` / 非法值(配置没读到、字段缺失、0 或负数)→ 兜底 900s;
130
+ * 否则 = 预算 + {@link IMAGE_GATEWAY_TIMEOUT_MARGIN_MS}。余量是留给中转与网络的:
131
+ * 它必须**大于**网关自己的超时,才能让「网关判超时(504 → IMAGE_TIMEOUT)」先于
132
+ * 「客户端放弃(GATEWAY_UNREACHABLE)」发生——两者对用户的含义完全不同(一个重试、
133
+ * 一个找运维)。
134
+ */
135
+ export function imageGenerateTimeoutMs(gatewayTimeoutSeconds) {
136
+ if (gatewayTimeoutSeconds === undefined ||
137
+ !Number.isFinite(gatewayTimeoutSeconds) ||
138
+ gatewayTimeoutSeconds <= 0) {
139
+ return IMAGE_GENERATE_TIMEOUT_FALLBACK_MS;
140
+ }
141
+ return gatewayTimeoutSeconds * 1000 + IMAGE_GATEWAY_TIMEOUT_MARGIN_MS;
142
+ }
143
+ /** `image-config` 的尺寸枚举兜底(网关契约恒给 `sizes`;缺字段时不回空数组)。 */
144
+ const IMAGE_FALLBACK_SIZES = ['1024x1024', '1024x1536', '1536x1024'];
145
+ /** 尺寸缺省值兜底(网关 `default_size` 缺字段 / 类型不符时)。 */
146
+ const IMAGE_FALLBACK_DEFAULT_SIZE = '1024x1024';
147
+ /**
148
+ * 生图端点各稳定 code 的本地兜底文案(网关响应体没有可展示的 message 时用)。
149
+ *
150
+ * 文案与 spec §6.6 / 卡片侧 `image-cards.tsx` 的 `IMAGE_ERROR_TEXT` **逐字对齐**:这份兜底
151
+ * 会经 `src/imagegen.ts` 的 message 消毒一路走到用户眼前(网关没给 `message` 时),
152
+ * 两处写法不一致就会变成「同一个错误、两种说法、看网关今天给不给文案」。
153
+ */
154
+ const IMAGE_ERROR_FALLBACK_MESSAGE = {
155
+ IMAGE_BAD_REQUEST: '这个提示词没能生成,换个说法再试',
156
+ IMAGE_BUDGET_EXCEEDED: '生图暂时不可用:可能是本月额度用尽,也可能是这个模型还没配置好——请联系管理员',
157
+ IMAGE_TOO_LARGE: '生成的图片太大了,换个小一点的尺寸(如 1024x1024)',
158
+ IMAGE_DISABLED: '管理员还没开启图片生成',
159
+ IMAGE_NOT_CONFIGURED: '图片生成还没有配置好,请联系管理员',
160
+ IMAGE_UPSTREAM_ERROR: '图片生成服务暂时不可用,请稍后重试',
161
+ IMAGE_TIMEOUT: '生成超时了,再试一次(复杂画面会比较慢)',
162
+ IMAGE_NOT_FOUND: '这张图片不存在或已被清理',
163
+ IMAGE_FORBIDDEN: '这张图片不属于当前账号',
164
+ IMAGE_WORKSPACE_NOT_FOUND: '这个工作区不存在或已被移除',
165
+ IMAGE_SAVE_FAILED: '图片已取回但没能保存到工作区',
166
+ };
167
+ /** 生图端点未知 code 的兜底文案(不暴露任何上游细节)。 */
168
+ const IMAGE_ERROR_DEFAULT_MESSAGE = '图片生成服务暂时不可用,请稍后重试';
169
+ /**
170
+ * 取单张图时的读取上限 20MiB(与桌面端附件上限、`src/imagegen.ts` 的 `MAX_IMAGE_BYTES`
171
+ * 同值)。放这里的是 **HTTP 读取侧**的防线:`content-length` 预判 + 读回后复核,
172
+ * 两道都过不去才继续 —— 超限一律 `IMAGE_TOO_LARGE`,绝不把巨图读进内存再 base64。
173
+ */
174
+ const IMAGE_MAX_BYTES = 20 * 1024 * 1024;
72
175
  /**
73
176
  * `voice-transcribe` 的单次请求超时:默认 10s 只够一次普通的 JSON 往返,而这条要
74
177
  * 「上传一段录音 + 等上游识别」,公网 + 冷启动时 10s 会稳定误判成网关不可达。
@@ -91,6 +194,10 @@ const VOICE_ERROR_DEFAULT_MESSAGE = '语音识别失败,请稍后重试';
91
194
  /** 授权请求默认值:桌面端 client_id 与自定义协议回调地址(网关侧精确匹配)。 */
92
195
  const DEFAULT_CLIENT_ID = 'hiwork-desktop';
93
196
  const DEFAULT_REDIRECT_URI = 'hiwork://auth/callback';
197
+ /** 该码是否属于生图链路(见 {@link ImagePrefixedCode})。 */
198
+ export function isImageErrorCode(code) {
199
+ return code.startsWith('IMAGE_');
200
+ }
94
201
  /** 网关错误:携带稳定 code,供编排层映射。 */
95
202
  export class GatewayError extends Error {
96
203
  code;
@@ -392,6 +499,21 @@ export class AiRouterClient {
392
499
  * 「部署未配置」的稳定信号,单独映射——消费方(hiwork-office)据此提示联系管理员
393
500
  * 并回退本地手工配置,而不是报一句含糊的「网关不可达」。
394
501
  */
502
+ async getMyKnowledgeToken(sessionCookie) {
503
+ const res = await this.requestOrUnreachable(ME_KNOWLEDGE_TOKEN_PATH, { method: 'POST', sessionCookie, body: {} });
504
+ if (res.status === 401)
505
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
506
+ if (res.status === 503) {
507
+ // 网关这条端点的 503 有两义:`KNOWLEDGE_NOT_CONFIGURED`(管理员还没开知识库)与
508
+ // `KNOWLEDGE_TOKEN_NOT_CONFIGURED`(开了知识库但没配签名私钥)。两者对员工的结论一致
509
+ // ——「本部署还没配好,找管理员」——所以归同一个码;body 里的 code 只用来把话说明白。
510
+ const code = await knowledgeErrorCodeFromBody(res);
511
+ throw new GatewayError('KNOWLEDGE_NOT_CONFIGURED', code === 'KNOWLEDGE_TOKEN_NOT_CONFIGURED' ? '知识库签名密钥未配置' : '知识库尚未配置', 503);
512
+ }
513
+ if (!res.ok)
514
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
515
+ return await parseMeJson(res);
516
+ }
395
517
  async getMyOfficeConfig(sessionCookie) {
396
518
  const res = await this.requestOrUnreachable(ME_OFFICE_CONFIG_PATH, { method: 'GET', sessionCookie });
397
519
  if (res.status === 401)
@@ -631,6 +753,173 @@ export class AiRouterClient {
631
753
  // 才回显**,绝不把非字符串字段(可能是对象 / 上游原文片段)塞进客户端。
632
754
  return { text: typeof data.text === 'string' ? data.text : '' };
633
755
  }
756
+ /**
757
+ * GET /api/me/image-config:当前账号的生图开关、模型名、默认尺寸与可选尺寸。
758
+ *
759
+ * 与 {@link getMyVoiceConfig} 同款,**不复用 `getMeJson`**:那条路径把 403 映射成
760
+ * `NO_KEY_ASSIGNED`(「尚未被分配虚拟 KEY」),而本端点的鉴权只有会话——分能力与分 Key
761
+ * 是两件事。契约**恒 200**(开关开与关都是 200 + `enabled` 布尔),所以除 401 以外没有
762
+ * 任何需要单独解释的拒绝语义:403 / 404 / 5xx / 反代错误页一律 `GATEWAY_UNREACHABLE`。
763
+ *
764
+ * 形状收敛(**本层负责**,消费方拿到的永远是一个合法的 {@link GatewayImageConfig}):
765
+ * `default_size` → `defaultSize`;字段缺失 / 类型不符按「禁用 + 默认尺寸 + 三尺寸枚举」
766
+ * 处理——配置读不出来时生图本就该消失,让消费方去猜 undefined 是把形状问题推给下游。
767
+ * 真正的「读不到就回落默认尺寸」在 `src/imagegen.ts`。
768
+ */
769
+ async getMyImageConfig(sessionCookie) {
770
+ const res = await this.requestOrUnreachable(ME_IMAGE_CONFIG_PATH, { method: 'GET', sessionCookie });
771
+ if (res.status === 401)
772
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
773
+ if (!res.ok)
774
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
775
+ const data = await parseMeJson(res);
776
+ const sizes = Array.isArray(data.sizes)
777
+ ? data.sizes.filter((item) => typeof item === 'string' && item !== '')
778
+ : [];
779
+ return {
780
+ enabled: data.enabled === true,
781
+ model: typeof data.model === 'string' ? data.model : '',
782
+ defaultSize: typeof data.default_size === 'string' && data.default_size !== ''
783
+ ? data.default_size
784
+ : IMAGE_FALLBACK_DEFAULT_SIZE,
785
+ sizes: sizes.length > 0 ? sizes : [...IMAGE_FALLBACK_SIZES],
786
+ quality: typeof data.quality === 'string' ? data.quality : '',
787
+ // 非法值一律折成 undefined(而非 0)——交给 imageGenerateTimeoutMs 走兜底。
788
+ ...(typeof data.timeout_seconds === 'number' &&
789
+ Number.isFinite(data.timeout_seconds) &&
790
+ data.timeout_seconds > 0
791
+ ? { timeoutSeconds: data.timeout_seconds }
792
+ : {}),
793
+ };
794
+ }
795
+ /**
796
+ * POST /api/me/image-generate:把一句提示词交给网关**用该用户自己的虚拟 Key**代发生图。
797
+ *
798
+ * 状态码映射与服务端 handler 一一对应(设计 §5.2 的冻结表),**不许合并**:
799
+ * - 401 = 会话过期(唯一一条能让人重新登录来解决的拒绝);
800
+ * - 400 = 坏 JSON / prompt 空或超长 / size 不在枚举内 → `IMAGE_BAD_REQUEST`;
801
+ * - 402 = 该 Key 对该模型的预算预检失败 → `IMAGE_BUDGET_EXCEEDED`(复用的既有 402
802
+ * 语义;网关侧有四条不同成因共用这个状态码,所以卡片文案写成「两种成因都覆盖」——
803
+ * 桌面端**不按中文文案分流**,那是脆契约);
804
+ * - 413 = 单张图超限 → `IMAGE_TOO_LARGE`;
805
+ * - 503 = 网关自己发的「功能不可用」,按响应体 `code` 细分 `IMAGE_NOT_CONFIGURED`
806
+ * (链路没配好)/ `IMAGE_DISABLED`(管理员关了开关)。响应体没有 `code`
807
+ * (非 JSON / 反代错误页)归 `IMAGE_DISABLED`——这条路由的 503 语义就是「没开」,
808
+ * 而**上游故障走 502**(见下),不会伪装成 503;
809
+ * - 504 = 超过网关预算 → `IMAGE_TIMEOUT`(与上游故障分开:一个重试、一个找运维);
810
+ * - 其余(含 502 = 上游出错)→ `IMAGE_UPSTREAM_ERROR`。
811
+ *
812
+ * `key_id` 走 **query**(缺省 = 该用户首个 Key),与 `POST /api/me/cost-estimate`
813
+ * 同一口径;桌面端目前不传它。
814
+ *
815
+ * 超时**单独放宽**:这条要等上游画完一张图,构造时的 10s 默认值在这里是稳定的误报源。
816
+ * 等待上限由调用方按网关下发的预算传进来(见 {@link imageGenerateTimeoutMs});
817
+ * 不传则走兜底——**不要**在这里写死一个数,那正是上一版故障的来源。
818
+ * `size` / `quality` 缺省时由网关落设置默认值。
819
+ *
820
+ * **响应体里的 base64 不进日志**:本方法只把它交给调用方(`src/imagegen.ts` 写盘),
821
+ * 任何日志都只记尺寸 / 耗时 / 字节数。
822
+ */
823
+ async generateImage(sessionCookie, req, keyId, timeoutMs) {
824
+ const query = meQuery([['key_id', keyId]]);
825
+ const res = await this.requestOrUnreachable(`${ME_IMAGE_GENERATE_PATH}${query}`, {
826
+ method: 'POST',
827
+ sessionCookie,
828
+ body: {
829
+ prompt: req.prompt,
830
+ // 缺省字段**不出现**在请求体里(网关据此落设置默认值),也不是 null。
831
+ ...(req.size === undefined ? {} : { size: req.size }),
832
+ ...(req.quality === undefined ? {} : { quality: req.quality }),
833
+ },
834
+ timeoutMs: timeoutMs ?? IMAGE_GENERATE_TIMEOUT_FALLBACK_MS,
835
+ });
836
+ if (res.status === 401)
837
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
838
+ if (!res.ok) {
839
+ const body = await readImageError(res);
840
+ const code = imageErrorCode(res.status, body.code);
841
+ throw new GatewayError(code, imageMessage(code, body), res.status);
842
+ }
843
+ const data = await parseMeJson(res);
844
+ return {
845
+ images: readGeneratedImages(data.images),
846
+ model: typeof data.model === 'string' ? data.model : '',
847
+ size: typeof data.size === 'string' ? data.size : '',
848
+ generationMs: typeof data.generation_ms === 'number' && Number.isFinite(data.generation_ms)
849
+ ? data.generation_ms
850
+ : 0,
851
+ requestId: typeof data.request_id === 'string' ? data.request_id : '',
852
+ };
853
+ }
854
+ /**
855
+ * GET /api/me/images:当前账号的生图历史分页列表(**既有**员工自助端点,管理台「我的
856
+ * 图片」也在用;桌面端画廊页只是第二个消费方)。
857
+ *
858
+ * 与技能/语音那几条同理**不复用 `getMeJson`**(403 是「没分配 KEY」,与这里无关)。
859
+ * 这条路径上只有 401 有独立语义,其余一律 `GATEWAY_UNREACHABLE`。
860
+ *
861
+ * 形状收敛在本层一次做完:`page_size` → `pageSize`,行的 snake_case 字段 → camelCase,
862
+ * 并**丢掉 `file_url`**(浏览器拿不到会话 cookie,看单张一律走 {@link fetchMyImage})。
863
+ * 分页缺省由网关决定(它按自己的缺省值翻页),这里原样回显它给的值。
864
+ */
865
+ async getMyImages(sessionCookie, req = {}) {
866
+ const query = meQuery([
867
+ ['page', req.page === undefined ? undefined : String(req.page)],
868
+ ['page_size', req.pageSize === undefined ? undefined : String(req.pageSize)],
869
+ ]);
870
+ const res = await this.requestOrUnreachable(`${ME_IMAGES_PATH}${query}`, { method: 'GET', sessionCookie });
871
+ if (res.status === 401)
872
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
873
+ if (!res.ok)
874
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
875
+ const data = await parseMeJson(res);
876
+ return {
877
+ items: readImageRows(data.items),
878
+ total: readCount(data.total),
879
+ page: readCount(data.page),
880
+ pageSize: readCount(data.page_size),
881
+ };
882
+ }
883
+ /**
884
+ * GET /api/me/images/:id/file:取一张图片的本体(网关侧代理流式下发,无预签名直链)。
885
+ *
886
+ * 与「我的图片」列表同款**不复用 `getMeJson`**。这条路径上的拒绝是有语义的、必须分开:
887
+ * - 404 = 这张图不存在(已被清理)→ `IMAGE_NOT_FOUND`;
888
+ * - 403 = 这张图不在当前账号名下(网关按「该用户的 Key 列表」判定)→ `IMAGE_FORBIDDEN`;
889
+ * - 401 = 会话过期。
890
+ *
891
+ * **体积闸**:超过 20MiB 直接拒(`IMAGE_TOO_LARGE`),**先按 `content-length` 预判**,
892
+ * 读回来再复核一次——绝不把几十 MB 的 base64 塞进 loopback RPC 信封。
893
+ * (20MiB 这个值与 `src/imagegen.ts` 的 `MAX_IMAGE_BYTES` 是同一个跨层常量:那边是
894
+ * 业务上限的单一事实源,这边是 HTTP 读取侧的防线,两处都写死以免任一侧被悄悄放宽。)
895
+ */
896
+ async fetchMyImage(sessionCookie, id) {
897
+ const res = await this.requestOrUnreachable(`${ME_IMAGES_PATH}/${encodeURIComponent(String(id))}/file`, {
898
+ method: 'GET',
899
+ sessionCookie,
900
+ });
901
+ if (res.status === 401)
902
+ throw new GatewayError('SESSION_EXPIRED', '网关会话已过期,请重新登录', 401);
903
+ if (res.status === 404)
904
+ throw new GatewayError('IMAGE_NOT_FOUND', '这张图片不存在或已被清理', 404);
905
+ if (res.status === 403)
906
+ throw new GatewayError('IMAGE_FORBIDDEN', '这张图片不属于当前账号', 403);
907
+ if (!res.ok)
908
+ throw new GatewayError('GATEWAY_UNREACHABLE', `网关返回错误(HTTP ${res.status})`, res.status);
909
+ const declared = Number(res.headers.get('content-length') ?? '');
910
+ if (Number.isFinite(declared) && declared > IMAGE_MAX_BYTES) {
911
+ throw new GatewayError('IMAGE_TOO_LARGE', '生成的图片太大了', 413);
912
+ }
913
+ const bytes = new Uint8Array(await res.arrayBuffer());
914
+ if (bytes.byteLength > IMAGE_MAX_BYTES) {
915
+ throw new GatewayError('IMAGE_TOO_LARGE', '生成的图片太大了', 413);
916
+ }
917
+ return {
918
+ dataBase64: Buffer.from(bytes).toString('base64'),
919
+ mimeType: res.headers.get('content-type') ?? 'image/png',
920
+ byteSize: bytes.byteLength,
921
+ };
922
+ }
634
923
  /**
635
924
  * /api/me 自助端点的 GET 公共路径:网络异常 → GATEWAY_UNREACHABLE,
636
925
  * 401/403/400 映射为稳定 code,响应体非 JSON 时不把原文带进异常。
@@ -663,7 +952,7 @@ export class AiRouterClient {
663
952
  }
664
953
  }
665
954
  async request(path, opts) {
666
- const headers = { 'user-agent': `hiwork-core/${PKG_VERSION}` };
955
+ const headers = { 'user-agent': gatewayUserAgent() };
667
956
  if (opts.body !== undefined)
668
957
  headers['content-type'] = 'application/json';
669
958
  if (opts.sessionCookie !== undefined)
@@ -802,6 +1091,109 @@ async function readVoiceError(res) {
802
1091
  function voiceMessage(code, body) {
803
1092
  return body.message ?? VOICE_ERROR_FALLBACK_MESSAGE[code] ?? VOICE_ERROR_DEFAULT_MESSAGE;
804
1093
  }
1094
+ /**
1095
+ * 读生图端点的错误响应体:只取字符串形态的 `code` 与 `message`,其余(含解析失败)
1096
+ * 一律当作「什么都没给」。**不抛错、不记录、不回显任何原文**——响应片段可能含内部地址
1097
+ * 或别的用户内容(与 `readVoiceError` 同源的理由)。
1098
+ */
1099
+ async function readImageError(res) {
1100
+ const data = await readJson(res);
1101
+ if (typeof data !== 'object' || data === null)
1102
+ return {};
1103
+ const record = data;
1104
+ return {
1105
+ ...(typeof record.code === 'string' && record.code !== '' ? { code: record.code } : {}),
1106
+ ...(typeof record.message === 'string' && record.message.trim() !== '' ? { message: record.message.trim() } : {}),
1107
+ };
1108
+ }
1109
+ /**
1110
+ * HTTP 状态码 → 生图稳定 code(设计 §5.2 的冻结表,逐档对齐服务端 handler 的 `respondError`)。
1111
+ *
1112
+ * 只有 503 需要看响应体(两义:链路没配好 vs 管理员关了开关);响应体没有 `code`
1113
+ * 时归 `IMAGE_DISABLED`——这条路由的 503 语义就是「没开」,上游故障走 502。
1114
+ * 504 与 502 分开:一个让员工重试,一个要找运维看链路。
1115
+ */
1116
+ function imageErrorCode(status, bodyCode) {
1117
+ if (status === 400)
1118
+ return 'IMAGE_BAD_REQUEST';
1119
+ if (status === 402)
1120
+ return 'IMAGE_BUDGET_EXCEEDED';
1121
+ if (status === 413)
1122
+ return 'IMAGE_TOO_LARGE';
1123
+ if (status === 503)
1124
+ return bodyCode === 'IMAGE_NOT_CONFIGURED' ? 'IMAGE_NOT_CONFIGURED' : 'IMAGE_DISABLED';
1125
+ if (status === 504)
1126
+ return 'IMAGE_TIMEOUT';
1127
+ return 'IMAGE_UPSTREAM_ERROR';
1128
+ }
1129
+ /** 生图错误文案:优先网关的冻结中文文案,否则用该 code 的本地兜底。 */
1130
+ function imageMessage(code, body) {
1131
+ return body.message ?? IMAGE_ERROR_FALLBACK_MESSAGE[code] ?? IMAGE_ERROR_DEFAULT_MESSAGE;
1132
+ }
1133
+ /**
1134
+ * 读 `images[]`:只收形状合法、且**有 base64 内容**的元素;非数组一律当空数组
1135
+ * (消费方据此判「响应里没有图片数据」并回 `IMAGE_UPSTREAM_ERROR`)。
1136
+ * `revised_prompt` 缺省时不造字段(`exactOptionalPropertyTypes` 下 null 与缺失是两回事)。
1137
+ */
1138
+ function readGeneratedImages(raw) {
1139
+ if (!Array.isArray(raw))
1140
+ return [];
1141
+ const images = [];
1142
+ for (const item of raw) {
1143
+ if (typeof item !== 'object' || item === null)
1144
+ continue;
1145
+ const record = item;
1146
+ if (typeof record.data_base64 !== 'string' || record.data_base64 === '')
1147
+ continue;
1148
+ images.push({
1149
+ dataBase64: record.data_base64,
1150
+ mimeType: typeof record.mime_type === 'string' ? record.mime_type : 'image/png',
1151
+ format: typeof record.format === 'string' ? record.format : '',
1152
+ byteSize: typeof record.byte_size === 'number' && Number.isFinite(record.byte_size) ? record.byte_size : 0,
1153
+ ...(typeof record.revised_prompt === 'string' && record.revised_prompt !== ''
1154
+ ? { revisedPrompt: record.revised_prompt }
1155
+ : {}),
1156
+ });
1157
+ }
1158
+ return images;
1159
+ }
1160
+ /** 非负安全整数(分页元数据);其余一律 0。 */
1161
+ function readCount(raw) {
1162
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 0;
1163
+ }
1164
+ /**
1165
+ * 读 `items[]`:逐行做 snake_case → camelCase 并**丢掉 `file_url`**(见
1166
+ * {@link GatewayImageRow})。`id` 不是正整数的行直接跳过——没有它就没法 `fetch`/`save`,
1167
+ * 渲染一个点不动的行比少一行更糟。
1168
+ */
1169
+ function readImageRows(raw) {
1170
+ if (!Array.isArray(raw))
1171
+ return [];
1172
+ const rows = [];
1173
+ for (const item of raw) {
1174
+ if (typeof item !== 'object' || item === null)
1175
+ continue;
1176
+ const record = item;
1177
+ if (typeof record.id !== 'number' || !Number.isFinite(record.id) || record.id <= 0)
1178
+ continue;
1179
+ const revisedPrompt = record.revised_prompt;
1180
+ rows.push({
1181
+ id: record.id,
1182
+ prompt: typeof record.prompt === 'string' ? record.prompt : '',
1183
+ ...(typeof revisedPrompt === 'string' && revisedPrompt !== '' ? { revisedPrompt } : {}),
1184
+ model: typeof record.model === 'string' ? record.model : '',
1185
+ size: typeof record.size === 'string' ? record.size : '',
1186
+ format: typeof record.format === 'string' ? record.format : '',
1187
+ mimeType: typeof record.mime_type === 'string' ? record.mime_type : '',
1188
+ byteSize: typeof record.byte_size === 'number' && Number.isFinite(record.byte_size) ? record.byte_size : 0,
1189
+ generationMs: typeof record.generation_ms === 'number' && Number.isFinite(record.generation_ms)
1190
+ ? record.generation_ms
1191
+ : 0,
1192
+ createdAt: typeof record.created_at === 'string' ? record.created_at : '',
1193
+ });
1194
+ }
1195
+ return rows;
1196
+ }
805
1197
  /** 判定 400 响应体是否为 invalid_grant(只读 error 字段)。 */
806
1198
  function isInvalidGrant(payload) {
807
1199
  return typeof payload === 'object' && payload !== null && payload.error === 'invalid_grant';
@@ -8,7 +8,7 @@
8
8
  * `projectLoginMode` 完成:**非 200 / 非 JSON / 枚举非法 / 超时 / 网络异常一律返回
9
9
  * undefined**,调用方据此回退 `DEFAULT_LOGIN_MODE`,绝不把用户锁在门外(spec §5.4 / §6.4)。
10
10
  */
11
- import { PKG_VERSION } from "./version.js";
11
+ import { gatewayUserAgent } from "./user-agent.js";
12
12
  /** fail-open 缺省值:策略缺失 / 失败 / 非法时一律按此投影(spec §5.4)。 */
13
13
  export const DEFAULT_LOGIN_MODE = 'both';
14
14
  /** 策略端点:域名一律来自 baseURL,不硬编码。 */
@@ -71,7 +71,7 @@ export async function fetchClientPolicy(opts) {
71
71
  try {
72
72
  const res = await fetch(url, {
73
73
  method: 'GET',
74
- headers: { 'user-agent': `hiwork-core/${PKG_VERSION}`, accept: 'application/json' },
74
+ headers: { 'user-agent': gatewayUserAgent(), accept: 'application/json' },
75
75
  signal: AbortSignal.timeout(opts.timeoutMs ?? CLIENT_POLICY_TIMEOUT_MS),
76
76
  });
77
77
  // 严格 200:304 / 204 等一律视为不可用(本插件不维护 ETag 缓存,宁可按缺省放行)。