@huanlin/dsh-plugin-aigc-canvas 0.1.10 → 0.2.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/lib/index.js CHANGED
@@ -1,24 +1,45 @@
1
- import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { basename, dirname, isAbsolute, join, sep } from "node:path";
4
4
  import { WebSocket, WebSocketServer } from "ws";
5
5
  import z from "@deepseek-ai/schemastery";
6
+ import { existsSync, readFileSync } from "node:fs";
6
7
  import { homedir } from "node:os";
7
- import { readFileSync } from "node:fs";
8
8
  import { defineTool } from "@deepseek-ai/dsh-tools";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawn } from "node:child_process";
11
+ //#region src/provider-shape.ts
12
+ /**
13
+ * Pure provider-id validation shared by the host half (Config / ProviderStore)
14
+ * and the client settings page. Kept free of schemastery imports so the
15
+ * client bundle (tsdown purity gate) can inline it.
16
+ *
17
+ * @module @huanlin/dsh-plugin-aigc-canvas/provider-shape
18
+ */
19
+ /** Provider id pattern: lowercase letters, digits, hyphens; must start with a letter. */
20
+ const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
21
+ /**
22
+ * Validate a provider id.
23
+ * @param id - candidate id (as typed into the settings page or passed to the store).
24
+ * @returns an error message, or undefined when valid.
25
+ */
26
+ function validateProviderId(id) {
27
+ if (id === "") return "provider id is required";
28
+ if (!PROVIDER_ID_PATTERN.test(id)) return `invalid provider id: ${JSON.stringify(id)} (must be lowercase, hyphenated, start with a letter)`;
29
+ }
30
+ //#endregion
11
31
  //#region src/config.ts
12
32
  /**
13
33
  * Serializable configuration and defaults for the AIGC canvas host half.
14
34
  * The `providers` array holds one or more AIGC provider configs (name /
15
- * endpoint / apiKey / instructions), editable at runtime through the DSH
16
- * GUI settings page; cordis.yml `config:` is the first-boot seed only.
35
+ * endpoint / apiKey / instructions). Since dsh 0.1.7-rc.1 the editable
36
+ * fields are `.volatile()` members of the entry's profile-owned Cordis
37
+ * Config (DSH-0.1.7-J1-04): the composition `config:` in cordis.patch.yml is
38
+ * the first-boot seed only, and runtime edits persist per profile under the
39
+ * entry id `dsh-aigc-canvas` through the settings service.
17
40
  *
18
41
  * @module @huanlin/dsh-plugin-aigc-canvas/config
19
42
  */
20
- /** Provider id pattern: lowercase letters, digits, hyphens; must start with a letter. */
21
- const PROVIDER_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
22
43
  /** Schemastery schema for the per-provider auth config. */
23
44
  const ProviderAuthSchema = z.object({
24
45
  scheme: z.union([
@@ -41,7 +62,14 @@ const ProviderSchema = z.object({
41
62
  }),
42
63
  builtin: z.boolean().description("Whether this provider is a builtin seed (cordis.yml).").default(false)
43
64
  });
44
- /** Schemastery schema for the plugin configuration. */
65
+ /**
66
+ * Schemastery schema for the plugin's profile-owned Config.
67
+ *
68
+ * `providers` is `.volatile()` (dsh 0.1.7-rc.1 DSH-0.1.7-J1-04): the settings
69
+ * service enumerates the entry's volatile fields for the configuration form,
70
+ * and a committed edit updates the running reference in place. The numeric
71
+ * knobs stay non-volatile (cordis.patch.yml seed only).
72
+ */
45
73
  const Config = z.object({
46
74
  providers: z.array(ProviderSchema).description("One or more AIGC providers; the first is the default.").default([{
47
75
  id: "stub",
@@ -54,7 +82,7 @@ const Config = z.object({
54
82
  name: ""
55
83
  },
56
84
  builtin: true
57
- }]),
85
+ }]).volatile(),
58
86
  requestTimeoutMs: z.number().step(1).min(1e3).default(3e5),
59
87
  mediaSizeLimit: z.number().step(1).min(1024).default(104857600)
60
88
  });
