@mokuyoaxis/dsh-iris 0.3.0-alpha.1 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/README.en.md +20 -13
  3. package/README.md +20 -13
  4. package/bin/dsh-iris.js +143 -51
  5. package/docs/AGENT_CORE_QUICKSTART.md +2 -2
  6. package/docs/AGENT_QUICKSTART.md +1 -1
  7. package/docs/ARCHITECTURE.md +5 -5
  8. package/docs/ARTIFACT_MANIFEST.md +4 -0
  9. package/docs/AUDIO_FLOWS.md +20 -4
  10. package/docs/BETA_SCOPE.md +42 -0
  11. package/docs/CONTACT_SHEET.md +1 -1
  12. package/docs/CORE_CONNECTIONS.md +3 -1
  13. package/docs/CORE_QUICKSTART.md +7 -5
  14. package/docs/CORE_RUNTIME_CONTRACT.md +3 -3
  15. package/docs/CORE_SERVICE.md +9 -5
  16. package/docs/CORE_SKILLS.md +2 -2
  17. package/docs/DSH_COMPATIBILITY.md +11 -1
  18. package/docs/DSH_CORE_MIGRATION.md +6 -0
  19. package/docs/DSH_RUNTIME.md +2 -2
  20. package/docs/HEADLESS_CLI.md +29 -4
  21. package/docs/HOST_ADAPTER_CONTRACT.md +5 -3
  22. package/docs/IMAGE_FLOW.md +2 -2
  23. package/docs/IMAGE_GENERATION.md +1 -1
  24. package/docs/LOCAL_API.md +2 -2
  25. package/docs/LOCAL_API_CONTRACT.md +17 -15
  26. package/docs/LOCAL_CREATION.md +2 -2
  27. package/docs/LOCAL_OPERATIONS.md +2 -2
  28. package/docs/LOCAL_SELECTION.md +1 -1
  29. package/docs/LOCAL_WEB.md +11 -5
  30. package/docs/MCP.md +30 -9
  31. package/docs/MEDIA_SCHEDULING.md +72 -0
  32. package/docs/NETWORK_RESOURCES.md +106 -0
  33. package/docs/NETWORK_ROUTING.md +103 -0
  34. package/docs/NETWORK_WORKBENCH.md +65 -0
  35. package/docs/PANEL_EXTENSIONS.md +1 -1
  36. package/docs/PLUGINS.md +9 -5
  37. package/docs/PLUGIN_DEVELOPMENT.md +39 -0
  38. package/docs/PLUGIN_STARTUP.md +15 -2
  39. package/docs/PROVIDER_ADAPTER_CONTRACT.md +8 -2
  40. package/docs/PROVIDER_CREDENTIALS.md +67 -0
  41. package/docs/PROVIDER_EXTENSIONS.md +8 -4
  42. package/docs/PROVIDER_HEALTH.md +4 -2
  43. package/docs/PROVIDER_NETWORK.md +45 -0
  44. package/docs/PROVIDER_PLUGIN_CONTRACT.md +24 -0
  45. package/docs/PROVIDER_SETTINGS.md +13 -7
  46. package/docs/PROVIDER_SUBMISSION_CONTRACT.md +3 -1
  47. package/docs/ROADMAP.md +6 -6
  48. package/docs/SECURITY.md +1 -1
  49. package/docs/SYNC_DELIVERY_RECOVERY.md +5 -1
  50. package/docs/VIDEO_FLOWS.md +89 -0
  51. package/docs/VISION_MODEL.md +2 -0
  52. package/docs/WEB_PROTOTYPE.md +3 -3
  53. package/docs/WEB_RECOVERY.md +2 -2
  54. package/docs/WORKBENCH_ARTIFACTS.md +6 -0
  55. package/docs/WORKBENCH_LOGIN.md +2 -2
  56. package/docs/releases/0.3.0-beta.1.md +34 -0
  57. package/docs/screenshots.md +2 -2
  58. package/lib/actions.js +5 -4
  59. package/lib/adapters.js +58 -41
  60. package/lib/api.js +23 -13
  61. package/lib/application-runtime.js +44 -15
  62. package/lib/application-writer.js +193 -0
  63. package/lib/client.d.ts +3 -3
  64. package/lib/client.js +62 -25
  65. package/lib/comfyui-adapter.js +3 -2
  66. package/lib/comfyui-client.js +10 -1
  67. package/lib/command-service.js +18 -5
  68. package/lib/config.js +13 -9
  69. package/lib/configured-image-runtime.js +28 -9
  70. package/lib/connection-skill.js +9 -0
  71. package/lib/core-artifact-store.js +68 -26
  72. package/lib/core-bridge-tools.js +5 -3
  73. package/lib/core-capability-catalog.js +21 -11
  74. package/lib/core-delivery-cache.js +8 -3
  75. package/lib/core-service-client.js +6 -2
  76. package/lib/core-tasks.js +86 -11
  77. package/lib/creation-request-store.js +8 -5
  78. package/lib/doctor.js +2 -2
  79. package/lib/dsh-core-adapter.js +61 -9
  80. package/lib/dsh-provider-config.js +1 -1
  81. package/lib/dsh-service-bridge.js +1 -0
  82. package/lib/flow-store.js +64 -17
  83. package/lib/image-flow.js +39 -17
  84. package/lib/index.js +10 -5
  85. package/lib/local-api-projection.js +6 -2
  86. package/lib/local-api.js +137 -49
  87. package/lib/local-creation.js +12 -5
  88. package/lib/local-operations.js +2 -2
  89. package/lib/local-request-root.js +2 -2
  90. package/lib/local-web-assets.js +2 -0
  91. package/lib/mcp-http.js +82 -9
  92. package/lib/mcp-server.js +23 -1
  93. package/lib/media-scheduler.js +194 -0
  94. package/lib/model-directory-service.js +25 -8
  95. package/lib/music-transport.js +4 -2
  96. package/lib/network-config-service.js +123 -0
  97. package/lib/network-config.js +127 -0
  98. package/lib/network-doctor.js +94 -0
  99. package/lib/network-management.js +51 -0
  100. package/lib/network-policy.js +19 -0
  101. package/lib/network-route-evidence.js +38 -0
  102. package/lib/network-routing.js +283 -0
  103. package/lib/operation-runner.js +83 -38
  104. package/lib/operation-store.js +6 -6
  105. package/lib/plugins.d.ts +25 -6
  106. package/lib/plugins.js +2 -1
  107. package/lib/provider-adapter.js +2 -2
  108. package/lib/provider-adapters.js +37 -5
  109. package/lib/provider-catalog.js +33 -16
  110. package/lib/provider-config-format.js +51 -0
  111. package/lib/provider-config-service.js +52 -22
  112. package/lib/provider-context.d.ts +90 -0
  113. package/lib/provider-context.js +42 -0
  114. package/lib/provider-credentials.js +97 -0
  115. package/lib/provider-diagnostics.js +5 -5
  116. package/lib/provider-health.js +10 -5
  117. package/lib/provider-monitor.js +5 -3
  118. package/lib/provider-network.js +52 -0
  119. package/lib/provider-protocol.js +3 -2
  120. package/lib/provider-registry.js +15 -5
  121. package/lib/provider-response.js +10 -0
  122. package/lib/provider-settings.js +14 -12
  123. package/lib/provider-task-runner.js +113 -37
  124. package/lib/provider-transport.js +16 -0
  125. package/lib/render.js +11 -1
  126. package/lib/scheduling-policy.js +66 -0
  127. package/lib/tasks.js +3 -2
  128. package/lib/video-flow-input.js +16 -0
  129. package/lib/video-input.js +12 -5
  130. package/lib/vision-model-routing.js +2 -0
  131. package/lib/web/app.js +107 -46
  132. package/lib/web/connections.js +151 -12
  133. package/lib/web/creation.js +29 -25
  134. package/lib/web/flow-view.js +4 -3
  135. package/lib/web/gallery.js +17 -16
  136. package/lib/web/index.html +58 -5
  137. package/lib/web/media-client.js +18 -4
  138. package/lib/web/network.js +232 -0
  139. package/lib/web/plugin-panel.js +4 -2
  140. package/lib/web/plugin-status.js +140 -0
  141. package/lib/web/providers.js +34 -13
  142. package/lib/web/recovery.js +2 -2
  143. package/lib/web/styles.css +54 -1
  144. package/lib/web/transport.js +6 -1
  145. package/lib/workbench-artifacts.js +3 -2
  146. package/package.json +5 -2
  147. package/skills/iris-core/references/comfyui.md +1 -1
  148. package/templates/plugin-starter/LICENSE +21 -0
  149. package/templates/plugin-starter/README.md +74 -0
  150. package/templates/plugin-starter/assets/peach.png +0 -0
  151. package/templates/plugin-starter/assets/violet.png +0 -0
  152. package/templates/plugin-starter/index.mjs +27 -0
  153. package/templates/plugin-starter/package.json +18 -0
  154. package/templates/plugin-starter/scripts/core-cli.mjs +20 -0
  155. package/templates/plugin-starter/scripts/environment.mjs +65 -0
  156. package/templates/plugin-starter/scripts/mcp-probe.mjs +22 -0
  157. package/templates/plugin-starter/scripts/setup.mjs +42 -0
  158. package/templates/plugin-starter/scripts/smoke.mjs +76 -0
  159. package/templates/plugin-starter/web/board.css +27 -0
  160. package/templates/plugin-starter/web/board.mjs +160 -0
  161. package/templates/plugin-starter/web/png.mjs +23 -0
  162. package/templates/provider-starter/LICENSE +21 -0
  163. package/templates/provider-starter/README.md +50 -0
  164. package/templates/provider-starter/index.d.mts +5 -0
  165. package/templates/provider-starter/index.mjs +165 -0
  166. package/templates/provider-starter/package.json +13 -0
  167. package/templates/provider-starter/scripts/core-cli.mjs +22 -0
  168. package/templates/provider-starter/scripts/environment.mjs +122 -0
  169. package/templates/provider-starter/scripts/fixture.mjs +54 -0
  170. package/templates/provider-starter/scripts/mcp-probe.mjs +21 -0
  171. package/templates/provider-starter/scripts/protocol-smoke.mjs +48 -0
  172. package/templates/provider-starter/scripts/setup.mjs +44 -0
  173. package/templates/provider-starter/scripts/smoke.mjs +145 -0
  174. package/user_guide.md +3 -3
