dsh-seework 0.1.0
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/LICENSE +21 -0
- package/README.md +102 -0
- package/assets/seework-image-capabilities.md +65 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +6966 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3930 -0
- package/package.json +80 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3930 @@
|
|
|
1
|
+
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
2
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { promises, readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import path, { isAbsolute } from "node:path";
|
|
9
|
+
import { isVolatile } from "@deepseek-ai/cosmokit";
|
|
10
|
+
import z from "@deepseek-ai/schemastery";
|
|
11
|
+
|
|
12
|
+
//#region src/capabilities-skill.ts
|
|
13
|
+
/**
|
|
14
|
+
* Precedence rank for a packaged skill provider.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors `BUNDLED_SKILL_RANK` (600) from `@deepseek-ai/dsh-skill`, and the
|
|
17
|
+
* import above stays **type-only** on purpose: a value import would turn the
|
|
18
|
+
* host's skill package into a load-time dependency, so a deployment without a
|
|
19
|
+
* skill subsystem would fail while *loading this plugin* — the optional
|
|
20
|
+
* injection below cannot save it, because the module never gets that far. Rank
|
|
21
|
+
* only orders skills that share a name, this plugin's name is unique, and a
|
|
22
|
+
* drift in the constant is therefore harmless; an unresolvable import is not.
|
|
23
|
+
*/
|
|
24
|
+
const BUNDLED_SKILL_RANK = 600;
|
|
25
|
+
/** Provider name this plugin registers in the host's skill registry. */
|
|
26
|
+
const SKILL_PROVIDER = "seework";
|
|
27
|
+
/** Skill name an agent or a user invokes. */
|
|
28
|
+
const SKILL_NAME = "seework-image-capabilities";
|
|
29
|
+
/** Packaged skill body; resolved beside the built bundle. */
|
|
30
|
+
const BODY_URL = new URL("../assets/seework-image-capabilities.md", import.meta.url);
|
|
31
|
+
/**
|
|
32
|
+
* Routing description — **a constant, and deliberately free of data**.
|
|
33
|
+
*
|
|
34
|
+
* It has no model count and no model name: the host caches summaries and only
|
|
35
|
+
* invalidates them when a provider registers or unregisters (see the module
|
|
36
|
+
* comment), so anything data-dependent here would stay frozen at that moment.
|
|
37
|
+
* What it does carry is the **order to load it before generating** (#665) —
|
|
38
|
+
* the same instruction the announcement and `generate_image`'s description
|
|
39
|
+
* give, so the model does not have to infer the skill's timing from a list of
|
|
40
|
+
* "when to load" triggers — followed by the secondary triggers, because with
|
|
41
|
+
* the announcement switched off (`announceToAgent=false`) this description is
|
|
42
|
+
* the only pointer the model has to the capability surface at all.
|
|
43
|
+
*/
|
|
44
|
+
const CANDIDATE = {
|
|
45
|
+
name: SKILL_NAME,
|
|
46
|
+
description: "**Load this before generating or editing any image with the SeeWork plugin** (its tool is `generate_image`). SeeWork image-generation manual: how to turn a user request into a confirmed generation or edit — understanding the visual intent, using the optional `reference_images`, choosing `n` / `resolution` / `aspect_ratio` from the model schema, confirming the parsed task before the first call, and iterating on the result. Its secondary triggers: a different model was chosen, or the user asks why the plugin is not usable. Its body is a fixed procedure, not a data table.",
|
|
47
|
+
invocation: {
|
|
48
|
+
modelInvocable: true,
|
|
49
|
+
userInvocable: true
|
|
50
|
+
},
|
|
51
|
+
provider: SKILL_PROVIDER,
|
|
52
|
+
source: "bundled",
|
|
53
|
+
resourceBase: {
|
|
54
|
+
kind: "directory",
|
|
55
|
+
path: fileURLToPath(new URL("../assets/", import.meta.url))
|
|
56
|
+
},
|
|
57
|
+
rank: BUNDLED_SKILL_RANK,
|
|
58
|
+
locator: BODY_URL
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Build the provider for the packaged skill.
|
|
62
|
+
*
|
|
63
|
+
* Takes nothing: since #659 the body is a fixed file and the catalog entry is a
|
|
64
|
+
* constant, so there is no settings input to thread in. The host still calls
|
|
65
|
+
* `get()` on every load, which is what re-reads the file.
|
|
66
|
+
*
|
|
67
|
+
* @returns a provider whose catalog entry and body are both constant.
|
|
68
|
+
*/
|
|
69
|
+
function createCapabilitiesSkillProvider() {
|
|
70
|
+
return {
|
|
71
|
+
name: SKILL_PROVIDER,
|
|
72
|
+
list: () => Promise.resolve([CANDIDATE]),
|
|
73
|
+
async get() {
|
|
74
|
+
const body = await readFile(BODY_URL, "utf8");
|
|
75
|
+
return {
|
|
76
|
+
name: CANDIDATE.name,
|
|
77
|
+
description: CANDIDATE.description,
|
|
78
|
+
invocation: CANDIDATE.invocation,
|
|
79
|
+
provider: CANDIDATE.provider,
|
|
80
|
+
source: CANDIDATE.source,
|
|
81
|
+
resourceBase: CANDIDATE.resourceBase,
|
|
82
|
+
content: body
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Register the bundled provider on the host's skill registry.
|
|
89
|
+
*
|
|
90
|
+
* @param ctx - a context whose `skills` service is attached.
|
|
91
|
+
* @returns the disposer that removes this exact registration (the caller owns
|
|
92
|
+
* the `enabled` switch: flipping the plugin off unregisters the skill).
|
|
93
|
+
*/
|
|
94
|
+
function registerCapabilitiesSkill(ctx) {
|
|
95
|
+
return ctx.skills.registerProvider(() => createCapabilitiesSkillProvider());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/protocol.ts
|
|
100
|
+
/**
|
|
101
|
+
* Wire contract shared by the host and browser halves of dsh-seework: the
|
|
102
|
+
* settings namespace, the route paths, the SeeAI Hub gateway request/response
|
|
103
|
+
* shapes, and the local material-library record shape.
|
|
104
|
+
*
|
|
105
|
+
* Pure types + constants — safe for the client bundle to inline.
|
|
106
|
+
*/
|
|
107
|
+
/** Settings namespace this plugin owns (host settings seam + bridge). */
|
|
108
|
+
const SEEWORK_SETTINGS_NAMESPACE = "dsh-seework";
|
|
109
|
+
/**
|
|
110
|
+
* Aspect ratios the gateway serves uniformly (#658/D-16).
|
|
111
|
+
*
|
|
112
|
+
* One list for every model, in the order a user should see them — the catalog's
|
|
113
|
+
* own arrays are lexicographically sorted sets, so they are not a usable order
|
|
114
|
+
* for a picker.
|
|
115
|
+
*/
|
|
116
|
+
const UNIFIED_ASPECT_RATIOS = [
|
|
117
|
+
"1:1",
|
|
118
|
+
"4:3",
|
|
119
|
+
"3:4",
|
|
120
|
+
"16:9",
|
|
121
|
+
"9:16",
|
|
122
|
+
"3:2",
|
|
123
|
+
"2:3",
|
|
124
|
+
"21:9"
|
|
125
|
+
];
|
|
126
|
+
/** Factory default for the ratio setting (#658/D-13/D-16). */
|
|
127
|
+
const DEFAULT_ASPECT_RATIO = "3:4";
|
|
128
|
+
/** Factory default for the output-format setting (#658/D-13). */
|
|
129
|
+
const DEFAULT_OUTPUT_FORMAT = "png";
|
|
130
|
+
/**
|
|
131
|
+
* Whether a list contains a value, ignoring case (#658).
|
|
132
|
+
*
|
|
133
|
+
* The one membership rule both halves need: the request layer asks whether the
|
|
134
|
+
* saved model can take the configured output format, and the settings card asks
|
|
135
|
+
* whether the stored value is already among the offered options.
|
|
136
|
+
*/
|
|
137
|
+
function includesIgnoringCase(list, wanted) {
|
|
138
|
+
return list.some((value) => value.toLowerCase() === wanted.toLowerCase());
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Default SeeAI Hub Gateway base URL — the OpenAI-compatible root, i.e. the
|
|
142
|
+
* part that `/images/generations` is appended to. Local development gateway is
|
|
143
|
+
* the default so a fresh install talks to the working stack; point it at a
|
|
144
|
+
* deployment domain in the settings card.
|
|
145
|
+
*/
|
|
146
|
+
const DEFAULT_API_URL = "http://127.0.0.1:8080/v1";
|
|
147
|
+
/**
|
|
148
|
+
* Default SeeAI Hub **service** base URL, used only for the model catalog.
|
|
149
|
+
*
|
|
150
|
+
* `docs/api/README.md` is explicit that the two surfaces are separate and that
|
|
151
|
+
* neither answers the other's paths: generation is the Gateway (`/v1/*`), while
|
|
152
|
+
* `GET /api/v1/catalog/models` is the service. Deriving one from the other
|
|
153
|
+
* works only behind a reverse proxy that mounts both under one host, which is
|
|
154
|
+
* why the catalog address is its own setting.
|
|
155
|
+
*/
|
|
156
|
+
const DEFAULT_SERVICE_URL = "http://127.0.0.1:8081";
|
|
157
|
+
/**
|
|
158
|
+
* Most images this plugin asks for in one request.
|
|
159
|
+
*
|
|
160
|
+
* **This is the plugin's own ceiling, not a contract value.**
|
|
161
|
+
* `docs/api/images.md` documents `n` as a positive integer whose ceiling is the
|
|
162
|
+
* *model's* own maximum — published since catalog v2 as the `n` descriptor's
|
|
163
|
+
* `max` — so the per-model limit comes from the catalog, while this constant is
|
|
164
|
+
* the plugin's own guard against a runaway `n` turning into a runaway bill.
|
|
165
|
+
*
|
|
166
|
+
* It is stated once because several places have to agree and each used to spell
|
|
167
|
+
* `10` itself. Every consumer now goes through the two compositions below rather
|
|
168
|
+
* than clamping on its own: the normalizer (`settings.ts`), the outgoing body
|
|
169
|
+
* (`engine.ts`), the tool argument (`agent-tools.ts`) and the promise the
|
|
170
|
+
* announcement makes (`model-summary.ts`), which must never exceed what is
|
|
171
|
+
* actually sent.
|
|
172
|
+
*
|
|
173
|
+
* The two compositions of it live here too ({@link imageCountCeiling} and
|
|
174
|
+
* {@link effectiveImageCount}), because spelling `min(10, max(1, …))` at each
|
|
175
|
+
* call site is how the task record and the outgoing body drifted apart (#668).
|
|
176
|
+
*/
|
|
177
|
+
const MAX_IMAGES_PER_REQUEST = 10;
|
|
178
|
+
/**
|
|
179
|
+
* The most images one request may ask for, given a model's declared ceiling.
|
|
180
|
+
*
|
|
181
|
+
* A **declared** ceiling below one is read as one image — {@link ModelConfig.maxImages}
|
|
182
|
+
* is "1 when unknown" — while a declared ceiling above the wire ceiling is cut
|
|
183
|
+
* back to it. Omitting `modelCap` means "no model is known at this call site"
|
|
184
|
+
* (the send-time guard), so only the wire ceiling applies.
|
|
185
|
+
*
|
|
186
|
+
* @param modelCap - the model's declared ceiling, when the catalog declared one.
|
|
187
|
+
* @returns the ceiling this model's requests are held to.
|
|
188
|
+
*/
|
|
189
|
+
function imageCountCeiling(modelCap) {
|
|
190
|
+
const declared = typeof modelCap === "number" && Number.isFinite(modelCap) ? Math.trunc(modelCap) : MAX_IMAGES_PER_REQUEST;
|
|
191
|
+
return Math.min(MAX_IMAGES_PER_REQUEST, Math.max(1, declared));
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The image count a request actually carries: what was asked for, clamped into
|
|
195
|
+
* the model's own ceiling.
|
|
196
|
+
*
|
|
197
|
+
* Every caller that records or sends a count uses this, so the task, the library
|
|
198
|
+
* entry and the outgoing body cannot disagree.
|
|
199
|
+
*
|
|
200
|
+
* @param requested - the caller's `n`.
|
|
201
|
+
* @param modelCap - the model's declared ceiling, when the catalog declared one.
|
|
202
|
+
* @returns the count that goes on the wire.
|
|
203
|
+
*/
|
|
204
|
+
function effectiveImageCount(requested, modelCap) {
|
|
205
|
+
const asked = Number.isFinite(requested) ? Math.trunc(requested) : 1;
|
|
206
|
+
return Math.min(imageCountCeiling(modelCap), Math.max(1, asked));
|
|
207
|
+
}
|
|
208
|
+
/** Same-origin, loopback-only settings bridge for this plugin's namespace. */
|
|
209
|
+
const SETTINGS_API = {
|
|
210
|
+
describe: "/api/dsh-seework/settings/describe",
|
|
211
|
+
mutate: "/api/dsh-seework/settings/mutate",
|
|
212
|
+
directoryPicker: "/api/dsh-seework/settings/directory-picker",
|
|
213
|
+
pickDirectory: "/api/dsh-seework/settings/pick-directory"
|
|
214
|
+
};
|
|
215
|
+
/** SeeAI Hub model-catalog discovery (host-mediated, keeps no secrets). */
|
|
216
|
+
const CATALOG_API = {
|
|
217
|
+
models: "/api/dsh-seework/catalog/models",
|
|
218
|
+
refresh: "/api/dsh-seework/catalog/refresh"
|
|
219
|
+
};
|
|
220
|
+
/** The image-generation proxy route: the browser submits, the host calls upstream. */
|
|
221
|
+
const GENERATE_API = "/api/dsh-seework/generate";
|
|
222
|
+
/** Host-resident generation queue (browser side of the shared runtime). */
|
|
223
|
+
const TASK_API = {
|
|
224
|
+
list: "/api/dsh-seework/tasks/list",
|
|
225
|
+
cancel: "/api/dsh-seework/tasks/cancel"
|
|
226
|
+
};
|
|
227
|
+
/** Update discovery for a published install, and the install it triggers. */
|
|
228
|
+
const UPDATE_API = {
|
|
229
|
+
status: "/api/dsh-seework/update/status",
|
|
230
|
+
apply: "/api/dsh-seework/update/apply"
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* The local material library — every image this plugin generates is written
|
|
234
|
+
* under the data root and indexed, which is what the sidebar library and the
|
|
235
|
+
* canvas read back.
|
|
236
|
+
*/
|
|
237
|
+
const LIBRARY_API = {
|
|
238
|
+
list: "/api/dsh-seework/library/list",
|
|
239
|
+
head: "/api/dsh-seework/library/head",
|
|
240
|
+
remove: "/api/dsh-seework/library/remove",
|
|
241
|
+
clear: "/api/dsh-seework/library/clear",
|
|
242
|
+
image: "/api/dsh-seework/library/image"
|
|
243
|
+
};
|
|
244
|
+
/**
|
|
245
|
+
* One durable image an Agent tool result points at.
|
|
246
|
+
*
|
|
247
|
+
* A tool result is not model-visible content, so the shell's own image loader
|
|
248
|
+
* refuses to resolve it (`session.readAttachment` proves reachability from the
|
|
249
|
+
* session log). The conversation card therefore asks this route with the
|
|
250
|
+
* complete reference the host persisted in the tool result's presentation
|
|
251
|
+
* metadata, and the host re-validates every field before reading the store.
|
|
252
|
+
*
|
|
253
|
+
* Prefix route: `/api/dsh-seework/attachment/image?attachment_id=…&media_type=…`.
|
|
254
|
+
*/
|
|
255
|
+
const ATTACHMENT_API = { image: "/api/dsh-seework/attachment/image" };
|
|
256
|
+
/** Image media types the attachment store accepts, as a runtime list. */
|
|
257
|
+
const IMAGE_MEDIA_TYPES = [
|
|
258
|
+
"image/png",
|
|
259
|
+
"image/jpeg",
|
|
260
|
+
"image/webp",
|
|
261
|
+
"image/gif"
|
|
262
|
+
];
|
|
263
|
+
/**
|
|
264
|
+
* Whether a wire value is an image media type this plugin may read and serve.
|
|
265
|
+
* @param value - the candidate media type.
|
|
266
|
+
* @returns true when it is one of the accepted image types.
|
|
267
|
+
*/
|
|
268
|
+
function isImageMedia(value) {
|
|
269
|
+
return IMAGE_MEDIA_TYPES.includes(value);
|
|
270
|
+
}
|
|
271
|
+
/** Data root the library lives under (overridable in settings). */
|
|
272
|
+
const DEFAULT_DATA_DIR_NAME = "dsh-seework";
|
|
273
|
+
/**
|
|
274
|
+
* Canvas documents live under `<data root>/canvas/`. The board is spatial, not
|
|
275
|
+
* a node graph: every card is just a placed rectangle, and the only structure
|
|
276
|
+
* is what the user sees (see `docs/architecture.md`).
|
|
277
|
+
*/
|
|
278
|
+
const CANVAS_API = {
|
|
279
|
+
list: "/api/dsh-seework/canvas/list",
|
|
280
|
+
create: "/api/dsh-seework/canvas/create",
|
|
281
|
+
read: "/api/dsh-seework/canvas/read",
|
|
282
|
+
save: "/api/dsh-seework/canvas/save",
|
|
283
|
+
remove: "/api/dsh-seework/canvas/remove",
|
|
284
|
+
asset: "/api/dsh-seework/canvas/asset",
|
|
285
|
+
assets: "/api/dsh-seework/canvas/assets",
|
|
286
|
+
pruneAssets: "/api/dsh-seework/canvas/assets/prune",
|
|
287
|
+
removeAsset: "/api/dsh-seework/canvas/asset/remove"
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/engine.ts
|
|
292
|
+
/** A generation failure with a stable machine code and a user-facing message. */
|
|
293
|
+
var SeeWorkError = class extends Error {
|
|
294
|
+
constructor(message, code, detail) {
|
|
295
|
+
super(message);
|
|
296
|
+
this.code = code;
|
|
297
|
+
this.detail = detail;
|
|
298
|
+
this.name = "SeeWorkError";
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
/** Cap on one downloaded/stored image, guarding against a runaway upstream. */
|
|
302
|
+
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
303
|
+
/** Per-request timeout: image generation is synchronous and can be slow. */
|
|
304
|
+
const REQUEST_TIMEOUT_MS = 3e5;
|
|
305
|
+
/** Join the configured base URL with the gateway path (tolerates a trailing slash). */
|
|
306
|
+
function gatewayUrl(apiUrl, path$1) {
|
|
307
|
+
return `${apiUrl.trim().replace(/\/+$/, "")}/${path$1.replace(/^\/+/, "")}`;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Derive a catalog URL from a configured base.
|
|
311
|
+
* `http://host:8081` -> `http://host:8081/api/v1/catalog/models`.
|
|
312
|
+
* A base that already ends in `/v1` (a gateway base) drops that suffix, so a
|
|
313
|
+
* reverse proxy that mounts both surfaces under one host still resolves.
|
|
314
|
+
*/
|
|
315
|
+
function catalogUrl(baseUrl) {
|
|
316
|
+
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
|
317
|
+
return `${/\/v1$/.test(trimmed) ? trimmed.slice(0, -3) : trimmed}/api/v1/catalog/models`;
|
|
318
|
+
}
|
|
319
|
+
/** The `image_urls` field name (the documented alias `image` is equivalent). */
|
|
320
|
+
const IMAGE_URLS_FIELD = "image_urls";
|
|
321
|
+
/** Build the gateway request body for one normalized request. */
|
|
322
|
+
function buildGenerationBody(request) {
|
|
323
|
+
const model = request.model.trim();
|
|
324
|
+
if (model === "") throw new SeeWorkError("未选择模型:请先在「设置 → 插件 → SeeWork」检测并勾选模型。", "model-missing");
|
|
325
|
+
const prompt = request.prompt.trim();
|
|
326
|
+
if (prompt === "") throw new SeeWorkError("提示词不能为空。", "prompt-missing");
|
|
327
|
+
const body = {
|
|
328
|
+
model,
|
|
329
|
+
prompt,
|
|
330
|
+
n: effectiveImageCount(request.n)
|
|
331
|
+
};
|
|
332
|
+
const resolution = request.resolution.trim();
|
|
333
|
+
if (resolution !== "" && resolution.toLowerCase() !== "auto") body.resolution = resolution;
|
|
334
|
+
const aspectRatio = request.aspectRatio.trim();
|
|
335
|
+
if (aspectRatio !== "" && aspectRatio.toLowerCase() !== "auto") body.aspect_ratio = aspectRatio;
|
|
336
|
+
const outputFormat = request.outputFormat.trim();
|
|
337
|
+
if (outputFormat !== "" && outputFormat.toLowerCase() !== "auto") body.output_format = outputFormat;
|
|
338
|
+
const references = request.imageUrls.filter((url) => typeof url === "string" && url.trim() !== "");
|
|
339
|
+
if (references.length > 0) body[IMAGE_URLS_FIELD] = references;
|
|
340
|
+
return body;
|
|
341
|
+
}
|
|
342
|
+
/** Parse the `{error, message}` failure envelope the gateway documents. */
|
|
343
|
+
function gatewayFailure(status, payload) {
|
|
344
|
+
const record = typeof payload === "object" && payload !== null ? payload : void 0;
|
|
345
|
+
const code = typeof record?.error === "string" && record.error !== "" ? record.error : `http_${status}`;
|
|
346
|
+
return new SeeWorkError(friendlyGatewayMessage(status, code, typeof record?.message === "string" && record.message !== "" ? record.message : ""), code);
|
|
347
|
+
}
|
|
348
|
+
/** Turn a gateway error into something a human can act on. */
|
|
349
|
+
function friendlyGatewayMessage(status, code, detail) {
|
|
350
|
+
const suffix = detail === "" ? "" : `:${detail}`;
|
|
351
|
+
const needsPermission = "请如实告知用户,得到许可后再发";
|
|
352
|
+
switch (code) {
|
|
353
|
+
case "unknown_field":
|
|
354
|
+
case "invalid_parameter":
|
|
355
|
+
case "unsupported_parameter":
|
|
356
|
+
case "constraint_conflict":
|
|
357
|
+
case "no_compatible_image_offering":
|
|
358
|
+
case "invalid_resource":
|
|
359
|
+
case "invalid_value": return `该模型不接受这次请求的参数组合(${code})${suffix}`;
|
|
360
|
+
case "invalid_json_body": return `请求体被网关判为非法 JSON(${code})${suffix}`;
|
|
361
|
+
case "model_protocol_mismatch": return `端点和模型协议不匹配(${code})${suffix}`;
|
|
362
|
+
case "model_not_found": return `模型不存在或未发布${suffix}`;
|
|
363
|
+
case "image_task_in_progress": return `该模型已有任务在跑:本次没有发起生成,也没有扣费。${needsPermission}${suffix}`;
|
|
364
|
+
default: break;
|
|
365
|
+
}
|
|
366
|
+
if (status === 401) return `API Key 无效或缺失,请在「设置 → 插件 → SeeWork」重新填写${suffix}`;
|
|
367
|
+
if (status === 402) return `余额不足,请先充值${suffix}`;
|
|
368
|
+
if (status === 403) return `账户已停用${suffix}`;
|
|
369
|
+
if (status === 429) return `触发限流:本次没有发起生成。${needsPermission}${suffix}`;
|
|
370
|
+
if (status === 502) return `上游不可达:本次没有交付。${needsPermission}${suffix}`;
|
|
371
|
+
if (status === 504) return `上游超时:本次没有交付。${needsPermission}${suffix}`;
|
|
372
|
+
if (status === 503) return `当前没有可用的供给:本次没有交付。${needsPermission}${suffix}`;
|
|
373
|
+
return `生图失败(HTTP ${status}${code === `http_${status}` ? "" : ` / ${code}`})${suffix}`;
|
|
374
|
+
}
|
|
375
|
+
/** One JSON POST to the gateway, with the documented error mapping. */
|
|
376
|
+
async function postJson(upstream, body, signal) {
|
|
377
|
+
if (upstream.apiUrl.trim() === "") throw new SeeWorkError("尚未配置 API 地址:请在「设置 → 插件 → SeeWork」填写 SeeAI Hub 网关地址。", "api-url-missing");
|
|
378
|
+
if (upstream.apiKey.trim() === "") throw new SeeWorkError("尚未配置 API Key:请在「设置 → 插件 → SeeWork」填写 SeeAI Hub 用户 API Key。", "api-key-missing");
|
|
379
|
+
const url = gatewayUrl(upstream.apiUrl, "/images/generations");
|
|
380
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
381
|
+
const composed = signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
|
|
382
|
+
let response;
|
|
383
|
+
try {
|
|
384
|
+
response = await fetch(url, {
|
|
385
|
+
method: "POST",
|
|
386
|
+
headers: {
|
|
387
|
+
"content-type": "application/json",
|
|
388
|
+
authorization: `Bearer ${upstream.apiKey.trim()}`
|
|
389
|
+
},
|
|
390
|
+
body: JSON.stringify(body),
|
|
391
|
+
signal: composed
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (signal?.aborted === true) throw new SeeWorkError("生图已取消。", "cancelled");
|
|
395
|
+
if (error instanceof SeeWorkError) throw error;
|
|
396
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
397
|
+
const timedOut = timeout.aborted;
|
|
398
|
+
throw new SeeWorkError(timedOut ? `生图超时(超过 ${REQUEST_TIMEOUT_MS / 1e3} 秒)` : `连接网关失败:${reason}`, timedOut ? "timeout" : "network");
|
|
399
|
+
}
|
|
400
|
+
const text = await response.text();
|
|
401
|
+
let payload;
|
|
402
|
+
try {
|
|
403
|
+
payload = text === "" ? void 0 : JSON.parse(text);
|
|
404
|
+
} catch {
|
|
405
|
+
payload = void 0;
|
|
406
|
+
}
|
|
407
|
+
if (response.ok) {
|
|
408
|
+
if (typeof payload !== "object" || payload === null) throw new SeeWorkError("网关返回了非 JSON 的成功响应。", "bad_response");
|
|
409
|
+
return payload;
|
|
410
|
+
}
|
|
411
|
+
throw gatewayFailure(response.status, payload);
|
|
412
|
+
}
|
|
413
|
+
/** Sniff a data URL's media type, falling back to a declared/assumed one. */
|
|
414
|
+
function mediaTypeOf(bytes, declared) {
|
|
415
|
+
if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) return "image/png";
|
|
416
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
417
|
+
if (bytes.length >= 12 && bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
|
|
418
|
+
if (bytes.length >= 6 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70) return "image/gif";
|
|
419
|
+
if (declared !== void 0 && declared.startsWith("image/")) return declared.split(";")[0].trim();
|
|
420
|
+
return "image/png";
|
|
421
|
+
}
|
|
422
|
+
/** Read an image from `data[]`: a remote URL to download, or inline base64. */
|
|
423
|
+
async function readImageItem(item, signal) {
|
|
424
|
+
const revisedPrompt = typeof item.revised_prompt === "string" ? item.revised_prompt : typeof item.revisedPrompt === "string" ? item.revisedPrompt : void 0;
|
|
425
|
+
const b64 = typeof item.b64_json === "string" && item.b64_json !== "" ? item.b64_json : void 0;
|
|
426
|
+
if (b64 !== void 0) {
|
|
427
|
+
if (Math.floor(b64.length * 3 / 4) > MAX_IMAGE_BYTES) throw new SeeWorkError(`生成结果超过 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB,已放弃保存。`, "image_too_large");
|
|
428
|
+
const bytes = Buffer.from(b64, "base64");
|
|
429
|
+
if (bytes.byteLength === 0) return void 0;
|
|
430
|
+
return {
|
|
431
|
+
b64,
|
|
432
|
+
mime: mediaTypeOf(bytes, void 0),
|
|
433
|
+
...revisedPrompt === void 0 ? {} : { revisedPrompt }
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const url = typeof item.url === "string" && item.url !== "" ? item.url : void 0;
|
|
437
|
+
if (url === void 0) return void 0;
|
|
438
|
+
const response = await fetch(url, signal === void 0 ? {} : { signal });
|
|
439
|
+
if (!response.ok) throw new SeeWorkError(`下载生成结果失败(HTTP ${response.status})。`, "download_failed");
|
|
440
|
+
const declared = Number(response.headers.get("content-length") ?? "");
|
|
441
|
+
if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) throw new SeeWorkError(`生成结果超过 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB,已放弃下载。`, "image_too_large");
|
|
442
|
+
const buffer = await readBounded(response, MAX_IMAGE_BYTES);
|
|
443
|
+
if (buffer === void 0) throw new SeeWorkError(`生成结果超过 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB,已放弃下载。`, "image_too_large");
|
|
444
|
+
if (buffer.byteLength === 0) throw new SeeWorkError("下载到的图片为空。", "download_failed");
|
|
445
|
+
return {
|
|
446
|
+
b64: buffer.toString("base64"),
|
|
447
|
+
mime: mediaTypeOf(buffer, response.headers.get("content-type") ?? void 0),
|
|
448
|
+
...revisedPrompt === void 0 ? {} : { revisedPrompt }
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Read a response body, giving up as soon as it exceeds `limit`.
|
|
453
|
+
*
|
|
454
|
+
* A server that lies about (or omits) `content-length` must not be able to make
|
|
455
|
+
* the host allocate an unbounded buffer.
|
|
456
|
+
*
|
|
457
|
+
* @returns the bytes, or undefined when the body exceeded the limit.
|
|
458
|
+
*/
|
|
459
|
+
async function readBounded(response, limit) {
|
|
460
|
+
if (response.body === null) {
|
|
461
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
462
|
+
return buffer.byteLength > limit ? void 0 : buffer;
|
|
463
|
+
}
|
|
464
|
+
const chunks = [];
|
|
465
|
+
let total = 0;
|
|
466
|
+
const reader = response.body.getReader();
|
|
467
|
+
try {
|
|
468
|
+
for (;;) {
|
|
469
|
+
const { done, value } = await reader.read();
|
|
470
|
+
if (done === true) break;
|
|
471
|
+
if (value === void 0) continue;
|
|
472
|
+
total += value.byteLength;
|
|
473
|
+
if (total > limit) {
|
|
474
|
+
await reader.cancel().catch(() => {});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
chunks.push(Buffer.from(value));
|
|
478
|
+
}
|
|
479
|
+
} finally {
|
|
480
|
+
reader.releaseLock();
|
|
481
|
+
}
|
|
482
|
+
return Buffer.concat(chunks);
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Run one generation against the gateway.
|
|
486
|
+
* @param upstream - resolved credentials.
|
|
487
|
+
* @param request - the normalized request.
|
|
488
|
+
* @param options - cancellation signal.
|
|
489
|
+
* @returns the generated images plus the charged amount when reported.
|
|
490
|
+
*/
|
|
491
|
+
async function generateImage(upstream, request, options = {}) {
|
|
492
|
+
const payload = await postJson(upstream, buildGenerationBody(request), options.signal);
|
|
493
|
+
const data = Array.isArray(payload.data) ? payload.data : [];
|
|
494
|
+
if (data.length === 0) throw new SeeWorkError("网关没有返回任何图片数据。", "empty_result");
|
|
495
|
+
const images = [];
|
|
496
|
+
for (const item of data) {
|
|
497
|
+
if (typeof item !== "object" || item === null) continue;
|
|
498
|
+
const image = await readImageItem(item, options.signal);
|
|
499
|
+
if (image !== void 0) images.push(image);
|
|
500
|
+
}
|
|
501
|
+
if (images.length === 0) throw new SeeWorkError("网关返回的 data[] 里没有可用的图片地址或数据。", "empty_result");
|
|
502
|
+
const rawCost = payload.cost;
|
|
503
|
+
const cost = typeof rawCost === "number" && Number.isFinite(rawCost) ? rawCost : void 0;
|
|
504
|
+
return {
|
|
505
|
+
images,
|
|
506
|
+
...cost === void 0 ? {} : { cost }
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/library.ts
|
|
512
|
+
/** Default data root: `<DSH_HOME|~/.dsh>/dsh-seework`. */
|
|
513
|
+
function defaultDataRoot() {
|
|
514
|
+
const home = process.env.DSH_HOME?.trim();
|
|
515
|
+
return path.join(home !== void 0 && home !== "" ? home : path.join(homedir(), ".dsh"), DEFAULT_DATA_DIR_NAME);
|
|
516
|
+
}
|
|
517
|
+
let dataRoot = defaultDataRoot();
|
|
518
|
+
/** The directory every library image and its index live under. */
|
|
519
|
+
function libraryDataRoot() {
|
|
520
|
+
return dataRoot;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Point the library at another root (settings `dataDir`; empty restores the
|
|
524
|
+
* default). The host half calls this on every settings resolution, so an
|
|
525
|
+
* unchanged value is a no-op and never disturbs in-flight writes.
|
|
526
|
+
*/
|
|
527
|
+
function setLibraryDataRoot(value) {
|
|
528
|
+
const trimmed = value?.trim();
|
|
529
|
+
const next = trimmed === void 0 || trimmed === "" ? defaultDataRoot() : path.resolve(trimmed);
|
|
530
|
+
if (next !== dataRoot) dataRoot = next;
|
|
531
|
+
}
|
|
532
|
+
function indexPath() {
|
|
533
|
+
return path.join(dataRoot, "index.json");
|
|
534
|
+
}
|
|
535
|
+
function imagesDir() {
|
|
536
|
+
return path.join(dataRoot, "images");
|
|
537
|
+
}
|
|
538
|
+
/** Entries kept in the index (oldest generations are trimmed, files removed). */
|
|
539
|
+
const LIBRARY_MAX_ENTRIES = 2e3;
|
|
540
|
+
/**
|
|
541
|
+
* Library mutations read and replace one shared index; serialize them so two
|
|
542
|
+
* concurrent generations cannot each read an old index and drop the other's row.
|
|
543
|
+
*/
|
|
544
|
+
let pendingMutation = Promise.resolve();
|
|
545
|
+
function mutateLibrary(operation) {
|
|
546
|
+
const next = pendingMutation.then(operation, operation);
|
|
547
|
+
pendingMutation = next.then(() => void 0, () => void 0);
|
|
548
|
+
return next;
|
|
549
|
+
}
|
|
550
|
+
/** File extension for a media type. */
|
|
551
|
+
function extensionOf(mime) {
|
|
552
|
+
switch (mime.split(";")[0].trim().toLowerCase()) {
|
|
553
|
+
case "image/jpeg": return "jpg";
|
|
554
|
+
case "image/webp": return "webp";
|
|
555
|
+
case "image/gif": return "gif";
|
|
556
|
+
default: return "png";
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
/** Media type for a stored file name. */
|
|
560
|
+
function mimeOfFile(file) {
|
|
561
|
+
switch (path.extname(file).toLowerCase()) {
|
|
562
|
+
case ".jpg":
|
|
563
|
+
case ".jpeg": return "image/jpeg";
|
|
564
|
+
case ".webp": return "image/webp";
|
|
565
|
+
case ".gif": return "image/gif";
|
|
566
|
+
default: return "image/png";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Intrinsic pixel size read straight from the container header — no image
|
|
571
|
+
* library needed, and a malformed header just yields undefined.
|
|
572
|
+
*/
|
|
573
|
+
function imageSize(bytes) {
|
|
574
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
575
|
+
if (bytes.byteLength >= 24 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71) return {
|
|
576
|
+
width: view.getUint32(16),
|
|
577
|
+
height: view.getUint32(20)
|
|
578
|
+
};
|
|
579
|
+
if (bytes.byteLength >= 10 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70) return {
|
|
580
|
+
width: view.getUint16(6, true),
|
|
581
|
+
height: view.getUint16(8, true)
|
|
582
|
+
};
|
|
583
|
+
if (bytes.byteLength >= 30 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
|
|
584
|
+
const fourCC = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
|
|
585
|
+
if (fourCC.startsWith("VP8X")) return {
|
|
586
|
+
width: 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16),
|
|
587
|
+
height: 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16)
|
|
588
|
+
};
|
|
589
|
+
if (fourCC.startsWith("VP8L") && bytes.byteLength >= 25) {
|
|
590
|
+
const bits = bytes[21] | bytes[22] << 8 | bytes[23] << 16 | bytes[24] << 24;
|
|
591
|
+
return {
|
|
592
|
+
width: (bits & 16383) + 1,
|
|
593
|
+
height: (bits >> 14 & 16383) + 1
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (fourCC.startsWith("VP8 ") && bytes.byteLength >= 30) return {
|
|
597
|
+
width: view.getUint16(26, true) & 16383,
|
|
598
|
+
height: view.getUint16(28, true) & 16383
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
if (bytes.byteLength >= 4 && bytes[0] === 255 && bytes[1] === 216) {
|
|
602
|
+
let offset = 2;
|
|
603
|
+
while (offset + 9 < bytes.byteLength) {
|
|
604
|
+
if (bytes[offset] !== 255) {
|
|
605
|
+
offset++;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const marker = bytes[offset + 1];
|
|
609
|
+
if (marker === 216 || marker === 1 || marker >= 208 && marker <= 215) {
|
|
610
|
+
offset += 2;
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const length = view.getUint16(offset + 2);
|
|
614
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
|
|
615
|
+
height: view.getUint16(offset + 5),
|
|
616
|
+
width: view.getUint16(offset + 7)
|
|
617
|
+
};
|
|
618
|
+
offset += 2 + length;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/** Structural guard for one persisted entry. */
|
|
623
|
+
function isStoredEntry(value) {
|
|
624
|
+
if (value === null || typeof value !== "object") return false;
|
|
625
|
+
const entry = value;
|
|
626
|
+
return typeof entry.id === "string" && typeof entry.createdAt === "number" && (entry.mode === "text" || entry.mode === "edit") && typeof entry.model === "string" && typeof entry.prompt === "string" && Array.isArray(entry.images) && entry.images.every((image) => {
|
|
627
|
+
if (image === null || typeof image !== "object") return false;
|
|
628
|
+
const record = image;
|
|
629
|
+
return typeof record.file === "string" && typeof record.mime === "string";
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
/** Read the index, tolerating a missing or corrupt file. */
|
|
633
|
+
async function readIndex() {
|
|
634
|
+
try {
|
|
635
|
+
const raw = await promises.readFile(indexPath(), "utf8");
|
|
636
|
+
const parsed = JSON.parse(raw);
|
|
637
|
+
if (parsed === null || typeof parsed !== "object") return [];
|
|
638
|
+
const entries = parsed.entries;
|
|
639
|
+
if (!Array.isArray(entries)) return [];
|
|
640
|
+
return entries.filter(isStoredEntry);
|
|
641
|
+
} catch {
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/** Persist the index atomically (temp file + rename). */
|
|
646
|
+
async function writeIndex(entries) {
|
|
647
|
+
await promises.mkdir(imagesDir(), { recursive: true });
|
|
648
|
+
const payload = {
|
|
649
|
+
version: 1,
|
|
650
|
+
entries
|
|
651
|
+
};
|
|
652
|
+
const tmp = `${indexPath()}.tmp-${process.pid}`;
|
|
653
|
+
await promises.writeFile(tmp, JSON.stringify(payload), "utf8");
|
|
654
|
+
await promises.rename(tmp, indexPath());
|
|
655
|
+
}
|
|
656
|
+
/** Project a stored entry onto the wire shape (served image URLs). */
|
|
657
|
+
function toWire(entry) {
|
|
658
|
+
return {
|
|
659
|
+
id: entry.id,
|
|
660
|
+
createdAt: entry.createdAt,
|
|
661
|
+
mode: entry.mode,
|
|
662
|
+
model: entry.model,
|
|
663
|
+
prompt: entry.prompt,
|
|
664
|
+
resolution: entry.resolution,
|
|
665
|
+
aspectRatio: entry.aspectRatio,
|
|
666
|
+
quality: entry.quality,
|
|
667
|
+
outputFormat: entry.outputFormat,
|
|
668
|
+
n: entry.n,
|
|
669
|
+
images: entry.images.map(toWireImage),
|
|
670
|
+
...entry.cost === void 0 ? {} : { cost: entry.cost },
|
|
671
|
+
...entry.refNames === void 0 ? {} : { refNames: entry.refNames },
|
|
672
|
+
source: entry.source,
|
|
673
|
+
...entry.sessionId === void 0 ? {} : { sessionId: entry.sessionId },
|
|
674
|
+
...entry.tags === void 0 ? {} : { tags: entry.tags }
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function toWireImage(image) {
|
|
678
|
+
return {
|
|
679
|
+
url: `${LIBRARY_API.image}/${image.file}`,
|
|
680
|
+
file: image.file,
|
|
681
|
+
mime: image.mime,
|
|
682
|
+
...image.width === void 0 ? {} : { width: image.width },
|
|
683
|
+
...image.height === void 0 ? {} : { height: image.height },
|
|
684
|
+
...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
/** Everything the library list route reports. */
|
|
688
|
+
async function listLibrary() {
|
|
689
|
+
const entries = await readIndex();
|
|
690
|
+
let imageCount = 0;
|
|
691
|
+
for (const entry of entries) imageCount += entry.images.length;
|
|
692
|
+
return {
|
|
693
|
+
entries: entries.map(toWire),
|
|
694
|
+
total: entries.length,
|
|
695
|
+
imageCount,
|
|
696
|
+
dataRoot
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* The library's identity, cheap enough for a poll.
|
|
701
|
+
*
|
|
702
|
+
* The browser has no push channel from the host, so "did a generation just
|
|
703
|
+
* finish?" is answered by watching the newest entry id. This deliberately does
|
|
704
|
+
* NOT return the list: the caller polls it every couple of seconds, and the
|
|
705
|
+
* full list carries every entry's metadata.
|
|
706
|
+
*
|
|
707
|
+
* @returns the newest entry's id and the total, or just the total when empty.
|
|
708
|
+
*/
|
|
709
|
+
async function readLibraryHead() {
|
|
710
|
+
const entries = await readIndex();
|
|
711
|
+
let newest;
|
|
712
|
+
for (const entry of entries) if (newest === void 0 || entry.createdAt > newest.createdAt) newest = entry;
|
|
713
|
+
return {
|
|
714
|
+
total: entries.length,
|
|
715
|
+
...newest === void 0 ? {} : {
|
|
716
|
+
newestId: newest.id,
|
|
717
|
+
newestAt: newest.createdAt
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
/** Remove one entry's image files (best effort). */
|
|
722
|
+
async function removeEntryFiles(entry) {
|
|
723
|
+
for (const image of entry.images) try {
|
|
724
|
+
await promises.rm(path.join(imagesDir(), image.file), { force: true });
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
/** File name of one stored image: `<entry id>-<index>.<ext>`. */
|
|
728
|
+
function imageFileName(entryId, index, mime) {
|
|
729
|
+
return `${entryId}-${index}.${extensionOf(mime)}`;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Where one generated image lives, in the three forms a caller may need.
|
|
733
|
+
*
|
|
734
|
+
* The naming rule is shared with the writer below rather than repeated, so the
|
|
735
|
+
* file/URL/path an Agent tool reports can never drift from what actually landed
|
|
736
|
+
* on disk. That matters because the model cannot see the picture: these strings
|
|
737
|
+
* are the only way it can say where the image is.
|
|
738
|
+
*
|
|
739
|
+
* @param entryId - the library entry the generation was stored as.
|
|
740
|
+
* @param index - 0-based image position inside that entry.
|
|
741
|
+
* @param mime - the image's media type (decides the extension).
|
|
742
|
+
* @returns the file name, its same-origin URL, and its absolute path.
|
|
743
|
+
*/
|
|
744
|
+
function libraryImageLocation(entryId, index, mime) {
|
|
745
|
+
const file = imageFileName(entryId, index, mime);
|
|
746
|
+
return {
|
|
747
|
+
file,
|
|
748
|
+
url: `${LIBRARY_API.image}/${file}`,
|
|
749
|
+
path: path.join(imagesDir(), file)
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Write one generation's images to disk and prepend its index entry.
|
|
754
|
+
* @returns the stored wire entry.
|
|
755
|
+
* @throws when an image cannot be written (no partial entry is left behind).
|
|
756
|
+
*/
|
|
757
|
+
async function appendLibraryEntry(input) {
|
|
758
|
+
return mutateLibrary(async () => {
|
|
759
|
+
await promises.mkdir(imagesDir(), { recursive: true });
|
|
760
|
+
const id = randomUUID();
|
|
761
|
+
const stored = [];
|
|
762
|
+
try {
|
|
763
|
+
for (let index = 0; index < input.images.length; index++) {
|
|
764
|
+
const image = input.images[index];
|
|
765
|
+
const bytes = Buffer.from(image.b64, "base64");
|
|
766
|
+
const file = imageFileName(id, index, image.mime);
|
|
767
|
+
await promises.writeFile(path.join(imagesDir(), file), bytes);
|
|
768
|
+
const size = imageSize(bytes);
|
|
769
|
+
stored.push({
|
|
770
|
+
file,
|
|
771
|
+
mime: image.mime,
|
|
772
|
+
...size === void 0 ? {} : size,
|
|
773
|
+
...image.revisedPrompt === void 0 ? {} : { revisedPrompt: image.revisedPrompt }
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
} catch (error) {
|
|
777
|
+
await removeEntryFiles({ images: stored });
|
|
778
|
+
throw error;
|
|
779
|
+
}
|
|
780
|
+
const entry = {
|
|
781
|
+
id,
|
|
782
|
+
createdAt: Date.now(),
|
|
783
|
+
mode: input.request.mode,
|
|
784
|
+
model: input.request.model,
|
|
785
|
+
prompt: input.request.prompt,
|
|
786
|
+
resolution: input.request.resolution,
|
|
787
|
+
aspectRatio: input.request.aspectRatio,
|
|
788
|
+
quality: "",
|
|
789
|
+
outputFormat: input.request.outputFormat,
|
|
790
|
+
n: input.request.n,
|
|
791
|
+
images: stored,
|
|
792
|
+
...input.cost === void 0 ? {} : { cost: input.cost },
|
|
793
|
+
...input.request.refNames === void 0 ? {} : { refNames: input.request.refNames },
|
|
794
|
+
source: input.source,
|
|
795
|
+
...input.sessionId === void 0 ? {} : { sessionId: input.sessionId }
|
|
796
|
+
};
|
|
797
|
+
const previous = await readIndex();
|
|
798
|
+
const merged = [entry, ...previous].slice(0, LIBRARY_MAX_ENTRIES);
|
|
799
|
+
await writeIndex(merged);
|
|
800
|
+
const keptIds = new Set(merged.map((candidate) => candidate.id));
|
|
801
|
+
for (const candidate of previous) if (!keptIds.has(candidate.id)) await removeEntryFiles(candidate);
|
|
802
|
+
return toWire(entry);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
/** Remove one entry (and its image files); returns the remaining entries. */
|
|
806
|
+
async function removeLibraryEntry(id) {
|
|
807
|
+
return mutateLibrary(async () => {
|
|
808
|
+
const entries = await readIndex();
|
|
809
|
+
const target = entries.find((entry) => entry.id === id);
|
|
810
|
+
if (target !== void 0) await removeEntryFiles(target);
|
|
811
|
+
const kept = entries.filter((entry) => entry.id !== id);
|
|
812
|
+
await writeIndex(kept);
|
|
813
|
+
return kept.map(toWire);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/** Remove every entry and image file. */
|
|
817
|
+
async function clearLibrary() {
|
|
818
|
+
return mutateLibrary(async () => {
|
|
819
|
+
const entries = await readIndex();
|
|
820
|
+
for (const entry of entries) await removeEntryFiles(entry);
|
|
821
|
+
await writeIndex([]);
|
|
822
|
+
return [];
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
/** Read one stored image file by its (validated) file name. */
|
|
826
|
+
async function readLibraryImage(file) {
|
|
827
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return void 0;
|
|
828
|
+
try {
|
|
829
|
+
return {
|
|
830
|
+
data: await promises.readFile(path.join(imagesDir(), file)),
|
|
831
|
+
mime: mimeOfFile(file)
|
|
832
|
+
};
|
|
833
|
+
} catch {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region src/capability.ts
|
|
840
|
+
/**
|
|
841
|
+
* The stored shape of one model (also the settings card's field contract).
|
|
842
|
+
*
|
|
843
|
+
* Declared once and reused by the settings schema: a field the document stores is
|
|
844
|
+
* exactly a field this module knows how to read, merge and compare.
|
|
845
|
+
*/
|
|
846
|
+
const modelSchema = z.object({
|
|
847
|
+
id: z.string(),
|
|
848
|
+
label: z.string().default(""),
|
|
849
|
+
resolutions: z.array(z.string()).default([]),
|
|
850
|
+
resolutionDefault: z.string().default(""),
|
|
851
|
+
aspectRatios: z.array(z.string()).default([]),
|
|
852
|
+
outputFormats: z.array(z.string()).default([]),
|
|
853
|
+
maxImages: z.number().default(1),
|
|
854
|
+
maxReferenceImages: z.number().default(0),
|
|
855
|
+
capabilitiesKnown: z.boolean().default(false),
|
|
856
|
+
discoveredAt: z.number()
|
|
857
|
+
});
|
|
858
|
+
/**
|
|
859
|
+
* Normalize a raw model entry (schema-adjacent guard for hand-built values).
|
|
860
|
+
*/
|
|
861
|
+
function normalizeModel(value) {
|
|
862
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
863
|
+
const raw = value;
|
|
864
|
+
const id = typeof raw.id === "string" ? raw.id.trim() : "";
|
|
865
|
+
if (id === "") return void 0;
|
|
866
|
+
const list = (input) => Array.isArray(input) ? input.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()) : [];
|
|
867
|
+
const positive = (input, fallback) => typeof input === "number" && Number.isFinite(input) && input >= 0 ? Math.trunc(input) : fallback;
|
|
868
|
+
return {
|
|
869
|
+
id,
|
|
870
|
+
label: typeof raw.label === "string" ? raw.label.trim() : "",
|
|
871
|
+
resolutions: list(raw.resolutions),
|
|
872
|
+
resolutionDefault: typeof raw.resolutionDefault === "string" ? raw.resolutionDefault.trim() : "",
|
|
873
|
+
aspectRatios: list(raw.aspectRatios),
|
|
874
|
+
outputFormats: list(raw.outputFormats),
|
|
875
|
+
maxImages: positive(raw.maxImages, 1),
|
|
876
|
+
maxReferenceImages: positive(raw.maxReferenceImages, 0),
|
|
877
|
+
capabilitiesKnown: raw.capabilitiesKnown === true,
|
|
878
|
+
...typeof raw.discoveredAt === "number" && Number.isFinite(raw.discoveredAt) && raw.discoveredAt > 0 ? { discoveredAt: Math.trunc(raw.discoveredAt) } : {}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
/** Union of two string lists, first occurrence order, no duplicates. */
|
|
882
|
+
function unionList(left, right) {
|
|
883
|
+
const out = [...left];
|
|
884
|
+
for (const item of right) if (!out.includes(item)) out.push(item);
|
|
885
|
+
return out;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Keep the newer of two discovery stamps, or nothing when neither knows.
|
|
889
|
+
*
|
|
890
|
+
* Spread-friendly so the caller does not have to spell the "absent means
|
|
891
|
+
* unknown" rule again: an unstamped copy stays unstamped instead of acquiring a
|
|
892
|
+
* `0` that other code would then have to special-case.
|
|
893
|
+
*
|
|
894
|
+
* @param left - one copy's stamp.
|
|
895
|
+
* @param right - the other copy's stamp.
|
|
896
|
+
* @returns `{ discoveredAt }` when at least one is known, otherwise `{}`.
|
|
897
|
+
*/
|
|
898
|
+
function newestStamp(left, right) {
|
|
899
|
+
const known = [left, right].filter((stamp) => stamp !== void 0);
|
|
900
|
+
return known.length === 0 ? {} : { discoveredAt: Math.max(...known) };
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Fold a duplicate catalog entry into the one already collected.
|
|
904
|
+
*
|
|
905
|
+
* The capability arrays are unioned rather than first-wins: a repeated id can
|
|
906
|
+
* only mean the same model was described twice, and keeping the union makes the
|
|
907
|
+
* request builder *more* permissive, never less. The declared default tier is
|
|
908
|
+
* the one thing a union cannot reconstruct, so a copy that carries one keeps it
|
|
909
|
+
* (there is exactly one default per model).
|
|
910
|
+
*/
|
|
911
|
+
function mergeModel(into, extra) {
|
|
912
|
+
return {
|
|
913
|
+
id: into.id,
|
|
914
|
+
label: into.label !== void 0 && into.label !== "" ? into.label : extra.label,
|
|
915
|
+
resolutions: unionList(into.resolutions, extra.resolutions),
|
|
916
|
+
resolutionDefault: into.resolutionDefault !== void 0 && into.resolutionDefault !== "" ? into.resolutionDefault : extra.resolutionDefault ?? "",
|
|
917
|
+
aspectRatios: unionList(into.aspectRatios, extra.aspectRatios),
|
|
918
|
+
outputFormats: unionList(into.outputFormats, extra.outputFormats),
|
|
919
|
+
maxImages: Math.max(into.maxImages, extra.maxImages),
|
|
920
|
+
maxReferenceImages: Math.max(into.maxReferenceImages, extra.maxReferenceImages),
|
|
921
|
+
capabilitiesKnown: into.capabilitiesKnown === true || extra.capabilitiesKnown === true,
|
|
922
|
+
...newestStamp(into.discoveredAt, extra.discoveredAt)
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Read a whole raw `models` list: normalize every entry, drop the unusable ones,
|
|
927
|
+
* and merge entries that repeat an id.
|
|
928
|
+
*
|
|
929
|
+
* @param value - the raw document value (anything; only an array is read).
|
|
930
|
+
* @returns the normalized models, in first-occurrence order.
|
|
931
|
+
*/
|
|
932
|
+
function normalizeModels(value) {
|
|
933
|
+
const models = [];
|
|
934
|
+
const byId = /* @__PURE__ */ new Map();
|
|
935
|
+
for (const candidate of Array.isArray(value) ? value : []) {
|
|
936
|
+
const model = normalizeModel(candidate);
|
|
937
|
+
if (model === void 0) continue;
|
|
938
|
+
const existing = byId.get(model.id);
|
|
939
|
+
if (existing === void 0) {
|
|
940
|
+
byId.set(model.id, models.length);
|
|
941
|
+
models.push(model);
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
models[existing] = mergeModel(models[existing], model);
|
|
945
|
+
}
|
|
946
|
+
return models;
|
|
947
|
+
}
|
|
948
|
+
/** Substrings that mark a non-image model even when no type field is present. */
|
|
949
|
+
const NON_IMAGE_HINTS = [
|
|
950
|
+
"embedding",
|
|
951
|
+
"embed",
|
|
952
|
+
"rerank",
|
|
953
|
+
"moderation",
|
|
954
|
+
"whisper",
|
|
955
|
+
"tts",
|
|
956
|
+
"audio",
|
|
957
|
+
"realtime",
|
|
958
|
+
"transcribe",
|
|
959
|
+
"speech",
|
|
960
|
+
"ocr",
|
|
961
|
+
"chat",
|
|
962
|
+
"instruct",
|
|
963
|
+
"vision"
|
|
964
|
+
];
|
|
965
|
+
/** Substrings that mark an image model when no type field is present. */
|
|
966
|
+
const IMAGE_HINTS = [
|
|
967
|
+
"image",
|
|
968
|
+
"dall-e",
|
|
969
|
+
"dalle",
|
|
970
|
+
"flux",
|
|
971
|
+
"seedream",
|
|
972
|
+
"seededit",
|
|
973
|
+
"imagen",
|
|
974
|
+
"kolors",
|
|
975
|
+
"qwen-image",
|
|
976
|
+
"wanx",
|
|
977
|
+
"grok-imagine",
|
|
978
|
+
"stable-diffusion",
|
|
979
|
+
"sdxl"
|
|
980
|
+
];
|
|
981
|
+
/** Read a string array from an unknown value, dropping blanks and duplicates. */
|
|
982
|
+
function stringList(value) {
|
|
983
|
+
if (!Array.isArray(value)) return [];
|
|
984
|
+
const out = [];
|
|
985
|
+
for (const item of value) {
|
|
986
|
+
const text = typeof item === "string" ? item.trim() : typeof item === "number" ? String(item) : "";
|
|
987
|
+
if (text !== "" && !out.includes(text)) out.push(text);
|
|
988
|
+
}
|
|
989
|
+
return out;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* One descriptor from the entry's `supported_parameters` map.
|
|
993
|
+
*
|
|
994
|
+
* Catalog v2 describes **every field this deployment can send** with one
|
|
995
|
+
* self-describing descriptor (`enum` / `range` / `boolean` / `array`). The key
|
|
996
|
+
* existing is the permission: a key that is absent is a field this contract did
|
|
997
|
+
* not publish — which is **not** a claim that upstream would refuse it.
|
|
998
|
+
*
|
|
999
|
+
* @param parameters - the entry's `supported_parameters`, when it carried one.
|
|
1000
|
+
* @param field - the Canonical field name (`resolution`, `n`, `image_urls`…).
|
|
1001
|
+
* @returns the descriptor, or undefined when the entry declared none.
|
|
1002
|
+
*/
|
|
1003
|
+
function descriptor(parameters, field) {
|
|
1004
|
+
const value = parameters?.[field];
|
|
1005
|
+
return value !== null && typeof value === "object" ? value : void 0;
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* The `values` of one `enum` descriptor, in the order it declares.
|
|
1009
|
+
*
|
|
1010
|
+
* **That order is the contract's order** — lowest to highest / display order —
|
|
1011
|
+
* so the returned list is used verbatim as the model's tier ranking. A
|
|
1012
|
+
* descriptor of any other shape declares no enum, and an entry without the key
|
|
1013
|
+
* declares the field not sendable at all.
|
|
1014
|
+
*/
|
|
1015
|
+
function enumValues(parameters, field) {
|
|
1016
|
+
const meta = descriptor(parameters, field);
|
|
1017
|
+
return meta?.type === "enum" ? stringList(meta.values) : [];
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* The `max` of one `range` or `array` descriptor — a field's own ceiling.
|
|
1021
|
+
*
|
|
1022
|
+
* The ceiling rides on the field it belongs to since v2 (`n.max` = images per
|
|
1023
|
+
* request, `image_urls.max` = reference images), replacing the old side keys.
|
|
1024
|
+
* `array`'s `max` counts elements; `range`'s is the numeric upper bound.
|
|
1025
|
+
*
|
|
1026
|
+
* @returns the ceiling, or undefined when the field declares none.
|
|
1027
|
+
*/
|
|
1028
|
+
function descriptorMax(parameters, field) {
|
|
1029
|
+
const meta = descriptor(parameters, field);
|
|
1030
|
+
if (meta === void 0 || meta.type !== "range" && meta.type !== "array") return void 0;
|
|
1031
|
+
const max = meta.max;
|
|
1032
|
+
return typeof max === "number" && Number.isFinite(max) && max >= 0 ? Math.trunc(max) : void 0;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* The `default` one descriptor declares, when it declares a string one.
|
|
1036
|
+
*
|
|
1037
|
+
* Read **verbatim**: the plugin never derives a default from the value list
|
|
1038
|
+
* (`values[0]` is not a substitute for a declared default), and the catalog's
|
|
1039
|
+
* own guarantee is that `resolution.default` is always published.
|
|
1040
|
+
*
|
|
1041
|
+
* @returns the declared default, or '' when there is none to read.
|
|
1042
|
+
*/
|
|
1043
|
+
function descriptorDefault(parameters, field) {
|
|
1044
|
+
const meta = descriptor(parameters, field);
|
|
1045
|
+
return meta !== void 0 && typeof meta.default === "string" ? meta.default.trim() : "";
|
|
1046
|
+
}
|
|
1047
|
+
/** Whether a model id / type pair reads as an image model. */
|
|
1048
|
+
function looksLikeImageModel(id, type) {
|
|
1049
|
+
if (typeof type === "string" && type.trim() !== "") return type.trim().toLowerCase() === "image";
|
|
1050
|
+
const lower = id.toLowerCase();
|
|
1051
|
+
const firstAt = (hints) => hints.reduce((best, hint) => {
|
|
1052
|
+
const at = lower.indexOf(hint);
|
|
1053
|
+
return at >= 0 && (best < 0 || at < best) ? at : best;
|
|
1054
|
+
}, -1);
|
|
1055
|
+
const imageAt = firstAt(IMAGE_HINTS);
|
|
1056
|
+
if (imageAt < 0) return false;
|
|
1057
|
+
const otherAt = firstAt(NON_IMAGE_HINTS);
|
|
1058
|
+
return otherAt < 0 || imageAt < otherAt;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Map one SeeAI Hub catalog entry onto the model shape.
|
|
1062
|
+
*
|
|
1063
|
+
* Catalog v2 (`docs/api/catalog.md`) describes the sendable field surface with
|
|
1064
|
+
* `supported_parameters` descriptors; there is **one** reading path and no v1
|
|
1065
|
+
* fallback (#663/D-2). An entry that carries no `supported_parameters` at all
|
|
1066
|
+
* (a pre-v2 snapshot, or an older deployment) becomes **capabilities unknown**:
|
|
1067
|
+
* discovery still reports the model, and the request builder stops trimming
|
|
1068
|
+
* rather than reading "absent" as "refused" (#663/D-1).
|
|
1069
|
+
*
|
|
1070
|
+
* The result **is** the shape the settings document stores (#669): discovery used
|
|
1071
|
+
* to answer with a second, nearly identical type that every consumer projected by
|
|
1072
|
+
* hand. `discoveredAt` is stamped on the whole round by discovery, not here.
|
|
1073
|
+
*
|
|
1074
|
+
* @returns the model, or undefined when the entry is not an image model.
|
|
1075
|
+
*/
|
|
1076
|
+
function catalogEntryToModel(value) {
|
|
1077
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
1078
|
+
const entry = value;
|
|
1079
|
+
const id = typeof entry.name === "string" ? entry.name.trim() : "";
|
|
1080
|
+
if (id === "" || !looksLikeImageModel(id, entry.type)) return void 0;
|
|
1081
|
+
const declared = entry.supported_parameters;
|
|
1082
|
+
const parameters = declared !== null && typeof declared === "object" ? declared : void 0;
|
|
1083
|
+
const displayName = typeof entry.display_name === "string" && entry.display_name.trim() !== "" ? entry.display_name.trim() : id;
|
|
1084
|
+
const maxReferenceImages = descriptorMax(parameters, "image_urls") ?? 0;
|
|
1085
|
+
return {
|
|
1086
|
+
id,
|
|
1087
|
+
label: displayName,
|
|
1088
|
+
resolutions: enumValues(parameters, "resolution"),
|
|
1089
|
+
resolutionDefault: descriptorDefault(parameters, "resolution"),
|
|
1090
|
+
aspectRatios: enumValues(parameters, "aspect_ratio"),
|
|
1091
|
+
outputFormats: enumValues(parameters, "output_format"),
|
|
1092
|
+
maxImages: Math.max(1, descriptorMax(parameters, "n") ?? 1),
|
|
1093
|
+
maxReferenceImages,
|
|
1094
|
+
capabilitiesKnown: parameters !== void 0
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
/** Map an OpenAI-compatible `/models` list onto the model shape. */
|
|
1098
|
+
function openAiEntryToModel(value) {
|
|
1099
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
1100
|
+
const entry = value;
|
|
1101
|
+
const id = typeof entry.id === "string" ? entry.id.trim() : "";
|
|
1102
|
+
if (id === "" || !looksLikeImageModel(id, entry.type ?? entry.model_type)) return void 0;
|
|
1103
|
+
return {
|
|
1104
|
+
id,
|
|
1105
|
+
label: id,
|
|
1106
|
+
resolutions: [],
|
|
1107
|
+
resolutionDefault: "",
|
|
1108
|
+
aspectRatios: [],
|
|
1109
|
+
outputFormats: [],
|
|
1110
|
+
maxImages: 1,
|
|
1111
|
+
maxReferenceImages: 0,
|
|
1112
|
+
capabilitiesKnown: false
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/** Whether two string lists are equal, order included. */
|
|
1116
|
+
function sameList(left, right) {
|
|
1117
|
+
const from = left ?? [];
|
|
1118
|
+
return from.length === right.length && from.every((item, index) => item === right[index]);
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Keys the settings schema no longer stores (#659 / #661 / #663).
|
|
1122
|
+
*
|
|
1123
|
+
* A document written before those batches carries them on every model. There is
|
|
1124
|
+
* no migration pass: an adopted entry is rebuilt field by field, so the residue
|
|
1125
|
+
* rides out with the next whole-`models` write. What these helpers add is the
|
|
1126
|
+
* **trigger** — see {@link CatalogRefresherDeps.hasRetiredKeys}.
|
|
1127
|
+
*
|
|
1128
|
+
* `qualities` joined the list in #661, when `quality` stopped being a plugin
|
|
1129
|
+
* parameter altogether. `orderedResolutions` / `resolutionOrderReliable` joined
|
|
1130
|
+
* it in #663, when catalog v2 made the enum descriptor's `values` order the one
|
|
1131
|
+
* declared order: both keys would be a second copy of `resolutions` itself.
|
|
1132
|
+
*/
|
|
1133
|
+
const RETIRED_MODEL_KEYS = [
|
|
1134
|
+
"parameters",
|
|
1135
|
+
"parameterDocs",
|
|
1136
|
+
"parameterRules",
|
|
1137
|
+
"guidePath",
|
|
1138
|
+
"qualities",
|
|
1139
|
+
"orderedResolutions",
|
|
1140
|
+
"resolutionOrderReliable"
|
|
1141
|
+
];
|
|
1142
|
+
/**
|
|
1143
|
+
* Whether one saved entry still carries keys this plugin no longer stores.
|
|
1144
|
+
*
|
|
1145
|
+
* Takes `unknown` on purpose: the caller usually holds the **raw** document
|
|
1146
|
+
* entry. The normalized view ({@link normalizeModels}) has already dropped these
|
|
1147
|
+
* keys, so asking it would always answer "clean" — which is exactly why the
|
|
1148
|
+
* cleanup signal has to be read from the document.
|
|
1149
|
+
*/
|
|
1150
|
+
function carriesRetiredKeys(entry) {
|
|
1151
|
+
if (entry === null || typeof entry !== "object") return false;
|
|
1152
|
+
const record = entry;
|
|
1153
|
+
return RETIRED_MODEL_KEYS.some((key) => key in record);
|
|
1154
|
+
}
|
|
1155
|
+
/** Whether any entry of a raw `models` list still carries those keys. */
|
|
1156
|
+
function hasRetiredModelKeys(models) {
|
|
1157
|
+
return Array.isArray(models) && models.some((entry) => carriesRetiredKeys(entry));
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Whether one saved model's catalog description differs from the advertisement.
|
|
1161
|
+
*
|
|
1162
|
+
* Compared field by field rather than by serializing both objects: a
|
|
1163
|
+
* `discoveredAt` stamp or a key order would otherwise read as "changed" on every
|
|
1164
|
+
* round and write the document for nothing.
|
|
1165
|
+
*
|
|
1166
|
+
* @param saved - the saved model.
|
|
1167
|
+
* @param fresh - what the catalog says now.
|
|
1168
|
+
* @returns true when at least one advertised capability moved.
|
|
1169
|
+
*/
|
|
1170
|
+
function capabilityChanged(saved, fresh) {
|
|
1171
|
+
return saved.label !== fresh.label || !sameList(saved.resolutions, fresh.resolutions) || (saved.resolutionDefault ?? "") !== fresh.resolutionDefault || !sameList(saved.aspectRatios, fresh.aspectRatios) || !sameList(saved.outputFormats, fresh.outputFormats) || saved.maxImages !== fresh.maxImages || saved.maxReferenceImages !== fresh.maxReferenceImages || saved.capabilitiesKnown !== fresh.capabilitiesKnown || saved.discoveredAt === void 0;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* The saved entry for a model the catalog just described.
|
|
1175
|
+
*
|
|
1176
|
+
* Field by field rather than a spread (#669 kept it that way on purpose): the
|
|
1177
|
+
* discovery answer and the stored entry are now the same type, so the only thing
|
|
1178
|
+
* this adds over `{ ...saved, ...fresh }` is the id and any unadvertised field
|
|
1179
|
+
* surviving, plus the stamp rule below. It stays explicit because the settings
|
|
1180
|
+
* schema is the authority on what may travel into the document.
|
|
1181
|
+
*
|
|
1182
|
+
* @param saved - the saved entry, whose id and any unadvertised field survive.
|
|
1183
|
+
* @param fresh - the catalog's current description.
|
|
1184
|
+
* @returns the entry to persist.
|
|
1185
|
+
*/
|
|
1186
|
+
function withRefreshedCapabilities(saved, fresh) {
|
|
1187
|
+
return {
|
|
1188
|
+
id: saved.id,
|
|
1189
|
+
label: fresh.label,
|
|
1190
|
+
resolutions: fresh.resolutions,
|
|
1191
|
+
resolutionDefault: fresh.resolutionDefault,
|
|
1192
|
+
aspectRatios: fresh.aspectRatios,
|
|
1193
|
+
outputFormats: fresh.outputFormats,
|
|
1194
|
+
maxImages: fresh.maxImages,
|
|
1195
|
+
maxReferenceImages: fresh.maxReferenceImages,
|
|
1196
|
+
capabilitiesKnown: fresh.capabilitiesKnown,
|
|
1197
|
+
...fresh.discoveredAt === void 0 ? saved.discoveredAt === void 0 ? {} : { discoveredAt: saved.discoveredAt } : { discoveredAt: fresh.discoveredAt }
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Apply one catalog round to the saved models.
|
|
1202
|
+
*
|
|
1203
|
+
* Pure on purpose: this is the whole of the "what may change automatically"
|
|
1204
|
+
* decision, so it is exercised directly rather than through a fake catalog.
|
|
1205
|
+
*
|
|
1206
|
+
* @param saved - the saved models, in their current order.
|
|
1207
|
+
* @param catalog - the models the catalog just reported.
|
|
1208
|
+
* @returns the list to persist (identical to `saved` when nothing changed) and
|
|
1209
|
+
* the ids that were adopted, added, or vanished.
|
|
1210
|
+
*/
|
|
1211
|
+
function adoptRefreshedCapabilities(saved, catalog) {
|
|
1212
|
+
const byId = new Map(catalog.map((model) => [model.id, model]));
|
|
1213
|
+
const savedIds = new Set(saved.map((model) => model.id));
|
|
1214
|
+
/** Saved models the catalog still describes and that actually moved. */
|
|
1215
|
+
const refreshed = /* @__PURE__ */ new Set();
|
|
1216
|
+
saved.forEach((model, index) => {
|
|
1217
|
+
const fresh = byId.get(model.id);
|
|
1218
|
+
if (fresh !== void 0 && capabilityChanged(model, fresh)) refreshed.add(index);
|
|
1219
|
+
});
|
|
1220
|
+
return {
|
|
1221
|
+
models: refreshed.size === 0 ? saved : saved.map((model) => {
|
|
1222
|
+
const fresh = byId.get(model.id);
|
|
1223
|
+
return fresh === void 0 ? model : withRefreshedCapabilities(model, fresh);
|
|
1224
|
+
}),
|
|
1225
|
+
adopted: [...refreshed].map((index) => saved[index].id),
|
|
1226
|
+
added: catalog.filter((model) => !savedIds.has(model.id)).map((model) => model.id),
|
|
1227
|
+
missing: saved.filter((model) => !byId.has(model.id)).map((model) => model.id)
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
/** Keep a value only when the model accepts it. */
|
|
1231
|
+
function accepted(field, value, allowed, dropped, capabilitiesKnown) {
|
|
1232
|
+
const trimmed = value.trim();
|
|
1233
|
+
if (trimmed === "" || trimmed.toLowerCase() === "auto") return "";
|
|
1234
|
+
if (allowed.length === 0 && !capabilitiesKnown) return trimmed;
|
|
1235
|
+
const match = allowed.find((candidate) => candidate.toLowerCase() === trimmed.toLowerCase());
|
|
1236
|
+
if (match !== void 0) return match;
|
|
1237
|
+
dropped.push({
|
|
1238
|
+
field,
|
|
1239
|
+
value: trimmed,
|
|
1240
|
+
allowed,
|
|
1241
|
+
reason: "invalid_value"
|
|
1242
|
+
});
|
|
1243
|
+
return "";
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* The tier a request uses when the caller names none.
|
|
1247
|
+
*
|
|
1248
|
+
* It is **exactly what the catalog declared** (`resolution.default`), never the
|
|
1249
|
+
* first entry of the tier list: a model may declare a default that is not its
|
|
1250
|
+
* lowest tier, so `resolutions[0]` is not a substitute (#663/D-3). An empty
|
|
1251
|
+
* answer means the contract declared none — a breach of the "always published"
|
|
1252
|
+
* invariant — and the caller then omits the field rather than guessing one
|
|
1253
|
+
* (#663/P3). The missing default is reported in the announcement instead.
|
|
1254
|
+
*/
|
|
1255
|
+
function defaultResolutionFor(model) {
|
|
1256
|
+
return model.resolutionDefault?.trim() ?? "";
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* A configured default the model actually declares, or `''` to omit the field.
|
|
1260
|
+
*
|
|
1261
|
+
* The plugin's own defaults are not values the caller named: a model that does
|
|
1262
|
+
* not declare one — or whose capabilities are unknown, so the plugin has no
|
|
1263
|
+
* evidence either way — gets the field omitted, and nothing is ever reported as
|
|
1264
|
+
* a dropped parameter. `accepted()` is the caller-facing half of the same rule.
|
|
1265
|
+
*
|
|
1266
|
+
* Spelling: a value the caller named is echoed back in the **model's** spelling
|
|
1267
|
+
* (`accepted()`); the plugin's configured default travels **verbatim** or not at
|
|
1268
|
+
* all, because there is no model spelling to prefer for a value the plugin chose.
|
|
1269
|
+
*
|
|
1270
|
+
* @param model - the selected model's catalog entry.
|
|
1271
|
+
* @param configured - the value from the plugin settings.
|
|
1272
|
+
* @param allowed - the values this model declares for that field.
|
|
1273
|
+
*/
|
|
1274
|
+
function configuredWhenAccepted(model, configured, allowed) {
|
|
1275
|
+
const wanted = configured.trim();
|
|
1276
|
+
if (wanted === "" || model.capabilitiesKnown !== true) return "";
|
|
1277
|
+
return includesIgnoringCase(allowed, wanted) ? wanted : "";
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The configured output format, when this model can actually take it (#658/D-13).
|
|
1281
|
+
*
|
|
1282
|
+
* A format the model does not declare (or a model whose capabilities are unknown)
|
|
1283
|
+
* yields '' so the field is simply omitted — the plugin must not gamble a field
|
|
1284
|
+
* on a model it knows nothing about.
|
|
1285
|
+
*/
|
|
1286
|
+
function defaultOutputFormatFor(model, configured) {
|
|
1287
|
+
return configuredWhenAccepted(model, configured, model.outputFormats);
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* The configured aspect ratio, when this model can actually take it (#668).
|
|
1291
|
+
*
|
|
1292
|
+
* Same rule as {@link defaultOutputFormatFor}, and for the same reason: the
|
|
1293
|
+
* contract publishes **no** default for `aspect_ratio` (`docs/api/images.md`), so
|
|
1294
|
+
* the plugin's own setting is the only candidate — and a model that does not
|
|
1295
|
+
* declare it (or whose capabilities are unknown) simply gets the field omitted.
|
|
1296
|
+
* The configured value is not the caller's, so it is never reported as a dropped
|
|
1297
|
+
* parameter.
|
|
1298
|
+
*/
|
|
1299
|
+
function defaultAspectRatioFor(model, configured) {
|
|
1300
|
+
return configuredWhenAccepted(model, configured, model.aspectRatios);
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Resolve a request against the selected model's capability table.
|
|
1304
|
+
*
|
|
1305
|
+
* SeeAI Hub validates every parameter against the model: a value outside the
|
|
1306
|
+
* model's enum is a 400, and so is a parameter the model does not take at all.
|
|
1307
|
+
* What an empty list means depends on where the model came from — see
|
|
1308
|
+
* {@link ModelConfig.capabilitiesKnown}.
|
|
1309
|
+
*
|
|
1310
|
+
* Two kinds of value meet here, and they are treated differently on purpose:
|
|
1311
|
+
* a value the **caller** named goes through `accepted()` (kept in the model's own
|
|
1312
|
+
* spelling, or reported as dropped), while a value the **plugin** configured goes
|
|
1313
|
+
* through {@link configuredWhenAccepted} (sent verbatim, or silently omitted and
|
|
1314
|
+
* never reported as the caller's mistake).
|
|
1315
|
+
*
|
|
1316
|
+
* @param request - the caller's request, already resolved to a configured model.
|
|
1317
|
+
* @param model - that model's catalog entry.
|
|
1318
|
+
* @param defaults - the configured defaults, used when the caller names neither.
|
|
1319
|
+
* @returns the request to send, plus what was left out.
|
|
1320
|
+
*/
|
|
1321
|
+
function resolveRequest(request, model, defaults = {}) {
|
|
1322
|
+
const dropped = [];
|
|
1323
|
+
const known = model.capabilitiesKnown === true;
|
|
1324
|
+
const count = effectiveImageCount(request.n, model.maxImages);
|
|
1325
|
+
const wantsReferences = request.imageUrls.length > 0;
|
|
1326
|
+
const referencesAllowed = model.maxReferenceImages > 0 || !known;
|
|
1327
|
+
if (wantsReferences && !referencesAllowed) dropped.push({
|
|
1328
|
+
field: "image_urls",
|
|
1329
|
+
value: `${request.imageUrls.length} 张参考图`,
|
|
1330
|
+
allowed: [],
|
|
1331
|
+
reason: "unsupported_field"
|
|
1332
|
+
});
|
|
1333
|
+
const requestedResolution = request.resolution.trim();
|
|
1334
|
+
return {
|
|
1335
|
+
request: {
|
|
1336
|
+
...request,
|
|
1337
|
+
n: count,
|
|
1338
|
+
resolution: requestedResolution !== "" ? accepted("resolution", requestedResolution, model.resolutions, dropped, known) : defaultResolutionFor(model),
|
|
1339
|
+
aspectRatio: request.aspectRatio.trim() !== "" ? accepted("aspect_ratio", request.aspectRatio, model.aspectRatios, dropped, known) : defaultAspectRatioFor(model, defaults.aspectRatio ?? ""),
|
|
1340
|
+
outputFormat: accepted("output_format", request.outputFormat.trim() || defaultOutputFormatFor(model, defaults.outputFormat ?? ""), model.outputFormats, dropped, known),
|
|
1341
|
+
imageUrls: wantsReferences && referencesAllowed ? request.imageUrls : [],
|
|
1342
|
+
...wantsReferences && !referencesAllowed ? { refNames: void 0 } : {},
|
|
1343
|
+
mode: wantsReferences && referencesAllowed ? "edit" : "text"
|
|
1344
|
+
},
|
|
1345
|
+
dropped
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
//#endregion
|
|
1350
|
+
//#region src/settings.ts
|
|
1351
|
+
/** The branded settings namespace of this plugin. */
|
|
1352
|
+
const SeeWorkSettingsNamespace = SEEWORK_SETTINGS_NAMESPACE;
|
|
1353
|
+
/**
|
|
1354
|
+
* The plugin's field contract, without the live-reference marker.
|
|
1355
|
+
*
|
|
1356
|
+
* This is what tests and tooling validate a raw document against; the loader
|
|
1357
|
+
* validates the entry with {@link Config} below, which is the same shape wrapped
|
|
1358
|
+
* once more.
|
|
1359
|
+
*
|
|
1360
|
+
* The assertion is load-bearing, not decoration: this fork's `object()` helper
|
|
1361
|
+
* leaves each field's mode generic in its output type (`SetRequired<Mode, true>`
|
|
1362
|
+
* rather than the resolved `'defined'`), so an inferred schema no longer
|
|
1363
|
+
* satisfies `z<Config>` even though it resolves to exactly that shape at
|
|
1364
|
+
* runtime — the same schema this plugin shipped before the fork.
|
|
1365
|
+
*/
|
|
1366
|
+
const ConfigShape = z.object({
|
|
1367
|
+
enabled: z.boolean().default(true),
|
|
1368
|
+
allowAgentGeneration: z.boolean().default(true),
|
|
1369
|
+
announceToAgent: z.boolean().default(true),
|
|
1370
|
+
apiUrl: z.string().default(DEFAULT_API_URL),
|
|
1371
|
+
serviceUrl: z.string().default(DEFAULT_SERVICE_URL),
|
|
1372
|
+
apiKey: z.string().role("secret").default(""),
|
|
1373
|
+
models: z.array(modelSchema).default([]),
|
|
1374
|
+
defaultModel: z.string().default(""),
|
|
1375
|
+
defaultAspectRatio: z.string().default(DEFAULT_ASPECT_RATIO),
|
|
1376
|
+
outputFormat: z.string().default(DEFAULT_OUTPUT_FORMAT),
|
|
1377
|
+
dataDir: z.string().default("")
|
|
1378
|
+
});
|
|
1379
|
+
/**
|
|
1380
|
+
* The plugin config schema the loader validates the profile entry with (also the
|
|
1381
|
+
* settings card's field contract).
|
|
1382
|
+
*
|
|
1383
|
+
* The trailing `.volatile()` is what makes these fields editable at all from
|
|
1384
|
+
* DSH 0.2 onwards: that host projects a settings form **out of this schema** and
|
|
1385
|
+
* admits only fields under a volatile node, so a schema without one yields an
|
|
1386
|
+
* empty form and every write is refused with "has no volatile fields". The same
|
|
1387
|
+
* marker is also how the plugin receives its values — the loader swaps one live
|
|
1388
|
+
* reference in place instead of re-applying the plugin, which is what
|
|
1389
|
+
* {@link installSettingsSection} unwraps.
|
|
1390
|
+
*
|
|
1391
|
+
* On 0.1.x the marker is inert (that host has no schema-projected forms and the
|
|
1392
|
+
* section registration below owns the values), so the same schema serves both.
|
|
1393
|
+
*/
|
|
1394
|
+
const Config$1 = ConfigShape.volatile();
|
|
1395
|
+
/**
|
|
1396
|
+
* Read the current value out of whatever the host handed over.
|
|
1397
|
+
*
|
|
1398
|
+
* DSH 0.2 and later resolve a volatile schema to one live reference and swap the
|
|
1399
|
+
* value in place, so the reference itself is the freshest answer on every call.
|
|
1400
|
+
* Older hosts and bare compositions pass a plain object.
|
|
1401
|
+
*
|
|
1402
|
+
* @param value - the resolved config, possibly wrapped in a volatile reference.
|
|
1403
|
+
* @returns the plain config.
|
|
1404
|
+
*/
|
|
1405
|
+
function readConfig(value) {
|
|
1406
|
+
return isVolatile(value) ? value.get() : value;
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Bind the plugin to its settings section.
|
|
1410
|
+
*
|
|
1411
|
+
* Two host generations, one read seam:
|
|
1412
|
+
*
|
|
1413
|
+
* - A host exposing `installSection` (DSH 0.1.x) keeps the section as the
|
|
1414
|
+
* authoritative source and re-notifies the caller on every commit.
|
|
1415
|
+
* - A host without it (DSH 0.2 onwards, where forms are projected from the
|
|
1416
|
+
* profile entry's own Config) has nothing to register: the value arrives as
|
|
1417
|
+
* a live reference, and there is no change notification to subscribe to
|
|
1418
|
+
* because the reference never goes stale.
|
|
1419
|
+
*
|
|
1420
|
+
* Either way the returned thunk answers a plain `Config`, so callers below are
|
|
1421
|
+
* independent of which generation is in force. The namespace registration is an
|
|
1422
|
+
* effect on the calling fiber, so unloading the plugin removes both the section
|
|
1423
|
+
* and its observers.
|
|
1424
|
+
*
|
|
1425
|
+
* @param ctx - host plugin context.
|
|
1426
|
+
* @param entry - the composition entry used as the base layer and fallback.
|
|
1427
|
+
* @param hooks - notified whenever the authoritative source or its value changes.
|
|
1428
|
+
* @returns a thunk reading the currently authoritative raw value.
|
|
1429
|
+
*/
|
|
1430
|
+
function installSettingsSection(ctx, entry, hooks) {
|
|
1431
|
+
let current = () => readConfig(entry);
|
|
1432
|
+
ctx.inject(["settings"], (sctx) => {
|
|
1433
|
+
const provider = sctx.get("settings");
|
|
1434
|
+
if (provider === void 0 || typeof provider.installSection !== "function") return;
|
|
1435
|
+
provider.installSection(ctx, SeeWorkSettingsNamespace, Config$1, entry, {
|
|
1436
|
+
setSource: (source) => {
|
|
1437
|
+
current = () => readConfig(source());
|
|
1438
|
+
hooks.onChange();
|
|
1439
|
+
},
|
|
1440
|
+
onChange: hooks.onChange
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
return () => current();
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Apply schema defaults and normalize a raw config into the runtime view.
|
|
1447
|
+
*
|
|
1448
|
+
* Keys an older plugin version wrote and this one no longer stores are
|
|
1449
|
+
* **deliberately not read** (#653 / #659 / #661 / #663): `defaultQuality` is not
|
|
1450
|
+
* read (the plugin never decides a picture's quality), a per-model `qualities`
|
|
1451
|
+
* list is not read either, and `orderedResolutions` / `resolutionOrderReliable`
|
|
1452
|
+
* are gone with catalog v1 (the declared order now *is* the `resolutions` array,
|
|
1453
|
+
* so keeping either would be a second truth). An old document keeps working with
|
|
1454
|
+
* those keys simply ignored; the per-model residue rides out with the next
|
|
1455
|
+
* whole-`models` write: `catalog-refresh.ts` triggers it when
|
|
1456
|
+
* `capability.ts::hasRetiredModelKeys` reports a raw document that still carries
|
|
1457
|
+
* them (the dep it is handed is `hasRetiredKeys`).
|
|
1458
|
+
*
|
|
1459
|
+
* The per-model shape itself is `capability.ts`'s business (#669): this module
|
|
1460
|
+
* owns the document — the connection, the generation defaults, the data root —
|
|
1461
|
+
* and hands the model list to {@link normalizeModels}.
|
|
1462
|
+
*/
|
|
1463
|
+
function effectiveConfig(value) {
|
|
1464
|
+
const source = value ?? {};
|
|
1465
|
+
const models = normalizeModels(source.models);
|
|
1466
|
+
const requestedDefault = typeof source.defaultModel === "string" ? source.defaultModel.trim() : "";
|
|
1467
|
+
return {
|
|
1468
|
+
enabled: source.enabled ?? true,
|
|
1469
|
+
allowAgentGeneration: source.allowAgentGeneration ?? true,
|
|
1470
|
+
announceToAgent: source.announceToAgent ?? true,
|
|
1471
|
+
apiUrl: typeof source.apiUrl === "string" && source.apiUrl.trim() !== "" ? source.apiUrl.trim() : DEFAULT_API_URL,
|
|
1472
|
+
serviceUrl: typeof source.serviceUrl === "string" && source.serviceUrl.trim() !== "" ? source.serviceUrl.trim() : DEFAULT_SERVICE_URL,
|
|
1473
|
+
apiKey: typeof source.apiKey === "string" ? source.apiKey.trim() : "",
|
|
1474
|
+
models,
|
|
1475
|
+
defaultModel: models.some((model) => model.id === requestedDefault) ? requestedDefault : models[0]?.id ?? "",
|
|
1476
|
+
defaultAspectRatio: typeof source.defaultAspectRatio === "string" && source.defaultAspectRatio.trim() !== "" ? source.defaultAspectRatio.trim() : DEFAULT_ASPECT_RATIO,
|
|
1477
|
+
outputFormat: typeof source.outputFormat === "string" && source.outputFormat.trim() !== "" ? source.outputFormat.trim() : DEFAULT_OUTPUT_FORMAT,
|
|
1478
|
+
dataDir: typeof source.dataDir === "string" ? source.dataDir.trim() : ""
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
/** The model entry a request should use, or undefined when nothing matches. */
|
|
1482
|
+
function resolveModel(config, requested) {
|
|
1483
|
+
const wanted = requested?.trim() ?? "";
|
|
1484
|
+
if (wanted !== "") {
|
|
1485
|
+
const exact = config.models.find((model) => model.id === wanted);
|
|
1486
|
+
if (exact !== void 0) return exact;
|
|
1487
|
+
const byLabel = config.models.find((model) => model.label === wanted);
|
|
1488
|
+
if (byLabel !== void 0) return byLabel;
|
|
1489
|
+
}
|
|
1490
|
+
if (config.defaultModel !== "") return config.models.find((model) => model.id === config.defaultModel);
|
|
1491
|
+
return config.models[0];
|
|
1492
|
+
}
|
|
1493
|
+
/** Human-readable model name (label when set, else the id). */
|
|
1494
|
+
function modelName(model) {
|
|
1495
|
+
return model.label !== void 0 && model.label !== "" ? model.label : model.id;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
//#endregion
|
|
1499
|
+
//#region src/agent-tools.ts
|
|
1500
|
+
/** How long one agent tool call may stay pending before it gives up waiting. */
|
|
1501
|
+
const AGENT_WAIT_MS = 3e5;
|
|
1502
|
+
/**
|
|
1503
|
+
* Shape of one image reference on the tool boundary. The trailing fields are
|
|
1504
|
+
* filled in on the way out (where the image lives in the material library) and
|
|
1505
|
+
* merely tolerated on the way in: the model is told to hand a previous result
|
|
1506
|
+
* back unchanged, so an object that still carries them must keep validating.
|
|
1507
|
+
* `restoreRef` reads only the attachment fields and ignores the rest.
|
|
1508
|
+
*/
|
|
1509
|
+
const imageRefSchema = {
|
|
1510
|
+
type: "object",
|
|
1511
|
+
additionalProperties: false,
|
|
1512
|
+
properties: {
|
|
1513
|
+
attachment_id: {
|
|
1514
|
+
type: "string",
|
|
1515
|
+
required: true
|
|
1516
|
+
},
|
|
1517
|
+
media_type: {
|
|
1518
|
+
type: "string",
|
|
1519
|
+
required: true
|
|
1520
|
+
},
|
|
1521
|
+
bytes: {
|
|
1522
|
+
type: "integer",
|
|
1523
|
+
required: true
|
|
1524
|
+
},
|
|
1525
|
+
width: {
|
|
1526
|
+
type: "integer",
|
|
1527
|
+
required: true
|
|
1528
|
+
},
|
|
1529
|
+
height: {
|
|
1530
|
+
type: "integer",
|
|
1531
|
+
required: true
|
|
1532
|
+
},
|
|
1533
|
+
name: { type: "string" },
|
|
1534
|
+
file: { type: "string" },
|
|
1535
|
+
url: { type: "string" },
|
|
1536
|
+
path: { type: "string" },
|
|
1537
|
+
absolute_url: { type: "string" }
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
const generationResultSchema = {
|
|
1541
|
+
type: "object",
|
|
1542
|
+
additionalProperties: false,
|
|
1543
|
+
properties: {
|
|
1544
|
+
status: {
|
|
1545
|
+
type: "string",
|
|
1546
|
+
required: true
|
|
1547
|
+
},
|
|
1548
|
+
message: {
|
|
1549
|
+
type: "string",
|
|
1550
|
+
required: true
|
|
1551
|
+
},
|
|
1552
|
+
error: { type: "string" },
|
|
1553
|
+
images: {
|
|
1554
|
+
type: "array",
|
|
1555
|
+
required: true,
|
|
1556
|
+
items: imageRefSchema
|
|
1557
|
+
},
|
|
1558
|
+
library_entry_id: { type: "string" }
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
/**
|
|
1562
|
+
* Wording for the tool's regular arguments (#658/D-10).
|
|
1563
|
+
*
|
|
1564
|
+
* One short sentence per argument: what it does and its default. Nothing about
|
|
1565
|
+
* where a value comes from, what its cap is, or where to look it up — every
|
|
1566
|
+
* earlier attempt to explain that grew into wording models misread as an
|
|
1567
|
+
* instruction to manage queues and retries themselves.
|
|
1568
|
+
*
|
|
1569
|
+
* The defaults are interpolated from their owner in `src/settings.ts` rather
|
|
1570
|
+
* than typed again.
|
|
1571
|
+
*/
|
|
1572
|
+
const SHARED_ARG_DESCRIPTION = {
|
|
1573
|
+
model: "Which saved model to use. Defaults to the default model.",
|
|
1574
|
+
n: "How many images this request returns. Default 1 — confirm with the user before requesting more.",
|
|
1575
|
+
resolution: "Resolution tier, e.g. 1K or 2K. Omit to use the default tier the catalog declares for this model.",
|
|
1576
|
+
aspect_ratio: `Aspect ratio: ${UNIFIED_ASPECT_RATIOS.join(", ")}. Default ${DEFAULT_ASPECT_RATIO}.`,
|
|
1577
|
+
output_format: `png, jpeg or webp. Default ${DEFAULT_OUTPUT_FORMAT}.`
|
|
1578
|
+
};
|
|
1579
|
+
/**
|
|
1580
|
+
* The one tool's description: a single, self-contained statement (#664).
|
|
1581
|
+
*
|
|
1582
|
+
* It used to be a paragraph two tools shared word for word, with each tool
|
|
1583
|
+
* adding its own first sentence (#658/D-9). Merging them into one tool removed
|
|
1584
|
+
* that structure: editing is no longer a second tool, it is this tool **with**
|
|
1585
|
+
* `reference_images`, so the sentence that says so lives here in the same
|
|
1586
|
+
* paragraph rather than in a second description.
|
|
1587
|
+
*
|
|
1588
|
+
* The capability boundary is stated here as well as in the announcement (#659):
|
|
1589
|
+
* this text is resident with the tool definition and is **not** governed by
|
|
1590
|
+
* `announceToAgent`, so it is the only place the honest "these five arguments
|
|
1591
|
+
* (plus the optional reference image list) and nothing else" survives once the
|
|
1592
|
+
* user switches the announcement off.
|
|
1593
|
+
*
|
|
1594
|
+
* The **order to load the bundled skill** lives in the sentence before this
|
|
1595
|
+
* block (#665): a skill is loaded on demand, so merely mentioning that the
|
|
1596
|
+
* details are over there reads as an option the model may never take. The same
|
|
1597
|
+
* order is in the announcement, with the same name — the requirement is stated
|
|
1598
|
+
* once per carrier, and the tool's copy is the one that survives with the
|
|
1599
|
+
* announcement off.
|
|
1600
|
+
*/
|
|
1601
|
+
const SHARED_TOOL_DESCRIPTION = "**One call is one generation request and returns synchronously** — there is no task id to poll. **Only one request per model runs at a time, and the plugin neither queues nor retries**: if this call fails, report it to the user and send another only after the user asks for it. **With `reference_images` this call edits those images; without it, it generates from the prompt alone** — the request carries no mode field of its own, so an absent or empty reference list simply means text-to-image. The result carries the image references plus the material-library entry id, and for each image where it lives: `file` (library file name), `url` (same-origin address), `path` (absolute path on this machine) and, when the GUI address is known, `absolute_url` (a fully qualified http URL). The model cannot see the picture itself — quote those fields when the user asks where an image is, and **show the finished picture in your own reply** with a markdown image (``), because the images beside the tool call sit in the folded tool-call row while your reply does not.";
|
|
1602
|
+
/**
|
|
1603
|
+
* Wording for the optional `reference_images` argument (#664).
|
|
1604
|
+
*
|
|
1605
|
+
* This one argument is what makes the single tool both a generator and an
|
|
1606
|
+
* editor: SeeAI Hub decides the mode from whether the reference list is
|
|
1607
|
+
* non-empty (`docs/api/images.md`), so the wording has to carry both halves —
|
|
1608
|
+
* omit it for text-to-image, hand a picture back unchanged to edit it. It is
|
|
1609
|
+
* the wording the second tool used to carry, now on this argument.
|
|
1610
|
+
*/
|
|
1611
|
+
const REFERENCE_IMAGES_DESCRIPTION = "Optional. Reference image(s) for this call — a picture already available in this conversation (a user-uploaded attachment, or an image from an earlier SeeWork result). They may be the image being edited, or a reference for subject, style or composition. Omit it (or pass an empty list) and the call is plain text-to-image from `prompt`; pass one or more and the model works from them. Pass each attachment object exactly as you received it — do not reconstruct it.";
|
|
1612
|
+
const NOT_CONFIGURED = "还没有配置 SeeAI Hub:请打开「设置 → 插件 → SeeWork」(侧边栏),填写 API 地址与用户 API Key,检测并保存图片模型。";
|
|
1613
|
+
/** Throw the actionable failure for a not-yet-usable configuration. */
|
|
1614
|
+
function ensureConfigured(config, options = {}) {
|
|
1615
|
+
if (!config.enabled) throw new SeeWorkError("SeeWork 插件已停用,请先在设置里启用。", "plugin-disabled");
|
|
1616
|
+
if (options.useAgent === true && !config.allowAgentGeneration) throw new SeeWorkError("Agent 生图已在设置里关闭。", "agent-generation-disabled");
|
|
1617
|
+
if (config.apiUrl.trim() === "" || config.apiKey.trim() === "") throw new SeeWorkError(NOT_CONFIGURED, "not-configured");
|
|
1618
|
+
if (config.models.length === 0) throw new SeeWorkError(`还没有可用的图片模型。${NOT_CONFIGURED}`, "no-models-configured");
|
|
1619
|
+
}
|
|
1620
|
+
/** Build the normalized request from the tool arguments. */
|
|
1621
|
+
function toRequest(config, args, referenceUrls, refNames) {
|
|
1622
|
+
const model = resolveModel(config, args.model);
|
|
1623
|
+
if (model === void 0) throw new SeeWorkError(`没有可用的图片模型。可用模型:${config.models.map((entry) => `"${modelName(entry)}"`).join("、")}。`, "no-models-configured");
|
|
1624
|
+
const n = effectiveImageCount(args.n ?? 1, model.maxImages);
|
|
1625
|
+
return {
|
|
1626
|
+
mode: referenceUrls.length > 0 ? "edit" : "text",
|
|
1627
|
+
model: model.id,
|
|
1628
|
+
prompt: args.prompt.trim(),
|
|
1629
|
+
resolution: args.resolution?.trim() ?? "",
|
|
1630
|
+
aspectRatio: args.aspect_ratio?.trim() ?? "",
|
|
1631
|
+
outputFormat: args.output_format?.trim() ?? "",
|
|
1632
|
+
n,
|
|
1633
|
+
imageUrls: referenceUrls,
|
|
1634
|
+
...refNames.length === 0 ? {} : { refNames }
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
/** Media types the attachment store accepts. */
|
|
1638
|
+
function isImageMediaType(value) {
|
|
1639
|
+
return isImageMedia(value);
|
|
1640
|
+
}
|
|
1641
|
+
/** Restore a durable reference from the model-supplied argument. */
|
|
1642
|
+
function restoreRef(value) {
|
|
1643
|
+
if (!isImageMediaType(value.media_type)) throw new SeeWorkError("reference_images[].media_type 不是受支持的图片类型。", "bad-reference-image");
|
|
1644
|
+
if (!Number.isInteger(value.bytes) || value.bytes < 1 || !Number.isInteger(value.width) || value.width < 1 || !Number.isInteger(value.height) || value.height < 1) throw new SeeWorkError("reference_images[] 的元数据不合法。", "bad-reference-image");
|
|
1645
|
+
return {
|
|
1646
|
+
attachmentId: value.attachment_id,
|
|
1647
|
+
mediaType: value.media_type,
|
|
1648
|
+
bytes: value.bytes,
|
|
1649
|
+
width: value.width,
|
|
1650
|
+
height: value.height,
|
|
1651
|
+
...value.name === void 0 ? {} : { name: value.name }
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
/** Project a durable reference onto the tool-boundary shape. */
|
|
1655
|
+
function projectRef(ref) {
|
|
1656
|
+
return {
|
|
1657
|
+
attachment_id: String(ref.attachmentId),
|
|
1658
|
+
media_type: ref.mediaType,
|
|
1659
|
+
bytes: ref.bytes,
|
|
1660
|
+
width: ref.width,
|
|
1661
|
+
height: ref.height,
|
|
1662
|
+
...ref.name === void 0 ? {} : { name: ref.name }
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Tell the model which arguments never reached the gateway.
|
|
1667
|
+
*
|
|
1668
|
+
* Silent dropping is the failure worth spending words on: the caller asked for
|
|
1669
|
+
* something, a picture came back, and nothing said the request differed from
|
|
1670
|
+
* what was asked for. The five regular parameters are the whole vocabulary now,
|
|
1671
|
+
* so the only drop left is a value this model does not accept.
|
|
1672
|
+
*/
|
|
1673
|
+
function droppedNotice(dropped) {
|
|
1674
|
+
if (dropped === void 0 || dropped.length === 0) return "";
|
|
1675
|
+
return ` 有参数没有发给网关:${dropped.map((entry) => `${entry.field}(${entry.reason})`).join(";")}。`;
|
|
1676
|
+
}
|
|
1677
|
+
/** The model-facing render: a compact textual status plus the JSON value. */
|
|
1678
|
+
function renderResult(value) {
|
|
1679
|
+
return [{
|
|
1680
|
+
type: "text",
|
|
1681
|
+
text: JSON.stringify(value)
|
|
1682
|
+
}];
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* The GUI's own origin, used to hand the model a fully qualified image URL.
|
|
1686
|
+
*
|
|
1687
|
+
* The web server binds before this plugin's tools are ever called, so the port
|
|
1688
|
+
* is the live one (including an OS-assigned port). A host that reports none
|
|
1689
|
+
* leaves the absolute URL out rather than inventing an address — a wrong URL in
|
|
1690
|
+
* a reply is worse than a relative one.
|
|
1691
|
+
*
|
|
1692
|
+
* @param ctx - the host context (its `webServer` service owns the port).
|
|
1693
|
+
* @returns `http://127.0.0.1:<port>`, or undefined when no usable port is known.
|
|
1694
|
+
*/
|
|
1695
|
+
function guiOrigin(ctx) {
|
|
1696
|
+
const port = ctx.webServer?.port;
|
|
1697
|
+
return typeof port === "number" && Number.isSafeInteger(port) && port > 0 ? `http://127.0.0.1:${port}` : void 0;
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Attach the library location to one result image.
|
|
1701
|
+
*
|
|
1702
|
+
* The model never receives the pixels, so these strings are the only way it can
|
|
1703
|
+
* tell the user where a generated image actually is — and `absolute_url` is the
|
|
1704
|
+
* one it can put into its own markdown reply. The paths come from
|
|
1705
|
+
* `libraryImageLocation` — the same rule the library writer uses to place the
|
|
1706
|
+
* file — so the reported location cannot drift from what landed on disk.
|
|
1707
|
+
*
|
|
1708
|
+
* @param entryId - the library entry this generation was stored as, if it was.
|
|
1709
|
+
* @param image - the projected attachment reference.
|
|
1710
|
+
* @param index - 0-based image position inside that entry.
|
|
1711
|
+
* @param origin - the GUI origin, when known (see {@link guiOrigin}).
|
|
1712
|
+
* @returns the image carrying the location fields, or unchanged without an entry.
|
|
1713
|
+
*/
|
|
1714
|
+
function withLibraryLocation(entryId, image, index, origin) {
|
|
1715
|
+
if (entryId === void 0) return image;
|
|
1716
|
+
const location = libraryImageLocation(entryId, index, image.media_type);
|
|
1717
|
+
return {
|
|
1718
|
+
...image,
|
|
1719
|
+
...location,
|
|
1720
|
+
...origin === void 0 ? {} : { absolute_url: `${origin}${location.url}` }
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* The UI-only projection that keeps images beside the tool call. Returned as
|
|
1725
|
+
* plain JSON records (the presentation contract forbids unknown class
|
|
1726
|
+
* instances and React nodes).
|
|
1727
|
+
*/
|
|
1728
|
+
function presentationMeta(value) {
|
|
1729
|
+
return { images: value.images.map((image, index) => {
|
|
1730
|
+
const record = {
|
|
1731
|
+
attachment_id: image.attachment_id,
|
|
1732
|
+
media_type: image.media_type,
|
|
1733
|
+
bytes: image.bytes,
|
|
1734
|
+
width: image.width,
|
|
1735
|
+
height: image.height
|
|
1736
|
+
};
|
|
1737
|
+
if (image.name !== void 0) record.name = image.name;
|
|
1738
|
+
if (value.library_entry_id !== void 0) record.file = libraryImageLocation(value.library_entry_id, index, image.media_type).file;
|
|
1739
|
+
return record;
|
|
1740
|
+
}) };
|
|
1741
|
+
}
|
|
1742
|
+
/** Rehydrate image attachments for the host-computed tool result view. */
|
|
1743
|
+
function presentResult(_args, result) {
|
|
1744
|
+
if (result.isError) return void 0;
|
|
1745
|
+
const meta = result.meta;
|
|
1746
|
+
if (typeof meta !== "object" || meta === null) return void 0;
|
|
1747
|
+
const images = meta.images;
|
|
1748
|
+
if (!Array.isArray(images)) return void 0;
|
|
1749
|
+
const content = [];
|
|
1750
|
+
for (const item of images) {
|
|
1751
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1752
|
+
const raw = item;
|
|
1753
|
+
if (typeof raw.attachment_id !== "string" || typeof raw.media_type !== "string") continue;
|
|
1754
|
+
if (typeof raw.bytes !== "number" || typeof raw.width !== "number" || typeof raw.height !== "number") continue;
|
|
1755
|
+
try {
|
|
1756
|
+
content.push({
|
|
1757
|
+
type: "image",
|
|
1758
|
+
attachment: restoreRef({
|
|
1759
|
+
attachment_id: raw.attachment_id,
|
|
1760
|
+
media_type: raw.media_type,
|
|
1761
|
+
bytes: raw.bytes,
|
|
1762
|
+
width: raw.width,
|
|
1763
|
+
height: raw.height,
|
|
1764
|
+
...typeof raw.name === "string" ? { name: raw.name } : {}
|
|
1765
|
+
})
|
|
1766
|
+
});
|
|
1767
|
+
} catch {}
|
|
1768
|
+
}
|
|
1769
|
+
return content.length === 0 ? void 0 : {
|
|
1770
|
+
card: "generic",
|
|
1771
|
+
content
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Register the plugin's agent tool.
|
|
1776
|
+
* @param ctx - host context providing `tools` and `attachments`.
|
|
1777
|
+
* @param runtime - the shared generation queue.
|
|
1778
|
+
* @param resolveConfig - reads the live settings per call.
|
|
1779
|
+
* @returns disposer unregistering the tool.
|
|
1780
|
+
*/
|
|
1781
|
+
function registerAgentImageTools(ctx, runtime, resolveConfig) {
|
|
1782
|
+
/** Persist a task's images as durable attachments (memoized per task). */
|
|
1783
|
+
const attachmentCache = /* @__PURE__ */ new Map();
|
|
1784
|
+
/** Bound the cache: the runtime only retains a fixed number of tasks. */
|
|
1785
|
+
const ATTACHMENT_CACHE_MAX = 64;
|
|
1786
|
+
const materialize = (task) => {
|
|
1787
|
+
if (task.status !== "completed") return Promise.resolve([]);
|
|
1788
|
+
const cached = attachmentCache.get(task.id);
|
|
1789
|
+
if (cached !== void 0) return cached;
|
|
1790
|
+
const images = task.result?.images ?? [];
|
|
1791
|
+
const pending = ctx.attachments.saveImages(images.map((image, index) => ({
|
|
1792
|
+
data: Buffer.from(image.b64, "base64"),
|
|
1793
|
+
mediaType: isImageMediaType(image.mime) ? image.mime : "image/png",
|
|
1794
|
+
name: `seework-${task.id}-${index + 1}.${image.mime === "image/jpeg" ? "jpg" : image.mime.slice(6)}`
|
|
1795
|
+
}))).then((refs) => refs.map(projectRef));
|
|
1796
|
+
attachmentCache.set(task.id, pending);
|
|
1797
|
+
while (attachmentCache.size > ATTACHMENT_CACHE_MAX) {
|
|
1798
|
+
const oldest = attachmentCache.keys().next();
|
|
1799
|
+
if (oldest.done === true || oldest.value === task.id) break;
|
|
1800
|
+
attachmentCache.delete(oldest.value);
|
|
1801
|
+
}
|
|
1802
|
+
pending.catch(() => {
|
|
1803
|
+
if (attachmentCache.get(task.id) === pending) attachmentCache.delete(task.id);
|
|
1804
|
+
});
|
|
1805
|
+
return pending;
|
|
1806
|
+
};
|
|
1807
|
+
const toResult = async (task) => {
|
|
1808
|
+
const origin = guiOrigin(ctx);
|
|
1809
|
+
const images = (await materialize(task)).map((image, index) => withLibraryLocation(task.entryId, image, index, origin));
|
|
1810
|
+
const summary = task.status === "completed" ? origin === void 0 ? "生成完成:图片已存入素材库。这台宿主没有报出 GUI 端口,所以结果里没有 absolute_url——不要编一个地址,图片就在素材库里。" : "生成完成:图片已存入素材库,absolute_url 可直接放进回复正文。" : task.status === "failed" ? "生成失败。" : task.status === "cancelled" ? "生成已取消。" : "生成仍在进行中:这次调用等待超时了,但请求本身没有失败——图片生成完成后会自己进入素材库,在「素材库」里就能看到,不需要再查任务。";
|
|
1811
|
+
return {
|
|
1812
|
+
status: task.status,
|
|
1813
|
+
message: `${summary}${droppedNotice(task.droppedParameters)}`,
|
|
1814
|
+
...task.error === void 0 ? {} : { error: task.error },
|
|
1815
|
+
images,
|
|
1816
|
+
...task.entryId === void 0 ? {} : { library_entry_id: task.entryId }
|
|
1817
|
+
};
|
|
1818
|
+
};
|
|
1819
|
+
const waitFor = (id, signal) => {
|
|
1820
|
+
const timeout = AbortSignal.timeout(AGENT_WAIT_MS);
|
|
1821
|
+
return runtime.waitFor(id, AbortSignal.any([signal, timeout])).catch((error) => {
|
|
1822
|
+
if (timeout.aborted) {
|
|
1823
|
+
const task = runtime.get(id);
|
|
1824
|
+
if (task !== void 0) return task;
|
|
1825
|
+
}
|
|
1826
|
+
throw error;
|
|
1827
|
+
});
|
|
1828
|
+
};
|
|
1829
|
+
/** Turn durable references into the data URLs the gateway accepts. */
|
|
1830
|
+
const referenceUrls = async (refs, signal) => {
|
|
1831
|
+
const urls = [];
|
|
1832
|
+
const names = [];
|
|
1833
|
+
for (const ref of refs ?? []) {
|
|
1834
|
+
const stored = await ctx.attachments.readImage(restoreRef(ref), signal);
|
|
1835
|
+
urls.push(`data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString("base64")}`);
|
|
1836
|
+
names.push(stored.ref.name ?? `参考图 ${names.length + 1}`);
|
|
1837
|
+
}
|
|
1838
|
+
return {
|
|
1839
|
+
urls,
|
|
1840
|
+
names
|
|
1841
|
+
};
|
|
1842
|
+
};
|
|
1843
|
+
const disposers = [ctx.tools.register(defineTool({
|
|
1844
|
+
name: "generate_image",
|
|
1845
|
+
description: `Generate images with SeeAI Hub (SeeWork plugin). **Before generating or editing with this plugin, load the skill \`${SKILL_NAME}\` first.** ${SHARED_TOOL_DESCRIPTION}`,
|
|
1846
|
+
parameters: {
|
|
1847
|
+
prompt: {
|
|
1848
|
+
type: "string",
|
|
1849
|
+
required: true,
|
|
1850
|
+
description: "Detailed image prompt: the picture to generate, or — with `reference_images` — what to change in them."
|
|
1851
|
+
},
|
|
1852
|
+
model: {
|
|
1853
|
+
type: "string",
|
|
1854
|
+
description: SHARED_ARG_DESCRIPTION.model
|
|
1855
|
+
},
|
|
1856
|
+
n: {
|
|
1857
|
+
type: "integer",
|
|
1858
|
+
description: SHARED_ARG_DESCRIPTION.n
|
|
1859
|
+
},
|
|
1860
|
+
resolution: {
|
|
1861
|
+
type: "string",
|
|
1862
|
+
description: SHARED_ARG_DESCRIPTION.resolution
|
|
1863
|
+
},
|
|
1864
|
+
aspect_ratio: {
|
|
1865
|
+
type: "string",
|
|
1866
|
+
description: SHARED_ARG_DESCRIPTION.aspect_ratio
|
|
1867
|
+
},
|
|
1868
|
+
output_format: {
|
|
1869
|
+
type: "string",
|
|
1870
|
+
description: SHARED_ARG_DESCRIPTION.output_format
|
|
1871
|
+
},
|
|
1872
|
+
reference_images: {
|
|
1873
|
+
type: "array",
|
|
1874
|
+
items: imageRefSchema,
|
|
1875
|
+
description: REFERENCE_IMAGES_DESCRIPTION
|
|
1876
|
+
}
|
|
1877
|
+
},
|
|
1878
|
+
output: {
|
|
1879
|
+
schema: generationResultSchema,
|
|
1880
|
+
render: (_args, value) => renderResult(value),
|
|
1881
|
+
presentationMeta: (_args, value) => presentationMeta(value)
|
|
1882
|
+
},
|
|
1883
|
+
presentResult,
|
|
1884
|
+
async execute(args, exec) {
|
|
1885
|
+
const config = resolveConfig();
|
|
1886
|
+
ensureConfigured(config, { useAgent: true });
|
|
1887
|
+
const input = args;
|
|
1888
|
+
const references = await referenceUrls(input.reference_images, exec.signal);
|
|
1889
|
+
return toResult(await waitFor(runtime.submit(toRequest(config, input, references.urls, references.names), "agent", exec.agent === void 0 ? void 0 : String(exec.agent.id)).id, exec.signal));
|
|
1890
|
+
}
|
|
1891
|
+
}))];
|
|
1892
|
+
return () => {
|
|
1893
|
+
for (const dispose of disposers) dispose();
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
//#endregion
|
|
1898
|
+
//#region src/catalog.ts
|
|
1899
|
+
/** Timeout for one discovery round trip. */
|
|
1900
|
+
const DISCOVERY_TIMEOUT_MS = 2e4;
|
|
1901
|
+
/**
|
|
1902
|
+
* Perform one JSON GET.
|
|
1903
|
+
* @param timeoutMs - how long this request may take.
|
|
1904
|
+
*/
|
|
1905
|
+
async function getJson(url, apiKey, timeoutMs = DISCOVERY_TIMEOUT_MS) {
|
|
1906
|
+
const headers = { accept: "application/json" };
|
|
1907
|
+
if (apiKey.trim() !== "") headers.authorization = `Bearer ${apiKey.trim()}`;
|
|
1908
|
+
try {
|
|
1909
|
+
const response = await fetch(url, {
|
|
1910
|
+
headers,
|
|
1911
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
1912
|
+
});
|
|
1913
|
+
if (!response.ok) return {
|
|
1914
|
+
kind: "http",
|
|
1915
|
+
status: response.status
|
|
1916
|
+
};
|
|
1917
|
+
return {
|
|
1918
|
+
kind: "ok",
|
|
1919
|
+
payload: await response.json()
|
|
1920
|
+
};
|
|
1921
|
+
} catch {
|
|
1922
|
+
return {
|
|
1923
|
+
kind: "http",
|
|
1924
|
+
status: 0
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
/** Deduplicate models by id, keeping the first occurrence. */
|
|
1929
|
+
function uniqueModels(models) {
|
|
1930
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1931
|
+
const out = [];
|
|
1932
|
+
for (const model of models) {
|
|
1933
|
+
if (seen.has(model.id)) continue;
|
|
1934
|
+
seen.add(model.id);
|
|
1935
|
+
out.push(model);
|
|
1936
|
+
}
|
|
1937
|
+
return out;
|
|
1938
|
+
}
|
|
1939
|
+
/** Read a catalog payload's `models` array, when it looks like one. */
|
|
1940
|
+
function catalogEntries(payload) {
|
|
1941
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
1942
|
+
const list = payload.models;
|
|
1943
|
+
return Array.isArray(list) ? list : void 0;
|
|
1944
|
+
}
|
|
1945
|
+
/** Read an OpenAI `/models` payload's `data` array, when it looks like one. */
|
|
1946
|
+
function modelEntries(payload) {
|
|
1947
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
1948
|
+
const list = payload.data;
|
|
1949
|
+
return Array.isArray(list) ? list : void 0;
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Stamp a successful discovery with the time it happened.
|
|
1953
|
+
*
|
|
1954
|
+
* The stamp is what lets a dated surface say how fresh the saved snapshot is, and
|
|
1955
|
+
* it is written **here** rather than when the user saves the selection: "when we
|
|
1956
|
+
* last read the catalog" is the fact that matters, and a selection saved an hour
|
|
1957
|
+
* after the probe must not look an hour fresher. A failed discovery throws
|
|
1958
|
+
* before this runs, so the previous data keeps its old stamp — which is exactly
|
|
1959
|
+
* the honest "this may be stale" signal.
|
|
1960
|
+
*/
|
|
1961
|
+
function stampDiscovery(models) {
|
|
1962
|
+
const discoveredAt = Date.now();
|
|
1963
|
+
return models.map((model) => ({
|
|
1964
|
+
...model,
|
|
1965
|
+
discoveredAt
|
|
1966
|
+
}));
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* Discover the image models a deployment offers.
|
|
1970
|
+
* @param sources - the candidate addresses, most specific first.
|
|
1971
|
+
* @returns the image models, which source answered, and every attempt made.
|
|
1972
|
+
* @throws {SeeWorkError} when no source answered with a usable list at all.
|
|
1973
|
+
*/
|
|
1974
|
+
async function discoverModels(sources) {
|
|
1975
|
+
const attempts = [];
|
|
1976
|
+
const catalogHosts = [];
|
|
1977
|
+
if (sources.serviceUrl.trim() !== "") catalogHosts.push(catalogUrl(sources.serviceUrl));
|
|
1978
|
+
const fromGateway = catalogUrl(sources.apiUrl);
|
|
1979
|
+
if (!catalogHosts.includes(fromGateway)) catalogHosts.push(fromGateway);
|
|
1980
|
+
for (const url of catalogHosts) {
|
|
1981
|
+
const outcome = await getJson(url, sources.apiKey);
|
|
1982
|
+
if (outcome.kind === "http") {
|
|
1983
|
+
attempts.push({
|
|
1984
|
+
url,
|
|
1985
|
+
outcome: outcome.status === 0 ? "unreachable" : `http ${outcome.status}`,
|
|
1986
|
+
models: 0
|
|
1987
|
+
});
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
const entries = catalogEntries(outcome.payload);
|
|
1991
|
+
if (entries === void 0) {
|
|
1992
|
+
attempts.push({
|
|
1993
|
+
url,
|
|
1994
|
+
outcome: "unusable",
|
|
1995
|
+
models: 0
|
|
1996
|
+
});
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
const models = uniqueModels(entries.map((entry) => catalogEntryToModel(entry)).filter((model) => model !== void 0));
|
|
2000
|
+
attempts.push({
|
|
2001
|
+
url,
|
|
2002
|
+
outcome: "ok",
|
|
2003
|
+
models: models.length
|
|
2004
|
+
});
|
|
2005
|
+
if (models.length > 0 || entries.length > 0) return {
|
|
2006
|
+
models: stampDiscovery(models),
|
|
2007
|
+
origin: "catalog",
|
|
2008
|
+
catalogUrl: url,
|
|
2009
|
+
scanned: entries.length,
|
|
2010
|
+
attempts
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
const modelsUrl = gatewayUrl(sources.apiUrl, "/models");
|
|
2014
|
+
const fallback = await getJson(modelsUrl, sources.apiKey);
|
|
2015
|
+
if (fallback.kind === "ok") {
|
|
2016
|
+
const entries = modelEntries(fallback.payload);
|
|
2017
|
+
if (entries !== void 0) {
|
|
2018
|
+
const models = uniqueModels(entries.map((entry) => openAiEntryToModel(entry)).filter((model) => model !== void 0));
|
|
2019
|
+
attempts.push({
|
|
2020
|
+
url: modelsUrl,
|
|
2021
|
+
outcome: "ok",
|
|
2022
|
+
models: models.length
|
|
2023
|
+
});
|
|
2024
|
+
return {
|
|
2025
|
+
models: stampDiscovery(models),
|
|
2026
|
+
origin: "models",
|
|
2027
|
+
catalogUrl: modelsUrl,
|
|
2028
|
+
scanned: entries.length,
|
|
2029
|
+
attempts
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
attempts.push({
|
|
2033
|
+
url: modelsUrl,
|
|
2034
|
+
outcome: "unusable",
|
|
2035
|
+
models: 0
|
|
2036
|
+
});
|
|
2037
|
+
} else attempts.push({
|
|
2038
|
+
url: modelsUrl,
|
|
2039
|
+
outcome: fallback.status === 0 ? "unreachable" : `http ${fallback.status}`,
|
|
2040
|
+
models: 0
|
|
2041
|
+
});
|
|
2042
|
+
const detailed = attempts.map((attempt) => `${attempt.url} → ${attempt.outcome}`).join(";");
|
|
2043
|
+
const unreachable = attempts.every((attempt) => attempt.outcome === "unreachable");
|
|
2044
|
+
throw new SeeWorkError(unreachable ? `连接不上你填的地址。已尝试:${detailed}。请确认网关地址与目录(service)地址是否可达。` : `没有读到可用的模型清单。已尝试:${detailed}。`, unreachable ? "catalog_unreachable" : "catalog_failed", attempts);
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
//#endregion
|
|
2048
|
+
//#region src/catalog-refresh.ts
|
|
2049
|
+
/**
|
|
2050
|
+
* How often the background refresh runs.
|
|
2051
|
+
*
|
|
2052
|
+
* A constant rather than a setting: the value only decides how stale a snapshot
|
|
2053
|
+
* may get while nobody looks, and a knob for it would cost more (a field, a
|
|
2054
|
+
* validation rule, a card control) than it explains. Six hours matches how often
|
|
2055
|
+
* a deployment realistically changes its model catalog.
|
|
2056
|
+
*/
|
|
2057
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 360 * 60 * 1e3;
|
|
2058
|
+
/**
|
|
2059
|
+
* How long two automatic rounds must be apart.
|
|
2060
|
+
*
|
|
2061
|
+
* Shared by the card-opening trigger and the background timer, so the two can
|
|
2062
|
+
* never stack into duplicate requests (AC-2 / AC-9). The user's own
|
|
2063
|
+
* 「检测可用模型」 is deliberately **not** throttled: it probes exactly what is on
|
|
2064
|
+
* screen, and a silent no-op there would be a bug, not a saving.
|
|
2065
|
+
*/
|
|
2066
|
+
const AUTO_REFRESH_THROTTLE_MS = 60 * 1e3;
|
|
2067
|
+
/** Build one refresher bound to a live settings view. */
|
|
2068
|
+
function createCatalogRefresher(deps) {
|
|
2069
|
+
const now = deps.now ?? (() => Date.now());
|
|
2070
|
+
const discover = deps.discover ?? discoverModels;
|
|
2071
|
+
const setTimer = deps.setTimer ?? ((callback, intervalMs) => setInterval(callback, intervalMs));
|
|
2072
|
+
const clearTimer = deps.clearTimer ?? ((handle) => {
|
|
2073
|
+
clearInterval(handle);
|
|
2074
|
+
});
|
|
2075
|
+
/** The last round that reached the network: what throttling compares against. */
|
|
2076
|
+
let lastAttemptAt;
|
|
2077
|
+
/** The last successful round's summary, returned to a throttled trigger. */
|
|
2078
|
+
let lastResult;
|
|
2079
|
+
let lastCatalog;
|
|
2080
|
+
/** The in-flight round, so a second trigger joins it instead of duplicating. */
|
|
2081
|
+
let inFlight;
|
|
2082
|
+
let backgroundStop;
|
|
2083
|
+
const skipped = (why) => ({
|
|
2084
|
+
ran: false,
|
|
2085
|
+
skipped: why,
|
|
2086
|
+
...lastResult === void 0 ? {} : { result: lastResult },
|
|
2087
|
+
...lastCatalog === void 0 ? {} : { catalog: lastCatalog }
|
|
2088
|
+
});
|
|
2089
|
+
const run = async () => {
|
|
2090
|
+
lastAttemptAt = now();
|
|
2091
|
+
try {
|
|
2092
|
+
const catalog = await discover(deps.sources());
|
|
2093
|
+
lastCatalog = catalog;
|
|
2094
|
+
const saved = deps.resolveModels();
|
|
2095
|
+
if (saved.length === 0) return skipped("not-configured");
|
|
2096
|
+
const next = adoptRefreshedCapabilities(saved, catalog.models);
|
|
2097
|
+
const cleanup = deps.hasRetiredKeys?.() === true;
|
|
2098
|
+
if (next.adopted.length > 0 || cleanup) await deps.mutate(next.models);
|
|
2099
|
+
lastResult = {
|
|
2100
|
+
refreshedAt: now(),
|
|
2101
|
+
adopted: next.adopted,
|
|
2102
|
+
added: next.added,
|
|
2103
|
+
missing: next.missing
|
|
2104
|
+
};
|
|
2105
|
+
return {
|
|
2106
|
+
ran: true,
|
|
2107
|
+
result: lastResult,
|
|
2108
|
+
catalog
|
|
2109
|
+
};
|
|
2110
|
+
} catch {
|
|
2111
|
+
return skipped("failed");
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
const refresh = async (options = {}) => {
|
|
2115
|
+
if (deps.sources().apiKey.trim() === "" || deps.resolveModels().length === 0) return skipped("not-configured");
|
|
2116
|
+
const withinWindow = lastAttemptAt !== void 0 && now() - lastAttemptAt < AUTO_REFRESH_THROTTLE_MS;
|
|
2117
|
+
if (options.automatic !== false && withinWindow) return skipped("throttled");
|
|
2118
|
+
if (inFlight !== void 0) return inFlight;
|
|
2119
|
+
const pending = run();
|
|
2120
|
+
inFlight = pending;
|
|
2121
|
+
try {
|
|
2122
|
+
return await pending;
|
|
2123
|
+
} finally {
|
|
2124
|
+
if (inFlight === pending) inFlight = void 0;
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
return {
|
|
2128
|
+
refresh,
|
|
2129
|
+
startBackground(intervalMs = DEFAULT_REFRESH_INTERVAL_MS) {
|
|
2130
|
+
backgroundStop?.();
|
|
2131
|
+
const handle = setTimer(() => {
|
|
2132
|
+
refresh({ automatic: true });
|
|
2133
|
+
}, intervalMs);
|
|
2134
|
+
const stop = () => {
|
|
2135
|
+
clearTimer(handle);
|
|
2136
|
+
if (backgroundStop === stop) backgroundStop = void 0;
|
|
2137
|
+
};
|
|
2138
|
+
backgroundStop = stop;
|
|
2139
|
+
return stop;
|
|
2140
|
+
}
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
//#endregion
|
|
2145
|
+
//#region src/model-summary.ts
|
|
2146
|
+
/**
|
|
2147
|
+
* The wording for a model whose entry declared no default tier.
|
|
2148
|
+
*
|
|
2149
|
+
* The contract always publishes `resolution.default`, so this state is a breach
|
|
2150
|
+
* of that invariant rather than a normal kind of model: the plugin omits the
|
|
2151
|
+
* field instead of guessing a tier (#663/P3), and the agent should ask the user
|
|
2152
|
+
* to name one of the listed tiers. Kept as a constant because it is a *claim
|
|
2153
|
+
* about the data*, not decoration.
|
|
2154
|
+
*/
|
|
2155
|
+
const RESOLUTION_DEFAULT_MISSING = "目录未提供缺省档,不指定时不发这一项";
|
|
2156
|
+
/**
|
|
2157
|
+
* The wording for a model the catalog never described (a bare `/models` entry,
|
|
2158
|
+
* or a settings document written by hand).
|
|
2159
|
+
*/
|
|
2160
|
+
const DETAILS_UNKNOWN = "细节未知:这个模型不是从 SeeAI Hub 目录读到的(可能只来自裸 `/models` 列表),插件不知道它收哪些字段。只用工具 schema 里的常用参数,并按设置里的默认值来;不要猜扩展字段。";
|
|
2161
|
+
/**
|
|
2162
|
+
* Images one request may ask for.
|
|
2163
|
+
*
|
|
2164
|
+
* Both halves of the limit are shared rather than restated: the per-model cap is
|
|
2165
|
+
* the catalog's `maxImages`, and the composition with the wire ceiling lives in
|
|
2166
|
+
* `protocol.ts` — the same one the request side clamps `n` with (#668).
|
|
2167
|
+
*/
|
|
2168
|
+
function maxImagesPerRequest(model) {
|
|
2169
|
+
return imageCountCeiling(model.maxImages);
|
|
2170
|
+
}
|
|
2171
|
+
/**
|
|
2172
|
+
* The tier clause of a capability line.
|
|
2173
|
+
*
|
|
2174
|
+
* The list is already in declared order, so the clause states it as a ranking
|
|
2175
|
+
* and only adds a caveat when the model's contract declared no default tier (in
|
|
2176
|
+
* which case a request that names no tier sends none at all).
|
|
2177
|
+
*
|
|
2178
|
+
* @param model - the configured model.
|
|
2179
|
+
* @returns the clause; it never claims a ranking the list does not carry,
|
|
2180
|
+
* because a list that carries none is empty.
|
|
2181
|
+
*/
|
|
2182
|
+
function tierLine(model) {
|
|
2183
|
+
const tiers = model.resolutions;
|
|
2184
|
+
if (tiers.length === 0) return "档位由上游决定";
|
|
2185
|
+
const line = `档位 ${tiers.join("/")}(低到高)`;
|
|
2186
|
+
return defaultResolutionFor(model) === "" ? `${line}(${RESOLUTION_DEFAULT_MISSING})` : line;
|
|
2187
|
+
}
|
|
2188
|
+
/**
|
|
2189
|
+
* The one line the announcement shows for a model.
|
|
2190
|
+
*
|
|
2191
|
+
* @param model - the configured model.
|
|
2192
|
+
* @returns tier order, ratios, reference-image cap and per-request image cap.
|
|
2193
|
+
*/
|
|
2194
|
+
function capabilityLineFor(model) {
|
|
2195
|
+
if (model.capabilitiesKnown !== true) return DETAILS_UNKNOWN;
|
|
2196
|
+
return [
|
|
2197
|
+
tierLine(model),
|
|
2198
|
+
model.aspectRatios.length > 0 ? `比例 ${model.aspectRatios.join("/")}` : "比例由上游决定",
|
|
2199
|
+
model.maxReferenceImages > 0 ? `可带参考图 ≤${model.maxReferenceImages}` : "仅文生图",
|
|
2200
|
+
`一次最多 ${maxImagesPerRequest(model)} 张`
|
|
2201
|
+
].join(",");
|
|
2202
|
+
}
|
|
2203
|
+
/** The model's identifier as the agent must spell it: `label[id]`, or the id. */
|
|
2204
|
+
function modelIdentifier(model) {
|
|
2205
|
+
const name$1 = modelName(model);
|
|
2206
|
+
return name$1 === model.id ? model.id : `${name$1}[${model.id}]`;
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Reduce one saved model to its identifier and capability line.
|
|
2210
|
+
*
|
|
2211
|
+
* @param model - the configured model.
|
|
2212
|
+
* @returns the summary the model-facing surface renders.
|
|
2213
|
+
*/
|
|
2214
|
+
function summarizeModel(model) {
|
|
2215
|
+
return {
|
|
2216
|
+
id: model.id,
|
|
2217
|
+
identifier: modelIdentifier(model),
|
|
2218
|
+
known: model.capabilitiesKnown === true,
|
|
2219
|
+
capabilityLine: capabilityLineFor(model)
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Split the saved models the way the announcement needs them.
|
|
2224
|
+
*
|
|
2225
|
+
* The default model is the one a request falls back to ({@link EffectiveConfig}
|
|
2226
|
+
* already resolved that), so "other models" means "every saved model except
|
|
2227
|
+
* it".
|
|
2228
|
+
*
|
|
2229
|
+
* @param config - the live settings view.
|
|
2230
|
+
* @returns the default summary, its position, and the others.
|
|
2231
|
+
*/
|
|
2232
|
+
function summarizeSavedModels(config) {
|
|
2233
|
+
const all = config.models.map((model) => summarizeModel(model));
|
|
2234
|
+
const found = all.findIndex((summary) => summary.id === config.defaultModel);
|
|
2235
|
+
const defaultIndex = found >= 0 ? found : all.length > 0 ? 0 : -1;
|
|
2236
|
+
return {
|
|
2237
|
+
defaultModel: defaultIndex >= 0 ? all[defaultIndex] : void 0,
|
|
2238
|
+
defaultIndex,
|
|
2239
|
+
others: all.filter((_summary, index) => index !== defaultIndex)
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
//#endregion
|
|
2244
|
+
//#region src/generation-runtime.ts
|
|
2245
|
+
/** Task states that will not change again. */
|
|
2246
|
+
function isFinalStatus(status) {
|
|
2247
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
2248
|
+
}
|
|
2249
|
+
/** The tail every per-model refusal shares, so the two wordings cannot drift. */
|
|
2250
|
+
const BUSY_REFUSAL = ":本次没有发起生成,也没有扣费。请如实告知用户,得到许可后再发。";
|
|
2251
|
+
/** Tasks kept in memory for status queries. */
|
|
2252
|
+
const MAX_RETAINED_TASKS = 200;
|
|
2253
|
+
/**
|
|
2254
|
+
* The shared generation store: submit, watch, cancel.
|
|
2255
|
+
*
|
|
2256
|
+
* `submit` starts the task immediately or **refuses** (no queue, #658); callers
|
|
2257
|
+
* that want the finished task await `waitFor`.
|
|
2258
|
+
*/
|
|
2259
|
+
var GenerationRuntime = class {
|
|
2260
|
+
records = /* @__PURE__ */ new Map();
|
|
2261
|
+
order = [];
|
|
2262
|
+
/** Models with a task in flight (see MAX_CONCURRENT_PER_MODEL). */
|
|
2263
|
+
runningModels = /* @__PURE__ */ new Set();
|
|
2264
|
+
constructor(resolveConfig) {
|
|
2265
|
+
this.resolveConfig = resolveConfig;
|
|
2266
|
+
}
|
|
2267
|
+
/** A snapshot of every retained task, newest first. */
|
|
2268
|
+
list() {
|
|
2269
|
+
return [...this.order].reverse().map((id) => this.records.get(id).task);
|
|
2270
|
+
}
|
|
2271
|
+
/** One task by id. */
|
|
2272
|
+
get(id) {
|
|
2273
|
+
return this.records.get(id)?.task;
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* Start one generation.
|
|
2277
|
+
* @param request - the normalized request; its model must exist in the catalog.
|
|
2278
|
+
* @param source - who asked (library records carry this).
|
|
2279
|
+
* @param sessionId - owning agent session, when applicable.
|
|
2280
|
+
* @returns the task that just started.
|
|
2281
|
+
*/
|
|
2282
|
+
submit(request, source, sessionId) {
|
|
2283
|
+
const config = this.resolveConfig();
|
|
2284
|
+
const requested = request.model.trim();
|
|
2285
|
+
const model = resolveModel(config, requested);
|
|
2286
|
+
if (model === void 0 || requested !== "" && model.id !== requested) {
|
|
2287
|
+
const available = config.models.map((entry) => entry.id).join("、");
|
|
2288
|
+
throw new SeeWorkRuntimeError(requested === "" ? "还没有可用的模型:请在「设置 → 插件 → SeeWork」点击「检测可用模型」并保存。" : `模型「${requested}」不在已配置的清单里。可用模型:${available === "" ? "(没有)" : available}。`, requested === "" ? "no-models-configured" : "model-not-configured");
|
|
2289
|
+
}
|
|
2290
|
+
const { request: normalized, dropped } = resolveRequest({
|
|
2291
|
+
...request,
|
|
2292
|
+
model: model.id,
|
|
2293
|
+
prompt: request.prompt.trim()
|
|
2294
|
+
}, model, {
|
|
2295
|
+
outputFormat: config.outputFormat,
|
|
2296
|
+
aspectRatio: config.defaultAspectRatio
|
|
2297
|
+
});
|
|
2298
|
+
if (this.runningModels.has(model.id)) throw new SeeWorkRuntimeError(`模型「${model.id}」已有生成请求在跑${BUSY_REFUSAL}`, "model-busy");
|
|
2299
|
+
const id = randomUUID();
|
|
2300
|
+
const task = {
|
|
2301
|
+
id,
|
|
2302
|
+
request: normalized,
|
|
2303
|
+
status: "running",
|
|
2304
|
+
createdAt: Date.now(),
|
|
2305
|
+
startedAt: Date.now(),
|
|
2306
|
+
source,
|
|
2307
|
+
...sessionId === void 0 ? {} : { sessionId },
|
|
2308
|
+
...dropped.length === 0 ? {} : { droppedParameters: dropped }
|
|
2309
|
+
};
|
|
2310
|
+
const record = {
|
|
2311
|
+
task,
|
|
2312
|
+
controller: new AbortController(),
|
|
2313
|
+
waiters: /* @__PURE__ */ new Set()
|
|
2314
|
+
};
|
|
2315
|
+
this.records.set(id, record);
|
|
2316
|
+
this.order.push(id);
|
|
2317
|
+
this.trim();
|
|
2318
|
+
this.runningModels.add(model.id);
|
|
2319
|
+
this.run(record, model.id);
|
|
2320
|
+
return task;
|
|
2321
|
+
}
|
|
2322
|
+
/** Cancel a running task. */
|
|
2323
|
+
cancel(id) {
|
|
2324
|
+
const record = this.records.get(id);
|
|
2325
|
+
if (record === void 0) return void 0;
|
|
2326
|
+
if (isFinalStatus(record.task.status)) return record.task;
|
|
2327
|
+
record.controller.abort(/* @__PURE__ */ new Error("cancelled"));
|
|
2328
|
+
this.settle(record, {
|
|
2329
|
+
status: "cancelled",
|
|
2330
|
+
error: "生图已取消。"
|
|
2331
|
+
});
|
|
2332
|
+
return record.task;
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Wait for one task to reach a final state.
|
|
2336
|
+
*
|
|
2337
|
+
* The caller's `signal` is deliberately decoupled from the task's lifetime:
|
|
2338
|
+
* aborting the wait rejects the caller, but the generation keeps running
|
|
2339
|
+
* (a caller that really wants it dead calls {@link cancel}). Only cancelling
|
|
2340
|
+
* or finishing the task moves it to a final state.
|
|
2341
|
+
*
|
|
2342
|
+
* @param id - task id from {@link submit}.
|
|
2343
|
+
* @param signal - caller-side cancellation of this wait.
|
|
2344
|
+
*/
|
|
2345
|
+
waitFor(id, signal) {
|
|
2346
|
+
const record = this.records.get(id);
|
|
2347
|
+
if (record === void 0) return Promise.reject(new SeeWorkRuntimeError(`找不到生图任务 ${id}。`, "task-not-found"));
|
|
2348
|
+
if (isFinalStatus(record.task.status)) return Promise.resolve(record.task);
|
|
2349
|
+
return new Promise((resolve, reject) => {
|
|
2350
|
+
let done = false;
|
|
2351
|
+
const finish = (task) => {
|
|
2352
|
+
if (done) return;
|
|
2353
|
+
done = true;
|
|
2354
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2355
|
+
resolve(task);
|
|
2356
|
+
};
|
|
2357
|
+
const onAbort = () => {
|
|
2358
|
+
if (done) return;
|
|
2359
|
+
done = true;
|
|
2360
|
+
record.waiters.delete(finish);
|
|
2361
|
+
reject(new SeeWorkRuntimeError("等待生图结果已中断(任务仍在继续)。", "wait-aborted"));
|
|
2362
|
+
};
|
|
2363
|
+
record.waiters.add(finish);
|
|
2364
|
+
if (signal?.aborted === true) {
|
|
2365
|
+
onAbort();
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
/** Execute one task end to end and settle it. */
|
|
2372
|
+
async run(record, model) {
|
|
2373
|
+
const config = this.resolveConfig();
|
|
2374
|
+
try {
|
|
2375
|
+
const result = await generateImage({
|
|
2376
|
+
apiUrl: config.apiUrl,
|
|
2377
|
+
apiKey: config.apiKey
|
|
2378
|
+
}, record.task.request, { signal: record.controller.signal });
|
|
2379
|
+
const entry = await appendLibraryEntry({
|
|
2380
|
+
request: record.task.request,
|
|
2381
|
+
images: result.images,
|
|
2382
|
+
...result.cost === void 0 ? {} : { cost: result.cost },
|
|
2383
|
+
source: record.task.source,
|
|
2384
|
+
...record.task.sessionId === void 0 ? {} : { sessionId: record.task.sessionId }
|
|
2385
|
+
});
|
|
2386
|
+
const settled = result;
|
|
2387
|
+
this.settle(record, {
|
|
2388
|
+
status: "completed",
|
|
2389
|
+
result: settled,
|
|
2390
|
+
entryId: entry.id
|
|
2391
|
+
});
|
|
2392
|
+
} catch (error) {
|
|
2393
|
+
if (record.controller.signal.aborted) {
|
|
2394
|
+
this.settle(record, {
|
|
2395
|
+
status: "cancelled",
|
|
2396
|
+
error: "生图已取消。"
|
|
2397
|
+
});
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
this.settle(record, {
|
|
2401
|
+
status: "failed",
|
|
2402
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2403
|
+
});
|
|
2404
|
+
} finally {
|
|
2405
|
+
this.runningModels.delete(model);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
/** Move a task to its final state and wake everyone waiting on it. */
|
|
2409
|
+
settle(record, outcome) {
|
|
2410
|
+
record.task = {
|
|
2411
|
+
...record.task,
|
|
2412
|
+
status: outcome.status,
|
|
2413
|
+
finishedAt: Date.now(),
|
|
2414
|
+
...outcome.result === void 0 ? {} : { result: outcome.result },
|
|
2415
|
+
...outcome.entryId === void 0 ? {} : { entryId: outcome.entryId },
|
|
2416
|
+
...outcome.error === void 0 ? {} : { error: outcome.error }
|
|
2417
|
+
};
|
|
2418
|
+
this.records.set(record.task.id, record);
|
|
2419
|
+
for (const waiter of [...record.waiters]) {
|
|
2420
|
+
record.waiters.delete(waiter);
|
|
2421
|
+
waiter(record.task);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
/** Drop the oldest settled tasks once the retention cap is exceeded. */
|
|
2425
|
+
trim() {
|
|
2426
|
+
while (this.order.length > MAX_RETAINED_TASKS) {
|
|
2427
|
+
const id = this.order[0];
|
|
2428
|
+
const record = this.records.get(id);
|
|
2429
|
+
if (record !== void 0 && !isFinalStatus(record.task.status)) break;
|
|
2430
|
+
this.order.shift();
|
|
2431
|
+
this.records.delete(id);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
};
|
|
2435
|
+
/** A runtime failure with a stable machine code. */
|
|
2436
|
+
var SeeWorkRuntimeError = class extends Error {
|
|
2437
|
+
constructor(message, code) {
|
|
2438
|
+
super(message);
|
|
2439
|
+
this.code = code;
|
|
2440
|
+
this.name = "SeeWorkRuntimeError";
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2443
|
+
|
|
2444
|
+
//#endregion
|
|
2445
|
+
//#region src/canvas-assets.ts
|
|
2446
|
+
/** Ceiling on one uploaded asset (a burned annotation of a 4K picture fits). */
|
|
2447
|
+
const MAX_CANVAS_ASSET_BYTES = 32 * 1024 * 1024;
|
|
2448
|
+
/** The file-name shape both stores share (also the board's validation rule). */
|
|
2449
|
+
const CANVAS_FILE_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/;
|
|
2450
|
+
/** Where canvas-owned assets live: `<data root>/canvas/assets`. */
|
|
2451
|
+
function canvasAssetsDir() {
|
|
2452
|
+
return path.join(libraryDataRoot(), "canvas", "assets");
|
|
2453
|
+
}
|
|
2454
|
+
/** One asset's file name, matching the board's validation shape. */
|
|
2455
|
+
function assetFileName(id, index, mime) {
|
|
2456
|
+
return `${id}-${index}.${extensionOf(mime)}`;
|
|
2457
|
+
}
|
|
2458
|
+
/** Media type of one stored asset, from its extension. */
|
|
2459
|
+
function mimeOfAsset(file) {
|
|
2460
|
+
if (file.endsWith(".jpg") || file.endsWith(".jpeg")) return "image/jpeg";
|
|
2461
|
+
if (file.endsWith(".webp")) return "image/webp";
|
|
2462
|
+
if (file.endsWith(".gif")) return "image/gif";
|
|
2463
|
+
return "image/png";
|
|
2464
|
+
}
|
|
2465
|
+
/** Parse a `data:image/...;base64,...` URL. */
|
|
2466
|
+
function parseImageDataUrl(value) {
|
|
2467
|
+
const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=\s]+)$/u.exec(value.trim());
|
|
2468
|
+
if (match === null || match[1] === void 0 || match[2] === void 0) return void 0;
|
|
2469
|
+
const data = Buffer.from(match[2], "base64");
|
|
2470
|
+
if (data.byteLength === 0 || data.byteLength > MAX_CANVAS_ASSET_BYTES) return void 0;
|
|
2471
|
+
return {
|
|
2472
|
+
mime: match[1],
|
|
2473
|
+
data
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
/**
|
|
2477
|
+
* Write one canvas asset and report where it landed.
|
|
2478
|
+
*
|
|
2479
|
+
* @param input - the image as a data URL.
|
|
2480
|
+
* @returns the file name and its served URL, or undefined when the payload is
|
|
2481
|
+
* not an acceptable image (the route answers 400 then, never a partial write).
|
|
2482
|
+
*/
|
|
2483
|
+
async function writeCanvasAsset(input) {
|
|
2484
|
+
const parsed = parseImageDataUrl(input.dataUrl);
|
|
2485
|
+
if (parsed === void 0) return void 0;
|
|
2486
|
+
await promises.mkdir(canvasAssetsDir(), { recursive: true });
|
|
2487
|
+
const file = assetFileName(randomUUID(), 0, parsed.mime);
|
|
2488
|
+
await promises.writeFile(path.join(canvasAssetsDir(), file), parsed.data);
|
|
2489
|
+
const size = imageSize(parsed.data);
|
|
2490
|
+
return {
|
|
2491
|
+
file,
|
|
2492
|
+
url: `${CANVAS_API.asset}/${file}`,
|
|
2493
|
+
mime: parsed.mime,
|
|
2494
|
+
...size === void 0 ? {} : {
|
|
2495
|
+
width: size.width,
|
|
2496
|
+
height: size.height
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Read one canvas asset back.
|
|
2502
|
+
*
|
|
2503
|
+
* The name shape is re-checked here as well as in the card validator: this is the
|
|
2504
|
+
* only place a name becomes a filesystem path, so it is the place that has to be
|
|
2505
|
+
* sure the name cannot escape the directory.
|
|
2506
|
+
*
|
|
2507
|
+
* @param file - the asset's file name.
|
|
2508
|
+
* @returns the bytes and their media type, or undefined when it is not there.
|
|
2509
|
+
*/
|
|
2510
|
+
async function readCanvasAsset(file) {
|
|
2511
|
+
if (!CANVAS_FILE_PATTERN.test(file)) return void 0;
|
|
2512
|
+
try {
|
|
2513
|
+
return {
|
|
2514
|
+
mime: mimeOfAsset(file),
|
|
2515
|
+
data: await promises.readFile(path.join(canvasAssetsDir(), file))
|
|
2516
|
+
};
|
|
2517
|
+
} catch {
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Every canvas-owned picture on disk, whatever references it.
|
|
2523
|
+
*
|
|
2524
|
+
* Names that do not match the shape are skipped rather than reported: the store
|
|
2525
|
+
* only ever writes that shape, so anything else was not put there by the plugin
|
|
2526
|
+
* and is none of its business to delete.
|
|
2527
|
+
*
|
|
2528
|
+
* @returns the files, sorted by name.
|
|
2529
|
+
*/
|
|
2530
|
+
async function listCanvasAssetFiles() {
|
|
2531
|
+
let names = [];
|
|
2532
|
+
try {
|
|
2533
|
+
names = await promises.readdir(canvasAssetsDir());
|
|
2534
|
+
} catch {
|
|
2535
|
+
return [];
|
|
2536
|
+
}
|
|
2537
|
+
const files = [];
|
|
2538
|
+
for (const name$1 of names) {
|
|
2539
|
+
if (!CANVAS_FILE_PATTERN.test(name$1)) continue;
|
|
2540
|
+
try {
|
|
2541
|
+
const stat = await promises.stat(path.join(canvasAssetsDir(), name$1));
|
|
2542
|
+
if (stat.isFile()) files.push({
|
|
2543
|
+
file: name$1,
|
|
2544
|
+
bytes: stat.size
|
|
2545
|
+
});
|
|
2546
|
+
} catch {}
|
|
2547
|
+
}
|
|
2548
|
+
files.sort((left, right) => left.file.localeCompare(right.file));
|
|
2549
|
+
return files;
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Delete canvas-owned pictures by name.
|
|
2553
|
+
*
|
|
2554
|
+
* Unsafe or unknown names are ignored instead of throwing: this runs after the
|
|
2555
|
+
* board stopped referencing them, and a file that is already gone is the outcome
|
|
2556
|
+
* the caller wanted.
|
|
2557
|
+
*
|
|
2558
|
+
* @param files - the file names to delete.
|
|
2559
|
+
* @returns how many files went away and how many bytes that freed.
|
|
2560
|
+
*/
|
|
2561
|
+
async function deleteCanvasAssets(files) {
|
|
2562
|
+
let removed = 0;
|
|
2563
|
+
let bytes = 0;
|
|
2564
|
+
for (const file of files) {
|
|
2565
|
+
if (!CANVAS_FILE_PATTERN.test(file)) continue;
|
|
2566
|
+
const target = path.join(canvasAssetsDir(), file);
|
|
2567
|
+
try {
|
|
2568
|
+
const stat = await promises.stat(target);
|
|
2569
|
+
if (!stat.isFile()) continue;
|
|
2570
|
+
await promises.rm(target);
|
|
2571
|
+
removed += 1;
|
|
2572
|
+
bytes += stat.size;
|
|
2573
|
+
} catch {}
|
|
2574
|
+
}
|
|
2575
|
+
return {
|
|
2576
|
+
removed,
|
|
2577
|
+
bytes
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
//#endregion
|
|
2582
|
+
//#region src/directory-picker.ts
|
|
2583
|
+
/** The service behind an unknown value, when it looks like the seam. */
|
|
2584
|
+
function serviceOf(value) {
|
|
2585
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
2586
|
+
const candidate = value;
|
|
2587
|
+
return typeof candidate.capability === "function" ? candidate : void 0;
|
|
2588
|
+
}
|
|
2589
|
+
/** The capability object, or undefined when the service refuses to describe itself. */
|
|
2590
|
+
function capabilityOf(service) {
|
|
2591
|
+
try {
|
|
2592
|
+
const capability = service.capability();
|
|
2593
|
+
return capability !== null && typeof capability === "object" ? capability : void 0;
|
|
2594
|
+
} catch {
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* How the host can choose a directory, without opening anything.
|
|
2600
|
+
* @param value - the `ctx.directoryPicker` service, or anything else.
|
|
2601
|
+
* @returns the capability kind plus an operator-facing note when it is not native.
|
|
2602
|
+
*/
|
|
2603
|
+
function directoryPickerStatus(value) {
|
|
2604
|
+
const service = serviceOf(value);
|
|
2605
|
+
if (service === void 0) return {
|
|
2606
|
+
kind: "none",
|
|
2607
|
+
message: "这台宿主没有挂载目录选择器,改不了素材目录(可以在插件设置文档里改 dataDir)。"
|
|
2608
|
+
};
|
|
2609
|
+
const capability = capabilityOf(service);
|
|
2610
|
+
if (capability?.kind === "native") return { kind: "native" };
|
|
2611
|
+
if (capability?.kind === "browse") return {
|
|
2612
|
+
kind: "browse",
|
|
2613
|
+
message: "这台宿主用的是网页版目录浏览(通常是远程或 SSH 场景),打不开系统窗口;可以在插件设置文档里改 dataDir。"
|
|
2614
|
+
};
|
|
2615
|
+
return {
|
|
2616
|
+
kind: "none",
|
|
2617
|
+
message: "宿主的目录选择器打不开系统窗口,可以在插件设置文档里改 dataDir。"
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Open the host's folder chooser and wait for the operator.
|
|
2622
|
+
*
|
|
2623
|
+
* @param value - the `ctx.directoryPicker` service.
|
|
2624
|
+
* @param signal - caller lifetime; aborting terminates the chooser (so a closed
|
|
2625
|
+
* page never leaves a stray dialog on someone's screen).
|
|
2626
|
+
* @returns the chosen absolute path, a cancellation, or why it could not run.
|
|
2627
|
+
*/
|
|
2628
|
+
async function pickDirectory(value, signal) {
|
|
2629
|
+
const service = serviceOf(value);
|
|
2630
|
+
const status = directoryPickerStatus(value);
|
|
2631
|
+
if (service === void 0 || status.kind !== "native") return {
|
|
2632
|
+
kind: "failed",
|
|
2633
|
+
code: `picker_${status.kind}`,
|
|
2634
|
+
message: status.message ?? "打不开目录选择窗口。"
|
|
2635
|
+
};
|
|
2636
|
+
const capability = capabilityOf(service);
|
|
2637
|
+
if (capability === void 0 || typeof capability.pick !== "function") return {
|
|
2638
|
+
kind: "failed",
|
|
2639
|
+
code: "picker_unusable",
|
|
2640
|
+
message: "宿主的目录选择器不可用。"
|
|
2641
|
+
};
|
|
2642
|
+
try {
|
|
2643
|
+
const chosen = await capability.pick(signal);
|
|
2644
|
+
if (typeof chosen !== "string" || chosen.trim() === "") return { kind: "cancelled" };
|
|
2645
|
+
const path$1 = chosen.trim();
|
|
2646
|
+
if (!isAbsolute(path$1)) return {
|
|
2647
|
+
kind: "failed",
|
|
2648
|
+
code: "picker_relative",
|
|
2649
|
+
message: `宿主返回的路径不是绝对路径:${path$1}`
|
|
2650
|
+
};
|
|
2651
|
+
return {
|
|
2652
|
+
kind: "picked",
|
|
2653
|
+
path: path$1
|
|
2654
|
+
};
|
|
2655
|
+
} catch (error) {
|
|
2656
|
+
if (signal.aborted) return {
|
|
2657
|
+
kind: "failed",
|
|
2658
|
+
code: "picker_aborted",
|
|
2659
|
+
message: "选择目录被中断。"
|
|
2660
|
+
};
|
|
2661
|
+
return {
|
|
2662
|
+
kind: "failed",
|
|
2663
|
+
code: "picker_failed",
|
|
2664
|
+
message: error instanceof Error ? error.message : "打开目录选择窗口失败。"
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
//#endregion
|
|
2670
|
+
//#region src/canvas-limits.ts
|
|
2671
|
+
/**
|
|
2672
|
+
* Canvas limits shared by both halves.
|
|
2673
|
+
*
|
|
2674
|
+
* These live in their own module — with no imports at all — because the client
|
|
2675
|
+
* bundle and the host store must agree on them, while the host store is full of
|
|
2676
|
+
* Node built-ins. Importing a constant from `canvas-store.ts` into browser code
|
|
2677
|
+
* drags `node:crypto` and friends into the bundle, where the module loader
|
|
2678
|
+
* cannot resolve them and the whole client half fails to load. Keeping the
|
|
2679
|
+
* constants dependency-free is what makes the shared use safe.
|
|
2680
|
+
*/
|
|
2681
|
+
/** Zoom range for a board. */
|
|
2682
|
+
const CANVAS_ZOOM_MIN = .1;
|
|
2683
|
+
const CANVAS_ZOOM_MAX = 5;
|
|
2684
|
+
/** Cards allowed on one board. */
|
|
2685
|
+
const CANVAS_MAX_CARDS = 500;
|
|
2686
|
+
/** Boards kept before the oldest is dropped (with its file). */
|
|
2687
|
+
const CANVAS_MAX_BOARDS = 200;
|
|
2688
|
+
|
|
2689
|
+
//#endregion
|
|
2690
|
+
//#region src/canvas-store.ts
|
|
2691
|
+
/** Board coordinate range accepted from a client (keeps NaN/Infinity out). */
|
|
2692
|
+
const CANVAS_COORD_LIMIT = 1e6;
|
|
2693
|
+
/** Card size range in board pixels. */
|
|
2694
|
+
const CANVAS_CARD_MIN = 24;
|
|
2695
|
+
const CANVAS_CARD_MAX = 1e4;
|
|
2696
|
+
/** A save refused because the board moved since the caller read it. */
|
|
2697
|
+
var CanvasConflictError = class extends Error {
|
|
2698
|
+
constructor(expected, actual) {
|
|
2699
|
+
super(`画布已被其它窗口修改(期望版本 ${expected},当前 ${actual})。`);
|
|
2700
|
+
this.expected = expected;
|
|
2701
|
+
this.actual = actual;
|
|
2702
|
+
this.name = "CanvasConflictError";
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
/** A save or read refused because the input is unusable. */
|
|
2706
|
+
var CanvasInputError = class extends Error {
|
|
2707
|
+
constructor(message) {
|
|
2708
|
+
super(message);
|
|
2709
|
+
this.name = "CanvasInputError";
|
|
2710
|
+
}
|
|
2711
|
+
};
|
|
2712
|
+
/** The directory boards live in (resolved per call, so a settings change applies). */
|
|
2713
|
+
function canvasDir() {
|
|
2714
|
+
return path.join(libraryDataRoot(), "canvas");
|
|
2715
|
+
}
|
|
2716
|
+
/** File name of one board, from its id. */
|
|
2717
|
+
function boardPath(id) {
|
|
2718
|
+
return path.join(canvasDir(), `${id}.json`);
|
|
2719
|
+
}
|
|
2720
|
+
/** Whether an id names a board the store could have written. */
|
|
2721
|
+
function isCanvasId(id) {
|
|
2722
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id);
|
|
2723
|
+
}
|
|
2724
|
+
/** Clamp a number into a range, rejecting non-finite input. */
|
|
2725
|
+
function finite(value, fallback) {
|
|
2726
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
2727
|
+
}
|
|
2728
|
+
/** Clamp a viewport into the supported zoom range and coordinate space. */
|
|
2729
|
+
function normalizeViewport(value) {
|
|
2730
|
+
const raw = typeof value === "object" && value !== null ? value : {};
|
|
2731
|
+
const k = Math.min(CANVAS_ZOOM_MAX, Math.max(CANVAS_ZOOM_MIN, finite(raw.k, 1)));
|
|
2732
|
+
return {
|
|
2733
|
+
x: Math.min(CANVAS_COORD_LIMIT, Math.max(-CANVAS_COORD_LIMIT, finite(raw.x, 0))),
|
|
2734
|
+
y: Math.min(CANVAS_COORD_LIMIT, Math.max(-CANVAS_COORD_LIMIT, finite(raw.y, 0))),
|
|
2735
|
+
k: Math.round(k * 1e3) / 1e3
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Normalize one card, or drop it when it is unusable.
|
|
2740
|
+
*
|
|
2741
|
+
* A dropped card is the right failure mode for a board: a malformed card should
|
|
2742
|
+
* cost the user one card, not the whole document.
|
|
2743
|
+
*/
|
|
2744
|
+
function normalizeCard(value) {
|
|
2745
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
2746
|
+
const raw = value;
|
|
2747
|
+
const id = typeof raw.id === "string" && raw.id !== "" ? raw.id : void 0;
|
|
2748
|
+
if (id === void 0) return void 0;
|
|
2749
|
+
const kind = raw.kind === "text" ? "text" : raw.kind === "image" ? "image" : void 0;
|
|
2750
|
+
if (kind === void 0) return void 0;
|
|
2751
|
+
const size = (input, fallback) => Math.min(CANVAS_CARD_MAX, Math.max(CANVAS_CARD_MIN, finite(input, fallback)));
|
|
2752
|
+
const card = {
|
|
2753
|
+
id,
|
|
2754
|
+
kind,
|
|
2755
|
+
x: Math.min(CANVAS_COORD_LIMIT, Math.max(-CANVAS_COORD_LIMIT, finite(raw.x, 0))),
|
|
2756
|
+
y: Math.min(CANVAS_COORD_LIMIT, Math.max(-CANVAS_COORD_LIMIT, finite(raw.y, 0))),
|
|
2757
|
+
width: size(raw.width, kind === "image" ? 320 : 240),
|
|
2758
|
+
height: size(raw.height, kind === "image" ? 320 : 120),
|
|
2759
|
+
z: finite(raw.z, 0)
|
|
2760
|
+
};
|
|
2761
|
+
if (kind === "image") {
|
|
2762
|
+
if (typeof raw.file !== "string" || raw.file === "") return void 0;
|
|
2763
|
+
if (!CANVAS_FILE_PATTERN.test(raw.file)) throw new CanvasInputError(`画布引用了非法的图片文件名:${raw.file}`);
|
|
2764
|
+
card.file = raw.file;
|
|
2765
|
+
card.source = raw.source === "canvas" ? "canvas" : "library";
|
|
2766
|
+
card.origin = raw.origin === "chat" || raw.origin === "panel" || raw.origin === "annotation" || raw.origin === "crop" ? raw.origin : void 0;
|
|
2767
|
+
if (typeof raw.model === "string") card.model = raw.model.slice(0, 200);
|
|
2768
|
+
if (typeof raw.prompt === "string") card.prompt = raw.prompt.slice(0, 4e3);
|
|
2769
|
+
} else {
|
|
2770
|
+
card.text = typeof raw.text === "string" ? raw.text.slice(0, 8e3) : "";
|
|
2771
|
+
card.fontSize = Math.min(96, Math.max(8, finite(raw.fontSize, 16)));
|
|
2772
|
+
}
|
|
2773
|
+
return card;
|
|
2774
|
+
}
|
|
2775
|
+
/** Build a document from untrusted input (a client save). */
|
|
2776
|
+
function normalizeDocument(value, previous) {
|
|
2777
|
+
if (value === null || typeof value !== "object") throw new CanvasInputError("画布数据不是对象。");
|
|
2778
|
+
const raw = value;
|
|
2779
|
+
const rawCards = Array.isArray(raw.cards) ? raw.cards : [];
|
|
2780
|
+
if (rawCards.length > CANVAS_MAX_CARDS) throw new CanvasInputError(`画布最多支持 ${CANVAS_MAX_CARDS} 个卡片。`);
|
|
2781
|
+
const cards = [];
|
|
2782
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2783
|
+
for (const entry of rawCards) {
|
|
2784
|
+
const card = normalizeCard(entry);
|
|
2785
|
+
if (card === void 0 || seen.has(card.id)) continue;
|
|
2786
|
+
seen.add(card.id);
|
|
2787
|
+
cards.push(card);
|
|
2788
|
+
}
|
|
2789
|
+
const title = typeof raw.title === "string" && raw.title.trim() !== "" ? raw.title.trim().slice(0, 120) : previous.title;
|
|
2790
|
+
return {
|
|
2791
|
+
id: previous.id,
|
|
2792
|
+
title,
|
|
2793
|
+
revision: previous.revision,
|
|
2794
|
+
viewport: normalizeViewport(raw.viewport),
|
|
2795
|
+
cards,
|
|
2796
|
+
createdAt: previous.createdAt,
|
|
2797
|
+
updatedAt: previous.updatedAt
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
/** Project a document onto the list shape. */
|
|
2801
|
+
function toSummary(document) {
|
|
2802
|
+
return {
|
|
2803
|
+
id: document.id,
|
|
2804
|
+
title: document.title,
|
|
2805
|
+
revision: document.revision,
|
|
2806
|
+
cardCount: document.cards.length,
|
|
2807
|
+
createdAt: document.createdAt,
|
|
2808
|
+
updatedAt: document.updatedAt
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
/** Structural check for a document read back from disk. */
|
|
2812
|
+
function isDocument(value) {
|
|
2813
|
+
if (value === null || typeof value !== "object") return false;
|
|
2814
|
+
const raw = value;
|
|
2815
|
+
return typeof raw.id === "string" && typeof raw.title === "string" && typeof raw.revision === "number" && Array.isArray(raw.cards) && typeof raw.createdAt === "number" && typeof raw.updatedAt === "number";
|
|
2816
|
+
}
|
|
2817
|
+
/** Read one board file, tolerating a missing or corrupt document. */
|
|
2818
|
+
async function readDocument(id) {
|
|
2819
|
+
if (!isCanvasId(id)) return void 0;
|
|
2820
|
+
try {
|
|
2821
|
+
const parsed = JSON.parse(await promises.readFile(boardPath(id), "utf8"));
|
|
2822
|
+
if (!isDocument(parsed)) return void 0;
|
|
2823
|
+
return {
|
|
2824
|
+
...normalizeDocument(parsed, parsed),
|
|
2825
|
+
id: parsed.id,
|
|
2826
|
+
revision: parsed.revision,
|
|
2827
|
+
createdAt: parsed.createdAt,
|
|
2828
|
+
updatedAt: parsed.updatedAt
|
|
2829
|
+
};
|
|
2830
|
+
} catch {
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
/** Write one document atomically. */
|
|
2835
|
+
async function writeDocument(document) {
|
|
2836
|
+
await promises.mkdir(canvasDir(), { recursive: true });
|
|
2837
|
+
const target = boardPath(document.id);
|
|
2838
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
2839
|
+
await promises.writeFile(tmp, JSON.stringify(document, null, 2), "utf8");
|
|
2840
|
+
await promises.rename(tmp, target);
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Every canvas-owned picture that some board still shows.
|
|
2844
|
+
*
|
|
2845
|
+
* Used by the housekeeping routes: a picture is only deletable once no board
|
|
2846
|
+
* references it, so deleting a card and pruning the file behind it can never pull
|
|
2847
|
+
* a picture out from under a second board.
|
|
2848
|
+
*
|
|
2849
|
+
* @returns the set of referenced file names.
|
|
2850
|
+
*/
|
|
2851
|
+
async function referencedCanvasFiles() {
|
|
2852
|
+
const files = /* @__PURE__ */ new Set();
|
|
2853
|
+
let names = [];
|
|
2854
|
+
try {
|
|
2855
|
+
names = await promises.readdir(canvasDir());
|
|
2856
|
+
} catch {
|
|
2857
|
+
return files;
|
|
2858
|
+
}
|
|
2859
|
+
for (const name$1 of names) {
|
|
2860
|
+
if (!name$1.endsWith(".json")) continue;
|
|
2861
|
+
const document = await readDocument(name$1.slice(0, -5));
|
|
2862
|
+
if (document === void 0) continue;
|
|
2863
|
+
for (const card of document.cards) if (card.source === "canvas" && card.file !== void 0) files.add(card.file);
|
|
2864
|
+
}
|
|
2865
|
+
return files;
|
|
2866
|
+
}
|
|
2867
|
+
/** Every board on disk, newest first. */
|
|
2868
|
+
async function listCanvases() {
|
|
2869
|
+
let names = [];
|
|
2870
|
+
try {
|
|
2871
|
+
names = await promises.readdir(canvasDir());
|
|
2872
|
+
} catch {
|
|
2873
|
+
names = [];
|
|
2874
|
+
}
|
|
2875
|
+
const summaries = [];
|
|
2876
|
+
for (const name$1 of names) {
|
|
2877
|
+
if (!name$1.endsWith(".json")) continue;
|
|
2878
|
+
const document = await readDocument(name$1.slice(0, -5));
|
|
2879
|
+
if (document !== void 0) summaries.push(toSummary(document));
|
|
2880
|
+
}
|
|
2881
|
+
summaries.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
2882
|
+
return {
|
|
2883
|
+
canvases: summaries.slice(0, CANVAS_MAX_BOARDS),
|
|
2884
|
+
dataRoot: libraryDataRoot()
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
/** Create an empty board. */
|
|
2888
|
+
async function createCanvas(title) {
|
|
2889
|
+
const now = Date.now();
|
|
2890
|
+
const document = {
|
|
2891
|
+
id: randomUUID(),
|
|
2892
|
+
title: title !== void 0 && title.trim() !== "" ? title.trim().slice(0, 120) : "未命名画布",
|
|
2893
|
+
revision: 1,
|
|
2894
|
+
viewport: {
|
|
2895
|
+
x: 0,
|
|
2896
|
+
y: 0,
|
|
2897
|
+
k: 1
|
|
2898
|
+
},
|
|
2899
|
+
cards: [],
|
|
2900
|
+
createdAt: now,
|
|
2901
|
+
updatedAt: now
|
|
2902
|
+
};
|
|
2903
|
+
await writeDocument(document);
|
|
2904
|
+
await trimBoards();
|
|
2905
|
+
return document;
|
|
2906
|
+
}
|
|
2907
|
+
/** Read one board. */
|
|
2908
|
+
async function readCanvas(id) {
|
|
2909
|
+
return readDocument(id);
|
|
2910
|
+
}
|
|
2911
|
+
/**
|
|
2912
|
+
* Save a board.
|
|
2913
|
+
* @param incoming - the client's document (its `revision` is ignored).
|
|
2914
|
+
* @param expectedRevision - the revision the client read; a mismatch refuses.
|
|
2915
|
+
* @returns the stored document (with the next revision).
|
|
2916
|
+
* @throws {CanvasConflictError} when the board moved on since the client read it.
|
|
2917
|
+
*/
|
|
2918
|
+
async function saveCanvas(incoming, expectedRevision) {
|
|
2919
|
+
const raw = incoming !== null && typeof incoming === "object" ? incoming : {};
|
|
2920
|
+
const id = typeof raw.id === "string" ? raw.id : "";
|
|
2921
|
+
if (!isCanvasId(id)) throw new CanvasInputError("画布 id 不合法。");
|
|
2922
|
+
const previous = await readDocument(id);
|
|
2923
|
+
if (previous === void 0) throw new CanvasInputError("画布不存在。");
|
|
2924
|
+
if (previous.revision !== expectedRevision) throw new CanvasConflictError(expectedRevision, previous.revision);
|
|
2925
|
+
const stored = {
|
|
2926
|
+
...normalizeDocument(raw, previous),
|
|
2927
|
+
revision: previous.revision + 1,
|
|
2928
|
+
updatedAt: Date.now()
|
|
2929
|
+
};
|
|
2930
|
+
await writeDocument(stored);
|
|
2931
|
+
return stored;
|
|
2932
|
+
}
|
|
2933
|
+
/** Delete one board. */
|
|
2934
|
+
async function removeCanvas(id) {
|
|
2935
|
+
if (!isCanvasId(id)) throw new CanvasInputError("画布 id 不合法。");
|
|
2936
|
+
try {
|
|
2937
|
+
await promises.rm(boardPath(id), { force: true });
|
|
2938
|
+
} catch {}
|
|
2939
|
+
return listCanvases();
|
|
2940
|
+
}
|
|
2941
|
+
/** Drop the oldest boards past the retention cap. */
|
|
2942
|
+
async function trimBoards() {
|
|
2943
|
+
const { canvases } = await listCanvases();
|
|
2944
|
+
if (canvases.length <= CANVAS_MAX_BOARDS) return;
|
|
2945
|
+
for (const stale of canvases.slice(CANVAS_MAX_BOARDS)) try {
|
|
2946
|
+
await promises.rm(boardPath(stale.id), { force: true });
|
|
2947
|
+
} catch {}
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
//#endregion
|
|
2951
|
+
//#region src/version.ts
|
|
2952
|
+
/**
|
|
2953
|
+
* Parse `major.minor.patch[-prerelease][+build]`.
|
|
2954
|
+
* @param value - the version text.
|
|
2955
|
+
* @returns the parts, or undefined when the text is not that shape.
|
|
2956
|
+
*/
|
|
2957
|
+
function parseVersion(value) {
|
|
2958
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim());
|
|
2959
|
+
if (match === null) return void 0;
|
|
2960
|
+
const prerelease = match[4];
|
|
2961
|
+
return {
|
|
2962
|
+
major: Number(match[1]),
|
|
2963
|
+
minor: Number(match[2]),
|
|
2964
|
+
patch: Number(match[3]),
|
|
2965
|
+
prerelease: prerelease === void 0 || prerelease === "" ? [] : prerelease.split(".")
|
|
2966
|
+
};
|
|
2967
|
+
}
|
|
2968
|
+
/** Compare two prerelease identifier lists by semver's precedence rules. */
|
|
2969
|
+
function comparePrerelease(left, right) {
|
|
2970
|
+
if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
|
|
2971
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
2972
|
+
const a = left[index];
|
|
2973
|
+
const b = right[index];
|
|
2974
|
+
if (a === void 0) return -1;
|
|
2975
|
+
if (b === void 0) return 1;
|
|
2976
|
+
const numericA = /^\d+$/.test(a);
|
|
2977
|
+
const numericB = /^\d+$/.test(b);
|
|
2978
|
+
if (numericA && numericB) {
|
|
2979
|
+
const delta = Number(a) - Number(b);
|
|
2980
|
+
if (delta !== 0) return delta < 0 ? -1 : 1;
|
|
2981
|
+
continue;
|
|
2982
|
+
}
|
|
2983
|
+
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
2984
|
+
if (a !== b) return a < b ? -1 : 1;
|
|
2985
|
+
}
|
|
2986
|
+
return 0;
|
|
2987
|
+
}
|
|
2988
|
+
/**
|
|
2989
|
+
* Compare two version strings.
|
|
2990
|
+
*
|
|
2991
|
+
* @param left - the version on the left.
|
|
2992
|
+
* @param right - the version on the right.
|
|
2993
|
+
* @returns negative when `left` precedes `right`, 0 when equal, positive when it follows.
|
|
2994
|
+
* @throws when either side is not a version. A registry answer nobody can parse
|
|
2995
|
+
* must not read as "up to date", which is what a silent 0 would report.
|
|
2996
|
+
*/
|
|
2997
|
+
function compareVersions(left, right) {
|
|
2998
|
+
const a = parseVersion(left);
|
|
2999
|
+
const b = parseVersion(right);
|
|
3000
|
+
if (a === void 0 || b === void 0) throw new TypeError(`not a version: ${a === void 0 ? left : right}`);
|
|
3001
|
+
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
|
|
3002
|
+
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
|
|
3003
|
+
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
|
|
3004
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
3005
|
+
}
|
|
3006
|
+
/**
|
|
3007
|
+
* Whether `candidate` is strictly newer than `current`.
|
|
3008
|
+
* @param candidate - the version offered.
|
|
3009
|
+
* @param current - the version in hand.
|
|
3010
|
+
* @returns true when an update is worth offering.
|
|
3011
|
+
*/
|
|
3012
|
+
function isNewer(candidate, current) {
|
|
3013
|
+
return compareVersions(candidate, current) > 0;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
//#endregion
|
|
3017
|
+
//#region src/update.ts
|
|
3018
|
+
/** The package name this plugin is published under. */
|
|
3019
|
+
const PACKAGE_NAME = "dsh-seework";
|
|
3020
|
+
/** The spec an update installs. */
|
|
3021
|
+
const UPDATE_SPEC = `${PACKAGE_NAME}@latest`;
|
|
3022
|
+
/**
|
|
3023
|
+
* Read this plugin's own version from the manifest shipped beside the bundle.
|
|
3024
|
+
* @param manifestUrl - the manifest location; defaults to the packaged one.
|
|
3025
|
+
* @returns the version string.
|
|
3026
|
+
* @throws when the manifest cannot be read or carries no version.
|
|
3027
|
+
*/
|
|
3028
|
+
function readOwnVersion(manifestUrl = new URL("../package.json", import.meta.url).href) {
|
|
3029
|
+
const version = JSON.parse(readFileSync(fileURLToPath(manifestUrl), "utf8")).version;
|
|
3030
|
+
if (typeof version !== "string" || version === "") throw new Error("dsh-seework: the packaged manifest carries no version");
|
|
3031
|
+
return version;
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* The profile directory this copy was installed into.
|
|
3035
|
+
*
|
|
3036
|
+
* Derived from this module's own location rather than read from a service: the
|
|
3037
|
+
* bundle always sits at `<profile>/node_modules/dsh-seework/lib/`, so three
|
|
3038
|
+
* levels up is the profile. A host that installs the plugin some other way
|
|
3039
|
+
* yields a directory whose manifest does not name this package, which
|
|
3040
|
+
* {@link readInstallKind} reports as `unknown` — no worse than not knowing.
|
|
3041
|
+
*
|
|
3042
|
+
* @param bundleUrl - this module's URL; defaults to its real one.
|
|
3043
|
+
* @returns the profile directory path.
|
|
3044
|
+
*/
|
|
3045
|
+
function profileDirectory(bundleUrl = import.meta.url) {
|
|
3046
|
+
return fileURLToPath(new URL("../../../", bundleUrl));
|
|
3047
|
+
}
|
|
3048
|
+
/**
|
|
3049
|
+
* How this copy reached the profile, read from the profile's own manifest.
|
|
3050
|
+
*
|
|
3051
|
+
* The distinction is load-bearing, not cosmetic: `pnpm sync` overwrites the
|
|
3052
|
+
* installed copy in place, and the next pnpm operation restores it from the
|
|
3053
|
+
* declared source. A `file:` install therefore belongs to the build-and-sync
|
|
3054
|
+
* loop, and offering it a registry update would silently move the user off that
|
|
3055
|
+
* loop.
|
|
3056
|
+
*
|
|
3057
|
+
* @param profileDir - the profile directory.
|
|
3058
|
+
* @param packageName - the dependency name to look up.
|
|
3059
|
+
* @returns the install kind, or `unknown` when the manifest cannot answer.
|
|
3060
|
+
*/
|
|
3061
|
+
function readInstallKind(profileDir, packageName = PACKAGE_NAME) {
|
|
3062
|
+
try {
|
|
3063
|
+
const spec = JSON.parse(readFileSync(`${profileDir}/package.json`, "utf8")).dependencies?.[packageName];
|
|
3064
|
+
if (typeof spec !== "string") return "unknown";
|
|
3065
|
+
return spec.startsWith("file:") ? "local" : "registry";
|
|
3066
|
+
} catch {
|
|
3067
|
+
return "unknown";
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
/**
|
|
3071
|
+
* Ask the registry what the newest version is, and compare it with ours.
|
|
3072
|
+
*
|
|
3073
|
+
* Never throws: the settings card has to keep rendering when the registry is
|
|
3074
|
+
* unreachable, when the host has no plugin manager, and when this is a local
|
|
3075
|
+
* install that has no update to offer.
|
|
3076
|
+
*
|
|
3077
|
+
* @param deps - the host seam, the running version, and how it was installed.
|
|
3078
|
+
* @returns the status the card renders.
|
|
3079
|
+
*/
|
|
3080
|
+
async function checkForUpdate(deps) {
|
|
3081
|
+
const base = {
|
|
3082
|
+
current: deps.current,
|
|
3083
|
+
kind: deps.kind,
|
|
3084
|
+
updateAvailable: false
|
|
3085
|
+
};
|
|
3086
|
+
if (deps.kind === "local") return base;
|
|
3087
|
+
if (deps.host === void 0) return {
|
|
3088
|
+
...base,
|
|
3089
|
+
error: "宿主没有提供插件管理服务。"
|
|
3090
|
+
};
|
|
3091
|
+
try {
|
|
3092
|
+
const inspection = await deps.host.inspect(PACKAGE_NAME);
|
|
3093
|
+
if (inspection.status !== "accepted" || inspection.version === void 0) return {
|
|
3094
|
+
...base,
|
|
3095
|
+
error: inspection.reason ?? "注册表上没有这个包。"
|
|
3096
|
+
};
|
|
3097
|
+
return {
|
|
3098
|
+
...base,
|
|
3099
|
+
latest: inspection.version,
|
|
3100
|
+
updateAvailable: isNewer(inspection.version, deps.current)
|
|
3101
|
+
};
|
|
3102
|
+
} catch (error) {
|
|
3103
|
+
return {
|
|
3104
|
+
...base,
|
|
3105
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3106
|
+
};
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
//#endregion
|
|
3111
|
+
//#region src/routes.ts
|
|
3112
|
+
/** Cap on JSON request bodies (edit requests carry data-URL reference images). */
|
|
3113
|
+
const MAX_JSON_BODY_BYTES = 48 * 1024 * 1024;
|
|
3114
|
+
/** How long a synchronous generate call waits before handing back a task id. */
|
|
3115
|
+
const SYNC_WAIT_MS = 3e5;
|
|
3116
|
+
/** Loopback literal check plus browser same-origin markers. */
|
|
3117
|
+
function isLoopbackRequest(request) {
|
|
3118
|
+
const address = request.socket.remoteAddress;
|
|
3119
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
3120
|
+
const host = request.headers.host;
|
|
3121
|
+
if (typeof host !== "string") return false;
|
|
3122
|
+
let hostUrl;
|
|
3123
|
+
try {
|
|
3124
|
+
hostUrl = new URL(`http://${host}`);
|
|
3125
|
+
} catch {
|
|
3126
|
+
return false;
|
|
3127
|
+
}
|
|
3128
|
+
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
3129
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
3130
|
+
const origin = request.headers.origin;
|
|
3131
|
+
if (origin === void 0) return true;
|
|
3132
|
+
try {
|
|
3133
|
+
return new URL(origin).host === hostUrl.host;
|
|
3134
|
+
} catch {
|
|
3135
|
+
return false;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
/** One JSON response. */
|
|
3139
|
+
function writeJson(res, status, body) {
|
|
3140
|
+
const payload = JSON.stringify(body);
|
|
3141
|
+
res.writeHead(status, {
|
|
3142
|
+
"content-type": "application/json; charset=utf-8",
|
|
3143
|
+
"content-length": Buffer.byteLength(payload),
|
|
3144
|
+
"cache-control": "no-store"
|
|
3145
|
+
});
|
|
3146
|
+
res.end(payload);
|
|
3147
|
+
}
|
|
3148
|
+
/** One success envelope. */
|
|
3149
|
+
function ok(res, value) {
|
|
3150
|
+
writeJson(res, 200, {
|
|
3151
|
+
ok: true,
|
|
3152
|
+
value
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
/** One failure envelope (stable `code` + human `message`). */
|
|
3156
|
+
function fail(res, status, code, message) {
|
|
3157
|
+
writeJson(res, status, {
|
|
3158
|
+
ok: false,
|
|
3159
|
+
code,
|
|
3160
|
+
message
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
/** Read a bounded JSON request body. */
|
|
3164
|
+
async function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
|
|
3165
|
+
const chunks = [];
|
|
3166
|
+
let total = 0;
|
|
3167
|
+
for await (const chunk of req) {
|
|
3168
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3169
|
+
total += buffer.byteLength;
|
|
3170
|
+
if (total > maxBytes) return void 0;
|
|
3171
|
+
chunks.push(buffer);
|
|
3172
|
+
}
|
|
3173
|
+
if (total === 0) return {};
|
|
3174
|
+
try {
|
|
3175
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
3176
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
|
|
3177
|
+
} catch {
|
|
3178
|
+
return;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
/** Serialize one descriptor into the bridge wire shape (secrets redacted). */
|
|
3182
|
+
function toView(descriptor$1) {
|
|
3183
|
+
return {
|
|
3184
|
+
ns: descriptor$1.ns,
|
|
3185
|
+
schema: descriptor$1.schema,
|
|
3186
|
+
value: descriptor$1.value,
|
|
3187
|
+
revision: descriptor$1.revision,
|
|
3188
|
+
...descriptor$1.base === void 0 ? {} : { base: descriptor$1.base },
|
|
3189
|
+
...descriptor$1.user === void 0 ? {} : { user: descriptor$1.user },
|
|
3190
|
+
secrets: (descriptor$1.secrets ?? []).map((secret) => ({
|
|
3191
|
+
path: [...secret.path],
|
|
3192
|
+
set: secret.set
|
|
3193
|
+
}))
|
|
3194
|
+
};
|
|
3195
|
+
}
|
|
3196
|
+
/** Turn a thrown value into the bridge failure envelope. */
|
|
3197
|
+
function failureOf(error) {
|
|
3198
|
+
if (error instanceof SettingsConflictError) return {
|
|
3199
|
+
status: 409,
|
|
3200
|
+
code: "conflict",
|
|
3201
|
+
message: `设置已被其它位置修改(期望版本 ${error.expected},当前 ${error.actual}),请重试。`
|
|
3202
|
+
};
|
|
3203
|
+
if (error instanceof CanvasConflictError) return {
|
|
3204
|
+
status: 409,
|
|
3205
|
+
code: "canvas_conflict",
|
|
3206
|
+
message: error.message
|
|
3207
|
+
};
|
|
3208
|
+
if (error instanceof CanvasInputError) return {
|
|
3209
|
+
status: 400,
|
|
3210
|
+
code: "canvas_invalid",
|
|
3211
|
+
message: error.message
|
|
3212
|
+
};
|
|
3213
|
+
if (error instanceof SeeWorkError || error instanceof SeeWorkRuntimeError) return {
|
|
3214
|
+
status: 400,
|
|
3215
|
+
code: error.code,
|
|
3216
|
+
message: error.message
|
|
3217
|
+
};
|
|
3218
|
+
return {
|
|
3219
|
+
status: 500,
|
|
3220
|
+
code: "internal",
|
|
3221
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
/** Validate and normalize a generate request body. */
|
|
3225
|
+
function parseGenerateRequest(body) {
|
|
3226
|
+
const prompt = typeof body.prompt === "string" ? body.prompt : "";
|
|
3227
|
+
const model = typeof body.model === "string" ? body.model : "";
|
|
3228
|
+
if (prompt.trim() === "" || model.trim() === "") return void 0;
|
|
3229
|
+
const mode = body.mode === "edit" ? "edit" : "text";
|
|
3230
|
+
const text = (value) => typeof value === "string" ? value.trim() : "";
|
|
3231
|
+
const imageUrls = [];
|
|
3232
|
+
if (Array.isArray(body.imageUrls)) {
|
|
3233
|
+
for (const item of body.imageUrls) if (typeof item === "string" && item.trim() !== "") imageUrls.push(item.trim());
|
|
3234
|
+
}
|
|
3235
|
+
const refNames = Array.isArray(body.refNames) ? body.refNames.filter((item) => typeof item === "string" && item.trim() !== "").map((item) => item.trim()) : void 0;
|
|
3236
|
+
return {
|
|
3237
|
+
mode: mode === "edit" && imageUrls.length > 0 ? "edit" : "text",
|
|
3238
|
+
model: model.trim(),
|
|
3239
|
+
prompt,
|
|
3240
|
+
resolution: text(body.resolution),
|
|
3241
|
+
aspectRatio: text(body.aspectRatio),
|
|
3242
|
+
outputFormat: text(body.outputFormat),
|
|
3243
|
+
n: typeof body.n === "number" && Number.isFinite(body.n) ? Math.trunc(body.n) : 1,
|
|
3244
|
+
imageUrls: mode === "edit" ? imageUrls : [],
|
|
3245
|
+
...refNames === void 0 || refNames.length === 0 ? {} : { refNames }
|
|
3246
|
+
};
|
|
3247
|
+
}
|
|
3248
|
+
/** Serve one library image file (prefix route). */
|
|
3249
|
+
async function serveLibraryImage(res, file) {
|
|
3250
|
+
const image = await readLibraryImage(file);
|
|
3251
|
+
if (image === void 0) {
|
|
3252
|
+
fail(res, 404, "image_not_found", "图片不存在或文件名非法。");
|
|
3253
|
+
return;
|
|
3254
|
+
}
|
|
3255
|
+
res.writeHead(200, {
|
|
3256
|
+
"content-type": image.mime,
|
|
3257
|
+
"content-length": image.data.byteLength,
|
|
3258
|
+
"cache-control": "private, max-age=31536000, immutable"
|
|
3259
|
+
});
|
|
3260
|
+
res.end(image.data);
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Rebuild one durable attachment reference from a tool-result image request.
|
|
3264
|
+
*
|
|
3265
|
+
* Every field is re-validated here rather than trusted: the values arrive in a
|
|
3266
|
+
* URL a page composed, and the route reads a store shared with every other
|
|
3267
|
+
* attachment in the harness. An incomplete or malformed reference is refused
|
|
3268
|
+
* outright — a partial one must never reach the store.
|
|
3269
|
+
*
|
|
3270
|
+
* @param rawUrl - the request URL (query carries the reference).
|
|
3271
|
+
* @returns the reference, or undefined when it is not complete and well-formed.
|
|
3272
|
+
*/
|
|
3273
|
+
function attachmentRefFrom(rawUrl) {
|
|
3274
|
+
if (rawUrl === void 0) return void 0;
|
|
3275
|
+
let url;
|
|
3276
|
+
try {
|
|
3277
|
+
url = new URL(rawUrl, "http://localhost");
|
|
3278
|
+
} catch {
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
if (url.pathname !== ATTACHMENT_API.image) return void 0;
|
|
3282
|
+
const attachmentId = url.searchParams.get("attachment_id") ?? "";
|
|
3283
|
+
const mediaType = url.searchParams.get("media_type") ?? "";
|
|
3284
|
+
const bytes = Number(url.searchParams.get("bytes"));
|
|
3285
|
+
const width = Number(url.searchParams.get("width"));
|
|
3286
|
+
const height = Number(url.searchParams.get("height"));
|
|
3287
|
+
if (attachmentId === "" || !isImageMedia(mediaType)) return void 0;
|
|
3288
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1) return void 0;
|
|
3289
|
+
if (!Number.isSafeInteger(width) || width < 1) return void 0;
|
|
3290
|
+
if (!Number.isSafeInteger(height) || height < 1) return void 0;
|
|
3291
|
+
return {
|
|
3292
|
+
attachmentId,
|
|
3293
|
+
mediaType,
|
|
3294
|
+
bytes,
|
|
3295
|
+
width,
|
|
3296
|
+
height
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
3299
|
+
/** Serve one durable attachment a tool result references (prefix route). */
|
|
3300
|
+
async function serveAttachmentImage(attachments, res, ref) {
|
|
3301
|
+
if (attachments === void 0) {
|
|
3302
|
+
fail(res, 503, "attachments_unavailable", "宿主没有挂载附件存储,无法读取这张图片。");
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
try {
|
|
3306
|
+
const stored = await attachments.readImage(ref);
|
|
3307
|
+
res.writeHead(200, {
|
|
3308
|
+
"content-type": stored.ref.mediaType,
|
|
3309
|
+
"content-length": stored.data.byteLength,
|
|
3310
|
+
"cache-control": "private, max-age=31536000, immutable"
|
|
3311
|
+
});
|
|
3312
|
+
res.end(Buffer.from(stored.data));
|
|
3313
|
+
} catch {
|
|
3314
|
+
fail(res, 404, "image_not_found", "这张图片已不在宿主的附件存储里。");
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
/** Build the plugin's routes. */
|
|
3318
|
+
function makeRoutes(deps) {
|
|
3319
|
+
const loopback = (req, res) => {
|
|
3320
|
+
if (isLoopbackRequest(req)) return true;
|
|
3321
|
+
fail(res, 403, "loopback_only", "该接口只允许本机访问。");
|
|
3322
|
+
return false;
|
|
3323
|
+
};
|
|
3324
|
+
const route = (kind, path$1, handler) => ({
|
|
3325
|
+
kind,
|
|
3326
|
+
path: path$1,
|
|
3327
|
+
handler
|
|
3328
|
+
});
|
|
3329
|
+
return [
|
|
3330
|
+
route("exact", SETTINGS_API.describe, async (req, res) => {
|
|
3331
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3332
|
+
if (!loopback(req, res)) return;
|
|
3333
|
+
try {
|
|
3334
|
+
const writable = deps.settings.writable !== false;
|
|
3335
|
+
const view = deps.settings.describe({ redactSecrets: true }).find((descriptor$1) => descriptor$1.ns === SEEWORK_SETTINGS_NAMESPACE);
|
|
3336
|
+
ok(res, {
|
|
3337
|
+
namespaces: view === void 0 ? [] : [toView(view)],
|
|
3338
|
+
writable
|
|
3339
|
+
});
|
|
3340
|
+
} catch (error) {
|
|
3341
|
+
const failure = failureOf(error);
|
|
3342
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3343
|
+
}
|
|
3344
|
+
}),
|
|
3345
|
+
route("exact", SETTINGS_API.mutate, async (req, res) => {
|
|
3346
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3347
|
+
if (!loopback(req, res)) return;
|
|
3348
|
+
const body = await readJsonBody(req);
|
|
3349
|
+
if (body === void 0) return fail(res, 400, "invalid_body", "请求体不是合法 JSON 对象。");
|
|
3350
|
+
const ops = Array.isArray(body.ops) ? body.ops : void 0;
|
|
3351
|
+
if (ops === void 0) return fail(res, 400, "invalid_body", "缺少设置操作 ops。");
|
|
3352
|
+
const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : void 0;
|
|
3353
|
+
try {
|
|
3354
|
+
await deps.settings.mutate(SEEWORK_SETTINGS_NAMESPACE, ops, expectedRevision);
|
|
3355
|
+
const view = deps.settings.describe({ redactSecrets: true }).find((descriptor$1) => descriptor$1.ns === SEEWORK_SETTINGS_NAMESPACE);
|
|
3356
|
+
ok(res, view === void 0 ? {
|
|
3357
|
+
ns: SEEWORK_SETTINGS_NAMESPACE,
|
|
3358
|
+
revision: 0,
|
|
3359
|
+
value: {},
|
|
3360
|
+
secrets: []
|
|
3361
|
+
} : toView(view));
|
|
3362
|
+
} catch (error) {
|
|
3363
|
+
const failure = failureOf(error);
|
|
3364
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3365
|
+
}
|
|
3366
|
+
}),
|
|
3367
|
+
route("exact", SETTINGS_API.directoryPicker, async (req, res) => {
|
|
3368
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3369
|
+
if (!loopback(req, res)) return;
|
|
3370
|
+
ok(res, directoryPickerStatus(deps.directoryPicker?.()));
|
|
3371
|
+
}),
|
|
3372
|
+
route("exact", SETTINGS_API.pickDirectory, async (req, res) => {
|
|
3373
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3374
|
+
if (!loopback(req, res)) return;
|
|
3375
|
+
const controller = new AbortController();
|
|
3376
|
+
req.on("close", () => {
|
|
3377
|
+
controller.abort();
|
|
3378
|
+
});
|
|
3379
|
+
const outcome = await pickDirectory(deps.directoryPicker?.(), controller.signal);
|
|
3380
|
+
if (res.writableEnded || res.destroyed) return;
|
|
3381
|
+
if (outcome.kind === "cancelled") return ok(res, { cancelled: true });
|
|
3382
|
+
if (outcome.kind === "failed") return fail(res, 503, outcome.code, outcome.message);
|
|
3383
|
+
ok(res, { path: outcome.path });
|
|
3384
|
+
}),
|
|
3385
|
+
route("exact", CATALOG_API.models, async (req, res) => {
|
|
3386
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3387
|
+
if (!loopback(req, res)) return;
|
|
3388
|
+
const body = await readJsonBody(req) ?? {};
|
|
3389
|
+
const config = effectiveConfig(deps.resolve());
|
|
3390
|
+
const apiUrl = typeof body.apiUrl === "string" && body.apiUrl.trim() !== "" ? body.apiUrl.trim() : config.apiUrl;
|
|
3391
|
+
const serviceUrl = typeof body.serviceUrl === "string" && body.serviceUrl.trim() !== "" ? body.serviceUrl.trim() : config.serviceUrl;
|
|
3392
|
+
const apiKey = typeof body.apiKey === "string" && body.apiKey.trim() !== "" ? body.apiKey.trim() : config.apiKey;
|
|
3393
|
+
try {
|
|
3394
|
+
ok(res, await discoverModels({
|
|
3395
|
+
serviceUrl,
|
|
3396
|
+
apiUrl,
|
|
3397
|
+
apiKey
|
|
3398
|
+
}));
|
|
3399
|
+
} catch (error) {
|
|
3400
|
+
const failure = failureOf(error);
|
|
3401
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3402
|
+
}
|
|
3403
|
+
}),
|
|
3404
|
+
route("exact", CATALOG_API.refresh, async (req, res) => {
|
|
3405
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3406
|
+
if (!loopback(req, res)) return;
|
|
3407
|
+
const refresher = deps.catalogRefresh?.();
|
|
3408
|
+
if (refresher === void 0) return fail(res, 503, "unavailable", "这台宿主没有可用的设置服务,无法自动检测。");
|
|
3409
|
+
try {
|
|
3410
|
+
ok(res, await refresher.refresh({ automatic: true }));
|
|
3411
|
+
} catch (error) {
|
|
3412
|
+
const failure = failureOf(error);
|
|
3413
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3414
|
+
}
|
|
3415
|
+
}),
|
|
3416
|
+
route("exact", GENERATE_API, async (req, res) => {
|
|
3417
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3418
|
+
if (!loopback(req, res)) return;
|
|
3419
|
+
const body = await readJsonBody(req);
|
|
3420
|
+
if (body === void 0) return fail(res, 400, "invalid_body", "请求体不是合法 JSON 对象,或超过了体积上限。");
|
|
3421
|
+
const request = parseGenerateRequest(body);
|
|
3422
|
+
if (request === void 0) return fail(res, 400, "invalid_request", "缺少必填的 model 或 prompt。");
|
|
3423
|
+
if (!effectiveConfig(deps.resolve()).enabled) return fail(res, 400, "plugin-disabled", "插件已停用,请先在设置里启用。");
|
|
3424
|
+
let task;
|
|
3425
|
+
try {
|
|
3426
|
+
task = deps.runtime.submit(request, "panel");
|
|
3427
|
+
} catch (error) {
|
|
3428
|
+
const failure = failureOf(error);
|
|
3429
|
+
return fail(res, failure.status, failure.code, failure.message);
|
|
3430
|
+
}
|
|
3431
|
+
if (body.waitForCompletion === false) return ok(res, { task });
|
|
3432
|
+
try {
|
|
3433
|
+
ok(res, { task: await deps.runtime.waitFor(task.id, AbortSignal.timeout(SYNC_WAIT_MS)) });
|
|
3434
|
+
} catch (error) {
|
|
3435
|
+
ok(res, { task: deps.runtime.get(task.id) ?? task });
|
|
3436
|
+
}
|
|
3437
|
+
}),
|
|
3438
|
+
route("exact", TASK_API.list, async (req, res) => {
|
|
3439
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3440
|
+
if (!loopback(req, res)) return;
|
|
3441
|
+
ok(res, { tasks: deps.runtime.list() });
|
|
3442
|
+
}),
|
|
3443
|
+
route("exact", TASK_API.cancel, async (req, res) => {
|
|
3444
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3445
|
+
if (!loopback(req, res)) return;
|
|
3446
|
+
const body = await readJsonBody(req);
|
|
3447
|
+
const taskId = typeof body?.taskId === "string" ? body.taskId : "";
|
|
3448
|
+
if (taskId === "") return fail(res, 400, "invalid_body", "缺少 taskId。");
|
|
3449
|
+
const task = deps.runtime.cancel(taskId);
|
|
3450
|
+
if (task === void 0) return fail(res, 404, "task_not_found", "找不到该生图任务。");
|
|
3451
|
+
ok(res, { task });
|
|
3452
|
+
}),
|
|
3453
|
+
route("exact", UPDATE_API.status, async (req, res) => {
|
|
3454
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3455
|
+
const current = readOwnVersion();
|
|
3456
|
+
const kind = readInstallKind(profileDirectory());
|
|
3457
|
+
ok(res, await checkForUpdate({
|
|
3458
|
+
host: deps.updateHost?.(),
|
|
3459
|
+
current,
|
|
3460
|
+
kind
|
|
3461
|
+
}));
|
|
3462
|
+
}),
|
|
3463
|
+
route("exact", UPDATE_API.apply, async (req, res) => {
|
|
3464
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3465
|
+
const host = deps.updateHost?.();
|
|
3466
|
+
if (host === void 0) return fail(res, 503, "unavailable", "宿主没有提供插件管理服务。");
|
|
3467
|
+
if (readInstallKind(profileDirectory()) === "local") return fail(res, 409, "local_install", "这是本地目录安装,更新请用 pnpm build && pnpm sync。");
|
|
3468
|
+
ok(res, { started: true });
|
|
3469
|
+
setTimeout(() => {
|
|
3470
|
+
host.installBundle(UPDATE_SPEC).catch((error) => {
|
|
3471
|
+
console.warn("[dsh-seework] self-update failed:", error);
|
|
3472
|
+
});
|
|
3473
|
+
}, 0);
|
|
3474
|
+
}),
|
|
3475
|
+
route("exact", LIBRARY_API.list, async (req, res) => {
|
|
3476
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3477
|
+
if (!loopback(req, res)) return;
|
|
3478
|
+
ok(res, await listLibrary());
|
|
3479
|
+
}),
|
|
3480
|
+
route("exact", LIBRARY_API.head, async (req, res) => {
|
|
3481
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3482
|
+
if (!loopback(req, res)) return;
|
|
3483
|
+
ok(res, await readLibraryHead());
|
|
3484
|
+
}),
|
|
3485
|
+
route("exact", LIBRARY_API.remove, async (req, res) => {
|
|
3486
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3487
|
+
if (!loopback(req, res)) return;
|
|
3488
|
+
const body = await readJsonBody(req);
|
|
3489
|
+
const id = typeof body?.id === "string" ? body.id : "";
|
|
3490
|
+
if (id === "") return fail(res, 400, "invalid_body", "缺少素材 id。");
|
|
3491
|
+
ok(res, { entries: await removeLibraryEntry(id) });
|
|
3492
|
+
}),
|
|
3493
|
+
route("exact", LIBRARY_API.clear, async (req, res) => {
|
|
3494
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3495
|
+
if (!loopback(req, res)) return;
|
|
3496
|
+
ok(res, { entries: await clearLibrary() });
|
|
3497
|
+
}),
|
|
3498
|
+
route("prefix", LIBRARY_API.image, async (req, res) => {
|
|
3499
|
+
if (req.method !== "GET") return fail(res, 405, "method_not_allowed", "请使用 GET。");
|
|
3500
|
+
if (!loopback(req, res)) return;
|
|
3501
|
+
const path$1 = (req.url ?? "").split("?")[0] ?? "";
|
|
3502
|
+
let file = "";
|
|
3503
|
+
try {
|
|
3504
|
+
file = decodeURIComponent(path$1.slice(LIBRARY_API.image.length + 1));
|
|
3505
|
+
} catch {
|
|
3506
|
+
return fail(res, 400, "invalid_body", "图片文件名编码不合法。");
|
|
3507
|
+
}
|
|
3508
|
+
if (file === "") return fail(res, 400, "invalid_body", "缺少图片文件名。");
|
|
3509
|
+
await serveLibraryImage(res, file);
|
|
3510
|
+
}),
|
|
3511
|
+
route("prefix", ATTACHMENT_API.image, async (req, res) => {
|
|
3512
|
+
if (req.method !== "GET") return fail(res, 405, "method_not_allowed", "请使用 GET。");
|
|
3513
|
+
if (!loopback(req, res)) return;
|
|
3514
|
+
const ref = attachmentRefFrom(req.url);
|
|
3515
|
+
if (ref === void 0) return fail(res, 400, "invalid_body", "图片引用不完整或不合法。");
|
|
3516
|
+
await serveAttachmentImage(deps.attachments, res, ref);
|
|
3517
|
+
}),
|
|
3518
|
+
route("exact", CANVAS_API.asset, async (req, res) => {
|
|
3519
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3520
|
+
if (!loopback(req, res)) return;
|
|
3521
|
+
const body = await readJsonBody(req);
|
|
3522
|
+
if (body === void 0) return fail(res, 400, "invalid_body", "请求体不是合法 JSON 对象。");
|
|
3523
|
+
if (typeof body.dataUrl !== "string") return fail(res, 400, "invalid_body", "缺少图片数据。");
|
|
3524
|
+
try {
|
|
3525
|
+
const image = await writeCanvasAsset({ dataUrl: body.dataUrl });
|
|
3526
|
+
if (image === void 0) return fail(res, 400, "invalid_body", "图片数据不合法(只接受 PNG/JPEG/WebP/GIF 的 data URL)。");
|
|
3527
|
+
ok(res, { image });
|
|
3528
|
+
} catch (error) {
|
|
3529
|
+
const failure = failureOf(error);
|
|
3530
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3531
|
+
}
|
|
3532
|
+
}),
|
|
3533
|
+
route("exact", CANVAS_API.assets, async (req, res) => {
|
|
3534
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3535
|
+
if (!loopback(req, res)) return;
|
|
3536
|
+
try {
|
|
3537
|
+
const referenced = await referencedCanvasFiles();
|
|
3538
|
+
const files = (await listCanvasAssetFiles()).map((file) => ({
|
|
3539
|
+
...file,
|
|
3540
|
+
referenced: referenced.has(file.file)
|
|
3541
|
+
}));
|
|
3542
|
+
const orphans = files.filter((file) => !file.referenced);
|
|
3543
|
+
ok(res, {
|
|
3544
|
+
files,
|
|
3545
|
+
orphans: orphans.length,
|
|
3546
|
+
orphanBytes: orphans.reduce((sum, file) => sum + file.bytes, 0)
|
|
3547
|
+
});
|
|
3548
|
+
} catch (error) {
|
|
3549
|
+
const failure = failureOf(error);
|
|
3550
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3551
|
+
}
|
|
3552
|
+
}),
|
|
3553
|
+
route("exact", CANVAS_API.pruneAssets, async (req, res) => {
|
|
3554
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3555
|
+
if (!loopback(req, res)) return;
|
|
3556
|
+
try {
|
|
3557
|
+
const referenced = await referencedCanvasFiles();
|
|
3558
|
+
ok(res, await deleteCanvasAssets((await listCanvasAssetFiles()).filter((file) => !referenced.has(file.file)).map((file) => file.file)));
|
|
3559
|
+
} catch (error) {
|
|
3560
|
+
const failure = failureOf(error);
|
|
3561
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3562
|
+
}
|
|
3563
|
+
}),
|
|
3564
|
+
route("exact", CANVAS_API.removeAsset, async (req, res) => {
|
|
3565
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3566
|
+
if (!loopback(req, res)) return;
|
|
3567
|
+
const body = await readJsonBody(req);
|
|
3568
|
+
if (body === void 0) return fail(res, 400, "invalid_body", "请求体不是合法 JSON 对象。");
|
|
3569
|
+
if (typeof body.file !== "string" || body.file === "") return fail(res, 400, "invalid_body", "缺少图片文件名。");
|
|
3570
|
+
try {
|
|
3571
|
+
if ((await referencedCanvasFiles()).has(body.file)) return fail(res, 409, "asset_in_use", "这张图还在画布上,先移除卡片再删它。");
|
|
3572
|
+
ok(res, await deleteCanvasAssets([body.file]));
|
|
3573
|
+
} catch (error) {
|
|
3574
|
+
const failure = failureOf(error);
|
|
3575
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3576
|
+
}
|
|
3577
|
+
}),
|
|
3578
|
+
route("prefix", CANVAS_API.asset, async (req, res) => {
|
|
3579
|
+
if (req.method !== "GET") return fail(res, 405, "method_not_allowed", "请使用 GET。");
|
|
3580
|
+
if (!loopback(req, res)) return;
|
|
3581
|
+
const path$1 = (req.url ?? "").split("?")[0] ?? "";
|
|
3582
|
+
let file = "";
|
|
3583
|
+
try {
|
|
3584
|
+
file = decodeURIComponent(path$1.slice(CANVAS_API.asset.length + 1));
|
|
3585
|
+
} catch {
|
|
3586
|
+
return fail(res, 400, "invalid_body", "图片文件名编码不合法。");
|
|
3587
|
+
}
|
|
3588
|
+
if (file === "") return fail(res, 400, "invalid_body", "缺少图片文件名。");
|
|
3589
|
+
const image = await readCanvasAsset(file);
|
|
3590
|
+
if (image === void 0) return fail(res, 404, "image_not_found", "图片不存在或文件名非法。");
|
|
3591
|
+
res.writeHead(200, {
|
|
3592
|
+
"content-type": image.mime,
|
|
3593
|
+
"content-length": image.data.byteLength,
|
|
3594
|
+
"cache-control": "private, max-age=31536000, immutable"
|
|
3595
|
+
});
|
|
3596
|
+
res.end(image.data);
|
|
3597
|
+
}),
|
|
3598
|
+
route("exact", CANVAS_API.list, async (req, res) => {
|
|
3599
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3600
|
+
if (!loopback(req, res)) return;
|
|
3601
|
+
try {
|
|
3602
|
+
ok(res, await listCanvases());
|
|
3603
|
+
} catch (error) {
|
|
3604
|
+
const failure = failureOf(error);
|
|
3605
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3606
|
+
}
|
|
3607
|
+
}),
|
|
3608
|
+
route("exact", CANVAS_API.create, async (req, res) => {
|
|
3609
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3610
|
+
if (!loopback(req, res)) return;
|
|
3611
|
+
const body = await readJsonBody(req) ?? {};
|
|
3612
|
+
try {
|
|
3613
|
+
ok(res, { canvas: await createCanvas(typeof body.title === "string" ? body.title : void 0) });
|
|
3614
|
+
} catch (error) {
|
|
3615
|
+
const failure = failureOf(error);
|
|
3616
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3617
|
+
}
|
|
3618
|
+
}),
|
|
3619
|
+
route("exact", CANVAS_API.read, async (req, res) => {
|
|
3620
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3621
|
+
if (!loopback(req, res)) return;
|
|
3622
|
+
const body = await readJsonBody(req);
|
|
3623
|
+
const id = typeof body?.id === "string" ? body.id : "";
|
|
3624
|
+
if (id === "") return fail(res, 400, "invalid_body", "缺少画布 id。");
|
|
3625
|
+
const canvas = await readCanvas(id);
|
|
3626
|
+
if (canvas === void 0) return fail(res, 404, "canvas_not_found", "画布不存在。");
|
|
3627
|
+
ok(res, { canvas });
|
|
3628
|
+
}),
|
|
3629
|
+
route("exact", CANVAS_API.save, async (req, res) => {
|
|
3630
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3631
|
+
if (!loopback(req, res)) return;
|
|
3632
|
+
const body = await readJsonBody(req);
|
|
3633
|
+
if (body === void 0) return fail(res, 400, "invalid_body", "请求体不是合法 JSON 对象。");
|
|
3634
|
+
const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : void 0;
|
|
3635
|
+
if (expectedRevision === void 0) return fail(res, 400, "invalid_body", "缺少 expectedRevision。");
|
|
3636
|
+
try {
|
|
3637
|
+
ok(res, { canvas: await saveCanvas(body.canvas, expectedRevision) });
|
|
3638
|
+
} catch (error) {
|
|
3639
|
+
const failure = failureOf(error);
|
|
3640
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3641
|
+
}
|
|
3642
|
+
}),
|
|
3643
|
+
route("exact", CANVAS_API.remove, async (req, res) => {
|
|
3644
|
+
if (req.method !== "POST") return fail(res, 405, "method_not_allowed", "请使用 POST。");
|
|
3645
|
+
if (!loopback(req, res)) return;
|
|
3646
|
+
const body = await readJsonBody(req);
|
|
3647
|
+
const id = typeof body?.id === "string" ? body.id : "";
|
|
3648
|
+
if (id === "") return fail(res, 400, "invalid_body", "缺少画布 id。");
|
|
3649
|
+
try {
|
|
3650
|
+
ok(res, await removeCanvas(id));
|
|
3651
|
+
} catch (error) {
|
|
3652
|
+
const failure = failureOf(error);
|
|
3653
|
+
fail(res, failure.status, failure.code, failure.message);
|
|
3654
|
+
}
|
|
3655
|
+
})
|
|
3656
|
+
];
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
//#endregion
|
|
3660
|
+
//#region src/index.ts
|
|
3661
|
+
/** Stable cordis plugin name. */
|
|
3662
|
+
const name = "seework";
|
|
3663
|
+
/** Services required before the host surfaces can mount. */
|
|
3664
|
+
const inject = [
|
|
3665
|
+
"webServer",
|
|
3666
|
+
"systemPrompt",
|
|
3667
|
+
"tools",
|
|
3668
|
+
"attachments"
|
|
3669
|
+
];
|
|
3670
|
+
/**
|
|
3671
|
+
* The schemastery config schema. The loader validates the composition entry
|
|
3672
|
+
* with the same-named export of this module.
|
|
3673
|
+
*/
|
|
3674
|
+
const Config = Config$1;
|
|
3675
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
3676
|
+
const SECTION_ORDER = 150;
|
|
3677
|
+
/**
|
|
3678
|
+
* Model-facing announcement: **plugin-level facts only**.
|
|
3679
|
+
*
|
|
3680
|
+
* What belongs here, and what deliberately does not, is design discipline 13 of
|
|
3681
|
+
* `docs/design/2026-09-15-agent-visible-surface.md`: the announcement carries
|
|
3682
|
+
* identity, the one tool, the live addresses, the default model's one
|
|
3683
|
+
* capability line, the other saved models' identifiers, where the library and
|
|
3684
|
+
* canvas live, how to configure the plugin, the plugin-level limits (one
|
|
3685
|
+
* generation per model at a time, the cost and quality disclaimers), and the
|
|
3686
|
+
* **order to load the skill** before generating (#665).
|
|
3687
|
+
*
|
|
3688
|
+
* The **tool-level call and result conventions** (confirm the prompt first,
|
|
3689
|
+
* never invent parameters, where defaults come from, what the result fields
|
|
3690
|
+
* mean, showing the picture in the reply body, serialize per model, passing
|
|
3691
|
+
* `reference_images` back unchanged) live in the tool description in
|
|
3692
|
+
* `src/agent-tools.ts`. That is not a cosmetic split: a tool description stays
|
|
3693
|
+
* resident with the tool definition and is **not** governed by
|
|
3694
|
+
* `announceToAgent`, so those sentences only survive with the announcement
|
|
3695
|
+
* switched off if they live there. One fact, one home — a fact stated twice
|
|
3696
|
+
* drifts (#651).
|
|
3697
|
+
*
|
|
3698
|
+
* What stays here and must not move: the **per-model generation limit** and the
|
|
3699
|
+
* cost/quality disclaimer (plugin-level facts), while the "do not retry" rule
|
|
3700
|
+
* itself is stated once, in `generate_image`'s description.
|
|
3701
|
+
*
|
|
3702
|
+
* The **capability boundary** is a plugin-level fact and is stated here once:
|
|
3703
|
+
* this plugin sends the five regular parameters (plus the optional
|
|
3704
|
+
* `reference_images` on the same tool) and nothing else — a model's own extra
|
|
3705
|
+
* gateway fields are not something the plugin can send, and since #661 the
|
|
3706
|
+
* announcement no longer points anywhere for them (there is no route from this
|
|
3707
|
+
* plugin to an extra field, so a pointer would only suggest one). Since #664
|
|
3708
|
+
* there is **one** tool rather than a generator plus an editor: the same call
|
|
3709
|
+
* is an edit when it carries reference images.
|
|
3710
|
+
*/
|
|
3711
|
+
/**
|
|
3712
|
+
* The order to load the bundled skill before using the tool (#665).
|
|
3713
|
+
*
|
|
3714
|
+
* A skill is loaded on demand, so "the details are in the skill" is advice the
|
|
3715
|
+
* model may never take: the announcement has to **order** the load, with the
|
|
3716
|
+
* name spelled out. `generate_image`'s description carries the same order, so
|
|
3717
|
+
* it survives with the announcement switched off.
|
|
3718
|
+
*
|
|
3719
|
+
* Its own constant rather than inlined twice: `SEEWORK_GUIDANCE` is
|
|
3720
|
+
* interpolated into both announcement branches, and one shared string cannot
|
|
3721
|
+
* drift between them.
|
|
3722
|
+
*/
|
|
3723
|
+
const LOAD_SKILL_INSTRUCTION = `用这个工具生图 / 改图之前,先加载技能 \`${SKILL_NAME}\`。`;
|
|
3724
|
+
const SEEWORK_GUIDANCE = "本机已安装 SeeWork 插件(dsh-seework):让 Agent 用用户自己的 SeeAI Hub 额度在对话里直接生图与改图。一个工具 `generate_image`:不带参考图就是文生图,带上可选的 `reference_images` 就是改图。" + LOAD_SKILL_INSTRUCTION + "图片生成是同步的:一次调用就拿到结果,没有任务号可以查询或取消。每张成图自动存进本地素材库(会话右上角的「素材库」入口,没有会话时在右下角浮动按钮;画布同理),不需要另外保存。API 地址与用户 API Key 在「设置 → 插件 → SeeWork」(侧边栏)配置,密钥仅存于本机设置文档,生图请求由本地宿主代理转发,浏览器拿不到密钥。限制:生图消耗 SeeAI Hub 账户额度,余额不足时网关会直接拒绝(响应里的 cost 即本次扣费);一次调用就是一次生成请求,同一模型同时只允许 1 个;插件不排队、不自动重试,失败要如实告知用户并经其许可后再发;`n` 的上限按模型(有的模型一次只能出 1 张);图片内容由上游模型生成,可能不符合预期。插件只发五个常规参数(`model` / `n` / `resolution` / `aspect_ratio` / `output_format`)外加可选的 `reference_images`,模型特有的额外字段插件不发;目录条目列出的是本插件可发参数的字段面,缺键不等于上游一定拒绝。用户提到「生图 / 画图 / 生成图片 / 文生图 / 图生图 / 改图 / 素材库 / 画布」时即指本插件。";
|
|
3725
|
+
/**
|
|
3726
|
+
* Model-facing announcement: plugin presence, the live endpoints, the default
|
|
3727
|
+
* model with **one** capability line, the other saved models' identifiers, where
|
|
3728
|
+
* the library and canvas live, and how to configure the plugin.
|
|
3729
|
+
*
|
|
3730
|
+
* Everything else lives in the on-demand skill (a fixed procedure) or in the
|
|
3731
|
+
* tool description: per-model capabilities, the model-specific extra
|
|
3732
|
+
* gateway fields and pixel `size` are deliberately absent, so the
|
|
3733
|
+
* announcement's length is essentially independent of how many models the user
|
|
3734
|
+
* saved (one more model costs one more identifier). The capability line itself
|
|
3735
|
+
* is rendered from `src/model-summary.ts`, never recomputed here.
|
|
3736
|
+
* **Tool-level call and result conventions are not here either**: they live in
|
|
3737
|
+
* the tool description, which `announceToAgent` does not govern (see design
|
|
3738
|
+
* discipline 13).
|
|
3739
|
+
*
|
|
3740
|
+
* Both branches build on `SEEWORK_GUIDANCE`, so the order to load the skill
|
|
3741
|
+
* (#665) reaches the agent whether or not the connection is configured yet.
|
|
3742
|
+
* The branches add state-specific sentences only — repeating the order here
|
|
3743
|
+
* would be the second home that drifts (#651).
|
|
3744
|
+
*
|
|
3745
|
+
* Which fields a given model accepts is **not** something this plugin carries
|
|
3746
|
+
* any more (#659), and since #661 the announcement does not point at a model's
|
|
3747
|
+
* own guide document either: the plugin states its own boundary — five regular
|
|
3748
|
+
* parameters, nothing else — and adds that a field missing from the catalog
|
|
3749
|
+
* entry is not a promise that upstream would refuse it (#663).
|
|
3750
|
+
*
|
|
3751
|
+
* @param config - the live settings view.
|
|
3752
|
+
* @returns the announcement text for the system prompt.
|
|
3753
|
+
*/
|
|
3754
|
+
function guidanceFor(config) {
|
|
3755
|
+
if (config.apiKey.trim() === "" || config.models.length === 0) return `${SEEWORK_GUIDANCE} 当前尚未配置完成:请在 GUI 里打开「设置 → 插件 → SeeWork」(侧边栏),填写 API 地址(默认 ${DEFAULT_API_URL})与用户 API Key,点「检测可用模型」并保存。`;
|
|
3756
|
+
const { defaultModel, others } = summarizeSavedModels(config);
|
|
3757
|
+
const catalog = catalogUrl(config.serviceUrl);
|
|
3758
|
+
return [
|
|
3759
|
+
SEEWORK_GUIDANCE,
|
|
3760
|
+
`当前网关 ${gatewayUrl(config.apiUrl, "/images/generations")},模型目录 ${catalog}。`,
|
|
3761
|
+
defaultModel === void 0 ? "" : `默认模型:${defaultModel.identifier}(默认)(${defaultModel.capabilityLine})。`,
|
|
3762
|
+
others.length === 0 ? "" : `其他已保存模型:${others.map((summary) => summary.identifier).join(";")}。`,
|
|
3763
|
+
"用户指定模型时用它的名字;未指定时用默认模型,不要自行挑选别的模型。"
|
|
3764
|
+
].filter((part) => part !== "").join("");
|
|
3765
|
+
}
|
|
3766
|
+
/**
|
|
3767
|
+
* Mount the settings section, routes, agent tools, and announcement.
|
|
3768
|
+
* @param ctx - host plugin context carrying webServer/systemPrompt/tools.
|
|
3769
|
+
* @param config - the composition entry (schema defaults + fallback source).
|
|
3770
|
+
* @param makeRefresher - test seam for the automatic-detection refresher.
|
|
3771
|
+
*/
|
|
3772
|
+
function apply(ctx, config, makeRefresher = createCatalogRefresher) {
|
|
3773
|
+
const read = installSettingsSection(ctx, config ?? {}, { onChange: () => {
|
|
3774
|
+
syncAnnouncement();
|
|
3775
|
+
syncSkill();
|
|
3776
|
+
} });
|
|
3777
|
+
/** Resolved view of the current settings. */
|
|
3778
|
+
const resolve = () => {
|
|
3779
|
+
const value = effectiveConfig(read());
|
|
3780
|
+
setLibraryDataRoot(value.dataDir);
|
|
3781
|
+
return value;
|
|
3782
|
+
};
|
|
3783
|
+
/** The shared generation queue, created lazily so settings exist first. */
|
|
3784
|
+
let runtime;
|
|
3785
|
+
const runtimeOf = () => {
|
|
3786
|
+
runtime ??= new GenerationRuntime(() => resolve());
|
|
3787
|
+
return runtime;
|
|
3788
|
+
};
|
|
3789
|
+
let refresher;
|
|
3790
|
+
const refresherOf = () => refresher;
|
|
3791
|
+
/**
|
|
3792
|
+
* Adopt refreshed capabilities into the saved models.
|
|
3793
|
+
*
|
|
3794
|
+
* Read-modify-write rather than a whole-document write: only `models` travels,
|
|
3795
|
+
* so the key, the defaults and `defaultModel` cannot be touched by a background
|
|
3796
|
+
* round. A concurrent write (the user saving the card while a round lands) is
|
|
3797
|
+
* retried once against the fresh document — adoption always re-applies to
|
|
3798
|
+
* whatever is saved at that moment, so re-reading is the correct recovery, not
|
|
3799
|
+
* a workaround.
|
|
3800
|
+
*/
|
|
3801
|
+
const adoptRefreshedModels = async (seam, models) => {
|
|
3802
|
+
const op = {
|
|
3803
|
+
op: "set",
|
|
3804
|
+
path: ["models"],
|
|
3805
|
+
value: models
|
|
3806
|
+
};
|
|
3807
|
+
try {
|
|
3808
|
+
await seam.mutate(SEEWORK_SETTINGS_NAMESPACE, [op]);
|
|
3809
|
+
} catch (error) {
|
|
3810
|
+
if (!(error instanceof SettingsConflictError)) throw error;
|
|
3811
|
+
await seam.mutate(SEEWORK_SETTINGS_NAMESPACE, [op]);
|
|
3812
|
+
}
|
|
3813
|
+
};
|
|
3814
|
+
let disposeSection;
|
|
3815
|
+
const syncAnnouncement = () => {
|
|
3816
|
+
if (disposeSection !== void 0) {
|
|
3817
|
+
disposeSection();
|
|
3818
|
+
disposeSection = void 0;
|
|
3819
|
+
}
|
|
3820
|
+
const value = resolve();
|
|
3821
|
+
if (!value.enabled || !value.announceToAgent) return;
|
|
3822
|
+
disposeSection = ctx.systemPrompt.section({
|
|
3823
|
+
name: "plugin:dsh-seework",
|
|
3824
|
+
order: SECTION_ORDER,
|
|
3825
|
+
text: guidanceFor(value)
|
|
3826
|
+
});
|
|
3827
|
+
};
|
|
3828
|
+
ctx.inject(["settings"], (sctx) => {
|
|
3829
|
+
const presentation = sctx.get("settings");
|
|
3830
|
+
if (typeof presentation?.configure === "function") sctx.effect(() => presentation.configure({ auto: false }, ctx.fiber), "dsh-seework: settings presentation");
|
|
3831
|
+
sctx.effect(() => mountRoutes(sctx, read, runtimeOf, refresherOf, () => probeUpdateHost(sctx)), "dsh-seework: routes");
|
|
3832
|
+
const seam = sctx.get("settings");
|
|
3833
|
+
if (seam !== void 0) refresher = makeRefresher({
|
|
3834
|
+
resolveModels: () => resolve().models,
|
|
3835
|
+
sources: () => {
|
|
3836
|
+
const value = resolve();
|
|
3837
|
+
return {
|
|
3838
|
+
serviceUrl: value.serviceUrl,
|
|
3839
|
+
apiUrl: value.apiUrl,
|
|
3840
|
+
apiKey: value.apiKey
|
|
3841
|
+
};
|
|
3842
|
+
},
|
|
3843
|
+
mutate: (models) => adoptRefreshedModels(seam, models),
|
|
3844
|
+
hasRetiredKeys: () => hasRetiredModelKeys(read().models)
|
|
3845
|
+
});
|
|
3846
|
+
sctx.effect(() => {
|
|
3847
|
+
refresher?.refresh({ automatic: true });
|
|
3848
|
+
const stop = refresher?.startBackground();
|
|
3849
|
+
return () => {
|
|
3850
|
+
stop?.();
|
|
3851
|
+
};
|
|
3852
|
+
}, "dsh-seework: automatic catalog detection");
|
|
3853
|
+
});
|
|
3854
|
+
let skillContext;
|
|
3855
|
+
let disposeSkill;
|
|
3856
|
+
/** Register or unregister the skill so it follows the current `enabled`. */
|
|
3857
|
+
const syncSkill = () => {
|
|
3858
|
+
if (skillContext === void 0) return;
|
|
3859
|
+
if (!resolve().enabled) {
|
|
3860
|
+
disposeSkill?.();
|
|
3861
|
+
disposeSkill = void 0;
|
|
3862
|
+
return;
|
|
3863
|
+
}
|
|
3864
|
+
disposeSkill ??= registerCapabilitiesSkill(skillContext);
|
|
3865
|
+
};
|
|
3866
|
+
ctx.inject(["skills"], (sctx) => {
|
|
3867
|
+
skillContext = sctx;
|
|
3868
|
+
sctx.effect(() => () => {
|
|
3869
|
+
disposeSkill?.();
|
|
3870
|
+
disposeSkill = void 0;
|
|
3871
|
+
skillContext = void 0;
|
|
3872
|
+
}, "dsh-seework: capability skill");
|
|
3873
|
+
syncSkill();
|
|
3874
|
+
});
|
|
3875
|
+
ctx.effect(() => registerAgentImageTools(ctx, runtimeOf(), () => resolve()), "dsh-seework: agent image tools");
|
|
3876
|
+
syncAnnouncement();
|
|
3877
|
+
ctx.effect(() => () => {
|
|
3878
|
+
disposeSection?.();
|
|
3879
|
+
disposeSection = void 0;
|
|
3880
|
+
}, "dsh-seework: announcement teardown");
|
|
3881
|
+
}
|
|
3882
|
+
/**
|
|
3883
|
+
* The host's plugin manager, when this host composes one.
|
|
3884
|
+
*
|
|
3885
|
+
* Probed rather than injected, and structurally rather than by type: the service
|
|
3886
|
+
* is optional, its shape differs between host generations, and an install that
|
|
3887
|
+
* cannot update itself must still load. A missing service costs the two update
|
|
3888
|
+
* routes only — they answer "unavailable" and the card hides the button.
|
|
3889
|
+
*
|
|
3890
|
+
* @param ctx - the context whose `pluginManager` may be attached.
|
|
3891
|
+
* @returns the seam, or undefined when this host has none.
|
|
3892
|
+
*/
|
|
3893
|
+
function probeUpdateHost(ctx) {
|
|
3894
|
+
const service = ctx.get("pluginManager");
|
|
3895
|
+
if (service === null || service === void 0) return void 0;
|
|
3896
|
+
const candidate = service;
|
|
3897
|
+
if (typeof candidate.inspect !== "function" || typeof candidate.installBundle !== "function") return void 0;
|
|
3898
|
+
return candidate;
|
|
3899
|
+
}
|
|
3900
|
+
/**
|
|
3901
|
+
* Register the route family with the host web server.
|
|
3902
|
+
*
|
|
3903
|
+
* @param ctx - the context whose `settings` and `webServer` are attached.
|
|
3904
|
+
* @param read - reads the live settings entry.
|
|
3905
|
+
* @param runtimeOf - the shared generation queue (created on first use).
|
|
3906
|
+
* @param updateHostOf - the host's plugin manager, when it composes one (the
|
|
3907
|
+
* two self-update routes answer "unavailable" otherwise).
|
|
3908
|
+
* @param catalogRefreshOf - the automatic detection refresher, when the host has
|
|
3909
|
+
* a settings provider (the two catalog routes answer "unavailable" otherwise).
|
|
3910
|
+
* @returns disposer removing every route.
|
|
3911
|
+
*/
|
|
3912
|
+
function mountRoutes(ctx, read, runtimeOf, catalogRefreshOf, updateHostOf) {
|
|
3913
|
+
const seam = ctx.get("settings");
|
|
3914
|
+
if (seam === void 0) return () => {};
|
|
3915
|
+
const disposers = makeRoutes({
|
|
3916
|
+
settings: seam,
|
|
3917
|
+
resolve: read,
|
|
3918
|
+
runtime: runtimeOf(),
|
|
3919
|
+
...ctx.attachments === void 0 ? {} : { attachments: ctx.attachments },
|
|
3920
|
+
directoryPicker: () => ctx.get("directoryPicker"),
|
|
3921
|
+
...catalogRefreshOf === void 0 ? {} : { catalogRefresh: catalogRefreshOf },
|
|
3922
|
+
...updateHostOf === void 0 ? {} : { updateHost: updateHostOf }
|
|
3923
|
+
}).map((route) => ctx.webServer.register(route));
|
|
3924
|
+
return () => {
|
|
3925
|
+
for (const dispose of disposers) dispose();
|
|
3926
|
+
};
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
//#endregion
|
|
3930
|
+
export { Config, GenerationRuntime, SEEWORK_GUIDANCE, SEEWORK_SETTINGS_NAMESPACE, SKILL_NAME, SeeWorkError, SeeWorkRuntimeError, appendLibraryEntry, apply, buildGenerationBody, capabilityLineFor, catalogEntryToModel, catalogUrl, clearLibrary, createCapabilitiesSkillProvider, createCatalogRefresher, discoverModels, effectiveConfig, ensureConfigured, gatewayUrl, generateImage, guidanceFor, hasRetiredModelKeys, imageSize, inject, isFinalStatus, libraryDataRoot, listLibrary, looksLikeImageModel, makeRoutes, modelName, mountRoutes, name, openAiEntryToModel, readLibraryImage, registerAgentImageTools, registerCapabilitiesSkill, removeLibraryEntry, resolveModel, setLibraryDataRoot, summarizeSavedModels };
|