@@ -62,11 +90,19 @@ const Config = z.object({
62
90
  function isStubEndpoint(endpoint) {
63
91
  return endpoint === "" || endpoint === "stub://aigc-backend";
64
92
  }
65
- /** Validate a provider id; returns an error message or undefined if valid. */
66
- function validateProviderId(id) {
67
- if (id === "") return "provider id is required";
68
- if (!PROVIDER_ID_PATTERN.test(id)) return `invalid provider id: ${JSON.stringify(id)} (must be lowercase, hyphenated, start with a letter)`;
69
- }
93
+ /** The fallback stub provider seeded when no provider is configured at load. */
94
+ const DEFAULT_STUB_PROVIDER = {
95
+ id: "stub",
96
+ name: "",
97
+ endpoint: "stub://aigc-backend",
98
+ apiKey: "",
99
+ instructions: "",
100
+ auth: {
101
+ scheme: "bearer",
102
+ name: ""
103
+ },
104
+ builtin: true
105
+ };
70
106
  /** Migrate + resolve a single provider from config input. */
71
107
  function resolveProvider(p) {
72
108
  const auth = p.auth ?? {};
@@ -83,21 +119,35 @@ function resolveProvider(p) {
83
119
  builtin: p.builtin ?? false
84
120
  };
85
121
  }
86
- /** Apply direct-call defaults after Loader schema validation has normally run. */
122
+ /**
123
+ * Normalize a raw provider list (the volatile reference's latest snapshot, a
124
+ * legacy imported document, or a hand-edited override) into resolved
125
+ * providers. Non-object entries are skipped; duplicate ids keep the first
126
+ * occurrence (insertion order preserved).
127
+ * @param raw - the raw list value.
128
+ * @returns the resolved providers, in order, deduplicated by id.
129
+ */
130
+ function resolveAigcProviders(raw) {
131
+ if (!Array.isArray(raw)) return [];
132
+ const byId = /* @__PURE__ */ new Map();
133
+ for (const item of raw) {
134
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
135
+ const resolved = resolveProvider(item);
136
+ if (!byId.has(resolved.id)) byId.set(resolved.id, resolved);
137
+ }
138
+ return [...byId.values()];
139
+ }
140
+ /**
141
+ * Resolve the apply-time seed config (direct-call defaults after Loader
142
+ * schema validation has normally run). The provider list reads the live
143
+ * volatile reference; when it resolves empty at load, the default stub is
144
+ * seeded so the tools always have one provider.
145
+ * @param config - the entry config the Loader passed to `apply`.
146
+ * @returns the fully defaulted seed settings.
147
+ */
87
148
  function resolveAigcConfig(config) {
88
- const providers = (config?.providers ?? []).map(resolveProvider);
89
- if (providers.length === 0) providers.push({
90
- id: "stub",
91
- name: "",
92
- endpoint: "stub://aigc-backend",
93
- apiKey: "",
94
- instructions: "",
95
- auth: {
96
- scheme: "bearer",
97
- name: ""
98
- },
99
- builtin: true
100
- });
149
+ const providers = [...resolveAigcProviders(config?.providers?.get())];
150
+ if (providers.length === 0) providers.push(DEFAULT_STUB_PROVIDER);
101
151
  return {
102
152
  providers,
103
153
  requestTimeoutMs: config?.requestTimeoutMs ?? 3e5,
@@ -107,184 +157,259 @@ function resolveAigcConfig(config) {
107
157
  //#endregion
108
158
  //#region src/provider-store.ts
109
159
  /**
110
- * In-memory provider store with CRUD + disk persistence. Holds the
111
- * canonical list of AIGC providers; tool registration and the settings-
112
- * page RPC share one instance per plugin fiber. Persisted to
113
- * `~/.dsh/aigc-canvas/providers.json` so restarts keep user-added
114
- * providers and instructions.
160
+ * Mutable provider store. Owns the canonical provider list; tool
161
+ * registration shares one instance per plugin fiber.
115
162
  *
116
- * @module @huanlin/dsh-plugin-aigc-canvas/provider-store
117
- */
118
- /** Directory for persisted AIGC canvas state (under the DSH user dir). */
119
- const DATA_DIR = join(homedir(), ".dsh", "aigc-canvas");
120
- /** Path to the persisted providers JSON. */
121
- const PROVIDERS_JSON = join(DATA_DIR, "providers.json");
122
- /** Atomic write: mkdir + temp file + rename. */
123
- async function writeJsonAtomic$1(path, value) {
124
- const tmp = `${path}.tmp-${process.pid}`;
125
- try {
126
- await mkdir(dirname(path), { recursive: true });
127
- await writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
128
- await rename(tmp, path);
129
- } catch {}
130
- }
131
- /**
132
- * Mutable provider store. Owns the canonical provider list; the backend
133
- * client map and RPC handlers share one instance per plugin fiber.
134
- *
135
- * Persistence: on construction the store loads `~/.dsh/aigc-canvas/
136
- * providers.json` (if present) and merges it over the cordis.yml seed —
137
- * persisted providers win, so user edits and deletions survive restarts.
138
- * Every mutation writes the list back to disk (fire-and-forget).
163
+ * While no persistence face is attached the in-memory map is canonical
164
+ * (headless mode). Once a face is attached, every read resolves through the
165
+ * face's source and every mutation commits through `persist` before its
166
+ * result is reported — the committed profile config is the single source of
167
+ * truth.
139
168
  */
140
169
  var ProviderStore = class {
141
170
  providers = /* @__PURE__ */ new Map();
142
- dataPath;
143
- /** Serializes disk writes so rapid mutations can't interleave. */
144
- persistChain = Promise.resolve();
145
- constructor(seed, dataPath = PROVIDERS_JSON) {
146
- this.dataPath = dataPath;
147
- const seedBuiltin = new Map(seed.map((p) => [p.id, p.builtin ?? false]));
148
- const sources = loadPersistedSync(dataPath) ?? seed;
149
- for (const p of sources) {
150
- const resolved = {
151
- id: p.id,
152
- name: p.name ?? "",
153
- endpoint: p.endpoint ?? "stub://aigc-backend",
154
- apiKey: p.apiKey ?? "",
155
- instructions: p.instructions ?? "",
156
- auth: {
157
- scheme: p.auth?.scheme ?? "bearer",
158
- name: p.auth?.name ?? ""
159
- },
160
- builtin: seedBuiltin.get(p.id) ?? false
161
- };
162
- this.providers.set(resolved.id, resolved);
163
- }
171
+ /** Optional persistence face; absent in headless mode. */
172
+ persistence;
173
+ /** @param seed - resolved seed providers (the composition layer). */
174
+ constructor(seed) {
175
+ for (const provider of seed) this.providers.set(provider.id, provider);
164
176
  }
165
- /** Snapshot of all providers, in insertion order. */
177
+ /**
178
+ * Attach a persistence face. Subsequent reads derive from the face's
179
+ * source and mutations commit through it.
180
+ * @param persistence - the read/write face over the entry's config.
181
+ */
182
+ attachPersistence(persistence) {
183
+ this.persistence = persistence;
184
+ }
185
+ /** The committed provider list (insertion order; duplicates keep the first). */
186
+ committed() {
187
+ if (this.persistence === void 0) return [...this.providers.values()];
188
+ return resolveAigcProviders(this.persistence.source());
189
+ }
190
+ /** Snapshot of all providers, in order. */
166
191
  list() {
167
- return [...this.providers.values()];
192
+ return this.committed();
168
193
  }
169
194
  /** Look up one provider by id. */
170
195
  get(id) {
171
- return this.providers.get(id);
196
+ return this.committed().find((provider) => provider.id === id);
172
197
  }
173
- /** The default provider (first in insertion order); undefined if empty. */
198
+ /** The default provider (first in order); undefined if empty. */
174
199
  defaultProvider() {
175
- return this.providers.values().next().value;
200
+ return this.committed()[0];
176
201
  }
177
202
  /** Add a new provider. Returns failure for duplicate id or invalid shape. */
178
- add(provider) {
203
+ async add(provider) {
179
204
  const idError = validateProviderId(provider.id);
180
205
  if (idError !== void 0) return {
181
206
  ok: false,
182
207
  error: idError
183
208
  };
184
- if (this.providers.has(provider.id)) return {
209
+ const committed = this.committed();
210
+ if (committed.some((p) => p.id === provider.id)) return {
185
211
  ok: false,
186
212
  error: `provider id already exists: ${provider.id}`
187
213
  };
188
214
  const stored = {
189
- id: provider.id,
190
- name: provider.name ?? "",
191
- endpoint: provider.endpoint ?? "stub://aigc-backend",
192
- apiKey: provider.apiKey ?? "",
193
- instructions: provider.instructions ?? "",
194
- auth: {
195
- scheme: provider.auth?.scheme ?? "bearer",
196
- name: provider.auth?.name ?? ""
197
- },
215
+ ...resolveStored(provider),
198
216
  builtin: false
199
217
  };
200
- this.providers.set(stored.id, stored);
201
- this.persist();
202
- return {
203
- ok: true,
204
- providers: this.list()
205
- };
218
+ return this.commit([...committed, stored]);
206
219
  }
207
220
  /** Update an existing provider. Returns failure if the id is unknown. */
208
- update(provider) {
221
+ async update(provider) {
209
222
  const idError = validateProviderId(provider.id);
210
223
  if (idError !== void 0) return {
211
224
  ok: false,
212
225
  error: idError
213
226
  };
214
- const existing = this.providers.get(provider.id);
227
+ const committed = this.committed();
228
+ const existing = committed.find((p) => p.id === provider.id);
215
229
  if (existing === void 0) return {
216
230
  ok: false,
217
231
  error: `provider id not found: ${provider.id}`
218
232
  };
219
233
  const stored = {
220
- id: provider.id,
221
- name: provider.name ?? "",
222
- endpoint: provider.endpoint ?? "stub://aigc-backend",
223
- apiKey: provider.apiKey ?? "",
224
- instructions: provider.instructions ?? "",
234
+ ...resolveStored(provider),
225
235
  auth: {
226
236
  scheme: provider.auth?.scheme ?? existing.auth.scheme,
227
237
  name: provider.auth?.name ?? existing.auth.name
228
238
  },
229
239
  builtin: existing.builtin
230
240
  };
231
- this.providers.set(stored.id, stored);
232
- this.persist();
233
- return {
234
- ok: true,
235
- providers: this.list()
236
- };
241
+ return this.commit(committed.map((p) => p.id === stored.id ? stored : p));
237
242
  }
238
243
  /**
239
244
  * Replace a provider's usage instructions (called by the model's
240
245
  * aigc_provider_set_instructions tool after it probes the API).
241
246
  */
242
- setInstructions(id, instructions) {
243
- const existing = this.providers.get(id);
244
- if (existing === void 0) return {
247
+ async setInstructions(id, instructions) {
248
+ const committed = this.committed();
249
+ if (committed.find((p) => p.id === id) === void 0) return {
245
250
  ok: false,
246
251
  error: `provider id not found: ${id}`
247
252
  };
248
- const stored = {
249
- ...existing,
253
+ return this.commit(committed.map((p) => p.id === id ? {
254
+ ...p,
250
255
  instructions
251
- };
252
- this.providers.set(stored.id, stored);
253
- this.persist();
254
- return {
255
- ok: true,
256
- providers: this.list()
257
- };
256
+ } : p));
258
257
  }
259
258
  /** Remove a provider. Returns failure for unknown id. */
260
- remove(id) {
261
- if (!this.providers.delete(id)) return {
259
+ async remove(id) {
260
+ const committed = this.committed();
261
+ if (!committed.some((p) => p.id === id)) return {
262
262
  ok: false,
263
263
  error: `provider id not found: ${id}`
264
264
  };
265
- this.persist();
266
- return {
267
- ok: true,
268
- providers: this.list()
269
- };
265
+ return this.commit(committed.filter((p) => p.id !== id));
270
266
  }
271
267
  /**
272
- * Persist the current provider list to disk (fire-and-forget, serialized).
273
- * Only the user-editable fields are written; `builtin` is re-derived from
274
- * the seed on load. Failures are swallowed — the in-memory state stays
275
- * canonical. Each call snapshots the CURRENT list, so a burst of mutations
276
- * ends with the latest state on disk.
268
+ * Commit a replacement list: through the persistence face when attached
269
+ * (the post-commit committed value is reported), otherwise into the
270
+ * in-memory map. A refused persistence write leaves the committed state
271
+ * untouched and reports `{ ok: false }`.
277
272
  */
278
- persist() {
279
- const snapshot = [...this.providers.values()].map(({ builtin: _b, ...rest }) => rest);
280
- this.persistChain = this.persistChain.then(() => writeJsonAtomic$1(this.dataPath, snapshot)).catch(() => {});
273
+ async commit(next) {
274
+ if (this.persistence === void 0) {
275
+ this.providers.clear();
276
+ for (const provider of resolveAigcProviders(next)) this.providers.set(provider.id, provider);
277
+ return {
278
+ ok: true,
279
+ providers: this.list()
280
+ };
281
+ }
282
+ try {
283
+ await this.persistence.persist(next);
284
+ return {
285
+ ok: true,
286
+ providers: this.list()
287
+ };
288
+ } catch (error) {
289
+ return {
290
+ ok: false,
291
+ error: error instanceof Error ? error.message : String(error)
292
+ };
293
+ }
281
294
  }
282
295
  };
283
- /** Read the persisted providers JSON; returns null when absent/unreadable. */
284
- function loadPersistedSync(dataPath) {
296
+ /** Normalize one mutation input into its resolved stored shape. */
297
+ function resolveStored(provider) {
298
+ const auth = provider.auth ?? {};
299
+ return {
300
+ id: provider.id,
301
+ name: provider.name ?? "",
302
+ endpoint: provider.endpoint ?? "stub://aigc-backend",
303
+ apiKey: provider.apiKey ?? "",
304
+ instructions: provider.instructions ?? "",
305
+ auth: {
306
+ scheme: auth.scheme ?? "bearer",
307
+ name: auth.name ?? ""
308
+ },
309
+ builtin: provider.builtin ?? false
310
+ };
311
+ }
312
+ //#endregion
313
+ //#region src/settings.ts
314
+ /**
315
+ * settings.ts — host-side bridge between the entry's profile-owned Config
316
+ * and the plugin's provider store, plus the one-time legacy import.
317
+ *
318
+ * dsh 0.1.7-rc.1 (`DSH-0.1.7-J1-04`) removed the namespace-registration API:
319
+ * a plugin declares a Cordis `Config` (see `config.ts`, `providers` marked
320
+ * `.volatile()`) and only declares the presentation policy for its own page.
321
+ * The bridge exposes:
322
+ *
323
+ * - `source()`: the raw committed provider list, read from the live
324
+ * volatile reference on every call, so the tools and RPC surface always
325
+ * serve the latest accepted value (including edits written by other
326
+ * surfaces through the settings service).
327
+ * - `persist(providers)`: commits a replacement list through the settings
328
+ * service, which writes it into the active profile's `cordis.patch.yml`
329
+ * under the entry id `dsh-aigc-canvas` and updates the live reference
330
+ * in place.
331
+ * - `writable`: whether a settings provider is mounted.
332
+ *
333
+ * On first mount the legacy persistence file of pre-0.1.11 versions
334
+ * (`~/.dsh/aigc-canvas/providers.json`) is imported once (the file is
335
+ * renamed before the import, so a partial import never repeats) and left
336
+ * behind as `providers.json.imported`.
337
+ *
338
+ * @module @huanlin/dsh-plugin-aigc-canvas/settings
339
+ */
340
+ /** Profile entry id under which the provider list persists (`cordis.patch.yml`). */
341
+ const SETTINGS_NAMESPACE = "dsh-aigc-canvas";
342
+ /** Default legacy persistence path (pre-0.1.11 custom file). */
343
+ const LEGACY_PROVIDERS_JSON = join(homedir(), ".dsh", "aigc-canvas", "providers.json");
344
+ /**
345
+ * Declare the plugin's settings presentation policy and return the bridge.
346
+ *
347
+ * `auto: false` suppresses the schema-generated page: this plugin ships its
348
+ * own editor as the bundle row's `plugins.row.config` entry on the Plugins
349
+ * page.
350
+ *
351
+ * @param ctx - host context.
352
+ * @param entry - the entry's volatile Cordis config.
353
+ * @param options - optional overrides (legacy import path, attach callback).
354
+ * @param options.legacyPath - path of the legacy providers file to import once.
355
+ * @param options.onReady - invoked when the settings provider is mounted (before the legacy import settles).
356
+ * @returns the bridge the provider store consumes.
357
+ */
358
+ function installAigcSettings(ctx, entry, options = {}) {
359
+ let settings;
360
+ ctx.inject(["settings"], (sctx) => {
361
+ settings = sctx.settings;
362
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
363
+ options.onReady?.();
364
+ importLegacyProviders(options.legacyPath ?? LEGACY_PROVIDERS_JSON, sctx.settings, ctx.logger);
365
+ return () => {
366
+ settings = void 0;
367
+ };
368
+ });
369
+ return {
370
+ source: () => {
371
+ const raw = entry?.providers?.get();
372
+ return Array.isArray(raw) ? raw : [];
373
+ },
374
+ persist: async (providers) => {
375
+ if (settings === void 0) return;
376
+ await settings.update(SETTINGS_NAMESPACE, { providers });
377
+ },
378
+ get writable() {
379
+ return settings !== void 0;
380
+ }
381
+ };
382
+ }
383
+ /**
384
+ * Import the legacy `providers.json` once: rename the file first (so a
385
+ * partial import never repeats), then commit the parsed list into the
386
+ * profile entry config. Failures are logged, never thrown.
387
+ *
388
+ * @param path - the legacy file path.
389
+ * @param settings - the mounted settings service.
390
+ * @param logger - the host logger.
391
+ */
392
+ async function importLegacyProviders(path, settings, logger) {
285
393
  try {
286
- const raw = readFileSync(dataPath, "utf8");
287
- const parsed = JSON.parse(raw);
394
+ if (!existsSync(path)) return;
395
+ const imported = `${path}.imported`;
396
+ await rm(imported, { force: true });
397
+ await rename(path, imported);
398
+ const providers = loadLegacyProviders(imported);
399
+ if (providers === null) return;
400
+ await settings.update(SETTINGS_NAMESPACE, { providers });
401
+ logger.info(`dsh-aigc-canvas: imported ${imported} into the profile entry config`);
402
+ } catch (error) {
403
+ logger.warn("dsh-aigc-canvas: legacy providers.json import failed:", error);
404
+ }
405
+ }
406
+ /**
407
+ * Read a legacy providers JSON document; null when absent or unreadable.
408
+ * Mirrors the pre-0.1.11 reader (defensive per-item coercion).
409
+ */
410
+ function loadLegacyProviders(path) {
411
+ try {
412
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
288
413
  if (!Array.isArray(parsed)) return null;
289
414
  const providers = [];
290
415
  for (const item of parsed) {
@@ -1903,7 +2028,7 @@ function mimeFromExt(filePath) {
1903
2028
  *
1904
2029
  * @param ctx - host plugin context (carries the tools service).
1905
2030
  * @param getProvider - live provider getter (takes optional provider id).
1906
- * @param setInstructions - persists usage instructions for one provider (the host's ProviderStore).
2031
+ * @param setInstructions - persists usage instructions for one provider (the host's ProviderStore; resolves after the profile-config commit).
1907
2032
  * @param listProviders - returns info for all providers (for aigc_get_provider_info).
1908
2033
  * @param canvas - the canvas registry service (host-owned state).
1909
2034
  * @param resolveCwd - live cwd resolver for one session id.
@@ -2238,16 +2363,16 @@ function registerTools(ctx, getProvider, setInstructions, listProviders, canvas,
2238
2363
  },
2239
2364
  render: textRender((v) => `Saved usage instructions for provider "${v.provider_id}".`)
2240
2365
  },
2241
- execute: (args) => {
2366
+ execute: async (args) => {
2242
2367
  if (typeof args.provider_id !== "string" || args.provider_id === "") throw new AigcError("bad-request", "provider_id is required");
2243
2368
  if (typeof args.instructions !== "string" || args.instructions === "") throw new AigcError("bad-request", "instructions is required");
2244
2369
  getProvider(args.provider_id);
2245
- const result = setInstructions(args.provider_id, args.instructions);
2370
+ const result = await setInstructions(args.provider_id, args.instructions);
2246
2371
  if (!result.ok) throw new AigcError("bad-request", result.error ?? "cannot save instructions");
2247
- return Promise.resolve({
2372
+ return {
2248
2373
  ok: true,
2249
2374
  provider_id: args.provider_id
2250
- });
2375
+ };
2251
2376
  }
2252
2377
  }));
2253
2378
  register(defineTool({
@@ -2721,10 +2846,10 @@ async function saveResponseToSession(content, ext, sessionId, cwd) {
2721
2846
  //#region src/index.ts
2722
2847
  /**
2723
2848
  * @huanlin/dsh-plugin-aigc-canvas host half: the canvas registry, the provider
2724
- * store (config + per-provider usage instructions), the fenced
2725
- * `/aigc-canvas/api/*` JSON API (provider CRUD + canvas.list/move) +
2726
- * `/aigc-canvas/file` media route + `/aigc-canvas/ws/canvas` push WebSocket,
2727
- * and the `ctx.aigcCanvas` service.
2849
+ * store (the entry's profile-owned Config + per-provider usage
2850
+ * instructions), the fenced `/aigc-canvas/api/*` JSON API
2851
+ * (canvas.list/move/delete/upload) + `/aigc-canvas/file` media route +
2852
+ * `/aigc-canvas/ws/canvas` push WebSocket, and the `ctx.aigcCanvas` service.
2728
2853
  *
2729
2854
  * Model-facing tools (see tools.ts): aigc_get_provider_info, the generic
2730
2855
  * aigc_http_request (auto-attaches endpoint + apiKey per provider config),
@@ -2732,10 +2857,13 @@ async function saveResponseToSession(content, ext, sessionId, cwd) {
2732
2857
  * probing the API), aigc_canvas_place / aigc_canvas_link / aigc_canvas_unlink
2733
2858
  * (put files on the free canvas), and aigc_canvas_list_elements.
2734
2859
  *
2735
- * Provider config is editable at runtime: the settings page posts to
2736
- * `/aigc-canvas/api/providers.add|update|remove`, which updates the
2737
- * ProviderStore. Tools read the provider through a getter so they always
2738
- * see the latest configuration.
2860
+ * Provider config is profile-owned (dsh 0.1.7-rc.1 DSH-0.1.7-J1-04): the
2861
+ * `providers` field of the entry's Cordis Config is `.volatile()` and
2862
+ * persists per profile in `cordis.patch.yml` under the entry id
2863
+ * `dsh-aigc-canvas`. The settings page reads/writes it through the client
2864
+ * `configForms` transport (DSH-0.1.7-J1-27); the model's instruction tool
2865
+ * writes through the same settings bridge. Tools read the provider through
2866
+ * a getter so they always see the latest committed configuration.
2739
2867
  */
2740
2868
  /** Plugin identity for cordis.yml rows. */
2741
2869
  const name = "dsh-aigc-canvas";
@@ -2764,13 +2892,6 @@ function sessionCwdOf(ctx, sessionId) {
2764
2892
  if (headerCwd !== void 0 && headerCwd !== "") return headerCwd;
2765
2893
  throw new AigcError("not-found", `session "${sessionId}" is not registered or has no cwd yet`, 404);
2766
2894
  }
2767
- /** Convert a resolved config to the runtime global settings wire shape. */
2768
- function toGlobalSettings(resolved) {
2769
- return {
2770
- requestTimeoutMs: resolved.requestTimeoutMs,
2771
- mediaSizeLimit: resolved.mediaSizeLimit
2772
- };
2773
- }
2774
2895
  /**
2775
2896
  * Build a minimal user-role message and inject it into the agent's
2776
2897
  * next-step context (non-waking). Used to notify the model of user-
@@ -2825,7 +2946,7 @@ function kindForExtension(ext) {
2825
2946
  return "prompt";
2826
2947
  }
2827
2948
  /** Build the JSON API method table. */
2828
- function buildApi(ctx, canvas, store, getResolved) {
2949
+ function buildApi(ctx, canvas, getMediaLimit) {
2829
2950
  return {
2830
2951
  "canvas.list": async (payload) => {
2831
2952
  const sessionId = requireString(payload, "sessionId");
@@ -2862,7 +2983,7 @@ function buildApi(ctx, canvas, store, getResolved) {
2862
2983
  const mediaBase64 = typeof record?.mediaBase64 === "string" ? record.mediaBase64 : "";
2863
2984
  if (fileName === "" || mediaBase64 === "") throw new AigcError("bad-request", "fileName and mediaBase64 are required strings");
2864
2985
  const bytes = Buffer.from(mediaBase64, "base64");
2865
- if (bytes.byteLength > getResolved().mediaSizeLimit) throw new AigcError("fs-error", `uploaded file too large (${bytes.byteLength} bytes)`);
2986
+ if (bytes.byteLength > getMediaLimit()) throw new AigcError("fs-error", `uploaded file too large (${bytes.byteLength} bytes)`);
2866
2987
  const cwd = sessionCwdOf(ctx, sessionId);
2867
2988
  const dir = canvasDirFor(cwd, sessionId);
2868
2989
  await mkdir(dir, { recursive: true });
@@ -2887,36 +3008,6 @@ function buildApi(ctx, canvas, store, getResolved) {
2887
3008
  ok: true,
2888
3009
  element: el
2889
3010
  };
2890
- },
2891
- "providers.list": () => {
2892
- return { providers: store.list() };
2893
- },
2894
- "providers.add": (payload) => {
2895
- const provider = payload?.provider;
2896
- if (provider === null || typeof provider !== "object" || Array.isArray(provider)) throw new AigcError("bad-request", "expected { provider: AigcProvider }");
2897
- const result = store.add(provider);
2898
- if (!result.ok) throw new AigcError("bad-request", result.error);
2899
- return { providers: result.providers };
2900
- },
2901
- "providers.update": (payload) => {
2902
- const provider = payload?.provider;
2903
- if (provider === null || typeof provider !== "object" || Array.isArray(provider)) throw new AigcError("bad-request", "expected { provider: AigcProvider }");
2904
- const result = store.update(provider);
2905
- if (!result.ok) throw new AigcError("bad-request", result.error);
2906
- return { providers: result.providers };
2907
- },
2908
- "providers.remove": (payload) => {
2909
- const id = payload?.id;
2910
- if (typeof id !== "string" || id === "") throw new AigcError("bad-request", "expected { id: string }");
2911
- const result = store.remove(id);
2912
- if (!result.ok) throw new AigcError("bad-request", result.error);
2913
- return { providers: result.providers };
2914
- },
2915
- "config.get": () => {
2916
- return {
2917
- ...toGlobalSettings(getResolved()),
2918
- providers: store.list()
2919
- };
2920
3011
  }
2921
3012
  };
2922
3013
  }
@@ -2929,11 +3020,9 @@ function apply(ctx, config) {
2929
3020
  const canvas = createAigcCanvasService((sessionId) => sessionCwdOf(ctx, sessionId), mediaLimit);
2930
3021
  ctx.provide("aigcCanvas", canvas);
2931
3022
  const store = new ProviderStore(resolved.providers);
2932
- const getResolved = () => ({
2933
- providers: store.list(),
2934
- requestTimeoutMs: resolved.requestTimeoutMs,
2935
- mediaSizeLimit: resolved.mediaSizeLimit
2936
- });
3023
+ const bridge = installAigcSettings(ctx, config, { onReady: () => {
3024
+ store.attachPersistence(bridge);
3025
+ } });
2937
3026
  const getProvider = (providerId) => {
2938
3027
  if (providerId !== void 0 && providerId !== "") {
2939
3028
  const provider = store.get(providerId);
@@ -2956,7 +3045,7 @@ function apply(ctx, config) {
2956
3045
  isDefault: p.id === defaultId
2957
3046
  }));
2958
3047
  };
2959
- const api = buildApi(ctx, canvas, store, getResolved);
3048
+ const api = buildApi(ctx, canvas, mediaLimit);
2960
3049
  ctx.effect(() => ctx.webServer.register({
2961
3050
  kind: "prefix",
2962
3051
  path: "/aigc-canvas/api",
@@ -3103,4 +3192,4 @@ async function attachCanvasPush(canvas, ws, req) {
3103
3192
  }
3104
3193
  }
3105
3194
  //#endregion
3106
- export { Config, apply, inject, name };
3195
+ export { Config, SETTINGS_NAMESPACE, apply, inject, name };