@easyai-canvas/canvas-plugin-sdk 0.1.3 → 0.1.5

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,11 +13,17 @@ 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
+
18
+ `init` 还会在项目根目录生成 `AGENTS.md`,供 Codex 等编程助手了解插件目录、SDK 边界与验证命令。项目约定变化时,请与代码一起维护该文件。
19
+
20
+ 开发工作台位于 `http://localhost:4310/studio`,按插件展示配置表单和待配置状态。`npm run build` 为新结构分别输出 `dist/plugins/<pluginId>.zip` 与摘要文件;管理员可分别导入,或用 `npm start` 运行同一服务下的多个插件。
21
+
22
+ 节点在 `contributes.nodes[].placement` 声明 `{ slot: 'canvas.add.node', group: 'tools', order: 100 }`,素材选择操作在 `contributes.actions[].placement` 声明 `{ slot: 'canvas.add.resource', order: 100 }`。节点分组还可选 `basic`、`storyboard`;素材入口不使用节点分组。插件的 Vue 组件负责内部样式与交互,画布负责添加入口及拖动、权限等边界。旧版 `menu` 声明继续可用;未知位置或贡献能力与位置不匹配会在打包校验时报错。
17
23
 
18
24
  运行 `npm run dev` 后,工作台会显示当前 SDK 与 npm 最新版本。若有新版本,点击“一键升级”会在当前项目执行固定版本安装,更新 `package.json` 和 npm 锁文件;安装完成后重启 `npm run dev`,新运行时才会生效。版本检查失败时仍可继续开发。
19
25
 
20
- 平台托管只接纳运行不超过 20 秒的节点任务;需要长任务、GPU 或自有基础设施时使用独立服务。构建还会生成 `dist/plugin.zip.sha256` 供管理员核对。独立服务生产环境必须提供 `PLUGIN_SERVICE_SIGNING_SECRET`、与平台连接 ID 一致的 `PLUGIN_SERVICE_AUDIENCE` 和持久化的 `PLUGIN_DATA_DIR`。业务密钥放在插件配置或部署环境,不写入前端组件。
26
+ 平台托管只接纳运行不超过 20 秒的节点任务;需要长任务、GPU 或自有基础设施时使用独立服务。构建还会生成每个 ZIP 的 `.sha256` 供管理员核对。独立服务生产环境必须提供 `PLUGIN_SERVICE_SIGNING_SECRET`、与平台连接 ID 一致的 `PLUGIN_SERVICE_AUDIENCE` 和持久化的 `PLUGIN_DATA_DIR`。业务密钥放在插件配置或部署环境,不写入前端组件。
21
27
 
22
28
  托管节点暂不支持文件和画布宿主能力。管理员导入 ZIP 后默认停用,审核插件代码、声明能力和版本摘要后启用。版本切换不会改变已提交任务使用的包摘要。
23
29
 
@@ -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,14 @@ 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
+ 'AGENTS.md': await readFile(join(packageRoot, 'templates/AGENTS.md'), 'utf8'),
142
+ [`src/plugins/${slug}/plugin.ts`]: pluginSource(id, title, template),
143
+ [`src/plugins/${slug}/ui.ts`]: uiSource(template),
144
+ [`test/${slug}.test.ts`]: testSource(id, template, slug),
143
145
  '.gitignore': 'node_modules/\ndist/\n.easyai-plugin/\n.env\n',
144
146
  '.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`,
147
+ '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',
148
+ '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
149
  '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
150
  };
149
151
  for (const [path, content] of Object.entries(files)) await writeFile(join(target, path), content, { flag: 'wx' });
@@ -158,34 +160,77 @@ async function run(executable, argv, directory = cwd, env = process.env) {
158
160
  child.once('exit', code => code === 0 ? resolvePromise() : reject(new Error(`${executable} 退出码 ${code}`)));
159
161
  });
160
162
  }
