@easyai-canvas/canvas-plugin-sdk 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -13,9 +13,15 @@ npm run build
13
13
  npm start
14
14
  ```
15
15
 
16
- `init --template node|action|combined` 选择项目类型;`--no-install` 跳过依赖安装。开发工作台位于 `http://localhost:4310/studio`,由浏览器完成 EasyAI 平台授权。`npm run build` 输出 `dist/plugin.zip`,可由管理员导入平台托管,或用 `npm start` 运行独立插件服务。
16
+ `init --template node|action|combined` 选择首个插件模板;`--no-install` 跳过依赖安装。新项目将插件放在 `src/plugins/<目录>/`,用 `npx easyai-plugin add <目录> --template node|action|combined` 增加独立插件。每个目录拥有独立 ID、配置、UI 和 ZIP;同一插件的多个节点可继续放在该目录内。旧项目的 `src/plugin.ts`、`src/ui.ts` 与 `dist/plugin.zip` 仍可使用。
17
17
 
18
- 平台托管只接纳运行不超过 20 秒的节点任务;需要长任务、GPU 或自有基础设施时使用独立服务。构建还会生成 `dist/plugin.zip.sha256` 供管理员核对。独立服务生产环境必须提供 `PLUGIN_SERVICE_SIGNING_SECRET`、与平台连接 ID 一致的 `PLUGIN_SERVICE_AUDIENCE` 和持久化的 `PLUGIN_DATA_DIR`。业务密钥放在插件配置或部署环境,不写入前端组件。
18
+ 开发工作台位于 `http://localhost:4310/studio`,按插件展示配置表单和待配置状态。`npm run build` 为新结构分别输出 `dist/plugins/<pluginId>.zip` 与摘要文件;管理员可分别导入,或用 `npm start` 运行同一服务下的多个插件。
19
+
20
+ 节点在 `contributes.nodes[].placement` 声明 `{ slot: 'canvas.add.node', group: 'tools', order: 100 }`,素材选择操作在 `contributes.actions[].placement` 声明 `{ slot: 'canvas.add.resource', order: 100 }`。节点分组还可选 `basic`、`storyboard`;素材入口不使用节点分组。插件的 Vue 组件负责内部样式与交互,画布负责添加入口及拖动、权限等边界。旧版 `menu` 声明继续可用;未知位置或贡献能力与位置不匹配会在打包校验时报错。
21
+
22
+ 运行 `npm run dev` 后,工作台会显示当前 SDK 与 npm 最新版本。若有新版本,点击“一键升级”会在当前项目执行固定版本安装,更新 `package.json` 和 npm 锁文件;安装完成后重启 `npm run dev`,新运行时才会生效。版本检查失败时仍可继续开发。
23
+
24
+ 平台托管只接纳运行不超过 20 秒的节点任务;需要长任务、GPU 或自有基础设施时使用独立服务。构建还会生成每个 ZIP 的 `.sha256` 供管理员核对。独立服务生产环境必须提供 `PLUGIN_SERVICE_SIGNING_SECRET`、与平台连接 ID 一致的 `PLUGIN_SERVICE_AUDIENCE` 和持久化的 `PLUGIN_DATA_DIR`。业务密钥放在插件配置或部署环境,不写入前端组件。
19
25
 
20
26
  托管节点暂不支持文件和画布宿主能力。管理员导入 ZIP 后默认停用,审核插件代码、声明能力和版本摘要后启用。版本切换不会改变已提交任务使用的包摘要。
21
27
 
@@ -2,7 +2,7 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { existsSync, watch } from 'node:fs';
5
- import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
5
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
6
6
  import net from 'node:net';
7
7
  import { basename, dirname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
@@ -40,11 +40,12 @@ export default definePlugin({
40
40
  inputs: [], outputs: [{ id: 'out', type: 'text' }],
41
41
  parameters: [{ key: 'text', label: '文本', type: 'string', default: '你好,画布' }],
42
42
  ui: { preview: 'EchoPreview', panel: 'EchoPanel' }, timeoutMs: 20000,
43
+ placement: { slot: 'canvas.add.node', group: 'tools' },
43
44
  })],` : ''}
44
45
  ${action ? `actions: [{
45
46
  id: 'select-image', name: '添加图片链接', action: 'asset-selection',
46
47
  presentation: 'dialog', mediaTypes: ['image'], maxCount: 1,
47
- ui: { component: 'ImageSelector' }, menu: { group: 'resources' },
48
+ ui: { component: 'ImageSelector' }, placement: { slot: 'canvas.add.resource' },
48
49
  }],` : ''}
49
50
  },
50
51
  },
@@ -92,12 +93,12 @@ ${action ? `export const ImageSelector = defineComponent({ setup() {
92
93
  } });` : ''}
93
94
  `;
94
95
  }
95
- function testSource(id, template) {
96
+ function testSource(id, template, directory) {
96
97
  const node = template !== 'action';
97
98
  const action = template !== 'node';
98
99
  return `import test from 'node:test';
99
100
  import assert from 'node:assert/strict';
100
- import plugin from '../src/plugin.ts';
101
+ import plugin from '../src/plugins/${directory}/plugin.ts';
101
102
  import { validateManifest, validateResult } from '${metadata.name}/protocol';
102
103
 
103
104
  test('插件声明和业务处理器符合画布契约', async () => {
@@ -125,7 +126,7 @@ async function init() {
125
126
  const slug = safeName(basename(target));
126
127
  const id = `customer.${slug.replaceAll('-', '.')}`;
127
128
  const title = slug.replaceAll('-', ' ');
128
- await mkdir(join(target, 'src'), { recursive: true });
129
+ await mkdir(join(target, 'src/plugins', slug), { recursive: true });
129
130
  await mkdir(join(target, 'test'), { recursive: true });
130
131
  const spec = process.env.EASYAI_SDK_INSTALL_SPEC ?? metadata.version;
131
132
  const project = {
@@ -137,13 +138,13 @@ async function init() {
137
138
  };
138
139
  const files = {
139
140
  'package.json': `${JSON.stringify(project, null, 2)}\n`,
140
- 'src/plugin.ts': pluginSource(id, title, template),
141
- 'src/ui.ts': uiSource(template),
142
- 'test/plugin.test.ts': testSource(id, template),
141
+ [`src/plugins/${slug}/plugin.ts`]: pluginSource(id, title, template),
142
+ [`src/plugins/${slug}/ui.ts`]: uiSource(template),
143
+ [`test/${slug}.test.ts`]: testSource(id, template, slug),
143
144
  '.gitignore': 'node_modules/\ndist/\n.easyai-plugin/\n.env\n',
144
145
  '.env.example': 'PORT=4310\nPLUGIN_DATA_DIR=./.easyai-plugin\nPLUGIN_AUTH_MODE=token\nPLUGIN_SERVICE_AUDIENCE=customer-plugin\n# PLUGIN_SERVICE_SIGNING_SECRET=由部署环境安全注入\n',
145
- 'Dockerfile': 'FROM node:22.22.0\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nCOPY dist/plugin.zip ./dist/plugin.zip\nENV NODE_ENV=production PLUGIN_BUILTINS=0\nCMD ["npm", "start"]\n',
146
- 'README.md': `# ${title}\n\n运行 npm run dev,打开 http://localhost:4310/studio,在浏览器中连接 EasyAI 画布。\n运行 npm run test:contract && npm run build 生成 dist/plugin.zip 和 dist/plugin.zip.sha256。\n独立部署时配置插件服务签名密钥并运行 npm start;平台托管时由管理员审核并导入 ZIP。\n`,
146
+ 'Dockerfile': 'FROM node:22.22.0\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nCOPY dist/plugins ./dist/plugins\nENV NODE_ENV=production PLUGIN_BUILTINS=0\nCMD ["npm", "start"]\n',
147
+ 'README.md': `# ${title}\n\n每个插件放在 src/plugins/<目录>/,用 easyai-plugin add <目录> 增加独立插件。\n运行 npm run dev,打开 http://localhost:4310/studio,在浏览器中连接 EasyAI 画布。\n运行 npm run test:contract && npm run build,分别生成 dist/plugins/<pluginId>.zip。\n独立部署时配置插件服务签名密钥并运行 npm start;平台托管时由管理员审核并分别导入 ZIP。\n`,
147
148
  'tsconfig.json': JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, skipLibCheck: true, allowImportingTsExtensions: true, lib: ['ES2022', 'DOM'], types: ['node'], noEmit: true }, include: ['src/**/*.ts', 'test/**/*.ts'] }, null, 2) + '\n',