@@ -0,0 +1,74 @@
1
+ # Iris Plugin Starter · 独立插件模板
2
+
3
+ A real, offline image-board plugin: choose existing Core images, make a PNG contact sheet, recover its original Operation after restart, and optionally expose the business to an explicitly authorized MCP connection. No provider account, GPU, or extra runtime dependency is required.
4
+
5
+ This project has its own package name, plugin ID, plugin version, page and PNG renderer. Its image business delegates to Iris's **public** `@mokuyoaxis/dsh-iris/contact-sheet` export, version `0.1.0`. It is a starting point for independent plugins, not a second copy of Core or a new image-generation protocol.
6
+
7
+ ## Start · 开始使用
8
+
9
+ Requires Node.js 22+ and Core `0.3.0-alpha.1` or a later compatible 0.3 build. Core beta.1 requires Node.js **22.1.0+**. Stable Iris `0.2.7` has no plugin SDK. The template is distributed with Core beta.1; the older alpha.1 package does **not** contain this directory.
10
+
11
+ Copy `templates/plugin-starter` from a current Iris checkout into a **new** directory outside that checkout. From the installed beta.1 package, copy `node_modules/@mokuyoaxis/dsh-iris/templates/plugin-starter` instead. The copied project can use the public SDK already present in alpha.1:
12
+
13
+ ```bash
14
+ cd /absolute/my-iris-plugin
15
+ npm install --save-dev --save-exact @mokuyoaxis/dsh-iris@0.3.0-beta.1
16
+ npm test
17
+ npm run setup -- --directory /absolute/new-private-demo
18
+ ```
19
+
20
+ `setup` requires an existing parent and a destination that does not exist, outside the plugin project. It creates a private demo directory, initializes a fresh execution root, imports two plain-colour sample PNGs, and writes absolute plugin-entry paths to `plugins.json`. Existing directories are rejected; an interrupted setup is retained for inspection, not silently retried. It prints paths and the exact startup argv, without starting a server or installing anything.
21
+
22
+ Start the workbench in the foreground from this installed project:
23
+
24
+ ```bash
25
+ npm exec -- dsh-iris plugins inspect --plugin-config /absolute/new-private-demo/plugins.json
26
+ npm exec -- dsh-iris web serve --data-root /absolute/new-private-demo/core \
27
+ --plugin-config /absolute/new-private-demo/plugins.json --port 3182 \
28
+ --connection-store /absolute/new-private-demo/connections \
29
+ --allow-connection-management --allow-mcp
30
+ ```
31
+
32
+ Use the printed login URL to set your workbench password. Open **灵感对照板**, select pictures, then click **制作对照板**. This business does not need `--allow-execution`; media creation and plugin operations have separate gates. Change the port explicitly if occupied. Keep the service running while a harness uses MCP.
33
+
34
+ ## MCP · 用户明确选择
35
+
36
+ Loading a package, enabling a business, and authorizing a connection are separate steps. In **连接**, create a connection, deselect unused media groups, choose `example.starter-board.create`, and grant `read` + `execute` (`stop` is optional). Export the transport and configuration format accepted by your harness, then import that configuration there. Iris does not install or scan harnesses.
37
+
38
+ After connecting, inspect `iris_core_plugin_catalog` and use its actual `dependency` pin. For this template the business input is:
39
+
40
+ ```json
41
+ {
42
+ "artifactIds": ["artifact_<24 lowercase hex characters>"],
43
+ "columns": 2,
44
+ "cellSize": 256
45
+ }
46
+ ```
47
+
48
+ Use real image IDs from the catalog, 1–16 images in desired order, 1–4 columns and a cell size of 128–512. Call `iris_core_plugin_call` with `{ key, operationId, dependency, input }`; generate a UUID once before submitting. Reuse that UUID only with identical input. After an uncertain response, use `iris_core_lookup` first. `iris_core_operation` reads the saved state; `iris_core_image` delivers the saved PNG. The test protocol driver under `scripts/` is only an acceptance probe, not an MCP client implementation for production harnesses.
49
+
50
+ ## Develop · 修改与生命周期
51
+
52
+ | File | Responsibility |
53
+ |---|---|
54
+ | `index.mjs` | Independent identity, compatibility, declared Core commands, operation/page/renderer versions and activation |
55
+ | `web/board.mjs` | Shared session/API/theme, explicit submit, saved UUID lookup, listeners/timers/blob URL cleanup |
56
+ | `web/png.mjs` | Reads original artifact bytes through the provided API and releases its image URL on unmount |
57
+ | `scripts/setup.mjs` | New-directory-only initialization through the public CLI |
58
+ | `scripts/smoke.mjs` | Real Core/MCP acceptance using fresh, private temporary data |
59
+
60
+ Rename the package and plugin ID before sharing your own plugin. Update the IDs in `scripts/setup.mjs` and the acceptance expectations too. The page derives its operation ID from its own plugin identity; it does not hardcode a Core installation path or request an owner token. Declare every browser entry and CSS resource in the manifest. Files outside those declarations are not automatically served; keep browser modules self-contained unless a future public asset contract says otherwise.
61
+
62
+ The delegated operation declares only `artifact.read` and `artifact.import`, with `artifactIds` declared as source inputs. Core validates closed JSON input, source access, current authorization, durable execution and output registration. A direct `createCorePluginHost().call()` is not a durable Operation; use the workbench/API/MCP business route when persistence is required. The template owns no backend resources. If you add sockets, watchers or similar resources, register cleanup using `context.onDispose()` during `activate`, and make work observe its AbortSignal. Close workbenches/hosts before disposing their shared registry.
63
+
64
+ Core saves the exact plugin and contribution versions with each Operation. Treat changed business semantics as a plugin/operation version change. Upgrading the underlying contact-sheet implementation requires review; this template deliberately refuses a version other than `0.1.0`. An incompatible/missing plugin blocks new execution and cannot substitute for a saved version. History and committed image bytes remain Core-owned. Interrupted work is retained as interrupted; it is not automatically submitted again. Editing the plugin requires a normal service stop and restart; there is no hot reload.
65
+
66
+ These are trusted Node and same-origin browser modules. Shadow DOM isolates styles, not code. Load code you trust. The small Core command interface constrains business calls but does not sandbox arbitrary Node imports. No arbitrary plugin settings storage or credential-reference API is invented here.
67
+
68
+ ## Acceptance · 自检范围
69
+
70
+ `npm test` creates a separate private temporary root and closes its own services. It verifies real PNG execution, explicit selection/authorization, duplicate UUID conflict, same-port restart, original bytes, dependency version change, plugin absence and reduced permissions. It prints a safe evidence path and PNG hash; temporary evidence is retained. It does not touch daily DSH/harness configuration or call a paid model. SDK-created test sessions use temporary owner tokens; ordinary CLI workbenches use password login.
71
+
72
+ The Iris repository additionally verifies packed Core and plugin archives outside the checkout and tests this page/renderer in a real browser. This does not establish compatibility with every harness, operating system or future plugin. See the public [plugin SDK](https://github.com/mokuyoaxis/dsh-iris/blob/main/docs/PLUGINS.md) and [startup guide](https://github.com/mokuyoaxis/dsh-iris/blob/main/docs/PLUGIN_STARTUP.md) for the full contracts.
73
+
74
+ To distribute your own package, review its identity/version/compatibility, replace this README, set your own repository metadata, and remove `private: true` deliberately when ready. Run `npm pack --ignore-scripts --dry-run --json`, inspect the allowlisted files, then test the real archive from another project before publishing. Keep the MIT attribution when reusing these files. Never include private demo roots or connection credentials in your package.
@@ -0,0 +1,27 @@
1
+ import { definePlugin } from '@mokuyoaxis/dsh-iris/plugins';
2
+ import contactSheet from '@mokuyoaxis/dsh-iris/contact-sheet';
3
+
4
+ // Review the delegated business before changing persistent operation semantics.
5
+ // This version is independent of both the npm package and Core's version.
6
+ if (contactSheet.manifest.version !== '0.1.0') {
7
+ throw new Error('Starter expects contact-sheet 0.1.0; review its changes and this plugin version before upgrading.');
8
+ }
9
+
10
+ export default definePlugin({
11
+ manifest: {
12
+ contractVersion: 0,
13
+ id: 'example.starter-board',
14
+ version: '0.1.0',
15
+ compatibility: { min: '0.3.0-alpha.1', maxExclusive: '0.4.0' },
16
+ contributions: {
17
+ operations: contactSheet.manifest.contributions.operations,
18
+ pages: [{ id: 'board', version: '0.1.0', title: '灵感对照板', entry: 'web/board.mjs', styles: ['web/board.css'] }],
19
+ renderers: [{ id: 'png', version: '0.1.0', title: '对照板预览', entry: 'web/png.mjs', styles: ['web/board.css'], mediaTypes: ['image/png'] }]
20
+ }
21
+ },
22
+ activate(context) {
23
+ // Core owns execution, permissions and artifact storage. No new queue/root.
24
+ // If you acquire resources here, register real cleanup with context.onDispose.
25
+ return contactSheet.activate(context);
26
+ }
27
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "iris-plugin-starter-board",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "An independent Iris contact-sheet plugin using only the public SDK.",
6
+ "type": "module",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs", "web", "assets", "scripts", "README.md", "LICENSE"],
9
+ "scripts": {
10
+ "setup": "node scripts/setup.mjs",
11
+ "test": "node scripts/smoke.mjs"
12
+ },
13
+ "peerDependencies": {
14
+ "@mokuyoaxis/dsh-iris": ">=0.3.0-alpha.1 <0.4.0"
15
+ },
16
+ "engines": { "node": ">=22.0.0" },
17
+ "license": "MIT"
18
+ }
@@ -0,0 +1,20 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const packageFile = fileURLToPath(import.meta.resolve('@mokuyoaxis/dsh-iris/package.json'));
8
+ const metadata = JSON.parse(await readFile(packageFile, 'utf8'));
9
+ export const coreCli = path.resolve(path.dirname(packageFile), metadata.bin['dsh-iris']);
10
+ const execute = promisify(execFile);
11
+
12
+ export async function runCore(dataRoot, ...args) {
13
+ const { stdout } = await execute(process.execPath, [coreCli, ...args, '--data-root', dataRoot], {
14
+ timeout: 30_000, maxBuffer: 2 * 1024 * 1024,
15
+ env: { ...process.env, IRIS_IMPORT_WORKBENCH_CONFIG: '', IRIS_CORE_CONNECTION_FILE: '', IRIS_CORE_SCOPES: '' }
16
+ });
17
+ const result = JSON.parse(stdout);
18
+ if (result.ok === false) throw new Error('Core command failed: ' + JSON.stringify(result));
19
+ return result.result ?? result;
20
+ }
@@ -0,0 +1,65 @@
1
+ // Acceptance helper: installed public SDK + CLI + HTTP. Not plugin runtime code.
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, chmod, readFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { loadCorePluginRegistry, createCorePluginRegistry, createCorePluginWorkbench, definePlugin } from '@mokuyoaxis/dsh-iris/plugins';
8
+ import plugin from '../index.mjs';
9
+ import { setup } from './setup.mjs';
10
+
11
+ export const businessId = plugin.manifest.id + '.create';
12
+ export async function createEnvironment() {
13
+ const work = await mkdtemp(path.join(os.tmpdir(), 'iris-starter-')); await chmod(work, 0o700);
14
+ const prepared = await setup(path.join(work, 'demo'));
15
+ const config = JSON.parse(await readFile(prepared.configPath, 'utf8'));
16
+ let api, registry, started, port;
17
+ async function start({ present = true, enabled = true, version } = {}) {
18
+ if (version) registry = await createCorePluginRegistry({ plugins: [definePlugin({
19
+ manifest: { ...plugin.manifest, version }, activate: context => plugin.activate(context)
20
+ })] });
21
+ else registry = await loadCorePluginRegistry({ entries: present ? [prepared.entry] : [] });
22
+ api = createCorePluginWorkbench({ dataRoot: prepared.dataRoot, registry,
23
+ allowedOperations: present && enabled ? [businessId] : [], connectionStore: prepared.connectionStore, allowMcp: true,
24
+ ...(present && !version ? { panelContributions: config.panel } : {}) });
25
+ started = await api.start(port ? { port } : {}); port ??= Number(new URL(started.origin).port);
26
+ return started;
27
+ }
28
+ async function close() { await api?.dispose(); api = undefined; await registry?.dispose(); registry = undefined; }
29
+ async function raw(route, { value, key, headers, method = value === undefined ? 'GET' : 'POST' } = {}) {
30
+ return fetch(started.origin + route, { method, headers: {
31
+ Authorization: 'Bearer ' + started.accessToken, ...(value === undefined ? {} : { 'Content-Type': 'application/json' }),
32
+ ...(key ? { 'Idempotency-Key': key } : {}), ...headers
33
+ }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(20_000) });
34
+ }
35
+ async function http(route, options, expected = 200) {
36
+ const response = await raw(route, options), value = await response.json();
37
+ assert.equal(response.status, expected, JSON.stringify(value)); return value;
38
+ }
39
+ async function terminal(id) {
40
+ for (let i = 0; i < 300; i++) {
41
+ const operation = (await http('/api/v0/operations/' + id)).operation;
42
+ if (!['queued', 'running'].includes(operation.state)) return operation;
43
+ await new Promise(resolve => setTimeout(resolve, 20));
44
+ }
45
+ assert.fail('Operation did not finish');
46
+ }
47
+ const pin = () => registry.dependency('operations', businessId);
48
+ const request = () => ({ key: crypto.randomUUID(), operationId: businessId, dependency: pin(),
49
+ input: { artifactIds: prepared.artifactIds, columns: 2, cellSize: 128 } });
50
+ const submit = ({ key, ...value }, expected = 202) => http('/api/v0/plugin-operations', { key, value }, expected);
51
+ const base = '/api/v0/connection-management/connections';
52
+ async function connection(pins = [pin()]) {
53
+ const result = await http(base, { value: { key: crypto.randomUUID(), label: 'Independent starter test',
54
+ scopes: ['read', 'execute', 'stop'], capabilities: [], pluginOperations: pins } }, 201);
55
+ return (await http(base)).connections.find(item => item.connectionId === result.connectionId);
56
+ }
57
+ const exported = record => http(base + '/' + record.connectionId + '/export', { value: { transport: 'http', format: 'entry' } });
58
+ const update = (record, scopes) => http(base + '/' + record.connectionId + '/update', { value: {
59
+ expectedRevision: record.revision, label: record.label, scopes, capabilities: record.capabilities,
60
+ tools: record.tools, pluginOperations: record.pluginOperations
61
+ } });
62
+ return { work, prepared, config, start, close, raw, http, terminal, pin, request, submit, connection, exported, update,
63
+ get registry() { return registry; }, get started() { return started; },
64
+ templateRoot: fileURLToPath(new URL('..', import.meta.url)) };
65
+ }
@@ -0,0 +1,22 @@
1
+ // Test-only protocol driver. Iris itself uses the official MCP server SDK.
2
+ // Harnesses should import exported MCP configuration, not copy this driver.
3
+ export async function mcpProbe(configuration) {
4
+ let sequence = 0;
5
+ async function request(method, params, notification = false) {
6
+ const id = notification ? undefined : ++sequence;
7
+ const response = await fetch(configuration.url, { method: 'POST', headers: {
8
+ ...configuration.headers, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream',
9
+ 'MCP-Protocol-Version': '2025-11-25'
10
+ }, body: JSON.stringify({ jsonrpc: '2.0', ...(notification ? {} : { id }), method, params }), signal: AbortSignal.timeout(20_000) });
11
+ const text = await response.text();
12
+ if (notification && response.status === 202) return;
13
+ const messages = response.headers.get('content-type')?.startsWith('text/event-stream')
14
+ ? text.split('\n').filter(line => line.startsWith('data:')).map(line => JSON.parse(line.slice(5))) : [JSON.parse(text)];
15
+ const message = messages.find(item => item.id === id) || messages.at(-1);
16
+ if (!response.ok || message.error) throw new Error(message.error?.message || 'MCP HTTP ' + response.status);
17
+ return message.result;
18
+ }
19
+ await request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'iris-plugin-starter-test', version: '0.1.0' } });
20
+ await request('notifications/initialized', {}, true);
21
+ return { request, call: (name, args) => request('tools/call', { name, arguments: args }) };
22
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import { coreCli, runCore } from './core-cli.mjs';
5
+
6
+ export async function setup(directory) {
7
+ if (!path.isAbsolute(directory)) throw new Error('--directory must be an absolute path to a new directory.');
8
+ const projectRoot = fileURLToPath(new URL('..', import.meta.url)), relative = path.relative(projectRoot, directory);
9
+ if (relative === '' || !relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative)) {
10
+ throw new Error('Demo data must be outside the plugin project so it cannot enter its npm package.');
11
+ }
12
+ // EEXIST deliberately rejects existing user data, including empty directories.
13
+ await mkdir(directory, { mode: 0o700 });
14
+ const dataRoot = path.join(directory, 'core'), connectionStore = path.join(directory, 'connections');
15
+ const entry = fileURLToPath(new URL('../index.mjs', import.meta.url));
16
+ const config = {
17
+ schemaVersion: 0, entries: [entry], operations: ['example.starter-board.create'],
18
+ panel: { pages: ['example.starter-board.board'], renderers: ['example.starter-board.png'] }
19
+ };
20
+ const configPath = path.join(directory, 'plugins.json');
21
+ await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
22
+ await runCore(dataRoot, 'flow', 'init');
23
+ await runCore(dataRoot, 'execution', 'init');
24
+ const artifactIds = [];
25
+ for (const name of ['violet.png', 'peach.png']) {
26
+ const imagePath = fileURLToPath(new URL('../assets/' + name, import.meta.url));
27
+ const result = await runCore(dataRoot, 'artifact', 'import', '--input', JSON.stringify({ image_path: imagePath }));
28
+ artifactIds.push(result.artifact.id);
29
+ }
30
+ return { directory, dataRoot, connectionStore, configPath, entry, artifactIds };
31
+ }
32
+
33
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
34
+ if (process.argv.length !== 4 || process.argv[2] !== '--directory') {
35
+ throw new Error('Usage: npm run setup -- --directory /absolute/new-private-directory');
36
+ }
37
+ const result = await setup(process.argv[3]);
38
+ // Exact argv avoids shell quoting assumptions; no server or browser is started.
39
+ console.log(JSON.stringify({ ...result, startArgv: [process.execPath, coreCli, 'web', 'serve',
40
+ '--data-root', result.dataRoot, '--plugin-config', result.configPath,
41
+ '--connection-store', result.connectionStore, '--allow-connection-management', '--allow-mcp', '--port', '3182'] }, null, 2));
42
+ }
@@ -0,0 +1,76 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { createEnvironment, businessId } from './environment.mjs';
5
+ import { setup } from './setup.mjs';
6
+ import { mcpProbe } from './mcp-probe.mjs';
7
+
8
+ const env = await createEnvironment(), checks = [];
9
+ const parsed = result => { assert(!result.isError, JSON.stringify(result)); return result.structuredContent ?? JSON.parse(result.content.find(item => item.type === 'text').text); };
10
+ try {
11
+ const configBefore = await readFile(env.prepared.configPath);
12
+ await assert.rejects(setup(env.prepared.directory), { code: 'EEXIST' });
13
+ await assert.rejects(setup(env.templateRoot + '/private-demo'), /outside the plugin project/);
14
+ assert.deepEqual(await readFile(env.prepared.configPath), configBefore);
15
+ checks.push('setup rejects an existing directory without replacing its configuration');
16
+ await env.start({ enabled: false });
17
+ assert.equal((await env.http('/api/v0/connection-management/connections')).connections.length, 0);
18
+ const call = env.request(); await env.submit(call, 403);
19
+ checks.push('loading the package grants no connection access; a disabled business cannot execute');
20
+ await env.close(); await env.start();
21
+ const session = await env.http('/api/v0/session');
22
+ assert.equal(session.workbenchMediaReadOnly, true);
23
+ assert.equal(session.panelContributions.pages[0].dependency.pluginId, 'example.starter-board');
24
+ assert.equal(session.panelContributions.renderers[0].id, 'example.starter-board.png');
25
+ for (const item of [...session.panelContributions.pages, ...session.panelContributions.renderers]) {
26
+ for (const route of [item.entry, ...item.styles]) assert.equal((await fetch(env.started.origin + route)).status, 200);
27
+ }
28
+ checks.push('the independent page and PNG renderer are actually served from selected, pinned package assets');
29
+ const unselected = await env.connection([]);
30
+ const without = await mcpProbe((await env.exported(unselected)).configuration);
31
+ assert(!(await without.request('tools/list', {})).tools.some(item => item.name === 'iris_core_plugin_call'));
32
+ const record = await env.connection(), config = (await env.exported(record)).configuration;
33
+ let client = await mcpProbe(config);
34
+ const names = (await client.request('tools/list', {})).tools.map(item => item.name);
35
+ assert(names.includes('iris_core_plugin_call')); assert(!names.includes('iris_core_continue'));
36
+ const catalog = parsed(await client.call('iris_core_plugin_catalog', {}));
37
+ assert.equal(catalog.operations[0].id, businessId);
38
+ const accepted = parsed(await client.call('iris_core_plugin_call', call));
39
+ const operation = await env.terminal(accepted.operation.id); assert.equal(operation.state, 'succeeded', JSON.stringify(operation));
40
+ assert.deepEqual(operation.sourceArtifactIds, env.prepared.artifactIds);
41
+ const outputId = operation.result.artifactIds[0];
42
+ const bytes = Buffer.from((await client.call('iris_core_image', { artifactId: outputId })).content.find(item => item.type === 'image').data, 'base64');
43
+ assert.deepEqual([...bytes.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
44
+ assert.equal(bytes.readUInt32BE(16), 292); assert.equal(bytes.readUInt32BE(20), 152);
45
+ const digest = createHash('sha256').update(bytes).digest('hex');
46
+ checks.push('a user-selected, business-only connection executes the real PNG operation through formal HTTP MCP');
47
+ assert.equal(parsed(await client.call('iris_core_plugin_call', call)).operation.id, operation.id);
48
+ await env.submit({ ...call, input: { ...call.input, columns: 1 } }, 409);
49
+ checks.push('same UUID returns the saved Operation; changed input under that UUID is rejected');
50
+ await env.close(); await env.start();
51
+ assert.deepEqual((await env.exported(record)).configuration, config);
52
+ client = await mcpProbe(config);
53
+ assert.deepEqual(parsed(await client.call('iris_core_operation', { operationId: operation.id })).operation, operation);
54
+ assert.equal(parsed(await client.call('iris_core_lookup', { key: call.key })).operation.id, operation.id);
55
+ assert.equal((await env.http('/api/v0/plugin-operations/catalog')).artifacts.length, 3);
56
+ const restored = Buffer.from((await client.call('iris_core_image', { artifactId: outputId })).content.find(item => item.type === 'image').data, 'base64');
57
+ assert.deepEqual(restored, bytes);
58
+ checks.push('restart preserves the original connection, UUID, exact Operation and PNG bytes without another business execution');
59
+ await env.update(record, ['read']);
60
+ client = await mcpProbe(config);
61
+ assert(!(await client.request('tools/list', {})).tools.some(item => item.name === 'iris_core_plugin_call'));
62
+ assert.equal(parsed(await client.call('iris_core_operation', { operationId: operation.id })).operation.state, 'succeeded');
63
+ await assert.rejects(client.call('iris_core_plugin_call', { ...call, key: crypto.randomUUID() }), /Tool iris_core_plugin_call not found/);
64
+ checks.push('reduced authority removes execution tools and rejects explicit calls while retaining granted history reads');
65
+ await env.close(); await env.start({ version: '0.1.1' });
66
+ await env.submit({ ...call, key: crypto.randomUUID() }, 409);
67
+ assert.deepEqual((await env.http('/api/v0/operations/' + operation.id)).operation, operation);
68
+ checks.push('a different installed version cannot replace a saved exact plugin dependency');
69
+ await env.close(); await env.start({ present: false });
70
+ assert.deepEqual((await env.http('/api/v0/operations/' + operation.id)).operation, operation);
71
+ assert.deepEqual((await env.http('/api/v0/plugin-operations/catalog')).operations, []);
72
+ assert.deepEqual(Buffer.from(await (await env.raw('/api/v0/artifacts/' + outputId + '/content')).arrayBuffer()), bytes);
73
+ await env.submit({ ...call, key: crypto.randomUUID() }, 403);
74
+ checks.push('missing implementation blocks new execution but keeps Core history and original artifact readable');
75
+ console.log(JSON.stringify({ ok: true, checks, work: env.work, operationId: operation.id, artifactId: outputId, sha256: digest }, null, 2));
76
+ } finally { await env.close(); }
@@ -0,0 +1,27 @@
1
+ .starter-intro { margin-bottom:24px; }
2
+ .starter-eyebrow { color:var(--accent); font-size:11px; letter-spacing:1.5px; }
3
+ .starter-intro h2 { font-size:clamp(21px,3vw,28px); letter-spacing:-.6px; margin:8px 0; }
4
+ .starter-intro::after { content:""; display:block; height:3px; width:80px; border-radius:9px; margin-top:20px; background:linear-gradient(105deg,#45317D,#6F69B8,#BB78AD,#E59B76,#ED9C91,#F0D09C); }
5
+ .muted { color:var(--muted); font-size:13px; line-height:1.6; }
6
+ .starter-layout { display:grid; grid-template-columns:minmax(0,1fr) 220px; gap:20px; align-items:start; }
7
+ .starter-choices { display:grid; grid-template-columns:repeat(auto-fill,minmax(110px,1fr)); gap:12px; min-width:0; }
8
+ .starter-choice { position:relative; padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--soft); cursor:pointer; }
9
+ .starter-choice:has(input:checked) { border-color:var(--accent); background:var(--tint); }
10
+ .starter-choice input { position:absolute; top:10px; left:10px; }
11
+ .starter-choice img { display:block; width:100%; height:120px; object-fit:contain; border-radius:6px; }
12
+ .starter-choice span { display:block; text-align:center; margin-top:8px; overflow-wrap:anywhere; }
13
+ .starter-editor { padding:18px; border:1px solid var(--line); border-radius:14px; background:var(--soft); }
14
+ .starter-editor label { display:block; font-size:13px; margin-bottom:14px; }
15
+ .starter-editor select { display:block; width:100%; margin-top:6px; min-height:40px; padding:6px; border:1px solid var(--line); border-radius:8px; color:var(--ink); background:var(--panel); }
16
+ .starter-editor button { display:block; width:100%; min-height:42px; margin:10px 0; border:0; border-radius:8px; padding:9px; background:var(--accent); color:var(--on-accent); cursor:pointer; }
17
+ .starter-editor button:disabled { opacity:.5; cursor:default; }
18
+ .starter-editor .secondary { border:1px solid var(--line); background:transparent; color:var(--ink); }
19
+ .starter-editor details { margin-top:18px; font-size:13px; }
20
+ .starter-editor summary { cursor:pointer; }
21
+ .starter-status { font-size:13px; line-height:1.8; overflow-wrap:anywhere; margin:20px 0; }
22
+ .starter-result { border-top:1px solid var(--line); padding-top:20px; }
23
+ .starter-result img, .starter-preview img { display:block; max-width:100%; max-height:600px; object-fit:contain; margin:0 auto 16px; }
24
+ .starter-result a { display:inline-block; padding:10px 14px; border:1px solid var(--line); border-radius:8px; color:var(--accent); }
25
+ :focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
26
+ [hidden] { display:none !important; }
27
+ @media(max-width:680px) { .starter-layout { grid-template-columns:1fr; } .starter-editor { order:-1; } }
@@ -0,0 +1,160 @@
1
+ const element = (tag, text, className) => {
2
+ const node = document.createElement(tag);
3
+ if (text) node.textContent = text;
4
+ if (className) node.className = className;
5
+ return node;
6
+ };
7
+
8
+ export async function mount({ container, api, session, dependency, signal: parentSignal, theme }) {
9
+ const lifetime = new AbortController(), signal = AbortSignal.any([parentSignal, lifetime.signal]);
10
+ const service = api.pluginOperations, urls = new Set();
11
+ const operationId = dependency.pluginId + '.create';
12
+ const storageKey = operationId + ':' + session.rootId;
13
+ let receipt, selected = [], catalog, busy = false, timer, renderedId, unsubscribe, closed = false;
14
+ function cleanup() {
15
+ if (closed) return;
16
+ closed = true; lifetime.abort(); clearTimeout(timer); unsubscribe?.();
17
+ for (const url of urls) URL.revokeObjectURL(url);
18
+ urls.clear(); container.replaceChildren();
19
+ }
20
+ signal.addEventListener('abort', cleanup, { once: true });
21
+ const intro = element('header', '', 'starter-intro');
22
+ intro.append(element('span', 'LOCAL · NO MODEL CALLS', 'starter-eyebrow'), element('h2', '让灵感,并排相遇。'),
23
+ element('p', '选择图片,制作一张属于你的灵感对照板。', 'muted'));
24
+ const layout = element('div', '', 'starter-layout'), choices = element('div', '', 'starter-choices');
25
+ choices.setAttribute('aria-label', '选择来源图片');
26
+ const editor = element('form', '', 'starter-editor');
27
+ const columnsLabel = element('label', '每行图片数'), columns = document.createElement('select'); columns.name = 'columns';
28
+ for (let i = 1; i <= 4; i++) columns.add(new Option(String(i), String(i)));
29
+ columns.value = '2'; columnsLabel.append(columns);
30
+ const sizeLabel = element('label', '单张边长'), size = document.createElement('select'); size.name = 'size';
31
+ for (const n of [128, 256, 512]) size.add(new Option(n + ' px', String(n)));
32
+ size.value = '256'; sizeLabel.append(size);
33
+ const count = element('p', '尚未选择图片', 'muted'), submit = element('button', '制作对照板');
34
+ submit.type = 'submit'; submit.disabled = true;
35
+ const recover = element('button', '查询原操作', 'secondary'); recover.type = 'button'; recover.hidden = true;
36
+ const fresh = element('button', '新建一张', 'secondary'); fresh.type = 'button'; fresh.hidden = true;
37
+ const more = element('button', '加载更多图片', 'secondary'); more.type = 'button'; more.hidden = true;
38
+ const details = element('details'), summary = element('summary', '使用说明');
39
+ details.append(summary, element('p', '按选择顺序排版,最多 16 张。作品保存在 Core;刷新只查询原操作,不会自动重新制作。', 'muted'));
40
+ editor.append(columnsLabel, sizeLabel, count, submit, recover, fresh, more, details);
41
+ const status = element('p', '正在读取现有图片…', 'starter-status');
42
+ status.setAttribute('role', 'status');
43
+ const output = element('section', '', 'starter-result'); output.hidden = true;
44
+ layout.append(choices, editor); container.append(intro, layout, status, output);
45
+ function remember(value) {
46
+ value ? sessionStorage.setItem(storageKey, JSON.stringify(value)) : sessionStorage.removeItem(storageKey);
47
+ receipt = value;
48
+ }
49
+ function controls() {
50
+ const locked = busy || Boolean(receipt);
51
+ submit.disabled = locked || !selected.length || !catalog?.operations.some(item => item.id === operationId && item.available);
52
+ columns.disabled = size.disabled = locked; more.disabled = busy;
53
+ for (const checkbox of choices.querySelectorAll('input')) checkbox.disabled = locked || !checkbox.checked && selected.length >= 16;
54
+ }
55
+ function note(error) { status.textContent = [error.message, error.code, error.requestId].filter(Boolean).join(' · '); }
56
+ async function present(operation) {
57
+ signal.throwIfAborted();
58
+ if (operation.action !== 'plugin.execute' || operation.operationId !== operationId) throw new Error('此恢复标识属于另一项业务,请到业务记录核对。');
59
+ status.textContent = ({ queued: '已接收,等待处理', running: '正在排版', succeeded: '对照板已保存',
60
+ interrupted: '操作已中断,不会自动重做', canceled: '已停止,记录保留', failed: '操作未完成,请查看业务记录' })[operation.state] + ' · ' + operation.id;
61
+ fresh.hidden = ['queued', 'running'].includes(operation.state); recover.hidden = true;
62
+ if (operation.state === 'succeeded' && renderedId !== operation.id) {
63
+ const id = operation.result.artifactIds[0];
64
+ const blob = await api.artifact({ id, mediaType: 'image/png' }, { signal }); signal.throwIfAborted();
65
+ const url = URL.createObjectURL(blob); urls.add(url);
66
+ const image = element('img'); image.src = url; image.alt = '已保存的灵感对照板';
67
+ const link = element('a', '下载原始 PNG'); link.href = url; link.download = 'iris-board-' + id + '.png';
68
+ output.replaceChildren(image, link); output.hidden = false; renderedId = operation.id;
69
+ } else if (['queued', 'running'].includes(operation.state)) {
70
+ clearTimeout(timer); timer = setTimeout(() => { void poll(operation.id); }, 450);
71
+ }
72
+ }
73
+ async function poll(id) {
74
+ try { await present((await service.inspect(id, { signal })).operation); }
75
+ catch (error) { if (!signal.aborted) { note(error); recover.hidden = false; } }
76
+ }
77
+ async function lookup() {
78
+ if (busy || !receipt) return;
79
+ busy = true; controls();
80
+ try { await present((await service.lookup(receipt.key, { signal })).operation); }
81
+ catch (error) { if (!signal.aborted) { note(error); recover.hidden = false; if (error.status === 404) fresh.hidden = false; } }
82
+ finally { busy = false; if (!signal.aborted) controls(); }
83
+ }
84
+ editor.addEventListener('submit', async event => {
85
+ event.preventDefault();
86
+ if (busy || receipt || !selected.length) return;
87
+ const contribution = catalog.operations.find(item => item.id === operationId && item.available);
88
+ if (!contribution) return;
89
+ busy = true;
90
+ // Save the UUID before submitting. Recovery only looks up this UUID.
91
+ try { remember({ key: crypto.randomUUID() }); }
92
+ catch { busy = false; status.textContent = '浏览器无法保存恢复标识,本次未提交。请允许会话存储后再试。'; controls(); return; }
93
+ controls();
94
+ status.textContent = '正在保存本次操作 · ' + receipt.key;
95
+ try {
96
+ const accepted = await service.execute({ key: receipt.key, operationId, dependency: contribution.dependency,
97
+ input: { artifactIds: [...selected], columns: Number(columns.value), cellSize: Number(size.value) } }, { signal });
98
+ await present(accepted.operation);
99
+ } catch (error) {
100
+ if (!signal.aborted) { note(error); status.textContent += '。请查询原操作;未自动重做。'; recover.hidden = false; }
101
+ } finally { busy = false; if (!signal.aborted) controls(); }
102
+ }, { signal });
103
+ recover.addEventListener('click', () => { void lookup(); }, { signal });
104
+ fresh.addEventListener('click', () => {
105
+ if (busy) return;
106
+ try { remember(null); }
107
+ catch { status.textContent = '无法清除恢复标识,原操作保留;请先检查浏览器存储。'; return; }
108
+ renderedId = undefined; output.hidden = true; output.replaceChildren();
109
+ // Only keep URLs that still back source thumbnails.
110
+ const thumbnails = new Set([...choices.querySelectorAll('img')].map(image => image.src));
111
+ for (const url of urls) if (!thumbnails.has(url)) { URL.revokeObjectURL(url); urls.delete(url); }
112
+ fresh.hidden = recover.hidden = true; status.textContent = '选择图片后,可以新建一次操作。'; controls();
113
+ }, { signal });
114
+ async function appendImages(page) {
115
+ for (const artifact of page.artifacts) {
116
+ const label = element('label', '', 'starter-choice'), checkbox = document.createElement('input');
117
+ checkbox.type = 'checkbox'; checkbox.value = artifact.id;
118
+ const image = element('img'); image.alt = '来源图片 ' + artifact.id;
119
+ label.append(checkbox, image, element('span', artifact.id.slice(-8), 'muted')); choices.append(label);
120
+ const blob = await api.artifact({ id: artifact.id, mediaType: artifact.mediaType }, { signal }); signal.throwIfAborted();
121
+ const url = URL.createObjectURL(blob); urls.add(url); image.src = url;
122
+ checkbox.addEventListener('change', () => {
123
+ selected = checkbox.checked ? [...selected, artifact.id] : selected.filter(id => id !== artifact.id);
124
+ count.textContent = '已选择 ' + selected.length + ' 张 · 按选择顺序排版'; controls();
125
+ }, { signal });
126
+ }
127
+ more.hidden = page.nextOffset === null || page.nextOffset === undefined; controls();
128
+ }
129
+ more.addEventListener('click', async () => {
130
+ if (busy || catalog.nextOffset === null) return;
131
+ busy = true; controls();
132
+ try {
133
+ catalog = await service.catalog({ limit: '30', offset: String(catalog.nextOffset) }, { signal });
134
+ signal.throwIfAborted(); await appendImages(catalog);
135
+ } catch (error) { if (!signal.aborted) note(error); }
136
+ finally { busy = false; if (!signal.aborted) controls(); }
137
+ }, { signal });
138
+ try {
139
+ signal.throwIfAborted();
140
+ const updateTheme = value => { container.dataset.theme = value.mode; };
141
+ updateTheme(theme.current()); unsubscribe = theme.subscribe(updateTheme);
142
+ if (!service) { status.textContent = '此服务尚未启用插件业务执行,请核对启动配置。'; return cleanup; }
143
+ catalog = await service.catalog({ limit: '30' }, { signal }); signal.throwIfAborted();
144
+ await appendImages(catalog);
145
+ status.textContent = catalog.artifacts.length ? '选择图片即可开始,不调用模型。' : '还没有图片。请先通过创作页或 CLI 导入。';
146
+ let saved;
147
+ try { saved = sessionStorage.getItem(storageKey); }
148
+ catch { status.textContent = '无法读取浏览器恢复标识,请到业务记录查看已有操作。'; }
149
+ if (saved) {
150
+ try {
151
+ receipt = JSON.parse(saved);
152
+ if (!receipt || !/^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(receipt.key)) throw new Error('invalid receipt');
153
+ } catch {
154
+ receipt = { invalid: true }; status.textContent = '浏览器恢复标识损坏,请核对业务记录后点击新建。'; fresh.hidden = false;
155
+ }
156
+ if (!receipt.invalid) await lookup();
157
+ }
158
+ controls(); return cleanup;
159
+ } catch (error) { cleanup(); throw error; }
160
+ }
@@ -0,0 +1,23 @@
1
+ export async function mount({ container, artifact, api, signal: parentSignal, theme }) {
2
+ const lifetime = new AbortController(), signal = AbortSignal.any([parentSignal, lifetime.signal]);
3
+ let url, unsubscribe, closed = false;
4
+ const image = new Image(); image.alt = 'Core 中保存的原始 PNG';
5
+ function cleanup() {
6
+ if (closed) return;
7
+ closed = true; lifetime.abort(); unsubscribe?.();
8
+ if (url) URL.revokeObjectURL(url);
9
+ image.removeAttribute('src'); container.replaceChildren();
10
+ }
11
+ signal.addEventListener('abort', cleanup, { once: true });
12
+ try {
13
+ const bytes = await api.artifact(artifact, { signal }); signal.throwIfAborted();
14
+ url = URL.createObjectURL(bytes); image.src = url;
15
+ await image.decode(); signal.throwIfAborted();
16
+ const caption = document.createElement('p'); caption.className = 'muted';
17
+ caption.textContent = image.naturalWidth + ' × ' + image.naturalHeight + ' · ' + bytes.size.toLocaleString() + ' bytes';
18
+ const update = value => { container.dataset.theme = value.mode; };
19
+ update(theme.current()); unsubscribe = theme.subscribe(update);
20
+ container.classList.add('starter-preview'); container.append(image, caption);
21
+ return cleanup;
22
+ } catch (error) { cleanup(); throw error; }
23
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Messia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.