hiwork-core 0.0.30 → 0.0.37
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 +15 -1
- package/lib/ai-router-client.js +394 -2
- package/lib/browser-login.js +42 -16
- package/lib/client-policy.js +2 -2
- package/lib/client.js +2496 -500
- package/lib/image-rpc.js +306 -0
- package/lib/imagegen.js +424 -0
- package/lib/index.js +18639 -7060
- package/lib/knowledge-config.js +91 -1
- package/lib/provision-session.js +10 -2
- package/lib/tool-names.js +10 -0
- package/lib/tools.js +205 -0
- package/lib/types/ai-router-client.d.ts +255 -2
- package/lib/types/browser-login.d.ts +10 -1
- package/lib/types/client/about-section.d.ts +27 -7
- package/lib/types/client/controller.d.ts +17 -9
- package/lib/types/client/daily-data.d.ts +31 -3
- package/lib/types/client/feature-center.d.ts +46 -1
- package/lib/types/client/feature-registry.d.ts +36 -3
- package/lib/types/client/image-cards.d.ts +218 -0
- package/lib/types/client/image-styles.d.ts +9 -0
- package/lib/types/client/images-data.d.ts +127 -0
- package/lib/types/client/images-page.d.ts +97 -0
- package/lib/types/client/index.d.ts +0 -1
- package/lib/types/client/login-gate.d.ts +2 -2
- package/lib/types/client/logo.d.ts +7 -2
- package/lib/types/client/menu-preferences.d.ts +163 -0
- package/lib/types/client/menu-section.d.ts +5 -0
- package/lib/types/client/menu-store.d.ts +101 -0
- package/lib/types/client/styles.d.ts +16 -0
- package/lib/types/client/types.d.ts +9 -0
- package/lib/types/image-rpc.d.ts +142 -0
- package/lib/types/imagegen.d.ts +241 -0
- package/lib/types/index.d.ts +89 -8
- package/lib/types/knowledge-config.d.ts +50 -2
- package/lib/types/tool-names.d.ts +11 -0
- package/lib/types/tools.d.ts +80 -0
- package/lib/types/user-agent.d.ts +31 -0
- package/lib/user-agent.js +54 -0
- package/lib/web-search.js +2 -2
- package/package.json +34 -24
package/lib/imagegen.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生图 Host 服务:调网关中继(`image-config` / `image-generate` / 我的图片)→ 解码 → 写进工作区。
|
|
3
|
+
*
|
|
4
|
+
* 形态与 `src/voice-rpc.ts` 同构,两处**刻意的不同**写在这里免得后来的人「对齐」掉:
|
|
5
|
+
* 1. 本模块是**服务**(有状态:`image-config` 的 ≤5 分钟缓存),RPC 频道(`src/image-rpc.ts`)
|
|
6
|
+
* 与 Agent 工具(`src/tools.ts`)都建在它上面;
|
|
7
|
+
* 2. 生图会**落盘**:图片写进 `<工作区>/图片/` 目录后由会话内的卡片经客户端同源文件路由
|
|
8
|
+
* (`/api/file?path=…`)显示(设计 §1.3 路径 ①)。写盘用 node `fs` 直写,**不**走
|
|
9
|
+
* `ctx.fs`——工具的写盘不是模型可见的文件操作,也不需要沙箱策略参与(设计 §6.2)。
|
|
10
|
+
*
|
|
11
|
+
* 三条硬纪律:
|
|
12
|
+
* - **凭据/大对象不进日志**:会话 cookie 只出现在 `resolveSession()` 的返回值里,日志只记
|
|
13
|
+
* 尺寸 / 耗时 / 字节数这类元数据,绝不记 prompt 之外的用户内容、绝不记 base64;
|
|
14
|
+
* - **越界校验**:拼出来的路径必须落在工作区根内,否则一律拒绝(`path-outside-workspace`)——
|
|
15
|
+
* 目录只建 `<工作区>/图片/` 这一层,写盘失败**不回滚**生图(上游已计费);
|
|
16
|
+
* - **不做缓存/去重**:同一 prompt 两次调用 = 两张图(与上游语义一致)。
|
|
17
|
+
*
|
|
18
|
+
* 本模块不 import cordis 运行时,只依赖结构化的副作用面(`ImagegenDeps`),便于单测直接伪造。
|
|
19
|
+
*/
|
|
20
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { Buffer } from 'node:buffer';
|
|
22
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
23
|
+
import { GatewayError, imageGenerateTimeoutMs, } from "./ai-router-client.js";
|
|
24
|
+
import { DEFAULT_WORKSPACE_DIR } from "./default-workspace.js";
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// 冻结契约:尺寸集合 / 体积上限 / 落盘位置
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* 允许的尺寸三值。**跨仓冻结契约**(与网关 `image.default_size` 的枚举、Agent 工具 schema
|
|
30
|
+
* 逐项一致):`defineTool` 在注册期就把 schema 冻死,没法让枚举跟着 `image-config` 走。
|
|
31
|
+
*/
|
|
32
|
+
export const IMAGE_SIZES = ['1024x1024', '1024x1536', '1536x1024'];
|
|
33
|
+
/** 尺寸缺省值(网关 `image-config` 读不到时的兜底)。 */
|
|
34
|
+
export const IMAGE_DEFAULT_SIZE = '1024x1024';
|
|
35
|
+
/** 工作区里存放生成图的目录名(设计 §6.2:`<会话工作区>/图片/`)。 */
|
|
36
|
+
export const IMAGE_DIR_NAME = '图片';
|
|
37
|
+
/**
|
|
38
|
+
* 单张图上限 20 MiB(与桌面端附件上限同值)。`fetch` 端点超过它直接拒绝——
|
|
39
|
+
* 绝不把几十 MB 的 base64 塞进 loopback RPC 信封。
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
42
|
+
/** 本次调用用的工作区根取不到时的兜底(无会话上下文的画廊页 `save` 也用它)。 */
|
|
43
|
+
export const IMAGE_FALLBACK_WORKSPACE = DEFAULT_WORKSPACE_DIR;
|
|
44
|
+
/** `image-config` 的 Host 侧缓存时长(设计 §6.3:≤5 分钟)。 */
|
|
45
|
+
export const IMAGE_CONFIG_CACHE_TTL_MS = 5 * 60_000;
|
|
46
|
+
/** 越出工作区的路径(内部异常,映射成 `path-outside-workspace`)。 */
|
|
47
|
+
export class ImagePathError extends Error {
|
|
48
|
+
target;
|
|
49
|
+
constructor(target) {
|
|
50
|
+
super('图片路径越出了工作区');
|
|
51
|
+
this.target = target;
|
|
52
|
+
this.name = 'ImagePathError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// 落盘:目录 / slug / 文件名 / 越界校验
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/** 工作区里的图片目录。 */
|
|
59
|
+
export function imageDirectory(workspaceRoot) {
|
|
60
|
+
return join(workspaceRoot, IMAGE_DIR_NAME);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `target` 是否严格落在 `workspaceRoot` 内(`workspaceRoot` 本身不算)。
|
|
64
|
+
*
|
|
65
|
+
* 用 `path.relative` 判定而不是字符串前缀:前缀法会被 `/tmp/ws-other` 这种「同前缀的
|
|
66
|
+
* 兄弟目录」骗过去。两侧都只做 `resolve`(不做 `realpath`)——拼路径与校验用同一套
|
|
67
|
+
* 拼法,符号链接的归一化是文件路由那边的事(设计 §1.3 路径 ①)。
|
|
68
|
+
*/
|
|
69
|
+
export function isInsideWorkspace(workspaceRoot, target) {
|
|
70
|
+
const root = resolve(workspaceRoot);
|
|
71
|
+
const full = resolve(target);
|
|
72
|
+
if (full === root)
|
|
73
|
+
return false;
|
|
74
|
+
const rel = relative(root, full);
|
|
75
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 拼一张图的落盘绝对路径并做越界校验(**唯一**的拼法,写盘与校验共用它)。
|
|
79
|
+
* 越界即抛 {@link ImagePathError}。
|
|
80
|
+
*
|
|
81
|
+
* **两道都要过**:
|
|
82
|
+
* 1. 落在工作区根内(设计 §6.2 的前缀校验);
|
|
83
|
+
* 2. 落在 `<工作区>/图片/` 内(「只写这个目录」)——`../x.png` 会滑进工作区根,
|
|
84
|
+
* 虽然没出工作区,但不在约定目录里,同样拒绝。
|
|
85
|
+
*/
|
|
86
|
+
export function imageFilePath(workspaceRoot, fileName) {
|
|
87
|
+
const directory = imageDirectory(workspaceRoot);
|
|
88
|
+
const target = join(directory, fileName);
|
|
89
|
+
if (!isInsideWorkspace(workspaceRoot, target) || !isInsideWorkspace(directory, target)) {
|
|
90
|
+
throw new ImagePathError(target);
|
|
91
|
+
}
|
|
92
|
+
return target;
|
|
93
|
+
}
|
|
94
|
+
/** slug 里不许出现的字符(设计 §6.2):路径分隔符、Windows 保留符、控制字符与空白。 */
|
|
95
|
+
const SLUG_ILLEGAL = /[\s\u0000-\u001f\u007f/\\:*?"<>|]/gu;
|
|
96
|
+
/** slug 取 prompt 的前 24 个字符(按码点,避免把 emoji 劈成半个代理对)。 */
|
|
97
|
+
const SLUG_MAX_CHARS = 24;
|
|
98
|
+
/**
|
|
99
|
+
* prompt → 文件名 slug:取前 24 个字符(先 trim,免得名字以 `-` 开头)、非法字符换成 `-`。
|
|
100
|
+
* 结果为空 / 只剩 `-`(纯标点、纯空白)时用 `image` 兜底。
|
|
101
|
+
*
|
|
102
|
+
* 中文与 emoji 是**合法**字符,原样保留(文件系统支持)。
|
|
103
|
+
*/
|
|
104
|
+
export function slugFromPrompt(prompt) {
|
|
105
|
+
const head = Array.from(prompt.trim()).slice(0, SLUG_MAX_CHARS).join('');
|
|
106
|
+
const slug = head.replace(SLUG_ILLEGAL, '-');
|
|
107
|
+
return slug === '' || /^-+$/.test(slug) ? 'image' : slug;
|
|
108
|
+
}
|
|
109
|
+
/** `<yyyyMMdd-HHmmss>`(本地时区,与用户看到的文件时间一致)。 */
|
|
110
|
+
export function imageStamp(when) {
|
|
111
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
112
|
+
return `${when.getFullYear()}${pad(when.getMonth() + 1)}${pad(when.getDate())}`
|
|
113
|
+
+ `-${pad(when.getHours())}${pad(when.getMinutes())}${pad(when.getSeconds())}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* 扩展名:**按图片自身的格式**取,未知格式落 `png`。
|
|
117
|
+
*
|
|
118
|
+
* 刻意的偏离(设计 §6.2 写的是 `<…>.png`):客户端同源文件路由按**扩展名**定 MIME
|
|
119
|
+
* (设计 §1.3),一张 JPEG 存成 `.png` 会被当成 PNG 下发。上游常态是 PNG,
|
|
120
|
+
* 所以正常路径与文档逐字一致,只有非 PNG 才不同。
|
|
121
|
+
*/
|
|
122
|
+
export function imageExtension(format, mimeType) {
|
|
123
|
+
const raw = (format.trim() === '' ? mimeType.replace(/^image\//, '') : format).trim().toLowerCase();
|
|
124
|
+
const normalized = raw === 'jpg' ? 'jpeg' : raw;
|
|
125
|
+
return /^[a-z0-9]{2,5}$/.test(normalized) && normalized !== 'image' ? normalized : 'png';
|
|
126
|
+
}
|
|
127
|
+
function isAlreadyExists(error) {
|
|
128
|
+
return typeof error === 'object' && error !== null && error.code === 'EEXIST';
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 写一张图到 `<workspaceRoot>/图片/`:目录只 `mkdir recursive` 一次,同名加 `-2` / `-3` 后缀。
|
|
132
|
+
*
|
|
133
|
+
* 用 `flag: 'wx'`(不存在才创建)而不是「先 exists 再写」:两者之间没有 TOCTOU 窗口,
|
|
134
|
+
* 命名冲突与真正的写盘失败因此能被分开(前者换名字重试,后者如实上报)。
|
|
135
|
+
*
|
|
136
|
+
* @returns 写入的**绝对路径**。
|
|
137
|
+
*/
|
|
138
|
+
export async function writeImageBytes(workspaceRoot, baseName, extension, bytes) {
|
|
139
|
+
await mkdir(imageDirectory(workspaceRoot), { recursive: true });
|
|
140
|
+
for (let index = 1; index <= 100; index++) {
|
|
141
|
+
const fileName = index === 1 ? `${baseName}.${extension}` : `${baseName}-${index}.${extension}`;
|
|
142
|
+
// 越界校验在拼路径里(imageFilePath):名字来自 slug,正常路径下不可能越界,
|
|
143
|
+
// 这一层是防线本身而不是装饰。
|
|
144
|
+
const target = imageFilePath(workspaceRoot, fileName);
|
|
145
|
+
try {
|
|
146
|
+
await writeFile(target, bytes, { flag: 'wx' });
|
|
147
|
+
return target;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (isAlreadyExists(error))
|
|
151
|
+
continue;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
throw new Error('图片同名冲突过多(已尝试 100 个后缀)');
|
|
156
|
+
}
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// image-config 读取(带 ≤5 分钟缓存)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/** 直接读网关配置;**永不抛异常**(读不到返回 undefined,调用方落默认值)。 */
|
|
161
|
+
export async function readImageConfig(gateway, sessionCookie, log) {
|
|
162
|
+
try {
|
|
163
|
+
return await gateway.getMyImageConfig(sessionCookie);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
log?.(`hiwork-core: 生图配置读取失败,回落默认尺寸(${error instanceof Error ? error.name : typeof error})`);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 包一层 ≤5 分钟缓存的 `image-config` 读取器(设计 §6.3:工具补默认尺寸时读它)。
|
|
172
|
+
*
|
|
173
|
+
* 缓存只存**配置**(开关/模型名/尺寸枚举),不存 cookie、不存任何凭据;同一 Host 进程
|
|
174
|
+
* 只服务一个登录账号,所以缓存不需要按账号分键。
|
|
175
|
+
*
|
|
176
|
+
* 但**要按 `key` 分**:`key` 是调用方给的缓存键(生产传 gateway baseURL)——用户在
|
|
177
|
+
* 「网关模型」里换了地址之后,旧网关的默认尺寸与模型名不该继续生效 5 分钟。
|
|
178
|
+
* 只保留最近一个键(换地址是罕见操作,为此维护一张有界 LRU 不值)。
|
|
179
|
+
*/
|
|
180
|
+
export function createCachedImageConfigReader(options = {}) {
|
|
181
|
+
const ttlMs = options.ttlMs ?? IMAGE_CONFIG_CACHE_TTL_MS;
|
|
182
|
+
const now = options.now ?? (() => Date.now());
|
|
183
|
+
let cached;
|
|
184
|
+
return async (gateway, sessionCookie, log, key = '') => {
|
|
185
|
+
if (cached !== undefined && cached.key === key && now() - cached.at < ttlMs)
|
|
186
|
+
return cached.value;
|
|
187
|
+
const value = await readImageConfig(gateway, sessionCookie, log);
|
|
188
|
+
if (value === undefined)
|
|
189
|
+
return undefined;
|
|
190
|
+
cached = { key, value, at: now() };
|
|
191
|
+
return value;
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// 生图
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
/** 通用失败文案(未知 code / message 不可用时的兜底):不暴露任何网关细节。 */
|
|
198
|
+
export const IMAGE_GENERIC_FAILURE_MESSAGE = '图片生成服务暂时不可用,请稍后重试';
|
|
199
|
+
/** 未登录 / 会话失效的统一文案(用量、语音、生图三条频道同口径)。 */
|
|
200
|
+
export const IMAGE_SESSION_EXPIRED_MESSAGE = '登录状态已过期,请重新登录';
|
|
201
|
+
const IMAGE_GATEWAY_UNCONFIGURED_MESSAGE = '网关地址未配置,请先在「网关模型」里完成登录';
|
|
202
|
+
/**
|
|
203
|
+
* 各稳定错误码的用户可见文案。
|
|
204
|
+
*
|
|
205
|
+
* **唯一的事实源**:`generate` 的失败收敛与 `/hiwork-image` 的消毒(`image-rpc.ts` 从这里
|
|
206
|
+
* 再导出 {@link displayImageGatewayMessage})都读它。文案与卡片侧
|
|
207
|
+
* `src/client/image-cards.tsx` 的 `IMAGE_ERROR_TEXT` **逐字对齐**——卡片按 code 分文案,
|
|
208
|
+
* 这里只是「网关 message 不可用时的兜底」,两处写着同一句话是有意的:
|
|
209
|
+
* 卡片不依赖 Host 文案,Host 也不依赖卡片(谁先加载都成立)。
|
|
210
|
+
*/
|
|
211
|
+
export const IMAGE_MESSAGE_FALLBACK = {
|
|
212
|
+
SESSION_EXPIRED: IMAGE_SESSION_EXPIRED_MESSAGE,
|
|
213
|
+
GATEWAY_UNREACHABLE: '网关暂时不可用,请稍后重试',
|
|
214
|
+
IMAGE_BAD_REQUEST: '这个提示词没能生成,换个说法再试',
|
|
215
|
+
IMAGE_BUDGET_EXCEEDED: '生图暂时不可用:可能是本月额度用尽,也可能是这个模型还没配置好——请联系管理员',
|
|
216
|
+
IMAGE_TOO_LARGE: '生成的图片太大了,换个小一点的尺寸(如 1024x1024)',
|
|
217
|
+
IMAGE_DISABLED: '管理员还没开启图片生成',
|
|
218
|
+
IMAGE_NOT_CONFIGURED: '图片生成还没有配置好,请联系管理员',
|
|
219
|
+
IMAGE_UPSTREAM_ERROR: IMAGE_GENERIC_FAILURE_MESSAGE,
|
|
220
|
+
IMAGE_TIMEOUT: '生成超时了,再试一次(复杂画面会比较慢)',
|
|
221
|
+
IMAGE_NOT_FOUND: '这张图片不存在或已被清理',
|
|
222
|
+
IMAGE_FORBIDDEN: '这张图片不属于当前账号',
|
|
223
|
+
IMAGE_WORKSPACE_NOT_FOUND: '这个工作区不存在或已被移除',
|
|
224
|
+
IMAGE_SAVE_FAILED: '图片已取回但没能保存到工作区',
|
|
225
|
+
RATE_LIMITED: '请求过于频繁,请稍后再试',
|
|
226
|
+
AUTH_FAILED: '登录状态异常,请重新登录',
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* 网关 message 消毒(与 `voice-rpc.ts` 的 `displayVoiceGatewayMessage` 同实现):
|
|
230
|
+
* 含 baseURL / HTTP 状态码 / URL 的原始消息一律换成稳定文案,其余(网关自己写好的
|
|
231
|
+
* 中文业务文案)原样保留。**code 不受影响**,始终原样透传。
|
|
232
|
+
*
|
|
233
|
+
* 放在 `imagegen.ts` 是因为**两条消费路径都要它**(工具的失败收敛 + RPC 的信封),
|
|
234
|
+
* 而 `image-rpc.ts` 依赖 `imagegen.ts`(反向 import 会成环)。
|
|
235
|
+
*/
|
|
236
|
+
export function displayImageGatewayMessage(code, message) {
|
|
237
|
+
const text = typeof message === 'string' ? message.trim() : '';
|
|
238
|
+
if (text === '')
|
|
239
|
+
return IMAGE_MESSAGE_FALLBACK[code] ?? IMAGE_GENERIC_FAILURE_MESSAGE;
|
|
240
|
+
const leaksImplementationDetail = /https?:\/\//i.test(text)
|
|
241
|
+
|| /\bHTTP\s*\d{3}\b/i.test(text)
|
|
242
|
+
|| /\bbaseURL\b/i.test(text);
|
|
243
|
+
if (leaksImplementationDetail)
|
|
244
|
+
return IMAGE_MESSAGE_FALLBACK[code] ?? IMAGE_GENERIC_FAILURE_MESSAGE;
|
|
245
|
+
return text;
|
|
246
|
+
}
|
|
247
|
+
function failure(code, message, extra = {}) {
|
|
248
|
+
return { ok: false, code, message, files: [], ...extra };
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* 把网关 `GatewayError` 收敛成失败结果:**code 原样透传**(稳定契约),message 交给
|
|
252
|
+
* {@link displayImageGatewayMessage} 消毒(含 baseURL / HTTP 细节的换稳定文案)。
|
|
253
|
+
*/
|
|
254
|
+
function gatewayFailure(error, extra = {}) {
|
|
255
|
+
return failure(error.code, displayImageGatewayMessage(error.code, error.message), extra);
|
|
256
|
+
}
|
|
257
|
+
/** 本次调用的工作区根:request > deps > 默认工作区。 */
|
|
258
|
+
function resolveWorkspaceRoot(deps, request) {
|
|
259
|
+
const explicit = request.workspaceRoot ?? deps.workspaceRoot;
|
|
260
|
+
return explicit === undefined || explicit.trim() === '' ? IMAGE_FALLBACK_WORKSPACE : explicit;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 生图并写盘(设计 §6.2)。
|
|
264
|
+
*
|
|
265
|
+
* 顺序:① prompt 校验 → ② 会话 → ③ 网关 → ④ 尺寸缺省时取 `image-config` 的默认值
|
|
266
|
+
* → ⑤ 调网关(带 timeoutMs 的那一条,见 `ai-router-client.ts`)→ ⑥ 解码写盘 → ⑦ 组装结果。
|
|
267
|
+
*
|
|
268
|
+
* 写盘失败**不回滚**:回 `code: 'write-failed'` + 上游信息(prompt/model/size/generationMs),
|
|
269
|
+
* `files` 置空。越界路径回 `code: 'path-outside-workspace'`。
|
|
270
|
+
*/
|
|
271
|
+
export async function generateImageFile(deps, request) {
|
|
272
|
+
const prompt = request.prompt.trim();
|
|
273
|
+
if (prompt === '')
|
|
274
|
+
return failure('IMAGE_BAD_REQUEST', '生图请求不合法');
|
|
275
|
+
const sessionCookie = await deps.resolveSession();
|
|
276
|
+
if (sessionCookie === undefined || sessionCookie === '') {
|
|
277
|
+
return failure('SESSION_EXPIRED', IMAGE_SESSION_EXPIRED_MESSAGE);
|
|
278
|
+
}
|
|
279
|
+
const gateway = deps.gateway();
|
|
280
|
+
if (gateway === undefined)
|
|
281
|
+
return failure('GATEWAY_UNREACHABLE', IMAGE_GATEWAY_UNCONFIGURED_MESSAGE);
|
|
282
|
+
// 配置读一次,两个用途:尺寸缺省值 + 本次调用的等待上限。
|
|
283
|
+
//
|
|
284
|
+
// 为什么**无条件**读(原实现只在缺 size 时读):超时预算也从这里来。配置本身在
|
|
285
|
+
// Host 侧缓存 ≤5 分钟,多读一次不产生额外往返;读不到只是两处都走兜底。
|
|
286
|
+
// **quality 一律不下发**(省钱档由网关定)。
|
|
287
|
+
const config = await (deps.imageConfig ?? ((cookie) => readImageConfig(gateway, cookie, deps.log)))(sessionCookie);
|
|
288
|
+
let size = request.size;
|
|
289
|
+
if (size === undefined || size.trim() === '') {
|
|
290
|
+
size = config?.defaultSize === undefined || config.defaultSize.trim() === '' ? IMAGE_DEFAULT_SIZE : config.defaultSize;
|
|
291
|
+
}
|
|
292
|
+
let result;
|
|
293
|
+
try {
|
|
294
|
+
result = await gateway.generateImage(sessionCookie, { prompt, size, ...(request.quality === undefined ? {} : { quality: request.quality }) }, undefined, imageGenerateTimeoutMs(config?.timeoutSeconds));
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (error instanceof GatewayError)
|
|
298
|
+
return gatewayFailure(error, { prompt });
|
|
299
|
+
deps.log?.(`hiwork-core: 生图失败(${error instanceof Error ? error.name : typeof error})`);
|
|
300
|
+
return failure('IMAGE_UPSTREAM_ERROR', IMAGE_GENERIC_FAILURE_MESSAGE, { prompt });
|
|
301
|
+
}
|
|
302
|
+
const image = result.images[0];
|
|
303
|
+
if (image === undefined || image.dataBase64 === '') {
|
|
304
|
+
// 网关契约违约(恒回 1 张):不静默回成功,否则卡片会渲染一张空图。
|
|
305
|
+
deps.log?.('hiwork-core: 生图响应里没有图片数据');
|
|
306
|
+
return failure('IMAGE_UPSTREAM_ERROR', IMAGE_GENERIC_FAILURE_MESSAGE, {
|
|
307
|
+
prompt, model: result.model, size: result.size, generationMs: result.generationMs,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
const upstream = {
|
|
311
|
+
prompt,
|
|
312
|
+
model: result.model,
|
|
313
|
+
size: result.size,
|
|
314
|
+
generationMs: result.generationMs,
|
|
315
|
+
};
|
|
316
|
+
let bytes;
|
|
317
|
+
try {
|
|
318
|
+
bytes = Buffer.from(image.dataBase64, 'base64');
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return failure('IMAGE_UPSTREAM_ERROR', IMAGE_GENERIC_FAILURE_MESSAGE, upstream);
|
|
322
|
+
}
|
|
323
|
+
if (bytes.byteLength === 0)
|
|
324
|
+
return failure('IMAGE_UPSTREAM_ERROR', IMAGE_GENERIC_FAILURE_MESSAGE, upstream);
|
|
325
|
+
if (bytes.byteLength > MAX_IMAGE_BYTES)
|
|
326
|
+
return failure('IMAGE_TOO_LARGE', '生成的图片太大了', upstream);
|
|
327
|
+
const byteSize = image.byteSize > 0 ? image.byteSize : bytes.byteLength;
|
|
328
|
+
const when = (deps.now ?? (() => new Date()))();
|
|
329
|
+
const slug = slugFromPrompt(prompt);
|
|
330
|
+
let path;
|
|
331
|
+
try {
|
|
332
|
+
path = await writeImageBytes(resolveWorkspaceRoot(deps, request), `${imageStamp(when)}-${slug}`, imageExtension(image.format, image.mimeType), bytes);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (error instanceof ImagePathError) {
|
|
336
|
+
deps.log?.('hiwork-core: 生图落盘路径越出工作区,已拒绝');
|
|
337
|
+
return failure('path-outside-workspace', '图片路径越出了工作区', upstream);
|
|
338
|
+
}
|
|
339
|
+
deps.log?.(`hiwork-core: 生图写盘失败(${error instanceof Error ? error.name : typeof error})`);
|
|
340
|
+
return failure('write-failed', '图片已生成但没能保存到工作区', upstream);
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
ok: true,
|
|
344
|
+
files: [{
|
|
345
|
+
path,
|
|
346
|
+
mimeType: image.mimeType,
|
|
347
|
+
byteSize,
|
|
348
|
+
...(image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt }),
|
|
349
|
+
}],
|
|
350
|
+
prompt,
|
|
351
|
+
model: result.model,
|
|
352
|
+
size: result.size,
|
|
353
|
+
generationMs: result.generationMs,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// 我的图片:列表 / 单张 / 存到工作区(画廊页三端点)
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
/**
|
|
360
|
+
* 画廊页 `save`(设计 §6.5):取本体 → 按生图同一套规则落盘 → 回绝对路径。
|
|
361
|
+
*
|
|
362
|
+
* 落点是**默认工作区** `~/HiWork/Default`(画廊页是中央页,没有会话上下文);给了
|
|
363
|
+
* `workspaceId` 就用注册表里那个工作区的路径,**取不到即拒绝**(绝不落到别处)。
|
|
364
|
+
*
|
|
365
|
+
* 文件名沿用 §6.2 的规则,slug 来自该记录的 `prompt`。记录本体在网关,而冻结入参只有
|
|
366
|
+
* `{ id, workspaceId? }`(页面按契约只传 id),所以这里**尽力**回查一次列表拿 prompt:
|
|
367
|
+
* 查不到、或第一页里没有这条记录,就用 `image` 兜底——**不为一个文件名把保存变成硬依赖**。
|
|
368
|
+
*
|
|
369
|
+
* 失败分两类:网关/会话错误**抛** `GatewayError`(由 RPC 层映射成稳定 code);
|
|
370
|
+
* 工作区解析 / 写盘这类业务失败回 `{ok:false, code, reason}`。
|
|
371
|
+
*/
|
|
372
|
+
export async function saveImageToWorkspace(deps, request) {
|
|
373
|
+
const sessionCookie = await deps.resolveSession();
|
|
374
|
+
if (sessionCookie === undefined || sessionCookie === '') {
|
|
375
|
+
throw new GatewayError('SESSION_EXPIRED', IMAGE_SESSION_EXPIRED_MESSAGE, 401);
|
|
376
|
+
}
|
|
377
|
+
const gateway = deps.gateway();
|
|
378
|
+
if (gateway === undefined)
|
|
379
|
+
throw new GatewayError('GATEWAY_UNREACHABLE', IMAGE_GATEWAY_UNCONFIGURED_MESSAGE);
|
|
380
|
+
let root = IMAGE_FALLBACK_WORKSPACE;
|
|
381
|
+
if (request.workspaceId !== undefined && request.workspaceId.trim() !== '') {
|
|
382
|
+
const resolved = deps.workspaces?.resolveWorkspacePath(request.workspaceId);
|
|
383
|
+
if (resolved === undefined) {
|
|
384
|
+
return { ok: false, code: 'IMAGE_WORKSPACE_NOT_FOUND', reason: '这个工作区不存在或已被移除' };
|
|
385
|
+
}
|
|
386
|
+
root = resolved;
|
|
387
|
+
}
|
|
388
|
+
const file = await gateway.fetchMyImage(sessionCookie, request.id);
|
|
389
|
+
const bytes = Buffer.from(file.dataBase64, 'base64');
|
|
390
|
+
if (bytes.byteLength === 0) {
|
|
391
|
+
return { ok: false, code: 'IMAGE_SAVE_FAILED', reason: '这张图片的内容是空的,请稍后重试' };
|
|
392
|
+
}
|
|
393
|
+
const prompt = request.prompt ?? await lookupPrompt(gateway, sessionCookie, request.id, deps.log);
|
|
394
|
+
const when = (deps.now ?? (() => new Date()))();
|
|
395
|
+
const slug = slugFromPrompt(prompt);
|
|
396
|
+
try {
|
|
397
|
+
const path = await writeImageBytes(root, `${imageStamp(when)}-${slug}`, imageExtension('', file.mimeType), bytes);
|
|
398
|
+
return { ok: true, path };
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (error instanceof ImagePathError) {
|
|
402
|
+
return { ok: false, code: 'IMAGE_SAVE_FAILED', reason: '这个位置超出工作区范围,已拒绝保存' };
|
|
403
|
+
}
|
|
404
|
+
deps.log?.(`hiwork-core: 图片保存失败(${error instanceof Error ? error.name : typeof error})`);
|
|
405
|
+
return { ok: false, code: 'IMAGE_SAVE_FAILED', reason: '图片已取回但没能保存到工作区' };
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* 回查一条记录的 prompt(只为文件名 slug)。**永不抛异常**:查不到返回空串
|
|
410
|
+
* (`slugFromPrompt('')` → `image`)。
|
|
411
|
+
*
|
|
412
|
+
* 只查第一页:`fetch` 的 id 通常就是用户刚在列表里点的那张,命中率足够;
|
|
413
|
+
* 为了一个文件名去做「遍历所有页」的调用显然不值。
|
|
414
|
+
*/
|
|
415
|
+
async function lookupPrompt(gateway, sessionCookie, id, log) {
|
|
416
|
+
try {
|
|
417
|
+
const view = await gateway.getMyImages(sessionCookie, { page: 1, pageSize: 100 });
|
|
418
|
+
return view.items.find((item) => item.id === id)?.prompt ?? '';
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
log?.(`hiwork-core: 保存前回查提示词失败,文件名用 image 兜底(${error instanceof Error ? error.name : typeof error})`);
|
|
422
|
+
return '';
|
|
423
|
+
}
|
|
424
|
+
}
|