148
149
  };
149
150
  for (const [path, content] of Object.entries(files)) await writeFile(join(target, path), content, { flag: 'wx' });
@@ -158,33 +159,77 @@ async function run(executable, argv, directory = cwd, env = process.env) {
158
159
  child.once('exit', code => code === 0 ? resolvePromise() : reject(new Error(`${executable} 退出码 ${code}`)));
159
160
  });
160
161
  }
162
+ function templateArg() {
163
+ return args.find(arg => arg.startsWith('--template='))?.slice('--template='.length)
164
+ ?? (args.includes('--template') ? args[args.indexOf('--template') + 1] : 'node');
165
+ }
166
+ async function add() {
167
+ const template = templateArg();
168
+ if (!['node', 'action', 'combined'].includes(template)) fail('模板只支持 node、action 或 combined');
169
+ const directory = args.find((arg, index) => !arg.startsWith('--') && args[index - 1] !== '--template');
170
+ if (!directory) fail('用法:easyai-plugin add <插件目录> [--template node|action|combined]');
171
+ const slug = safeName(directory);
172
+ if (slug !== directory) fail('插件目录只能使用小写英文字母、数字和短横线');
173
+ const path = join(cwd, 'src/plugins', slug);
174
+ if (existsSync(path)) fail(`插件目录已存在:${path}`);
175
+ await mkdir(path, { recursive: true });
176
+ await mkdir(join(cwd, 'test'), { recursive: true });
177
+ const id = `customer.${slug.replaceAll('-', '.')}`;
178
+ await writeFile(join(path, 'plugin.ts'), pluginSource(id, slug.replaceAll('-', ' '), template), { flag: 'wx' });
179
+ await writeFile(join(path, 'ui.ts'), uiSource(template), { flag: 'wx' });
180
+ await writeFile(join(cwd, 'test', `${slug}.test.ts`), testSource(id, template, slug), { flag: 'wx' });
181
+ console.log(`已添加插件 ${id};运行 npm run test:contract && npm run build`);
182
+ }
183
+ async function pluginEntries() {
184
+ const root = join(cwd, 'src/plugins');
185
+ const folders = existsSync(root) ? (await readdir(root, { withFileTypes: true }))
186
+ .filter(item => item.isDirectory()).map(item => item.name).sort() : [];
187
+ const entries = folders.map(name => ({ name, server: join(root, name, 'plugin.ts'), ui: join(root, name, 'ui.ts'), legacy: false }));
188
+ if (existsSync(join(cwd, 'src/plugin.ts'))) entries.push({ name: 'plugin', server: join(cwd, 'src/plugin.ts'), ui: join(cwd, 'src/ui.ts'), legacy: true });
189
+ if (!entries.length) fail('缺少插件入口:src/plugins/<目录>/plugin.ts 或 src/plugin.ts');
190
+ for (const entry of entries) for (const path of [entry.server, entry.ui]) if (!existsSync(path)) fail(`缺少 ${path}`);
191
+ return entries;
192
+ }
161
193
  async function build() {
162
194
  nodeVersion();
163
- for (const path of ['src/plugin.ts', 'src/ui.ts']) if (!existsSync(join(cwd, path))) fail(`缺少 ${path}`);
195
+ const entries = await pluginEntries();
164
196
  const { buildPluginPackage } = await import(builderEntry);
165
- const output = join(cwd, 'dist/plugin.zip');
166
- await buildPluginPackage({ serverEntry: join(cwd, 'src/plugin.ts'), uiEntry: join(cwd, 'src/ui.ts'),
167
- globalName: `EasyAIPlugin${createHash('sha256').update(cwd).digest('hex').slice(0, 12)}`, output });
168
- const bytes = await readFile(output);
169
197
  const { inspectPluginPackage } = await import(join(packageRoot, 'dist/runtime/package-runtime.js'));
170
- await inspectPluginPackage(bytes);
171
- const digest = createHash('sha256').update(bytes).digest('hex');
172
- await writeFile(`${output}.sha256`, `${digest} plugin.zip\n`);
173
- console.log(`SHA-256 ${digest}(dist/plugin.zip.sha256)`);
198
+ await rm(join(cwd, 'dist/plugins'), { recursive: true, force: true });
199
+ const ids = new Set();
200
+ for (const entry of entries) {
201
+ const temporary = join(cwd, 'dist/plugins', `${entry.name}.zip`);
202
+ await buildPluginPackage({ serverEntry: entry.server, uiEntry: entry.ui,
203
+ globalName: `EasyAIPlugin${createHash('sha256').update(`${cwd}/${entry.name}`).digest('hex').slice(0, 12)}`, output: temporary });
204
+ const bytes = await readFile(temporary);
205
+ const inspected = await inspectPluginPackage(bytes);
206
+ const id = inspected.descriptor.manifest.id;
207
+ if (ids.has(id)) fail(`插件 ID 重复:${id}`);
208
+ ids.add(id);
209
+ const output = entry.legacy ? join(cwd, 'dist/plugin.zip') : join(cwd, 'dist/plugins', `${id}.zip`);
210
+ if (output !== temporary) await rename(temporary, output);
211
+ const digest = createHash('sha256').update(bytes).digest('hex');
212
+ await writeFile(`${output}.sha256`, `${digest} ${basename(output)}\n`);
213
+ console.log(`${id} → ${output}(SHA-256 ${digest})`);
214
+ }
174
215
  }