163
+ function templateArg() {
164
+ return args.find(arg => arg.startsWith('--template='))?.slice('--template='.length)
165
+ ?? (args.includes('--template') ? args[args.indexOf('--template') + 1] : 'node');
166
+ }
167
+ async function add() {
168
+ const template = templateArg();
169
+ if (!['node', 'action', 'combined'].includes(template)) fail('模板只支持 node、action 或 combined');
170
+ const directory = args.find((arg, index) => !arg.startsWith('--') && args[index - 1] !== '--template');
171
+ if (!directory) fail('用法:easyai-plugin add <插件目录> [--template node|action|combined]');
172
+ const slug = safeName(directory);
173
+ if (slug !== directory) fail('插件目录只能使用小写英文字母、数字和短横线');
174
+ const path = join(cwd, 'src/plugins', slug);
175
+ if (existsSync(path)) fail(`插件目录已存在:${path}`);
176
+ await mkdir(path, { recursive: true });
177
+ await mkdir(join(cwd, 'test'), { recursive: true });
178
+ const id = `customer.${slug.replaceAll('-', '.')}`;
179
+ await writeFile(join(path, 'plugin.ts'), pluginSource(id, slug.replaceAll('-', ' '), template), { flag: 'wx' });
180
+ await writeFile(join(path, 'ui.ts'), uiSource(template), { flag: 'wx' });
181
+ await writeFile(join(cwd, 'test', `${slug}.test.ts`), testSource(id, template, slug), { flag: 'wx' });
182
+ console.log(`已添加插件 ${id};运行 npm run test:contract && npm run build`);
183
+ }
184
+ async function pluginEntries() {
185
+ const root = join(cwd, 'src/plugins');
186
+ const folders = existsSync(root) ? (await readdir(root, { withFileTypes: true }))
187
+ .filter(item => item.isDirectory()).map(item => item.name).sort() : [];
188
+ const entries = folders.map(name => ({ name, server: join(root, name, 'plugin.ts'), ui: join(root, name, 'ui.ts'), legacy: false }));
189
+ 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 });
190
+ if (!entries.length) fail('缺少插件入口:src/plugins/<目录>/plugin.ts 或 src/plugin.ts');
191
+ for (const entry of entries) for (const path of [entry.server, entry.ui]) if (!existsSync(path)) fail(`缺少 ${path}`);
192
+ return entries;
193
+ }
161
194
  async function build() {
162
195
  nodeVersion();
163
- for (const path of ['src/plugin.ts', 'src/ui.ts']) if (!existsSync(join(cwd, path))) fail(`缺少 ${path}`);
196
+ const entries = await pluginEntries();
164
197
  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
198
  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)`);
199
+ await rm(join(cwd, 'dist/plugins'), { recursive: true, force: true });
200
+ const ids = new Set();
201
+ for (const entry of entries) {
202
+ const temporary = join(cwd, 'dist/plugins', `${entry.name}.zip`);
203
+ await buildPluginPackage({ serverEntry: entry.server, uiEntry: entry.ui,
204
+ globalName: `EasyAIPlugin${createHash('sha256').update(`${cwd}/${entry.name}`).digest('hex').slice(0, 12)}`, output: temporary });
205
+ const bytes = await readFile(temporary);
206
+ const inspected = await inspectPluginPackage(bytes);
207
+ const id = inspected.descriptor.manifest.id;
208
+ if (ids.has(id)) fail(`插件 ID 重复:${id}`);
209
+ ids.add(id);
210
+ const output = entry.legacy ? join(cwd, 'dist/plugin.zip') : join(cwd, 'dist/plugins', `${id}.zip`);
211
+ if (output !== temporary) await rename(temporary, output);
212
+ const digest = createHash('sha256').update(bytes).digest('hex');
213
+ await writeFile(`${output}.sha256`, `${digest} ${basename(output)}\n`);
214
+ console.log(`${id} → ${output}(SHA-256 ${digest})`);
215
+ }
174
216
  }
175
- function serviceEnv(dev) {
176
- const zip = join(cwd, 'dist/plugin.zip');
177
- if (!existsSync(zip)) fail('缺少 dist/plugin.zip;请先运行 npm run build');
217
+ async function serviceEnv(dev) {
218
+ const entries = await pluginEntries();
219
+ const folder = join(cwd, 'dist/plugins');
220
+ const zips = existsSync(folder) ? (await readdir(folder)).filter(name => name.endsWith('.zip')).map(name => join(folder, name)) : [];
221
+ if (entries.some(entry => entry.legacy)) zips.push(join(cwd, 'dist/plugin.zip'));
222
+ if (!zips.length || zips.some(zip => !existsSync(zip))) fail('缺少插件 ZIP;请先运行 npm run build');
178
223
  if (!existsSync(runtimeEntry)) fail('SDK 运行时未构建');
179
224
  if (!dev && !process.env.PLUGIN_SERVICE_SIGNING_SECRET) fail('生产服务需要 PLUGIN_SERVICE_SIGNING_SECRET');
180
225
  return { ...process.env, NODE_ENV: dev ? 'development' : 'production',
181
- PLUGIN_BUILTINS: '0', PLUGIN_PACKAGES: JSON.stringify([zip]),
226
+ PLUGIN_BUILTINS: '0', PLUGIN_PACKAGES: JSON.stringify(zips),
182
227
  PLUGIN_SDK_VERSION: metadata.version,
183
228
  PLUGIN_DATA_DIR: process.env.PLUGIN_DATA_DIR ?? join(cwd, '.easyai-plugin'),
184
229
  ...(dev ? { PLUGIN_AUTH_MODE: 'local', PLUGIN_BIND: '127.0.0.1', PLUGIN_DEV_WATCH: '1' } : {}) };
185
230
  }
186
231
  async function start() {
187
232
  nodeVersion();
188
- await run(process.execPath, [runtimeEntry], cwd, serviceEnv(false));
233
+ await run(process.execPath, [runtimeEntry], cwd, await serviceEnv(false));
189
234
  }
190
235
  async function dev() {
191
236
  await build();
@@ -195,9 +240,10 @@ async function dev() {
195
240
  const status = async (state, message = '') => writeFile(join(dataDir, 'dev-build-status.json'),
196
241
  JSON.stringify({ state, message: message.slice(0, 500), updatedAt: new Date().toISOString() }));
197
242
  const launch = () => {
198
- child = spawn(process.execPath, [runtimeEntry], { cwd, env: serviceEnv(true), stdio: 'inherit' });
243
+ child = spawn(process.execPath, [runtimeEntry], { cwd, env: launchEnv, stdio: 'inherit' });
199
244
  child.on('error', error => console.error(error));
200
245
  };
246
+ let launchEnv = await serviceEnv(true);
201
247
  await status('ready');
202
248
  launch();
203
249
  console.log(`真实画布调试工作台:http://localhost:${process.env.PORT ?? 4310}/studio`);
