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>
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
/**
|
|
* Public SDK for AGC plugins.
|
|
*
|
|
* The SDK is transport agnostic. A plugin can run inside the AGC process or
|
|
* as a child process speaking line-delimited JSON-RPC; only the transport
|
|
* implementation changes. Lifecycle, UI and capability registration remain
|
|
* the same for Cocos, Unity and Godot adapters.
|
|
*/
|
|
|
|
export const AGC_PLUGIN_PROTOCOL_VERSION = 'agc.plugin.v1' as const;
|
|
export const AGC_PLUGIN_API_VERSION = 'v1' as const;
|
|
export const AGENT_PLUGINS_SCHEMA =
|
|
'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json' as const;
|
|
export type PluginRpcHandler = (params: unknown) => unknown | Promise<unknown>;
|
|
|
|
export type AgcPluginPermission =
|
|
| 'events.subscribe'
|
|
| 'project.read'
|
|
| 'editor.rpc'
|
|
| 'ui.register'
|
|
| 'capability.register';
|
|
|
|
export type PluginCommand = {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
};
|
|
|
|
export type PluginPanel = {
|
|
id: string;
|
|
title: string;
|
|
entry: string;
|
|
placement?: string;
|
|
};
|
|
|
|
export type PluginCapability = {
|
|
id: string;
|
|
description?: string;
|
|
};
|
|
|
|
export type AgentPluginManifest = {
|
|
$schema: typeof AGENT_PLUGINS_SCHEMA;
|
|
name: string;
|
|
version?: string;
|
|
description?: string;
|
|
extensions?: {
|
|
'world.genarrative.agc'?: {
|
|
apiVersion?: typeof AGC_PLUGIN_API_VERSION;
|
|
entry?: string;
|
|
adapter?: string;
|
|
permissions?: AgcPluginPermission[];
|
|
panels?: PluginPanel[];
|
|
};
|
|
[namespace: string]: unknown;
|
|
};
|
|
};
|
|
|
|
export type PluginEvent = {
|
|
type: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
export type PluginHostTransport = {
|
|
request<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
subscribe?(listener: (event: PluginEvent) => void): () => void;
|
|
registerHandler?(method: string, handler: PluginRpcHandler): Unsubscribe;
|
|
};
|
|
|
|
type StdioProcess = {
|
|
stdin: {
|
|
setEncoding?(encoding: string): void;
|
|
on(event: string, listener: (chunk: string) => void): void;
|
|
};
|
|
stdout: { write(chunk: string): void };
|
|
};
|
|
|
|
export type Unsubscribe = () => void;
|
|
|
|
export type PluginHostApi = {
|
|
registerCommand(
|
|
command: PluginCommand,
|
|
handler?: PluginRpcHandler,
|
|
): Promise<Unsubscribe>;
|
|
registerPanel(panel: PluginPanel): Promise<Unsubscribe>;
|
|
registerCapability(
|
|
capability: PluginCapability,
|
|
handler?: PluginRpcHandler,
|
|
): Promise<Unsubscribe>;
|
|
events: {
|
|
subscribe(
|
|
type: string,
|
|
listener: (event: PluginEvent) => void,
|
|
): Promise<Unsubscribe>;
|
|
};
|
|
project: {
|
|
read<T = unknown>(path: string): Promise<T>;
|
|
};
|
|
rpc<T = unknown>(method: string, params?: unknown): Promise<T>;
|
|
};
|
|
|
|
type Listener = (event: PluginEvent) => void;
|
|
|
|
/** In-memory UI registry used by the AGC shell to mount and unmount panels. */
|
|
export class PluginPanelRegistry {
|
|
private readonly panels = new Map<string, PluginPanel>();
|
|
private readonly listeners = new Set<() => void>();
|
|
|
|
register(panel: PluginPanel): Unsubscribe {
|
|
if (this.panels.has(panel.id)) throw new Error('插件面板已注册');
|
|
this.panels.set(panel.id, panel);
|
|
this.notify();
|
|
return () => this.unregister(panel.id);
|
|
}
|
|
|
|
unregister(id: string): void {
|
|
if (this.panels.delete(id)) this.notify();
|
|
}
|
|
|
|
list(): PluginPanel[] {
|
|
return [...this.panels.values()];
|
|
}
|
|
|
|
subscribe(listener: () => void): Unsubscribe {
|
|
this.listeners.add(listener);
|
|
return () => this.listeners.delete(listener);
|
|
}
|
|
|
|
private notify(): void {
|
|
for (const listener of this.listeners) listener();
|
|
}
|
|
}
|
|
|
|
/** JSON-RPC transport for the default AGC child-process protocol. */
|
|
export function createJsonRpcStdioTransport(
|
|
processLike = (globalThis as typeof globalThis & { process?: StdioProcess })
|
|
.process,
|
|
): PluginHostTransport {
|
|
if (!processLike) throw new Error('当前运行环境没有 stdio 进程');
|
|
let nextId = 1;
|
|
let buffer = '';
|
|
const pending = new Map<
|
|
number,
|
|
{
|
|
resolve(value: unknown): void;
|
|
reject(error: Error): void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
>();
|
|
const handlers = new Map<string, PluginRpcHandler>();
|
|
const listeners = new Set<(event: PluginEvent) => void>();
|
|
processLike.stdin.setEncoding?.('utf8');
|
|
processLike.stdin.on('data', (chunk) => {
|
|
buffer += chunk;
|
|
if (buffer.length > 2 * 1024 * 1024) {
|
|
buffer = '';
|
|
for (const request of pending.values()) {
|
|
clearTimeout(request.timer);
|
|
request.reject(new Error('插件 RPC 消息过大'));
|
|
}
|
|
pending.clear();
|
|
return;
|
|
}
|
|
let newline = buffer.indexOf('\n');
|
|
while (newline >= 0) {
|
|
const line = buffer.slice(0, newline).trim();
|
|
buffer = buffer.slice(newline + 1);
|
|
newline = buffer.indexOf('\n');
|
|
if (!line) continue;
|
|
let message: {
|
|
jsonrpc?: string;
|
|
id?: number;
|
|
result?: unknown;
|
|
error?: { message?: string };
|
|
method?: string;
|
|
params?: unknown;
|
|
};
|
|
try {
|
|
message = JSON.parse(line) as typeof message;
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (message.jsonrpc !== '2.0') continue;
|
|
if (
|
|
!message.method &&
|
|
typeof message.id === 'number' &&
|
|
pending.has(message.id)
|
|
) {
|
|
const request = pending.get(message.id);
|
|
pending.delete(message.id);
|
|
if (request) clearTimeout(request.timer);
|
|
if (message.error)
|
|
request?.reject(
|
|
new Error(message.error.message ?? '插件宿主 RPC 失败'),
|
|
);
|
|
else request?.resolve(message.result);
|
|
} else if (message.method === 'host.event') {
|
|
const params = (message.params ?? {}) as {
|
|
type?: unknown;
|
|
payload?: unknown;
|
|
};
|
|
if (typeof params.type === 'string') {
|
|
const event = { type: params.type, payload: params.payload };
|
|
for (const listener of listeners) listener(event);
|
|
}
|
|
} else if (message.method && message.id !== undefined) {
|
|
const handler = handlers.get(message.method);
|
|
const id = message.id;
|
|
void Promise.resolve()
|
|
.then(() => {
|
|
if (!handler) throw new Error('插件未注册该方法');
|
|
return handler(message.params);
|
|
})
|
|
.then(
|
|
(result) =>
|
|
processLike.stdout.write(
|
|
`${JSON.stringify({ jsonrpc: '2.0', id, result: result ?? null })}\n`,
|
|
),
|
|
() =>
|
|
processLike.stdout.write(
|
|
`${JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32603, message: '插件方法执行失败' } })}\n`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
return {
|
|
request: <T = unknown>(method: string, params?: unknown) => {
|
|
const id = nextId++;
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
pending.delete(id);
|
|
reject(new Error('插件宿主 RPC 超时'));
|
|
}, 10000);
|
|
pending.set(id, { resolve, reject, timer });
|
|
processLike.stdout.write(
|
|
`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`,
|
|
);
|
|
});
|
|
},
|
|
subscribe: (listener) => {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
registerHandler: (method, handler) => {
|
|
if (handlers.has(method)) throw new Error('插件方法已注册');
|
|
handlers.set(method, handler);
|
|
return () => {
|
|
if (handlers.get(method) === handler) handlers.delete(method);
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Creates the single host API exposed to plugin code. Every mutating call is
|
|
* sent through the host transport so Rust can enforce manifest permissions and
|
|
* record the outcome in the audit log.
|
|
*/
|
|
export function createPluginHost(
|
|
transport: PluginHostTransport,
|
|
panelRegistry = new PluginPanelRegistry(),
|
|
): PluginHostApi & { panels: PluginPanelRegistry; dispose(): void } {
|
|
const eventListeners = new Map<string, Set<Listener>>();
|
|
const disposers = new Set<Unsubscribe>();
|
|
const removeTransportSubscription = transport.subscribe?.((event) => {
|
|
for (const listener of eventListeners.get(event.type) ?? [])
|
|
listener(event);
|
|
});
|
|
|
|
const register = async <T extends { id: string }>(
|
|
method: string,
|
|
value: T,
|
|
onDispose: () => void,
|
|
): Promise<Unsubscribe> => {
|
|
await transport.request(method, value);
|
|
let disposed = false;
|
|
const dispose = () => {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
onDispose();
|
|
disposers.delete(dispose);
|
|
void transport
|
|
.request(method.replace('register', 'unregister'), { id: value.id })
|
|
.catch(() => undefined);
|
|
};
|
|
disposers.add(dispose);
|
|
return dispose;
|
|
};
|
|
|
|
return {
|
|
panels: panelRegistry,
|
|
registerCommand: async (command, handler) => {
|
|
if (handler && !transport.registerHandler)
|
|
throw new Error('传输不支持插件方法处理器');
|
|
const disposeHandler = handler
|
|
? transport.registerHandler?.(command.id, handler)
|
|
: undefined;
|
|
try {
|
|
return await register('host.registerCommand', command, () =>
|
|
disposeHandler?.(),
|
|
);
|
|
} catch (error) {
|
|
disposeHandler?.();
|
|
throw error;
|
|
}
|
|
},
|
|
registerPanel: async (panel) => {
|
|
let disposeLocal: Unsubscribe = () => undefined;
|
|
const dispose = await register('host.registerPanel', panel, () =>
|
|
disposeLocal(),
|
|
);
|
|
disposeLocal = panelRegistry.register(panel);
|
|
return dispose;
|
|
},
|
|
registerCapability: async (capability, handler) => {
|
|
if (handler && !transport.registerHandler)
|
|
throw new Error('传输不支持插件方法处理器');
|
|
const disposeHandler = handler
|
|
? transport.registerHandler?.(capability.id, handler)
|
|
: undefined;
|
|
try {
|
|
return await register('host.registerCapability', capability, () =>
|
|
disposeHandler?.(),
|
|
);
|
|
} catch (error) {
|
|
disposeHandler?.();
|
|
throw error;
|
|
}
|
|
},
|
|
events: {
|
|
subscribe: async (type, listener) => {
|
|
const { subscriptionId } = await transport.request<{
|
|
subscriptionId: string;
|
|
}>('host.events.subscribe', { type });
|
|
const listeners = eventListeners.get(type) ?? new Set<Listener>();
|
|
listeners.add(listener);
|
|
eventListeners.set(type, listeners);
|
|
const dispose = () => {
|
|
listeners.delete(listener);
|
|
if (listeners.size === 0) eventListeners.delete(type);
|
|
disposers.delete(dispose);
|
|
void transport
|
|
.request('host.events.unsubscribe', { subscriptionId })
|
|
.catch(() => undefined);
|
|
};
|
|
disposers.add(dispose);
|
|
return dispose;
|
|
},
|
|
},
|
|
project: {
|
|
read: (path) => transport.request('host.project.read', { path }),
|
|
},
|
|
rpc: (method, params) => transport.request('host.rpc', { method, params }),
|
|
dispose: () => {
|
|
for (const dispose of [...disposers]) dispose();
|
|
removeTransportSubscription?.();
|
|
},
|
|
};
|
|
}
|