175
- function serviceEnv(dev) {
176
- const zip = join(cwd, 'dist/plugin.zip');
177
- if (!existsSync(zip)) fail('缺少 dist/plugin.zip;请先运行 npm run build');
216
+ async function serviceEnv(dev) {
217
+ const entries = await pluginEntries();
218
+ const folder = join(cwd, 'dist/plugins');
219
+ const zips = existsSync(folder) ? (await readdir(folder)).filter(name => name.endsWith('.zip')).map(name => join(folder, name)) : [];
220
+ if (entries.some(entry => entry.legacy)) zips.push(join(cwd, 'dist/plugin.zip'));
221
+ if (!zips.length || zips.some(zip => !existsSync(zip))) fail('缺少插件 ZIP;请先运行 npm run build');
178
222
  if (!existsSync(runtimeEntry)) fail('SDK 运行时未构建');
179
223
  if (!dev && !process.env.PLUGIN_SERVICE_SIGNING_SECRET) fail('生产服务需要 PLUGIN_SERVICE_SIGNING_SECRET');
180
224
  return { ...process.env, NODE_ENV: dev ? 'development' : 'production',
181
- PLUGIN_BUILTINS: '0', PLUGIN_PACKAGES: JSON.stringify([zip]),
225
+ PLUGIN_BUILTINS: '0', PLUGIN_PACKAGES: JSON.stringify(zips),
226
+ PLUGIN_SDK_VERSION: metadata.version,
182
227
  PLUGIN_DATA_DIR: process.env.PLUGIN_DATA_DIR ?? join(cwd, '.easyai-plugin'),
183
228
  ...(dev ? { PLUGIN_AUTH_MODE: 'local', PLUGIN_BIND: '127.0.0.1', PLUGIN_DEV_WATCH: '1' } : {}) };
184
229
  }
185
230
  async function start() {
186
231
  nodeVersion();
187
- await run(process.execPath, [runtimeEntry], cwd, serviceEnv(false));
232
+ await run(process.execPath, [runtimeEntry], cwd, await serviceEnv(false));
188
233
  }
189
234
  async function dev() {
190
235
  await build();
@@ -194,9 +239,10 @@ async function dev() {
194
239
  const status = async (state, message = '') => writeFile(join(dataDir, 'dev-build-status.json'),
195
240
  JSON.stringify({ state, message: message.slice(0, 500), updatedAt: new Date().toISOString() }));
196
241
  const launch = () => {
197
- child = spawn(process.execPath, [runtimeEntry], { cwd, env: serviceEnv(true), stdio: 'inherit' });
242
+ child = spawn(process.execPath, [runtimeEntry], { cwd, env: launchEnv, stdio: 'inherit' });
198
243
  child.on('error', error => console.error(error));
199
244
  };
245
+ let launchEnv = await serviceEnv(true);
200
246
  await status('ready');
201
247
  launch();
202
248
  console.log(`真实画布调试工作台:http://localhost:${process.env.PORT ?? 4310}/studio`);
@@ -210,6 +256,7 @@ async function dev() {
210
256
  await status('building');
211
257
  try {
212
258
  await build();
259
+ launchEnv = await serviceEnv(true);
213
260
  child?.kill('SIGTERM');
214
261
  await new Promise(resolvePromise => child?.once('exit', resolvePromise));
215
262
  launch();
@@ -234,8 +281,9 @@ async function doctor() {
234
281
  const checks = [];
235
282
  const [major, minor] = process.versions.node.split('.').map(Number);
236
283
  checks.push({ name: 'Node 22.22+', ok: major === 22 && minor >= 22 });
237
- checks.push({ name: '插件入口', ok: existsSync(join(cwd, 'src/plugin.ts')) });
238
- checks.push({ name: 'UI 入口', ok: existsSync(join(cwd, 'src/ui.ts')) });
284
+ const entries = await pluginEntries().catch(() => []);
285
+ checks.push({ name: `插件入口(${entries.length} 个)`, ok: entries.length > 0 });
286
+ checks.push({ name: '插件 ID 唯一', ok: entries.length > 0 && (await build().then(() => true).catch(() => false)) });
239
287
  const port = Number(process.env.PORT ?? 4310);
240
288
  const available = await new Promise(resolvePromise => {
241
289
  const server = net.createServer();
@@ -255,12 +303,13 @@ async function doctor() {
255
303
 
256
304
  try {
257
305
  if (command === 'init') await init();
306
+ else if (command === 'add') await add();
258
307
  else if (command === 'dev') await dev();
259
308
  else if (command === 'test:contract') await contractTest();
260
309
  else if (command === 'build') await build();
261
310
  else if (command === 'start') await start();
262
311
  else if (command === 'doctor') await doctor();
263
- else fail('用法:easyai-plugin <init|dev|test:contract|build|start|doctor>');
312
+ else fail('用法:easyai-plugin <init|add|dev|test:contract|build|start|doctor>');
264
313
  } catch (error) {
265
314
  console.error(error instanceof Error ? error.message : String(error));
266
315
  process.exitCode = 1;
@@ -6,16 +6,18 @@ import { Runtime } from './runtime/engine.js';
6
6
  import { loadPackagedPlugins } from './runtime/packaged-plugins.js';
7
7
  import { authenticate } from './runtime/auth.js';
8
8
  import { RemoteStudioConnector } from './studio/remote-connector.js';
9
+ import { SdkVersionManager } from './studio/sdk-version.js';
9
10
  import { ContractError, object, validatePluginRuntimeContext } from './protocol/index.js';
10
11
  const dataDir = resolve(process.env.PLUGIN_DATA_DIR ?? './.easyai-plugin');
11
12
  const port = Number(process.env.PORT ?? 4310);
12
13
  const packaged = await loadPackagedPlugins(JSON.parse(process.env.PLUGIN_PACKAGES ?? '[]'), join(dataDir, 'packages'));
13
14
  const runtime = new Runtime(join(dataDir, 'runtime.sqlite'), packaged.plugins, packaged.presentations, packaged.executionEntries);
14
15
  const studio = new RemoteStudioConnector();
16
+ const sdkVersions = new SdkVersionManager(process.cwd(), process.env.PLUGIN_SDK_VERSION ?? '');
15
17
  if (process.env.PLUGIN_DEV_WATCH === '1') {
16
18
  for (const plugin of packaged.plugins) {
17
19
  const entry = runtime.catalog().plugins.find(item => item.manifest.id === plugin.manifest.id);
18
- if (entry && !entry.enabled)
20
+ if (entry && !entry.enabled && !(entry.manifest.configFields ?? []).some(field => field.required && !(field.type === 'secret' ? entry.secretConfigured?.[field.key] : entry.config[field.key] !== undefined && entry.config[field.key] !== '')))
19
21
  runtime.configure(plugin.manifest.id, { enabled: true });
20
22
  }
21
23
  }
@@ -71,6 +73,10 @@ async function studioCatalog() {
71
73
  return {
72
74
  id: entry.manifest.id, name: entry.manifest.name, release: entry.manifest.release,
73
75
  enabled: entry.enabled, manifest: entry.manifest, uiBundle,
76
+ config: entry.config, secretConfigured: entry.secretConfigured,
77
+ configCheckAvailable: entry.configCheckAvailable,
78
+ configurationRequired: (entry.manifest.configFields ?? []).some(field => field.required &&
79
+ !(field.type === 'secret' ? entry.secretConfigured?.[field.key] : entry.config[field.key] !== undefined && entry.config[field.key] !== '')),
74
80
  nodes: entry.manifest.contributes.nodes?.length ?? 0,
75
81
  actions: entry.manifest.contributes.actions?.length ?? 0,
76
82
  package: packaged.downloads[entry.manifest.id] ? { mode: 'download' } : null,
@@ -195,6 +201,38 @@ const server = createServer(async (request, response) => {
195
201
  send(response, 200, await studioCatalog());
196
202
  return;
197
203
  }
204
+ const studioPlugin = /^\/studio\/plugins\/([A-Za-z0-9_.-]+)\/(configure|check)$/.exec(path);
205
+ if (method === 'POST' && studioPlugin) {
206
+ if (request.headers.origin && ![`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://[::1]:${port}`].includes(request.headers.origin))
207
+ throw new ContractError('本地配置请求来源无效', 403);
208
+ if (studioPlugin[2] === 'configure') {
209
+ const input = await body(request);
210
+ const configured = runtime.configure(studioPlugin[1], {
211
+ config: object(input.config, 'config'), enabled: input.enabled === true,
212
+ });
213
+ send(response, 200, { enabled: configured.enabled });
214
+ }
215
+ else
216
+ send(response, 200, await runtime.checkConfig(studioPlugin[1]));
217
+ return;
218
+ }
219
+ if (method === 'GET' && path === '/studio/sdk') {
220
+ send(response, 200, await sdkVersions.status());
221
+ return;
222
+ }
223
+ if (method === 'POST' && path === '/studio/sdk/upgrade') {
224
+ if (process.env.PLUGIN_DEV_WATCH !== '1' || request.headers.origin !== `http://localhost:${port}`)
225
+ throw new ContractError('仅允许从本机开发工作台升级 SDK', 403);
226
+ if (studio.status().phase === 'connected')
227
+ throw new ContractError('请先断开画布连接再升级 SDK', 409);
228
+ try {
229
+ send(response, 200, await sdkVersions.upgrade());
230
+ }
231
+ catch {
232
+ throw new ContractError('SDK 安装失败,请在项目终端手动安装并查看错误', 502);
233
+ }
234
+ return;
235
+ }
198
236
  if (method === 'GET' && path === '/studio/status') {
199
237
  send(response, 200, studio.status());
200
238
  return;
@@ -50,6 +50,23 @@ export type PluginMenuContribution = {
50
50
  order?: number;
51
51
  icon?: string;
52
52
  };
53
+ export type NodePlacement = {
54
+ slot: 'canvas.add.node';
55
+ group?: 'basic' | 'storyboard' | 'tools';
56
+ order?: number;
57
+ icon?: string;
58
+ };
59
+ export type ResourcePlacement = {
60
+ slot: 'canvas.add.resource';
61
+ order?: number;
62
+ icon?: string;
63
+ };
64
+ export type PlacementOverride = {
65
+ visible?: boolean;
66
+ group?: 'basic' | 'storyboard' | 'tools';
67
+ order?: number;
68
+ };
69
+ export type PlacementOverrides = Record<string, PlacementOverride>;
53
70
  /** Platform-derived identity and location, shared by backend node handlers and queries. Never accepts a login token. */
54
71
  export type PluginRuntimeContext = {
55
72
  user: {
@@ -100,6 +117,7 @@ export type NodeDefinition = {
100
117
  defaults?: Record<string, unknown>;
101
118
  timeoutMs?: number;
102
119
  menu?: PluginMenuContribution;
120
+ placement?: NodePlacement;
103
121
  capabilities?: PluginNodeCapabilities;
104
122
  runtime?: PluginNodeRuntimePolicy;
105
123
  };
@@ -128,7 +146,8 @@ export type PluginAction = {
128
146
  url?: string;
129
147
  origin?: string;
130
148
  };
131
- menu: PluginMenuContribution;
149
+ menu?: PluginMenuContribution;
150
+ placement?: ResourcePlacement;
132
151
  /** Set only on the normalized view returned by listPluginActions(). */
133
152
  legacyAssetSource?: boolean;
134
153
  };
@@ -252,6 +271,7 @@ export type HostSnapshot = {
252
271
  serviceId: string;
253
272
  entry: CatalogEntry;
254
273
  enabled: boolean;
274
+ placementOverrides?: PlacementOverrides;
255
275
  uiBundle?: {
256
276
  url: string;
257
277
  digest: string;
@@ -268,6 +288,8 @@ export declare class ContractError extends Error {
268
288
  }
269
289
  export declare function object(value: unknown, path?: string): Record<string, unknown>;
270
290
  export declare function validatePluginRuntimeContext(value: unknown): PluginRuntimeContext;
291
+ export declare function resolveNodePlacement(node: NodeDefinition): NodePlacement | null;
292
+ export declare function resolveResourcePlacement(action: PluginAction): ResourcePlacement | null;
271
293
  export declare function identifier(value: unknown, path: string): string;
272
294
  export declare function validateParameters(fields: ConfigField[], input: Record<string, unknown>): Record<string, unknown>;
273
295
  /** Returns the interaction-oriented view while preserving legacy asset source manifests. */
@@ -62,6 +62,41 @@ function validateMenu(value, path) {
62
62
  if (menu.icon !== undefined && typeof menu.icon !== 'string')
63
63
  throw new ContractError(`${path} 菜单图标无效`);
64
64
  }
65
+ function validatePlacement(value, path, slot) {
66
+ if (value === undefined)
67
+ return;
68
+ const placement = keys(value, ['slot', 'group', 'order', 'icon'], path);
69
+ if (placement.slot !== slot)
70
+ throw new ContractError(`${path} 位置与贡献类型不匹配`);
71
+ if (slot === 'canvas.add.node') {
72
+ if (placement.group !== undefined && !['basic', 'storyboard', 'tools'].includes(String(placement.group)))
73
+ throw new ContractError(`${path} 节点分组无效`);
74
+ }
75
+ else if (placement.group !== undefined)
76
+ throw new ContractError(`${path} 素材入口不能设置节点分组`);
77
+ if (placement.order !== undefined && (typeof placement.order !== 'number' || !Number.isFinite(placement.order)))
78
+ throw new ContractError(`${path} 顺序无效`);
79
+ if (placement.icon !== undefined && typeof placement.icon !== 'string')
80
+ throw new ContractError(`${path} 图标无效`);
81
+ }
82
+ export function resolveNodePlacement(node) {
83
+ if (node.placement)
84
+ return { ...node.placement, group: node.placement.group ?? 'tools' };
85
+ if (node.menu?.group === 'resources')
86
+ return null;
87
+ return { slot: 'canvas.add.node', group: node.menu?.group ?? 'tools',
88
+ ...(node.menu?.order !== undefined ? { order: node.menu.order } : {}),
89
+ ...(node.menu?.icon !== undefined ? { icon: node.menu.icon } : {}) };
90
+ }
91
+ export function resolveResourcePlacement(action) {
92
+ if (action.placement)
93
+ return action.placement;
94
+ if (action.menu && action.menu.group !== 'resources')
95
+ return null;
96
+ return { slot: 'canvas.add.resource',
97
+ ...(action.menu?.order !== undefined ? { order: action.menu.order } : {}),
98
+ ...(action.menu?.icon !== undefined ? { icon: action.menu.icon } : {}) };
99
+ }
65
100
  function optionalBoolean(value, path) {
66
101
  if (value !== undefined && typeof value !== 'boolean')
67
102
  throw new ContractError(`${path} 必须是布尔值`);
@@ -182,7 +217,7 @@ export function validateManifest(value) {
182
217
  throw new ContractError('nodes 必须为数组');
183
218
  const names = new Set();
184
219
  for (const raw of c.nodes) {
185
- const node = keys(raw, ['type', 'name', 'description', 'inputs', 'outputs', 'parameters', 'execution', 'ui', 'defaults', 'timeoutMs', 'menu', 'capabilities', 'runtime'], 'node');
220
+ const node = keys(raw, ['type', 'name', 'description', 'inputs', 'outputs', 'parameters', 'execution', 'ui', 'defaults', 'timeoutMs', 'menu', 'placement', 'capabilities', 'runtime'], 'node');
186
221
  const id = identifier(node.type, 'node.type');
187
222
  if (names.has(id))
188
223
  throw new ContractError('节点标识重复');
@@ -192,6 +227,7 @@ export function validateManifest(value) {
192
227
  if (node.parameters !== undefined)
193
228
  validateFields(node.parameters);
194
229
  validateMenu(node.menu, 'node.menu');
230
+ validatePlacement(node.placement, 'node.placement', 'canvas.add.node');
195
231
  validateNodeCapabilities(node.capabilities);
196
232
  validateNodeRuntime(node.runtime);
197
233
  if (node.timeoutMs !== undefined && (typeof node.timeoutMs !== 'number' || !Number.isInteger(node.timeoutMs) || node.timeoutMs < 100 || node.timeoutMs > 3_600_000))
@@ -216,7 +252,7 @@ export function validateManifest(value) {
216
252
  throw new ContractError('actions 必须为数组');
217
253
  const actionIds = new Set();
218
254
  for (const raw of c.actions) {
219
- const action = keys(raw, ['id', 'name', 'description', 'action', 'presentation', 'mediaTypes', 'maxCount', 'ui', 'menu'], 'action');
255
+ const action = keys(raw, ['id', 'name', 'description', 'action', 'presentation', 'mediaTypes', 'maxCount', 'ui', 'menu', 'placement'], 'action');
220
256
  const actionId = identifier(action.id, 'action.id');
221
257
  if (actionIds.has(actionId))
222
258
  throw new ContractError('操作标识重复');
@@ -231,6 +267,7 @@ export function validateManifest(value) {
231
267
  throw new ContractError('操作素材类型无效');
232
268
  boundedInteger(action.maxCount, '操作素材数量', 1, 256);
233
269
  validateMenu(action.menu, 'action.menu');
270
+ validatePlacement(action.placement, 'action.placement', 'canvas.add.resource');
234
271
  const ui = keys(action.ui, ['component', 'url', 'origin'], 'action.ui');
235
272
  const component = optionalString(ui.component, 'action.ui.component');
236
273
  if (component)
@@ -85,6 +85,13 @@ button:focus-visible, select:focus-visible, input:focus-visible, summary:focus-v
85
85
  .diagnostics-list .diag-error { color: var(--danger); }
86
86
  .diagnostics-list .diag-ready { color: var(--success); }
87
87
  .diagnostics-footnote { margin: 18px 0 0; color: var(--quiet); font-size: 11px; }
88
+ .sdk-card { margin-top: 24px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--soft); }
89
+ .sdk-card[hidden] { display: none; }
90
+ .sdk-card strong { display: block; margin-bottom: 4px; font-size: 12px; }
91
+ .sdk-card p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
92
+ .sdk-card button { margin-top: 10px; min-height: 34px; padding: 0 12px; border: 1px solid var(--line-strong); border-radius: 8px; color: var(--ink); background: #fff; font-size: 12px; font-weight: 600; }
93
+ .sdk-card button[hidden] { display: none; }
94
+ .sdk-card button:hover:not(:disabled) { background: var(--page); }
88
95
 
89
96
  /* Canvas controls */
90
97
  .dock { position: fixed; z-index: 3; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 3px; max-width: calc(100vw - 16px); padding: 5px; border: 1px solid var(--line-strong); border-radius: 13px; background: #fff; box-shadow: var(--floating-shadow); }
@@ -141,6 +148,14 @@ dialog::backdrop { background: #30384266; }
141
148
  .status-badge.error { color: var(--danger); background: #fff1ef; }
142
149
  .plugin-list { display: grid; gap: 8px; max-height: 224px; overflow: auto; }
143
150
  .plugin-list:empty::before { content: "暂无可调试的插件"; padding: 18px; border: 1px dashed var(--line-strong); border-radius: 12px; color: var(--quiet); font-size: 12px; text-align: center; }
151
+ .plugin-config { display: grid; gap: 10px; margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--soft); }
152
+ .plugin-config[hidden] { display: none; }
153
+ .plugin-config h4 { margin: 0; font-size: 13px; }
154
+ .plugin-config label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; }
155
+ .plugin-config input:not([type="checkbox"]), .plugin-config select { width: 100%; min-height: 36px; padding: 6px 9px; border: 1px solid var(--line-strong); border-radius: 8px; background: #fff; }
156
+ .plugin-config button { min-height: 34px; padding: 0 12px; border: 1px solid var(--line-strong); border-radius: 8px; background: #fff; }
157
+ .plugin-config .config-actions { display: flex; gap: 8px; }
158
+ .plugin-config small { color: var(--quiet); }
144
159
  .plugin-choice { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 68px; border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; background: #fff; cursor: pointer; }
145
160
  .plugin-choice:hover { border-color: var(--line-strong); background: var(--soft); }
146
161
  .plugin-choice:has(input:checked) { border-color: #aab6c1; background: #f9fafb; }
@@ -255,6 +270,8 @@ dialog::backdrop { background: #30384266; }
255
270
  <section class="setup-content" aria-labelledby="connection-title">
256
271
  <h1 id="connection-title">连接画布平台</h1>
257
272
  <p class="setup-intro">输入平台地址,在新窗口授权后进入私人调试画布。</p>
273
+ <div class="sdk-card" hidden><strong>画布插件 SDK</strong><p class="sdk-message" role="status" aria-live="polite"></p><button class="sdk-action" type="button" hidden>升级 SDK</button></div>
274
+ <div class="form-actions"><button id="open-local-config" type="button">配置本地插件</button></div>
258
275
  <form id="connect-form"><label for="platform-address">画布平台地址</label><input id="platform-address" type="url" placeholder="https://canvas.example.com" autocomplete="url" aria-describedby="address-help" required><p id="address-help" class="field-help">线上平台使用 HTTPS,本机平台可使用 HTTP。</p><div class="form-actions"><button id="connect-button" type="submit" disabled><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 10h11m-4-4 4 4-4 4M15.5 4h2v12h-2"/></svg><span>登录并连接</span></button></div></form>
259
276
  <p id="setup-status" role="status" aria-live="polite"></p>
260
277
  </section>
@@ -279,8 +296,8 @@ dialog::backdrop { background: #30384266; }
279
296
  <div id="notice" class="notice" role="status" hidden></div>
280
297
  <dialog id="panel" aria-labelledby="panel-title">
281
298
  <div class="dialog-head"><div><h2 id="panel-title">插件管理</h2><p>选择调试插件并下载可用的插件包</p></div><button id="close" type="button" aria-label="关闭管理面板"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M4.5 4.5 15.5 15.5M15.5 4.5 4.5 15.5"/></svg></button></div>
282
- <div class="dialog-body"><div class="dialog-main"><section class="target-section" aria-labelledby="target-title"><div class="section-heading"><h3 id="target-title">插件</h3><span id="panel-status" class="status-badge" role="status">正在连接</span></div><div id="plugin-list" class="plugin-list" role="radiogroup" aria-labelledby="target-title"></div><div class="plugin-actions"><button id="open-plugin" type="button"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 10h11m-4-4 4 4-4 4M16 4h2v12h-2"/></svg><span>在画布中调试</span></button><button id="download-package" type="button" disabled><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2.5v10m-3.5-3.5L10 12.5 13.5 9M3.5 14v2.5h13V14"/></svg><span id="download-label">下载插件包</span></button></div><p id="package-note" class="package-note" role="status" aria-live="polite"></p></section>
283
- <section class="session-section" aria-labelledby="session-title"><div class="section-heading"><h3 id="session-title">当前会话</h3><span id="active-plugin" class="section-context"></span></div><dl class="session-summary"><div><dt>开发服务</dt><dd id="connection">—</dd></div><div><dt>当前构建</dt><dd id="build">—</dd></div><div><dt>调试画布</dt><dd id="project">—</dd></div></dl><details class="technical-details"><summary>查看连接详情</summary><dl><dt>连接 ID</dt><dd id="connection-id">—</dd><dt>构建摘要</dt><dd id="build-full">—</dd><dt>项目 ID</dt><dd id="project-id">—</dd></dl></details></section></div>
299
+ <div class="dialog-body"><div class="dialog-main"><section class="target-section" aria-labelledby="target-title"><div class="section-heading"><h3 id="target-title">插件</h3><span id="panel-status" class="status-badge" role="status">正在连接</span></div><div id="plugin-list" class="plugin-list" role="radiogroup" aria-labelledby="target-title"></div><div class="plugin-actions"><button id="open-plugin" type="button"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 10h11m-4-4 4 4-4 4M16 4h2v12h-2"/></svg><span>在画布中调试</span></button><button id="download-package" type="button" disabled><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2.5v10m-3.5-3.5L10 12.5 13.5 9M3.5 14v2.5h13V14"/></svg><span id="download-label">下载插件包</span></button></div><p id="package-note" class="package-note" role="status" aria-live="polite"></p><form id="plugin-config" class="plugin-config" hidden><h4>本地插件配置</h4><p id="config-status" role="status"></p><div id="config-fields"></div><div class="config-actions"><button type="submit">保存并启用</button><button id="config-check" type="button">检查配置</button></div></form></section>
300
+ <section class="session-section" aria-labelledby="session-title"><div class="section-heading"><h3 id="session-title">当前会话</h3><span id="active-plugin" class="section-context"></span></div><dl class="session-summary"><div><dt>开发服务</dt><dd id="connection">—</dd></div><div><dt>当前构建</dt><dd id="build">—</dd></div><div><dt>调试画布</dt><dd id="project">—</dd></div></dl><div class="sdk-card" hidden><strong>画布插件 SDK</strong><p class="sdk-message" role="status" aria-live="polite"></p><button class="sdk-action" type="button" hidden>升级 SDK</button></div><details class="technical-details"><summary>查看连接详情</summary><dl><dt>连接 ID</dt><dd id="connection-id">—</dd><dt>构建摘要</dt><dd id="build-full">—</dd><dt>项目 ID</dt><dd id="project-id">—</dd></dl></details></section></div>
284
301
  <aside class="activity-section" aria-labelledby="activity-title"><div class="section-heading"><h3 id="activity-title">最近活动</h3><span id="activity-count" class="activity-count">0 条</span></div><p>当前工作台会话的连接与构建记录</p><div id="log" class="activity-list" role="log" aria-label="调试活动" aria-live="polite"></div></aside></div>
285
302
  <div class="dialog-actions"><button id="disconnect-panel" type="button">退出连接</button><div><button id="panel-cancel" type="button">关闭</button><button id="reconnect" class="primary" type="button"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16.6 8.5A6.7 6.7 0 1 0 17 12M16.5 3.5v5h-5"/></svg><span>重新连接</span></button></div></div>
286
303
  </dialog>
@@ -290,6 +307,47 @@ dialog::backdrop { background: #30384266; }
290
307
  const $ = id => document.getElementById(id);
291
308
  const frame = $('canvas'), panel = $('panel'), dock = $('dock');
292
309
  let connectorStatus = null, connectAttemptError = '', localServiceAvailable = false, connectInProgress = false;
310
+ let sdkStatus = null, sdkBusy = false, sdkError = '';
311
+ function renderSdkStatus() {
312
+ const state = sdkStatus?.state;
313
+ const version = sdkStatus?.runningVersion || '未知';
314
+ const connected = connectorStatus?.phase === 'connected';
315
+ const message = sdkBusy ? '正在安装新版本并更新项目依赖,请稍候…'
316
+ : sdkError || (state === 'update' ? `当前运行 ${version},npm 最新 ${sdkStatus.latestVersion}。${connected ? '请先断开画布连接再升级。' : '可以升级。'}`
317
+ : state === 'restart' ? `已安装 ${sdkStatus.installedVersion},重启 npm run dev 后生效。`
318
+ : state === 'current' ? `当前运行 ${version},已是最新版本。`
319
+ : state === 'error' ? sdkStatus.message : '');
320
+ for (const card of document.querySelectorAll('.sdk-card')) {
321
+ card.hidden = !state || state === 'unavailable';
322
+ card.querySelector('.sdk-message').textContent = message;
323
+ const button = card.querySelector('.sdk-action');
324
+ button.hidden = state !== 'update' && state !== 'error';
325
+ button.textContent = state === 'error' ? '重新检查' : connected ? '断开连接后升级' : '一键升级';
326
+ button.disabled = sdkBusy || connected && state === 'update';
327
+ }
328
+ }
329
+ async function loadSdkStatus() {
330
+ if (sdkBusy) return;
331
+ try {
332
+ const response = await fetch('/studio/sdk', { cache: 'no-store' });
333
+ if (!response.ok) throw new Error('无法读取 SDK 版本');
334
+ sdkStatus = await response.json(); sdkError = '';
335
+ } catch { sdkStatus = { state: 'error', message: '无法读取 SDK 版本,请检查本地服务。' }; }
336
+ renderSdkStatus();
337
+ }
338
+ for (const button of document.querySelectorAll('.sdk-action')) button.addEventListener('click', async () => {
339
+ if (sdkStatus?.state === 'error') { await loadSdkStatus(); return; }
340
+ if (sdkStatus?.state !== 'update' || sdkBusy) return;
341
+ sdkBusy = true; sdkError = ''; renderSdkStatus();
342
+ try {
343
+ const response = await fetch('/studio/sdk/upgrade', { method: 'POST' });
344
+ const result = await response.json();
345
+ if (!response.ok) throw new Error(result.error || '升级失败');
346
+ sdkStatus = result;
347
+ } catch (error) { sdkError = `${error.message}。可在终端手动运行 npm install --save-exact @easyai-canvas/canvas-plugin-sdk@latest。`; }
348
+ finally { sdkBusy = false; renderSdkStatus(); }
349
+ });
350
+ void loadSdkStatus(); setInterval(loadSdkStatus, 10 * 60_000);
293
351
  try { $('platform-address').value = localStorage.getItem('plugin-debug-platform-url') || ''; } catch {}
294
352
  $('diag-local').textContent = window.location.host;
295
353
  function renderTargetAddress(raw = $('platform-address').value) {
@@ -326,6 +384,7 @@ dialog::backdrop { background: #30384266; }
326
384
  const response = await fetch('/studio/status', { cache: 'no-store' });
327
385
  if (!response.ok) throw new Error('本地服务状态读取失败');
328
386
  connectorStatus = await response.json();
387
+ renderSdkStatus();
329
388
  setLocalServiceState('ready');
330
389
  const targetAddress = connectorStatus.phase === 'idle' ? $('platform-address').value : connectorStatus.platformOrigin || $('platform-address').value;
331
390
  renderTargetAddress(targetAddress);
@@ -448,9 +507,44 @@ dialog::backdrop { background: #30384266; }
448
507
  $('dock-expand').addEventListener('click', () => { manuallyCollapsed = false; expandedOverDialog = canvasDialogOpen; renderDock(); });
449
508
  const chosen = () => catalog?.plugins.find(item => item.id === selected);
450
509
  const panelPlugin = () => catalog?.plugins.find(item => item.id === panelSelected);
510
+ let configFormKey = '';
511
+ function renderConfig() {
512
+ const plugin = panelPlugin();
513
+ const form = $('plugin-config');
514
+ const fields = plugin?.manifest.configFields || [];
515
+ form.hidden = !fields.length;
516
+ if (!fields.length) { configFormKey = ''; return; }
517
+ const key = JSON.stringify([plugin.id, plugin.buildKey, plugin.config, plugin.secretConfigured]);
518
+ if (key === configFormKey) return;
519
+ configFormKey = key;
520
+ $('config-fields').replaceChildren(); $('config-status').textContent = plugin.configurationRequired ? '待配置:填写必填项后才能启用。' : '配置仅保存在本地开发服务中。';
521
+ for (const field of fields) {
522
+ const label = document.createElement('label');
523
+ label.textContent = `${field.label}${field.required ? ' *' : ''}`;
524
+ let input;
525
+ if (field.type === 'select') {
526
+ input = document.createElement('select');
527
+ const empty = document.createElement('option'); empty.value = ''; empty.textContent = '请选择'; input.append(empty);
528
+ for (const choice of field.options || []) { const option = document.createElement('option'); option.value = choice.value; option.textContent = choice.label; input.append(option); }
529
+ input.value = String(plugin.config?.[field.key] ?? field.default ?? '');
530
+ } else {
531
+ input = document.createElement('input');
532
+ input.type = field.type === 'secret' ? 'password' : field.type === 'number' ? 'number' : field.type === 'boolean' ? 'checkbox' : 'text';
533
+ if (field.type === 'boolean') input.checked = Boolean(plugin.config?.[field.key] ?? field.default);
534
+ else if (field.type !== 'secret') input.value = String(plugin.config?.[field.key] ?? field.default ?? '');
535
+ if (field.type === 'number') input.step = 'any';
536
+ }
537
+ input.name = field.key;
538
+ input.required = Boolean(field.required && (field.type !== 'secret' || !plugin.secretConfigured?.[field.key]));
539
+ label.append(input);
540
+ if (field.type === 'secret') { const hint = document.createElement('small'); hint.textContent = plugin.secretConfigured?.[field.key] ? '已配置;留空保留原密钥' : '尚未配置'; label.append(hint); }
541
+ $('config-fields').append(label);
542
+ }
543
+ $('config-check').hidden = !plugin.configCheckAvailable;
544
+ }
451
545
  function renderPluginList() {
452
546
  const plugins = catalog?.plugins || [];
453
- const key = JSON.stringify(plugins.map(item => [item.id, item.name, item.nodes, item.actions, item.enabled]));
547
+ const key = JSON.stringify(plugins.map(item => [item.id, item.name, item.nodes, item.actions, item.enabled, item.configurationRequired]));
454
548
  if (key !== pluginListKey) {
455
549
  pluginListKey = key;
456
550
  $('plugin-list').replaceChildren();
@@ -463,7 +557,7 @@ dialog::backdrop { background: #30384266; }
463
557
  const id = document.createElement('code'); id.textContent = item.id;
464
558
  copy.append(name, id);
465
559
  const meta = document.createElement('span'); meta.className = `plugin-choice-meta${item.enabled ? '' : ' disabled'}`;
466
- meta.textContent = item.enabled ? `${item.nodes} 个节点 / ${item.actions} 个操作` : '已停用';
560
+ meta.textContent = item.configurationRequired ? '待配置' : item.enabled ? `${item.nodes} 个节点 / ${item.actions} 个操作` : '已停用';
467
561
  row.append(input, mark, copy, meta); $('plugin-list').append(row);
468
562
  }
469
563
  }
@@ -483,8 +577,8 @@ dialog::backdrop { background: #30384266; }
483
577
  const plugin = chosen();
484
578
  $('plugin-name').textContent = plugin?.name || '插件调试';
485
579
  const building = catalog?.devBuild?.state;
486
- $('state').textContent = building === 'error' ? '构建失败' : building === 'building' ? '正在构建' : frameFailed ? '画布加载失败' : busy ? (session ? '正在刷新' : '正在打开画布') : session ? (!session.active ? '会话已失效' : plugin?.buildKey === session.fingerprint ? '已连接' : '发现新构建') : '等待画布';
487
- $('dot').className = `dot ${building === 'error' || frameFailed ? 'error' : building === 'building' ? 'warning' : session ? !session.active ? 'error' : plugin?.buildKey === session.fingerprint ? 'ready' : 'warning' : ''}`;
580
+ $('state').textContent = building === 'error' ? '构建失败' : building === 'building' ? '正在构建' : plugin?.configurationRequired ? '待配置' : frameFailed ? '画布加载失败' : busy ? (session ? '正在刷新' : '正在打开画布') : session ? (!session.active ? '会话已失效' : plugin?.buildKey === session.fingerprint ? '已连接' : '发现新构建') : '等待画布';
581
+ $('dot').className = `dot ${building === 'error' || frameFailed ? 'error' : building === 'building' || plugin?.configurationRequired ? 'warning' : session ? !session.active ? 'error' : plugin?.buildKey === session.fingerprint ? 'ready' : 'warning' : ''}`;
488
582
  $('refresh').disabled = !session?.active || busy || frameFailed || Boolean(building && building !== 'ready');
489
583
  const stateText = $('state').textContent;
490
584
  $('panel-status').textContent = stateText;
@@ -497,6 +591,7 @@ dialog::backdrop { background: #30384266; }
497
591
  $('build-full').textContent = plugin?.buildKey || '—';
498
592
  $('project-id').textContent = session?.projectId || '—';
499
593
  renderPluginList();
594
+ renderConfig();
500
595
  const inspected = panelPlugin();
501
596
  $('open-plugin').disabled = !inspected?.enabled || busy || packageDownloading;
502
597
  $('download-package').disabled = !inspected?.package || packageDownloading;
@@ -609,13 +704,41 @@ dialog::backdrop { background: #30384266; }
609
704
  render();
610
705
  });
611
706
  $('manage').addEventListener('click', () => { panelSelected = selected; packageMessage = ''; packageError = false; render(); panel.showModal(); $('plugin-list').querySelector('input:checked')?.focus(); renderDock(); });
612
- panel.addEventListener('close', () => { renderDock(); $('manage').focus(); });
707
+ $('open-local-config').addEventListener('click', () => { panelSelected = selected; packageMessage = ''; packageError = false; render(); panel.showModal(); $('plugin-list').querySelector('input:checked')?.focus(); });
708
+ panel.addEventListener('close', () => { renderDock(); ($('setup').hidden ? $('manage') : $('open-local-config')).focus(); });
613
709
  $('close').addEventListener('click', () => panel.close());
614
710
  $('panel-cancel').addEventListener('click', () => panel.close());
615
711
  $('plugin-list').addEventListener('change', event => {
616
712
  if (!event.target.matches('input[name="studio-plugin"]')) return;
617
713
  panelSelected = event.target.value; packageMessage = ''; packageError = false; render();
618
714
  });
715
+ $('plugin-config').addEventListener('submit', async event => {
716
+ event.preventDefault();
717
+ const plugin = panelPlugin(); if (!plugin) return;
718
+ const config = {};
719
+ for (const field of plugin.manifest.configFields || []) {
720
+ const input = $('plugin-config').elements.namedItem(field.key);
721
+ if (!input) continue;
722
+ if (field.type === 'secret' && !input.value) continue;
723
+ config[field.key] = field.type === 'boolean' ? input.checked : field.type === 'number' ? Number(input.value) : input.value;
724
+ }
725
+ try {
726
+ const response = await fetch(`/studio/plugins/${encodeURIComponent(plugin.id)}/configure`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ config, enabled: true }) });
727
+ const result = await response.json();
728
+ if (!response.ok) throw new Error(result.error || '配置保存失败');
729
+ $('config-status').textContent = '配置已保存,插件已启用。';
730
+ await loadCatalog();
731
+ } catch (error) { $('config-status').textContent = error.message || '配置保存失败'; }
732
+ });
733
+ $('config-check').addEventListener('click', async () => {
734
+ const plugin = panelPlugin(); if (!plugin) return;
735
+ try {
736
+ const response = await fetch(`/studio/plugins/${encodeURIComponent(plugin.id)}/check`, { method: 'POST' });
737
+ const result = await response.json();
738
+ if (!response.ok) throw new Error(result.error || '配置检查失败');
739
+ $('config-status').textContent = result.message || result.result?.message || '配置检查通过。';
740
+ } catch (error) { $('config-status').textContent = error.message || '配置检查失败'; }
741
+ });
619
742
  $('open-plugin').addEventListener('click', () => {
620
743
  if (!panelPlugin()?.enabled || busy) return;
621
744
  selected = panelSelected; show(''); panel.close(); connect();
@@ -0,0 +1,74 @@
1
+ export declare const SDK_NAME = "@easyai-canvas/canvas-plugin-sdk";
2
+ type Runner = (args: string[], cwd: string, timeout: number) => Promise<string>;
3
+ export declare function compareVersions(left: string, right: string): number | null;
4
+ export declare class SdkVersionManager {
5
+ private readonly projectDir;
6
+ private readonly runningVersion;
7
+ private readonly run;
8
+ private latest;
9
+ private checkedAt;
10
+ private checking;
11
+ private upgrading;
12
+ constructor(projectDir: string, runningVersion: string, run?: Runner);
13
+ private packageVersion;
14
+ private latestVersion;
15
+ status(): Promise<{
16
+ state: "unavailable";
17
+ runningVersion: string;
18
+ installedVersion?: undefined;
19
+ declared?: undefined;
20
+ latestVersion?: undefined;
21
+ message?: undefined;
22
+ } | {
23
+ state: "restart";
24
+ runningVersion: string;
25
+ installedVersion: string;
26
+ declared: string;
27
+ latestVersion?: undefined;
28
+ message?: undefined;
29
+ } | {
30
+ state: "update" | "current";
31
+ runningVersion: string;
32
+ installedVersion: string;
33
+ latestVersion: string;
34
+ declared: string;
35
+ message?: undefined;
36
+ } | {
37
+ state: "error";
38
+ runningVersion: string;
39
+ installedVersion: string;
40
+ declared: string;
41
+ message: string;
42
+ latestVersion?: undefined;
43
+ }>;
44
+ upgrade(): Promise<{
45
+ state: "unavailable";
46
+ runningVersion: string;
47
+ installedVersion?: undefined;
48
+ declared?: undefined;
49
+ latestVersion?: undefined;
50
+ message?: undefined;
51
+ } | {
52
+ state: "restart";
53
+ runningVersion: string;
54
+ installedVersion: string;
55
+ declared: string;
56
+ latestVersion?: undefined;
57
+ message?: undefined;
58
+ } | {
59
+ state: "update" | "current";
60
+ runningVersion: string;
61
+ installedVersion: string;
62
+ latestVersion: string;
63
+ declared: string;
64
+ message?: undefined;
65
+ } | {
66
+ state: "error";
67
+ runningVersion: string;
68
+ installedVersion: string;
69
+ declared: string;
70
+ message: string;
71
+ latestVersion?: undefined;
72
+ }>;
73
+ }
74
+ export {};
@@ -0,0 +1,101 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execute = promisify(execFile);
6
+ export const SDK_NAME = '@easyai-canvas/canvas-plugin-sdk';
7
+ async function npm(args, cwd, timeout) {
8
+ const { stdout } = await execute('npm', args, { cwd, timeout, maxBuffer: 1024 * 1024 });
9
+ return stdout.trim();
10
+ }
11
+ function versionParts(value) {
12
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
13
+ return match ? match.slice(1).map(Number) : null;
14
+ }
15
+ export function compareVersions(left, right) {
16
+ const a = versionParts(left);
17
+ const b = versionParts(right);
18
+ if (!a || !b)
19
+ return null;
20
+ for (let index = 0; index < 3; index++) {
21
+ if (a[index] !== b[index])
22
+ return Math.sign(a[index] - b[index]);
23
+ }
24
+ return 0;
25
+ }
26
+ export class SdkVersionManager {
27
+ projectDir;
28
+ runningVersion;
29
+ run;
30
+ latest = null;
31
+ checkedAt = 0;
32
+ checking = null;
33
+ upgrading = false;
34
+ constructor(projectDir, runningVersion, run = npm) {
35
+ this.projectDir = projectDir;
36
+ this.runningVersion = runningVersion;
37
+ this.run = run;
38
+ }
39
+ async packageVersion(path) {
40
+ try {
41
+ return JSON.parse(await readFile(path, 'utf8'));
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ async latestVersion() {
48
+ if (this.latest && Date.now() - this.checkedAt < 10 * 60_000)
49
+ return this.latest;
50
+ if (!this.checking) {
51
+ this.checking = this.run(['view', `${SDK_NAME}@latest`, 'version', '--json', '--fetch-retries=0'], this.projectDir, 8000)
52
+ .then(output => {
53
+ const version = JSON.parse(output);
54
+ if (typeof version !== 'string' || !versionParts(version))
55
+ throw new Error('npm 返回了无效的 SDK 版本');
56
+ this.latest = version;
57
+ this.checkedAt = Date.now();
58
+ return version;
59
+ }).finally(() => { this.checking = null; });
60
+ }
61
+ return this.checking;
62
+ }
63
+ async status() {
64
+ const project = await this.packageVersion(join(this.projectDir, 'package.json'));
65
+ const declared = project?.dependencies?.[SDK_NAME] ?? project?.devDependencies?.[SDK_NAME];
66
+ if (!declared)
67
+ return { state: 'unavailable', runningVersion: this.runningVersion };
68
+ const installed = await this.packageVersion(join(this.projectDir, 'node_modules', SDK_NAME, 'package.json'));
69
+ const installedVersion = installed?.version ?? this.runningVersion;
70
+ if (compareVersions(installedVersion, this.runningVersion) === 1)
71
+ return { state: 'restart', runningVersion: this.runningVersion, installedVersion, declared };
72
+ try {
73
+ const latestVersion = await this.latestVersion();
74
+ return { state: compareVersions(installedVersion, latestVersion) === -1 ? 'update' : 'current',
75
+ runningVersion: this.runningVersion, installedVersion, latestVersion, declared };
76
+ }
77
+ catch {
78
+ return { state: 'error', runningVersion: this.runningVersion, installedVersion, declared,
79
+ message: '暂时无法查询 npm 最新版本,请检查网络或 npm 仓库配置。' };
80
+ }
81
+ }
82
+ async upgrade() {
83
+ if (this.upgrading)
84
+ throw new Error('SDK 正在升级,请稍后再试');
85
+ this.upgrading = true;
86
+ try {
87
+ const status = await this.status();
88
+ if (status.state === 'unavailable')
89
+ throw new Error('当前项目没有声明 SDK 依赖');
90
+ if (status.state === 'error')
91
+ throw new Error(status.message);
92
+ if (status.state !== 'update')
93
+ return status;
94
+ await this.run(['install', '--save-exact', `${SDK_NAME}@${status.latestVersion}`], this.projectDir, 120_000);
95
+ return this.status();
96
+ }
97
+ finally {
98
+ this.upgrading = false;
99
+ }
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easyai-canvas/canvas-plugin-sdk",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "EasyAI 画布插件开发工具与运行时",
5
5
  "type": "module",
6
6
  "engines": {
@@ -31,7 +31,8 @@
31
31
  "LICENSE"
32
32
  ],
33
33
  "publishConfig": {
34
- "access": "public"
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org/"
35
36
  },
36
37
  "dependencies": {
37
38
  "better-sqlite3": "12.4.1",
@@ -30,6 +30,7 @@ export async function buildPluginPackage({
30
30
  const plugin = (
31
31
  await import(pathToFileURL(resolve(directory, 'server/plugin.mjs')).href)
32
32
  ).default;
33
+ if (!plugin?.manifest) throw new Error(`插件入口 ${serverEntry} 必须默认导出 definePlugin(...)`);
33
34
  await build({
34
35
  configFile: false,
35
36
  publicDir: false,