4a46f89c9b
客户端新增随包提供的插件宿主和 Cocos Creator 集成:识别并导入 Cocos 项目,通过内置桥接操作已打开的编辑器,无需安装项目 MCP 扩展。DirectProject 现在公开 36 个独立 cocos_* 工具,保留通用 JavaScript 执行入口。 - 通用插件 SDK、命令/能力/面板注册、编辑器适配器和跨进程内置插件开关。 - Cocos 场景、节点、组件、Prefab、UI、Layout/Widget、资源、保存、撤销、日志与预览调试;目录和实现由 JS/native 共用。 - 编辑事务回读、失败回滚、后续手动修改保护及不确定结果禁止重放;预览截图通过 MCP image 返回。 - DirectProject 跳过无关专业 Agent 历史,将项目打开和历史读取中的同步 I/O 移出窗口线程,消除 Cocos 执行与项目文件锁的错误耦合。 验证: - 合并 master 后:类型/配置检查、编码检查、Rust 格式检查和提交钩子通过。 - 合并 master 后:Cocos 项目打开、插件面板和开发启动定向测试 10 通过、2 跳过;DirectProject MCP 测试 17 通过、1 项真实 Creator opt-in 忽略;插件宿主测试 9/9。 - 插件行为测试 17/17;native 测试 20/20,4 项 opt-in 测试默认忽略。 - 真实 Creator 3.8.8 的 36/36 操作 smoke,以及客户端 MCP tools/list、tools/call、UI/撤销和预览截图,在功能实现阶段已验证通过;本次 master 合并后未重复真实 GUI smoke。 验证边界:发行安装包和远端 CI 尚未验收。 Reviewed-on: #338 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
301 lines
9.6 KiB
JavaScript
301 lines
9.6 KiB
JavaScript
/**
|
||
* AGC Cocos Creator 插件运行时入口。
|
||
*
|
||
* 宿主以插件目录为 cwd 用系统 `node` 启动本文件,stdio 上使用一行一个 JSON-RPC
|
||
* 2.0 消息(`agc.plugin.v1`)。入口只做注册与转发:命令、能力、面板通过通用宿主
|
||
* 注册,编辑器操作经 `host.rpc` 路由到插件包自带 native 适配器。
|
||
*
|
||
* 说明:通用 SDK `@genarrative/agc-plugin-sdk` 目前以 TypeScript 源码分发,而运行
|
||
* 入口必须能被系统 node 直接执行,因此这里内联同一份 stdio 协议实现;package.json
|
||
* 仍声明 SDK 版本依赖,测试会校验协议常量、方法名与 native 适配器一致。
|
||
*/
|
||
|
||
import { createInterface } from 'node:readline';
|
||
import { pathToFileURL } from 'node:url';
|
||
|
||
import {
|
||
buildEditorRpcRequest,
|
||
COCOS_EDITOR_OPERATIONS,
|
||
validateExecuteCode,
|
||
} from './cocos-editor-adapter.mjs';
|
||
import {
|
||
buildCocosOperationCode,
|
||
COCOS_EDITOR_OPERATIONS as COCOS_OPERATION_NAMES,
|
||
} from './cocos-editor-operations.mjs';
|
||
|
||
export const COCOS_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1';
|
||
export const COCOS_EXECUTE_COMMAND_ID = 'cocos.editor.execute';
|
||
export const COCOS_OPERATION_COMMAND_ID = 'cocos.editor.operation';
|
||
export const COCOS_CONNECTION_CAPABILITY_ID = 'cocos.editor.connection';
|
||
export const COCOS_EDITOR_PANEL = Object.freeze({
|
||
id: 'cocos-editor',
|
||
title: 'Cocos Creator',
|
||
entry: './panels/cocos-editor.html',
|
||
placement: 'sidebar',
|
||
});
|
||
export const PROJECT_CHANGED_EVENT = 'project.changed';
|
||
|
||
// 默认执行可先安装桥接;覆盖 15 秒引导、最长 60 秒命令和发现开销,早于宿主 90 秒截止。
|
||
const RPC_TIMEOUT_MS = 85_000;
|
||
|
||
export function createCocosEditorPlugin({
|
||
send,
|
||
log = () => undefined,
|
||
timeoutMs = RPC_TIMEOUT_MS,
|
||
}) {
|
||
let nextId = 1;
|
||
let activeProjectPath = null;
|
||
let disposed = false;
|
||
let executionUncertain = false;
|
||
let executionPending = false;
|
||
const pending = new Map();
|
||
|
||
const handlers = new Map([
|
||
[COCOS_EXECUTE_COMMAND_ID, handleExecute],
|
||
[COCOS_OPERATION_COMMAND_ID, handleOperation],
|
||
[COCOS_CONNECTION_CAPABILITY_ID, handleConnection],
|
||
]);
|
||
|
||
function request(method, params) {
|
||
const id = nextId++;
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
pending.delete(id);
|
||
reject(new Error(`插件宿主 RPC 超时:${method}`));
|
||
}, timeoutMs);
|
||
pending.set(id, { resolve, reject, timer });
|
||
send({ jsonrpc: '2.0', id, method, params });
|
||
});
|
||
}
|
||
|
||
function respond(id, result, error) {
|
||
if (error) {
|
||
send({
|
||
jsonrpc: '2.0',
|
||
id,
|
||
error: { code: -32603, message: error },
|
||
});
|
||
return;
|
||
}
|
||
send({ jsonrpc: '2.0', id, result: result ?? null });
|
||
}
|
||
|
||
function resolveProjectPath(params) {
|
||
const explicit = params?.projectPath;
|
||
if (typeof explicit === 'string' && explicit) return explicit;
|
||
if (activeProjectPath) return activeProjectPath;
|
||
throw new Error('当前没有受控项目路径,宿主需先设置项目上下文');
|
||
}
|
||
|
||
async function callEditor(operation, params) {
|
||
const { method, params: editorParams } = buildEditorRpcRequest(
|
||
operation,
|
||
params,
|
||
);
|
||
return request('host.rpc', { method, params: editorParams });
|
||
}
|
||
|
||
async function handleExecute(params) {
|
||
const code = params?.code;
|
||
validateExecuteCode(code);
|
||
const projectPath = resolveProjectPath(params);
|
||
const reconcile = (response) => ({
|
||
status: 'needs-reconciliation',
|
||
retryAllowed: false,
|
||
response,
|
||
});
|
||
if (executionUncertain)
|
||
return reconcile({ ok: false, error: '先前执行结果待核对' });
|
||
// 不积压稍后执行的 mutation,避免调用方超时后请求仍从队列发出。
|
||
if (executionPending)
|
||
return {
|
||
status: 'failed',
|
||
retryAllowed: false,
|
||
response: {
|
||
ok: false,
|
||
error: '已有 Cocos execute 正在执行,请等待回执',
|
||
},
|
||
};
|
||
executionPending = true;
|
||
try {
|
||
const response = await callEditor('execute', {
|
||
projectPath,
|
||
code,
|
||
timeoutMs: params?.timeoutMs,
|
||
});
|
||
if (response?.status === 'needs-reconciliation') {
|
||
executionUncertain = true;
|
||
return reconcile(response);
|
||
}
|
||
if (typeof response?.ok !== 'boolean')
|
||
throw new Error('宿主缺少可信执行回执');
|
||
return { status: response.ok ? 'completed' : 'failed', response };
|
||
} catch (error) {
|
||
// 已交给宿主的 execute 超时/断线不能推断为未执行。
|
||
executionUncertain = true;
|
||
return reconcile({ ok: false, error: error.message });
|
||
} finally {
|
||
executionPending = false;
|
||
}
|
||
}
|
||
|
||
async function handleConnection(params) {
|
||
const operation = params?.operation ?? 'detect';
|
||
if (!COCOS_EDITOR_OPERATIONS.includes(operation)) {
|
||
throw new Error(`不支持的能力操作:${operation}`);
|
||
}
|
||
if (operation === 'execute') return handleExecute(params);
|
||
if (operation === 'disconnect') {
|
||
return callEditor('disconnect', {});
|
||
}
|
||
return callEditor(operation, {
|
||
projectPath: resolveProjectPath(params),
|
||
processId: params?.processId,
|
||
timeoutMs: params?.timeoutMs,
|
||
payloadPath: params?.payloadPath,
|
||
});
|
||
}
|
||
|
||
async function handleOperation(params) {
|
||
const operation = params?.operation;
|
||
if (!COCOS_OPERATION_NAMES.includes(operation)) {
|
||
throw new Error(`不支持的 Cocos 操作:${operation}`);
|
||
}
|
||
const projectPath = resolveProjectPath(params);
|
||
const result = await handleExecute({
|
||
projectPath,
|
||
code: buildCocosOperationCode(operation, params?.args ?? {}),
|
||
timeoutMs: 60_000,
|
||
});
|
||
const status = result?.response?.result?.status;
|
||
if (status === 'failed' || status === 'needs-reconciliation') {
|
||
result.status = status;
|
||
if (status === 'needs-reconciliation') {
|
||
executionUncertain = true;
|
||
result.retryAllowed = false;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function handleMessage(message) {
|
||
if (disposed) return;
|
||
const envelope =
|
||
typeof message === 'string' ? JSON.parse(message) : message;
|
||
if (!envelope || envelope.jsonrpc !== '2.0') return;
|
||
|
||
if (envelope.method === 'host.event') {
|
||
const event = envelope.params ?? {};
|
||
if (event.type === PROJECT_CHANGED_EVENT) {
|
||
const projectPath = event.payload?.projectPath;
|
||
activeProjectPath =
|
||
typeof projectPath === 'string' && projectPath ? projectPath : null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (envelope.method !== undefined) {
|
||
if (envelope.id === undefined) return;
|
||
const handler = handlers.get(envelope.method);
|
||
if (!handler) {
|
||
respond(envelope.id, undefined, `插件未注册方法:${envelope.method}`);
|
||
return;
|
||
}
|
||
try {
|
||
respond(envelope.id, await handler(envelope.params ?? {}));
|
||
} catch (error) {
|
||
log(`cocos plugin 处理 ${envelope.method} 失败:${error.message}`);
|
||
respond(envelope.id, undefined, error.message);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const request = pending.get(envelope.id);
|
||
if (!request) return;
|
||
pending.delete(envelope.id);
|
||
clearTimeout(request.timer);
|
||
if (envelope.error) {
|
||
request.reject(new Error(envelope.error.message ?? '插件宿主 RPC 失败'));
|
||
return;
|
||
}
|
||
request.resolve(envelope.result);
|
||
}
|
||
|
||
async function start() {
|
||
const command = await request('host.registerCommand', {
|
||
id: COCOS_EXECUTE_COMMAND_ID,
|
||
title: '在 Cocos Creator 中执行代码',
|
||
description: '在已打开的 Cocos Creator 项目中执行受控 JavaScript 函数体',
|
||
});
|
||
const capability = await request('host.registerCapability', {
|
||
id: COCOS_CONNECTION_CAPABILITY_ID,
|
||
description: 'Cocos Creator 连接探测、目标预检、ping、status 与注入',
|
||
});
|
||
const operations = await request('host.registerCommand', {
|
||
id: COCOS_OPERATION_COMMAND_ID,
|
||
title: '调用 Cocos Creator 编辑器能力',
|
||
description: `调用内置 Cocos 编辑器操作(${COCOS_OPERATION_NAMES.join(', ')})`,
|
||
});
|
||
const panel = await request('host.registerPanel', {
|
||
...COCOS_EDITOR_PANEL,
|
||
});
|
||
const subscription = await request('host.events.subscribe', {
|
||
type: PROJECT_CHANGED_EVENT,
|
||
});
|
||
activeProjectPath = subscription?.projectPath ?? null;
|
||
return {
|
||
command,
|
||
capability,
|
||
operations,
|
||
panel,
|
||
subscriptionId: subscription?.subscriptionId ?? null,
|
||
};
|
||
}
|
||
|
||
function dispose() {
|
||
disposed = true;
|
||
for (const request of pending.values()) {
|
||
clearTimeout(request.timer);
|
||
request.reject(new Error('插件已停止'));
|
||
}
|
||
pending.clear();
|
||
}
|
||
|
||
return {
|
||
start,
|
||
handleMessage,
|
||
dispose,
|
||
get activeProjectPath() {
|
||
return activeProjectPath;
|
||
},
|
||
};
|
||
}
|
||
|
||
export function startCocosEditorStdioPlugin({
|
||
stdin = process.stdin,
|
||
stdout = process.stdout,
|
||
log = (message) => process.stderr.write(`${message}\n`),
|
||
} = {}) {
|
||
const plugin = createCocosEditorPlugin({
|
||
send: (message) => stdout.write(`${JSON.stringify(message)}\n`),
|
||
log,
|
||
});
|
||
const lines = createInterface({ input: stdin, crlfDelay: Infinity });
|
||
lines.on('line', (line) => {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) return;
|
||
void plugin.handleMessage(trimmed).catch((error) => {
|
||
log(`cocos plugin 消息处理失败:${error.message}`);
|
||
});
|
||
});
|
||
void plugin.start().catch((error) => {
|
||
log(`cocos plugin 注册失败:${error.message}`);
|
||
});
|
||
return plugin;
|
||
}
|
||
|
||
const entryPoint = process.argv[1];
|
||
if (entryPoint && import.meta.url === pathToFileURL(entryPoint).href) {
|
||
startCocosEditorStdioPlugin();
|
||
}
|