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