@@ -211,6 +257,7 @@ async function dev() {
211
257
  await status('building');
212
258
  try {
213
259
  await build();
260
+ launchEnv = await serviceEnv(true);
214
261
  child?.kill('SIGTERM');
215
262
  await new Promise(resolvePromise => child?.once('exit', resolvePromise));
216
263
  launch();
@@ -235,8 +282,9 @@ async function doctor() {
235
282
  const checks = [];
236
283
  const [major, minor] = process.versions.node.split('.').map(Number);
237
284
  checks.push({ name: 'Node 22.22+', ok: major === 22 && minor >= 22 });
238
- checks.push({ name: '插件入口', ok: existsSync(join(cwd, 'src/plugin.ts')) });
239
- checks.push({ name: 'UI 入口', ok: existsSync(join(cwd, 'src/ui.ts')) });
285
+ const entries = await pluginEntries().catch(() => []);
286
+ checks.push({ name: `插件入口(${entries.length} 个)`, ok: entries.length > 0 });
287
+ checks.push({ name: '插件 ID 唯一', ok: entries.length > 0 && (await build().then(() => true).catch(() => false)) });
240
288
  const port = Number(process.env.PORT ?? 4310);
241
289
  const available = await new Promise(resolvePromise => {
242
290
  const server = net.createServer();
@@ -256,12 +304,13 @@ async function doctor() {
256
304
 
257
305
  try {
258
306
  if (command === 'init') await init();
307
+ else if (command === 'add') await add();
259
308
  else if (command === 'dev') await dev();
260
309
  else if (command === 'test:contract') await contractTest();
261
310
  else if (command === 'build') await build();
262
311
  else if (command === 'start') await start();
263
312
  else if (command === 'doctor') await doctor();
264
- else fail('用法:easyai-plugin <init|dev|test:contract|build|start|doctor>');
313
+ else fail('用法:easyai-plugin <init|add|dev|test:contract|build|start|doctor>');
265
314
  } catch (error) {
266
315
  console.error(error instanceof Error ? error.message : String(error));
267
316
  process.exitCode = 1;
@@ -17,7 +17,7 @@ const sdkVersions = new SdkVersionManager(process.cwd(), process.env.PLUGIN_SDK_
17
17
  if (process.env.PLUGIN_DEV_WATCH === '1') {
18
18
  for (const plugin of packaged.plugins) {
19
19
  const entry = runtime.catalog().plugins.find(item => item.manifest.id === plugin.manifest.id);
20
- 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] !== '')))
21
21
  runtime.configure(plugin.manifest.id, { enabled: true });
22
22
  }
23
23
  }
@@ -73,6 +73,10 @@ async function studioCatalog() {
73
73
  return {
74
74
  id: entry.manifest.id, name: entry.manifest.name, release: entry.manifest.release,
75
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] !== '')),
76
80
  nodes: entry.manifest.contributes.nodes?.length ?? 0,
77
81
  actions: entry.manifest.contributes.actions?.length ?? 0,
78
82
  package: packaged.downloads[entry.manifest.id] ? { mode: 'download' } : null,
@@ -197,6 +201,21 @@ const server = createServer(async (request, response) => {
197
201
  send(response, 200, await studioCatalog());
198
202
  return;
199
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
+ }
200
219
  if (method === 'GET' && path === '/studio/sdk') {
201
220
  send(response, 200, await sdkVersions.status());
202
221
  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)
@@ -127,28 +127,51 @@ button:focus-visible, select:focus-visible, input:focus-visible, summary:focus-v
127
127
  .empty button { justify-self: center; min-height: 40px; border: 0; border-radius: 10px; padding: 0 16px; font-size: 12px; font-weight: 600; }
128
128
 
129
129
  /* Plugin management dialog */
130
- dialog { width: min(820px, calc(100vw - 32px)); max-width: calc(100vw - 32px); max-height: min(720px, calc(100dvh - 32px)); margin: auto; overflow: hidden; border: 1px solid var(--line-strong); border-radius: 18px; padding: 0; color: var(--ink); background: #fff; box-shadow: var(--floating-shadow); }
130
+ dialog { width: min(1040px, calc(100vw - 32px)); max-width: calc(100vw - 32px); max-height: min(900px, calc(100dvh - 32px)); margin: auto; overflow: hidden; border: 1px solid var(--line-strong); border-radius: 18px; padding: 0; color: var(--ink); background: #fff; box-shadow: var(--floating-shadow); }
131
131
  dialog[open] { display: flex; flex-direction: column; }
132
132
  dialog::backdrop { background: #30384266; }
133
- .dialog-head { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 24px 28px 20px; border-bottom: 1px solid var(--line); }
134
- .dialog-head h2 { margin: 0 0 5px; font-size: 20px; line-height: 1.35; font-weight: 600; }
133
+ .dialog-head { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 24px 30px 20px; border-bottom: 1px solid var(--line); }
134
+ .dialog-head h2 { margin: 0 0 5px; font-size: 22px; line-height: 1.35; font-weight: 650; }
135
135
  .dialog-head p { margin: 0; color: var(--quiet); font-size: 12px; }
136
136
  .dialog-head button { display: grid; place-items: center; width: 36px; height: 36px; flex: none; border: 0; border-radius: 9px; color: var(--quiet); background: transparent; }
137
137
  .dialog-head button:hover { color: var(--ink); background: var(--page); }
138
138
  .dialog-head svg { width: 18px; height: 18px; }
139
- .dialog-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) 290px; overflow: hidden; }
140
- .dialog-main { min-width: 0; overflow: auto; padding: 0 28px 26px; }
141
- .target-section, .session-section { padding: 24px 0; }
142
- .target-section { border-bottom: 1px solid var(--line); }
139
+ .dialog-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(248px, 30%) minmax(0, 1fr); overflow: hidden; }
140
+ .plugin-sidebar { display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 24px 16px 20px 24px; border-right: 1px solid var(--line); background: var(--soft); }
141
+ .plugin-sidebar > p { margin: -6px 0 18px; color: var(--quiet); font-size: 12px; line-height: 1.5; }
142
+ .plugin-detail { min-width: 0; overflow: auto; padding: 27px 32px 30px; }
143
+ .detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
144
+ .detail-head h3 { margin: 0 0 5px; font-size: 20px; font-weight: 650; line-height: 1.35; }
145
+ .detail-head code { display: block; color: var(--quiet); font: 11px/1.5 var(--font-code); overflow-wrap: anywhere; }
146
+ .detail-meta { display: flex; flex-wrap: wrap; gap: 8px; margin: 17px 0 0; }
147
+ .detail-meta span { padding: 6px 10px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: var(--soft); font-size: 11px; }
148
+ .detail-empty { color: var(--quiet); font-size: 13px; }
149
+ .detail-empty[hidden], .plugin-detail-content[hidden] { display: none; }
143
150
  .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 15px; }
144
151
  .section-heading h3 { margin: 0; font-size: 13px; font-weight: 700; }
145
152
  .status-badge { display: inline-flex; align-items: center; gap: 7px; padding: 6px 10px; border-radius: 999px; color: var(--warning); background: #faf1df; font-size: 11px; font-weight: 600; white-space: nowrap; }
146
153
  .status-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
147
154
  .status-badge.ready { color: var(--success); background: #e9f4ef; }
148
155
  .status-badge.error { color: var(--danger); background: #fff1ef; }
149
- .plugin-list { display: grid; gap: 8px; max-height: 224px; overflow: auto; }
156
+ .plugin-list { display: grid; align-content: start; gap: 8px; min-height: 0; overflow: auto; padding-right: 7px; }
150
157
  .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-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; }
158
+ .configuration-section { margin-top: 28px; padding-top: 22px; border-top: 1px solid var(--line); }
159
+ .configuration-section h4 { margin: 0; font-size: 15px; font-weight: 650; }
160
+ .configuration-section > p { margin: 7px 0 16px; color: var(--quiet); font-size: 12px; line-height: 1.5; }
161
+ .no-config { padding: 16px; border: 1px solid var(--line); border-radius: 10px; color: var(--muted); background: var(--soft); font-size: 12px; }
162
+ .no-config[hidden] { display: none; }
163
+ .plugin-config { display: grid; gap: 14px; padding: 18px; border: 1px solid var(--line); border-radius: 12px; background: var(--soft); }
164
+ .plugin-config[hidden] { display: none; }
165
+ .plugin-config label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 600; }
166
+ .plugin-config input:not([type="checkbox"]), .plugin-config select { width: 100%; min-height: 42px; padding: 8px 11px; border: 1px solid var(--line-strong); border-radius: 9px; color: var(--ink); background: #fff; font-weight: 400; }
167
+ .plugin-config input[type="checkbox"] { width: 18px; height: 18px; }
168
+ .plugin-config button { min-height: 40px; padding: 0 14px; border: 1px solid var(--line-strong); border-radius: 9px; color: var(--ink); background: #fff; font-size: 12px; font-weight: 600; }
169
+ .plugin-config .config-actions { display: flex; flex-wrap: wrap; gap: 8px; }
170
+ .plugin-config .config-actions button[type="submit"] { border-color: var(--accent); color: #fff; background: var(--accent); }
171
+ .plugin-config #config-status { margin: 0; color: var(--quiet); font-size: 12px; line-height: 1.5; }
172
+ #config-fields { display: grid; gap: 14px; }
173
+ .plugin-config small { color: var(--quiet); }
174
+ .plugin-choice { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 78px; border: 1px solid var(--line); border-radius: 12px; padding: 11px 12px; background: #fff; cursor: pointer; }
152
175
  .plugin-choice:hover { border-color: var(--line-strong); background: var(--soft); }
153
176
  .plugin-choice:has(input:checked) { border-color: #aab6c1; background: #f9fafb; }
154
177
  .plugin-choice:focus-within { outline: 2px solid #8e9ca9; outline-offset: -3px; }
@@ -159,16 +182,25 @@ dialog::backdrop { background: #30384266; }
159
182
  .plugin-choice-copy strong, .plugin-choice-copy code { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
160
183
  .plugin-choice-copy strong { color: var(--ink); font-size: 13px; font-weight: 600; }
161
184
  .plugin-choice-copy code { color: var(--quiet); font: 400 10px/1.4 var(--font-code); }
162
- .plugin-choice-meta { color: var(--quiet); font-size: 11px; white-space: nowrap; }
185
+ .plugin-choice-meta { grid-column: 2; color: var(--quiet); font-size: 11px; white-space: nowrap; }
163
186
  .plugin-choice-meta.disabled { color: var(--danger); }
164
- .plugin-actions { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
165
- .plugin-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; border: 1px solid var(--line-strong); border-radius: 10px; padding: 0 12px; color: var(--ink); background: #fff; box-shadow: var(--control-shadow); font-size: 12px; font-weight: 600; }
187
+ .plugin-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 22px; }
188
+ .plugin-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 42px; border: 1px solid var(--line-strong); border-radius: 10px; padding: 0 14px; color: var(--ink); background: #fff; box-shadow: var(--control-shadow); font-size: 12px; font-weight: 600; }
166
189
  .plugin-actions button:hover:not(:disabled) { background: var(--page); }
167
- .plugin-actions #open-plugin { background: #f2f4f6; }
190
+ .plugin-actions #open-plugin { border-color: var(--accent); color: #fff; background: var(--accent); }
191
+ .plugin-actions #open-plugin:hover:not(:disabled) { background: #252b31; }
168
192
  .plugin-actions svg { width: 16px; height: 16px; flex: none; }
169
193
  .package-note { margin: 10px 0 0; color: var(--quiet); font-size: 11px; line-height: 1.5; }
170
194
  .package-note:empty { display: none; }
171
195
  .package-note.error { color: var(--danger); }
196
+ .secondary-panels { display: grid; gap: 12px; margin-top: 26px; }
197
+ .secondary-panels > details { border: 1px solid var(--line); border-radius: 11px; background: #fff; }
198
+ .secondary-panels > details > summary { display: flex; align-items: center; gap: 8px; padding: 14px 16px; font-size: 12px; font-weight: 650; cursor: pointer; list-style: none; }
199
+ .secondary-panels > details > summary::-webkit-details-marker { display: none; }
200
+ .secondary-panels > details > summary::after { content: "+"; margin-left: auto; color: var(--quiet); font-size: 18px; font-weight: 400; line-height: 1; }
201
+ .secondary-panels > details[open] > summary::after { content: "−"; }
202
+ .secondary-panels .panel-content { padding: 0 16px 16px; }
203
+ .secondary-panels .activity-list { max-height: 220px; }
172
204
  .section-context { overflow: hidden; color: var(--quiet); font-size: 11px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
173
205
  .session-summary { margin: 0; }
174
206
  .session-summary > div { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 51px; border-top: 1px solid #e9ebea; }
@@ -180,10 +212,8 @@ dialog::backdrop { background: #30384266; }
180
212
  .technical-details dl { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 10px 12px; margin: 14px 0 0; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--soft); font-size: 11px; }
181
213
  .technical-details dt { color: var(--quiet); }
182
214
  .technical-details dd { margin: 0; overflow-wrap: anywhere; font-family: var(--font-code); }
183
- .activity-section { display: flex; flex-direction: column; min-width: 0; overflow: hidden; padding: 24px 18px; border-left: 1px solid var(--line); background: var(--soft); }
184
- .activity-section > p { margin: -6px 0 16px; color: var(--quiet); font-size: 12px; line-height: 1.5; }
185
215
  .activity-count { color: var(--quiet); font-size: 11px; }
186
- .activity-list { flex: 1; min-height: 120px; max-height: 350px; overflow: auto; border-top: 1px solid var(--line); }
216
+ .activity-list { min-height: 80px; overflow: auto; border-top: 1px solid var(--line); }
187
217
  .activity-list:empty::before { content: "暂无活动"; display: block; padding: 25px; color: var(--quiet); font-size: 12px; text-align: center; }
188
218
  .activity-item { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 10px; padding: 11px 2px; color: var(--muted); font-size: 12px; line-height: 1.5; }
189
219
  .activity-item + .activity-item { border-top: 1px solid #eceeed; }
@@ -203,10 +233,13 @@ dialog::backdrop { background: #30384266; }
203
233
  .diagnostics { padding: 32px 46px 38px; border-top: 1px solid var(--line); border-left: 0; }
204
234
  }
205
235
  @media (max-width: 760px) {
206
- .dialog-body { grid-template-columns: 1fr; overflow: auto; }
207
- .dialog-main { overflow: visible; }
208
- .activity-section { min-height: 180px; border-top: 1px solid var(--line); border-left: 0; }
209
- .activity-list { max-height: 180px; }
236
+ .dialog-body { display: block; overflow: auto; }
237
+ .plugin-sidebar { padding: 18px 20px; border-right: 0; border-bottom: 1px solid var(--line); }
238
+ .plugin-sidebar > p { margin-bottom: 12px; }
239
+ .plugin-list { display: flex; flex: none; overflow-x: auto; padding: 2px 0 8px; }
240
+ .plugin-choice { flex: 0 0 min(245px, 75vw); }
241
+ .plugin-detail { overflow: visible; padding: 24px 20px; }
242
+ .detail-head { position: sticky; top: 0; z-index: 1; margin: -24px -20px 0; padding: 14px 20px; border-bottom: 1px solid var(--line); background: #fff; }
210
243
  }
211
244
  @media (max-width: 520px) {
212
245
  dialog { height: min(760px, calc(100dvh - 32px)); }
@@ -230,21 +263,17 @@ dialog::backdrop { background: #30384266; }
230
263
  .controls .button-label { display: none; }
231
264
  .dialog-head { padding: 18px 20px; }
232
265
  .dialog-head button { width: 44px; height: 44px; margin: -6px -6px 0 0; }
233
- .dialog-main { padding-inline: 20px; }
234
- .target-section, .session-section { padding-block: 18px; }
266
+ .detail-head h3 { font-size: 18px; }
235
267
  .section-heading { margin-bottom: 12px; }
236
268
  .session-summary > div { min-height: 42px; }
237
269
  .technical-details { padding-top: 12px; }
238
- .activity-section { padding: 18px 20px; }
239
270
  .dialog-actions { padding: 12px 20px; }
240
271
  .dialog-actions button { min-height: 44px; padding-inline: 9px; }
241
272
  .dock button { min-height: 44px; padding-inline: 0; }
242
273
  .dock-grip, .dock-collapse { width: 44px; }
243
274
  }
244
275
  @media (max-width: 375px) {
245
- .plugin-choice { grid-template-columns: 18px minmax(0, 1fr); }
246
276
  .plugin-choice-copy code { white-space: normal; overflow-wrap: anywhere; }
247
- .plugin-choice-meta { grid-column: 2; }
248
277
  .dialog-actions { flex-wrap: wrap; }
249
278
  .dialog-actions > div { margin-left: auto; }
250
279
  .dialog-actions button { padding-inline: 8px; }
@@ -263,6 +292,7 @@ dialog::backdrop { background: #30384266; }
263
292
  <h1 id="connection-title">连接画布平台</h1>
264
293
  <p class="setup-intro">输入平台地址,在新窗口授权后进入私人调试画布。</p>
265
294
  <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>
295
+ <div class="form-actions"><button id="open-local-config" type="button">配置本地插件</button></div>
266
296
  <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>
267
297
  <p id="setup-status" role="status" aria-live="polite"></p>
268
298
  </section>
@@ -286,10 +316,17 @@ dialog::backdrop { background: #30384266; }
286
316
  </div>
287
317
  <div id="notice" class="notice" role="status" hidden></div>
288
318
  <dialog id="panel" aria-labelledby="panel-title">
289
- <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>
290
- <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>
291
- <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>
292
- <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>
319
+ <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>
320
+ <div class="dialog-body">
321
+ <aside class="plugin-sidebar" aria-labelledby="target-title"><div class="section-heading"><h3 id="target-title">本地插件</h3><span id="plugin-count" class="activity-count">0 个</span></div><p>选择插件后在右侧管理</p><div id="plugin-list" class="plugin-list" role="radiogroup" aria-labelledby="target-title"></div></aside>
322
+ <div class="plugin-detail"><p id="detail-empty" class="detail-empty" hidden>暂无插件。请在本地服务中注册插件。</p><div id="plugin-detail-content" class="plugin-detail-content">
323
+ <div class="detail-head"><div><h3 id="detail-name">插件</h3><code id="detail-id"></code></div><span id="detail-status" class="status-badge" role="status">检查中</span></div>
324
+ <div class="detail-meta"><span id="detail-nodes">0 个节点</span><span id="detail-actions">0 个操作</span><span id="detail-package">插件包不可用</span></div>
325
+ <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>
326
+ <div class="configuration-section" aria-labelledby="configuration-title"><h4 id="configuration-title">插件配置</h4><p>配置仅保存在本地开发服务中。</p><div id="no-config" class="no-config" hidden>此插件无需配置,可以直接调试或下载插件包。</div><form id="plugin-config" class="plugin-config" hidden><p id="config-status" role="status" aria-live="polite"></p><div id="config-fields"></div><div class="config-actions"><button type="submit">保存并启用</button><button id="config-check" type="button">检查配置</button></div></form></div>
327
+ <div class="secondary-panels"><details><summary>连接与构建 <span id="panel-status" class="status-badge" role="status">正在连接</span></summary><div class="panel-content"><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><span id="active-plugin" class="section-context" hidden></span><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></div></details><details><summary>最近活动 <span id="activity-count" class="activity-count">0 条</span></summary><div class="panel-content"><div id="log" class="activity-list" role="log" aria-label="调试活动" aria-live="polite"></div></div></details></div>
328
+ </div></div>
329
+ </div>
293
330
  <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>
294
331
  </dialog>
295
332
  <script>
@@ -498,9 +535,47 @@ dialog::backdrop { background: #30384266; }
498
535
  $('dock-expand').addEventListener('click', () => { manuallyCollapsed = false; expandedOverDialog = canvasDialogOpen; renderDock(); });
499
536
  const chosen = () => catalog?.plugins.find(item => item.id === selected);
500
537
  const panelPlugin = () => catalog?.plugins.find(item => item.id === panelSelected);
538
+ let configFormKey = '', configFeedback = null;
539
+ function renderConfig() {
540
+ const plugin = panelPlugin();
541
+ const form = $('plugin-config');
542
+ const fields = plugin?.manifest?.configFields || [];
543
+ form.hidden = !fields.length;
544
+ $('no-config').hidden = !plugin || Boolean(fields.length);
545
+ if (!fields.length) { configFormKey = ''; return; }
546
+ const key = JSON.stringify([plugin.id, plugin.buildKey, plugin.config, plugin.secretConfigured]);
547
+ if (key === configFormKey) return;
548
+ configFormKey = key;
549
+ $('config-fields').replaceChildren();
550
+ $('config-status').textContent = configFeedback?.pluginId === plugin.id ? configFeedback.message : plugin.configurationRequired ? '待配置:填写必填项后才能启用。' : '可以修改配置并保存。';
551
+ for (const field of fields) {
552
+ const label = document.createElement('label');
553
+ label.textContent = `${field.label}${field.required ? ' *' : ''}`;
554
+ let input;
555
+ if (field.type === 'select') {
556
+ input = document.createElement('select');
557
+ const empty = document.createElement('option'); empty.value = ''; empty.textContent = '请选择'; input.append(empty);
558
+ for (const choice of field.options || []) { const option = document.createElement('option'); option.value = choice.value; option.textContent = choice.label; input.append(option); }
559
+ input.value = String(plugin.config?.[field.key] ?? field.default ?? '');
560
+ } else {
561
+ input = document.createElement('input');
562
+ input.type = field.type === 'secret' ? 'password' : field.type === 'number' ? 'number' : field.type === 'boolean' ? 'checkbox' : 'text';
563
+ if (field.type === 'boolean') input.checked = Boolean(plugin.config?.[field.key] ?? field.default);
564
+ else if (field.type !== 'secret') input.value = String(plugin.config?.[field.key] ?? field.default ?? '');
565
+ if (field.type === 'number') input.step = 'any';
566
+ }
567
+ input.name = field.key;
568
+ input.required = Boolean(field.required && (field.type !== 'secret' || !plugin.secretConfigured?.[field.key]));
569
+ label.append(input);
570
+ if (field.type === 'secret') { const hint = document.createElement('small'); hint.textContent = plugin.secretConfigured?.[field.key] ? '已配置;留空保留原密钥' : '尚未配置'; label.append(hint); }
571
+ $('config-fields').append(label);
572
+ }
573
+ $('config-check').hidden = !plugin.configCheckAvailable;
574
+ }
501
575
  function renderPluginList() {
502
576
  const plugins = catalog?.plugins || [];
503
- const key = JSON.stringify(plugins.map(item => [item.id, item.name, item.nodes, item.actions, item.enabled]));
577
+ $('plugin-count').textContent = `${plugins.length} 个`;
578
+ const key = JSON.stringify(plugins.map(item => [item.id, item.name, item.nodes, item.actions, item.enabled, item.configurationRequired]));
504
579
  if (key !== pluginListKey) {
505
580
  pluginListKey = key;
506
581
  $('plugin-list').replaceChildren();
@@ -513,7 +588,7 @@ dialog::backdrop { background: #30384266; }
513
588
  const id = document.createElement('code'); id.textContent = item.id;
514
589
  copy.append(name, id);
515
590
  const meta = document.createElement('span'); meta.className = `plugin-choice-meta${item.enabled ? '' : ' disabled'}`;
516
- meta.textContent = item.enabled ? `${item.nodes} 个节点 / ${item.actions} 个操作` : '已停用';
591
+ meta.textContent = item.configurationRequired ? '待配置' : item.enabled ? `${item.nodes} 个节点 / ${item.actions} 个操作` : '已停用';
517
592
  row.append(input, mark, copy, meta); $('plugin-list').append(row);
518
593
  }
519
594
  }
@@ -533,8 +608,8 @@ dialog::backdrop { background: #30384266; }
533
608
  const plugin = chosen();
534
609
  $('plugin-name').textContent = plugin?.name || '插件调试';
535
610
  const building = catalog?.devBuild?.state;
536
- $('state').textContent = building === 'error' ? '构建失败' : building === 'building' ? '正在构建' : frameFailed ? '画布加载失败' : busy ? (session ? '正在刷新' : '正在打开画布') : session ? (!session.active ? '会话已失效' : plugin?.buildKey === session.fingerprint ? '已连接' : '发现新构建') : '等待画布';
537
- $('dot').className = `dot ${building === 'error' || frameFailed ? 'error' : building === 'building' ? 'warning' : session ? !session.active ? 'error' : plugin?.buildKey === session.fingerprint ? 'ready' : 'warning' : ''}`;
611
+ $('state').textContent = building === 'error' ? '构建失败' : building === 'building' ? '正在构建' : plugin?.configurationRequired ? '待配置' : frameFailed ? '画布加载失败' : busy ? (session ? '正在刷新' : '正在打开画布') : session ? (!session.active ? '会话已失效' : plugin?.buildKey === session.fingerprint ? '已连接' : '发现新构建') : '等待画布';
612
+ $('dot').className = `dot ${building === 'error' || frameFailed ? 'error' : building === 'building' || plugin?.configurationRequired ? 'warning' : session ? !session.active ? 'error' : plugin?.buildKey === session.fingerprint ? 'ready' : 'warning' : ''}`;
538
613
  $('refresh').disabled = !session?.active || busy || frameFailed || Boolean(building && building !== 'ready');
539
614
  const stateText = $('state').textContent;
540
615
  $('panel-status').textContent = stateText;
@@ -547,7 +622,17 @@ dialog::backdrop { background: #30384266; }
547
622
  $('build-full').textContent = plugin?.buildKey || '—';
548
623
  $('project-id').textContent = session?.projectId || '—';
549
624
  renderPluginList();
625
+ renderConfig();
550
626
  const inspected = panelPlugin();
627
+ $('detail-empty').hidden = Boolean(inspected);
628
+ $('plugin-detail-content').hidden = !inspected;
629
+ $('detail-name').textContent = inspected?.name || '插件';
630
+ $('detail-id').textContent = inspected?.id || '';
631
+ $('detail-nodes').textContent = `${inspected?.nodes || 0} 个节点`;
632
+ $('detail-actions').textContent = `${inspected?.actions || 0} 个操作`;
633
+ $('detail-package').textContent = inspected?.package ? '插件包可下载' : '插件包不可用';
634
+ $('detail-status').textContent = inspected?.configurationRequired ? '待配置' : inspected?.enabled ? '已启用' : '已停用';
635
+ $('detail-status').className = `status-badge ${inspected?.configurationRequired ? '' : inspected?.enabled ? 'ready' : 'error'}`;
551
636
  $('open-plugin').disabled = !inspected?.enabled || busy || packageDownloading;
552
637
  $('download-package').disabled = !inspected?.package || packageDownloading;
553
638
  $('download-package').setAttribute('aria-busy', String(packageDownloading));
@@ -658,13 +743,50 @@ dialog::backdrop { background: #30384266; }
658
743
  frame.contentWindow.postMessage({ protocol, type: 'refresh', sessionId: session.sessionId, pluginId: selected, requestId: waiting }, catalog.platformOrigin);
659
744
  render();
660
745
  });
661
- $('manage').addEventListener('click', () => { panelSelected = selected; packageMessage = ''; packageError = false; render(); panel.showModal(); $('plugin-list').querySelector('input:checked')?.focus(); renderDock(); });
662
- panel.addEventListener('close', () => { renderDock(); $('manage').focus(); });
746
+ function openPanel(forConfiguration = false) {
747
+ panelSelected = forConfiguration ? catalog?.plugins.find(item => item.configurationRequired)?.id || selected : selected;
748
+ packageMessage = ''; packageError = false; configFeedback = null; configFormKey = ''; render(); panel.showModal();
749
+ const firstField = forConfiguration ? $('config-fields').querySelector('input, select') : null;
750
+ (firstField || $('plugin-list').querySelector('input:checked') || $('close')).focus();
751
+ renderDock();
752
+ }
753
+ $('manage').addEventListener('click', () => openPanel());
754
+ $('open-local-config').addEventListener('click', () => openPanel(true));
755
+ panel.addEventListener('close', () => { renderDock(); ($('setup').hidden ? $('manage') : $('open-local-config')).focus(); });
663
756
  $('close').addEventListener('click', () => panel.close());
664
757
  $('panel-cancel').addEventListener('click', () => panel.close());
665
758
  $('plugin-list').addEventListener('change', event => {
666
759
  if (!event.target.matches('input[name="studio-plugin"]')) return;
667
- panelSelected = event.target.value; packageMessage = ''; packageError = false; render();
760
+ panelSelected = event.target.value; packageMessage = ''; packageError = false; configFeedback = null; render(); document.querySelector('.plugin-detail').scrollTop = 0;
761
+ });
762
+ $('plugin-config').addEventListener('submit', async event => {
763
+ event.preventDefault();
764
+ const plugin = panelPlugin(); if (!plugin) return;
765
+ const config = {};
766
+ for (const field of plugin.manifest.configFields || []) {
767
+ const input = $('plugin-config').elements.namedItem(field.key);
768
+ if (!input) continue;
769
+ if (field.type === 'secret' && !input.value) continue;
770
+ config[field.key] = field.type === 'boolean' ? input.checked : field.type === 'number' ? Number(input.value) : input.value;
771
+ }
772
+ try {
773
+ const response = await fetch(`/studio/plugins/${encodeURIComponent(plugin.id)}/configure`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ config, enabled: true }) });
774
+ const result = await response.json();
775
+ if (!response.ok) throw new Error(result.error || '配置保存失败');
776
+ configFeedback = { pluginId: plugin.id, message: '配置已保存,插件已启用。' };
777
+ $('config-status').textContent = configFeedback.message;
778
+ await loadCatalog();
779
+ } catch (error) { configFeedback = { pluginId: plugin.id, message: error.message || '配置保存失败' }; $('config-status').textContent = configFeedback.message; }
780
+ });
781
+ $('config-check').addEventListener('click', async () => {
782
+ const plugin = panelPlugin(); if (!plugin) return;
783
+ try {
784
+ const response = await fetch(`/studio/plugins/${encodeURIComponent(plugin.id)}/check`, { method: 'POST' });
785
+ const result = await response.json();
786
+ if (!response.ok) throw new Error(result.error || '配置检查失败');
787
+ configFeedback = { pluginId: plugin.id, message: result.message || result.result?.message || '配置检查通过。' };
788
+ $('config-status').textContent = configFeedback.message;
789
+ } catch (error) { configFeedback = { pluginId: plugin.id, message: error.message || '配置检查失败' }; $('config-status').textContent = configFeedback.message; }
668
790
  });
669
791
  $('open-plugin').addEventListener('click', () => {
670
792
  if (!panelPlugin()?.enabled || busy) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easyai-canvas/canvas-plugin-sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "EasyAI 画布插件开发工具与运行时",
5
5
  "type": "module",
6
6
  "engines": {
@@ -27,11 +27,13 @@
27
27
  "bin",
28
28
  "dist",
29
29
  "tools",
30
+ "templates",
30
31
  "README.md",
31
32
  "LICENSE"
32
33
  ],
33
34
  "publishConfig": {
34
- "access": "public"
35
+ "access": "public",
36
+ "registry": "https://registry.npmjs.org/"
35
37
  },
36
38
  "dependencies": {
37
39
  "better-sqlite3": "12.4.1",
@@ -0,0 +1,21 @@
1
+ # 项目开发指引
2
+
3
+ 本项目使用 `@easyai-canvas/canvas-plugin-sdk` 开发 EasyAI 画布插件。请以当前安装版本的类型定义和项目代码为准;项目使用 Node 22.22.0 或更新的 Node 22 版本。
4
+
5
+ ## 代码位置
6
+
7
+ - 每个独立插件放在 `src/plugins/<目录>/`,其中 `plugin.ts` 声明 manifest、节点或操作及服务端处理器,`ui.ts` 实现 Vue 界面。对应契约测试放在 `test/`。
8
+ - 新增独立插件时运行 `npx easyai-plugin add <目录> --template node|action|combined`。同一插件的多个节点继续放在同一个目录,并保持插件 ID 与节点类型唯一。
9
+ - 只通过 SDK 的公开导入路径使用能力:`@easyai-canvas/canvas-plugin-sdk`、`/protocol`、`/ui`。不要依赖 EasyAI 平台源码或 SDK 内部文件。
10
+
11
+ ## 开发约定
12
+
13
+ - 修改 manifest、参数、输入输出或处理器时,同步更新对应契约测试。服务端从 `request.parameters` 和 `request.inputsByPort` 读取业务输入,并返回符合协议的输出。
14
+ - 前端通过 `usePluginNode()` 或 `usePluginAction()` 与画布交互。业务密钥放在插件配置或部署环境中,不写入 `ui.ts` 或前端产物。
15
+ - 有外部副作用的处理器使用 `executionId` 保证业务幂等;只有确认可安全重试时才声明 `safeRetry`。
16
+
17
+ ## 验证与交付
18
+
19
+ - 开发预览:`npm run dev`,工作台位于 `http://localhost:4310/studio`。
20
+ - 修改插件后运行 `npm run test:contract`;交付前运行 `npm run build`,检查 `dist/plugins/<pluginId>.zip` 与 `.sha256`。
21
+ - ZIP 由平台管理员审核、导入并启用;独立服务部署的配置参见 SDK README 和项目 README。
@@ -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,