@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 +8 -2
- package/bin/easyai-plugin.mjs +77 -28
- package/dist/customer-main.js +20 -1
- package/dist/protocol/index.d.ts +23 -1
- package/dist/protocol/index.js +39 -2
- package/dist/studio/index.html +158 -36
- package/package.json +4 -2
- package/templates/AGENTS.md +21 -0
- package/tools/build-plugin-package.mjs +1 -0
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`
|
|
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
|
|
26
|
+
平台托管只接纳运行不超过 20 秒的节点任务;需要长任务、GPU 或自有基础设施时使用独立服务。构建还会生成每个 ZIP 的 `.sha256` 供管理员核对。独立服务生产环境必须提供 `PLUGIN_SERVICE_SIGNING_SECRET`、与平台连接 ID 一致的 `PLUGIN_SERVICE_AUDIENCE` 和持久化的 `PLUGIN_DATA_DIR`。业务密钥放在插件配置或部署环境,不写入前端组件。
|
|
21
27
|
|
|
22
28
|
托管节点暂不支持文件和画布宿主能力。管理员导入 ZIP 后默认停用,审核插件代码、声明能力和版本摘要后启用。版本切换不会改变已提交任务使用的包摘要。
|
|
23
29
|
|
package/bin/easyai-plugin.mjs
CHANGED
|
@@ -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' },
|
|
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
|
-
'
|
|
141
|
-
|
|
142
|
-
|
|
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/
|
|
146
|
-
'README.md': `# ${title}\n\n运行 npm run dev,打开 http://localhost:4310/studio,在浏览器中连接 EasyAI 画布。\n运行 npm run test:contract && npm run build
|
|
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
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
177
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
239
|
-
checks.push({ name:
|
|
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;
|
package/dist/customer-main.js
CHANGED
|
@@ -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;
|
package/dist/protocol/index.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/protocol/index.js
CHANGED
|
@@ -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)
|
package/dist/studio/index.html
CHANGED
|
@@ -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(
|
|
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
|
|
134
|
-
.dialog-head h2 { margin: 0 0 5px; font-size:
|
|
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)
|
|
140
|
-
.
|
|
141
|
-
.
|
|
142
|
-
.
|
|
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;
|
|
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
|
-
.
|
|
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:
|
|
165
|
-
.plugin-actions button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height:
|
|
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 {
|
|
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 {
|
|
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 {
|
|
207
|
-
.
|
|
208
|
-
.
|
|
209
|
-
.
|
|
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
|
-
.
|
|
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
|
|
290
|
-
<div class="dialog-body"
|
|
291
|
-
<
|
|
292
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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
|
+
"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,
|