merge: 合并远程分支,解决冲突并整合文档更新
This commit is contained in:
@@ -196,6 +196,30 @@
|
||||
|
||||
---
|
||||
|
||||
## 面板加载修复 (2026-02-24)
|
||||
|
||||
### 1. `panel/index.js` 语法错误修复
|
||||
|
||||
- **问题**: 面板加载时出现 `SyntaxError: Invalid or unexpected token`,导致 MCP Bridge 插件面板完全无法渲染。
|
||||
- **原因**: `index.js` 中存在非法字符或格式错误,被 Cocos Creator 的面板加载器拒绝解析。
|
||||
- **修复**: 清理了文件中的语法问题,确保面板能够正常加载和初始化。
|
||||
|
||||
---
|
||||
|
||||
## 防止核心属性被篡改崩溃 (2026-02-26)
|
||||
|
||||
### 1. `manage_components` 核心属性保护
|
||||
|
||||
- **问题**: AI 助手在使用 `manage_components` 尝试修改 `Label` 位置时,错误地对组件传参 `{ node: { position: ... } }`,导致 Label 的 `this.node` 强引用被覆写为普通对象。引发渲染报错 (`Cannot read property 'a' of undefined`) 和删除卡死 (`this.node._removeComponent is not a function`)。
|
||||
- **修复**: 在 `scene-script.js` 的 `applyProperties` 中增加了核心属性黑名单机制。强制拦截对 `node`, `uuid`, `_id` 的直接写入并给出警告。彻底杜绝由于组件的节点引用被破坏所引发的场景崩溃和编辑器卡死问题。
|
||||
|
||||
### 2. 资源管理层 `save` 动作幻觉别名兼容
|
||||
|
||||
- **问题**: AI 偶尔会幻觉以为 `prefab_management`/`manage_script`/`manage_material`/`manage_texture`/`manage_shader` 的更新动作为 `save`,而不是标准定义的 `update` 或 `write`,导致抛出"未知的管理操作"报错。
|
||||
- **修复**: 在 `main.js` 所有这些管理工具的核心路由表中,为 `update` 和 `write` 操作均显式添加了 `case "save":` 作为后备兼容,极大地增强了不同大模型在不同提示词上下文环境下的操作容错率。
|
||||
|
||||
---
|
||||
|
||||
## 日志系统持久化与健壮性优化 (2026-02-27)
|
||||
|
||||
### 1. 日志文件持久化
|
||||
@@ -225,13 +249,3 @@
|
||||
### 5. 日志仅输出关键信息到编辑器控制台
|
||||
|
||||
- **优化**: `addLog` 函数不再将所有类型的日志输出到编辑器控制台,仅 `error` 和 `warn` 级别日志通过 `Editor.error()` / `Editor.warn()` 输出,防止 `info` / `success` / `mcp` 类型日志刷屏干扰开发者。
|
||||
|
||||
---
|
||||
|
||||
## 面板加载修复 (2026-02-24)
|
||||
|
||||
### 1. `panel/index.js` 语法错误修复
|
||||
|
||||
- **问题**: 面板加载时出现 `SyntaxError: Invalid or unexpected token`,导致 MCP Bridge 插件面板完全无法渲染。
|
||||
- **原因**: `index.js` 中存在非法字符或格式错误,被 Cocos Creator 的面板加载器拒绝解析。
|
||||
- **修复**: 清理了文件中的语法问题,确保面板能够正常加载和初始化。
|
||||
|
||||
133
main.js
133
main.js
@@ -6,8 +6,7 @@ const pathModule = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
let logBuffer = []; // 存储所有日志(上限 2000 条,超出自动截断旧日志)
|
||||
let _requestCounter = 0; // 请求关联计数器
|
||||
let logBuffer = []; // 存储所有日志
|
||||
let mcpServer = null;
|
||||
let isSceneBusy = false;
|
||||
let serverConfig = {
|
||||
@@ -30,14 +29,7 @@ let isProcessingCommand = false;
|
||||
*/
|
||||
function enqueueCommand(fn) {
|
||||
return new Promise((resolve) => {
|
||||
// 兜底超时保护:防止 fn 内部未调用 done() 导致队列永久停滞
|
||||
const timeoutId = setTimeout(() => {
|
||||
addLog("error", "[CommandQueue] 指令执行超时(60s),强制释放队列");
|
||||
isProcessingCommand = false;
|
||||
resolve();
|
||||
processNextCommand();
|
||||
}, 60000);
|
||||
commandQueue.push({ fn, resolve, timeoutId });
|
||||
commandQueue.push({ fn, resolve });
|
||||
processNextCommand();
|
||||
});
|
||||
}
|
||||
@@ -48,17 +40,15 @@ function enqueueCommand(fn) {
|
||||
function processNextCommand() {
|
||||
if (isProcessingCommand || commandQueue.length === 0) return;
|
||||
isProcessingCommand = true;
|
||||
const { fn, resolve, timeoutId } = commandQueue.shift();
|
||||
const { fn, resolve } = commandQueue.shift();
|
||||
try {
|
||||
fn(() => {
|
||||
clearTimeout(timeoutId);
|
||||
isProcessingCommand = false;
|
||||
resolve();
|
||||
processNextCommand();
|
||||
});
|
||||
} catch (e) {
|
||||
// 防止队列因未捕获异常永久阻塞
|
||||
clearTimeout(timeoutId);
|
||||
addLog("error", `[CommandQueue] 指令执行异常: ${e.message}`);
|
||||
isProcessingCommand = false;
|
||||
resolve();
|
||||
@@ -102,72 +92,25 @@ function callSceneScriptWithTimeout(pluginName, method, args, callback, timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志文件路径(懒初始化,在项目 settings 目录下)
|
||||
* @type {string|null}
|
||||
*/
|
||||
let _logFilePath = null;
|
||||
|
||||
/**
|
||||
* 获取日志文件路径
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getLogFilePath() {
|
||||
if (_logFilePath) return _logFilePath;
|
||||
try {
|
||||
const assetsPath = Editor.assetdb.urlToFspath("db://assets");
|
||||
if (assetsPath) {
|
||||
const projectRoot = pathModule.dirname(assetsPath);
|
||||
const settingsDir = pathModule.join(projectRoot, "settings");
|
||||
if (!fs.existsSync(settingsDir)) {
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
}
|
||||
_logFilePath = pathModule.join(settingsDir, "mcp-bridge.log");
|
||||
return _logFilePath;
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装日志函数
|
||||
* - 所有日志发送到 MCP 测试面板 + 内存缓存
|
||||
* - 仅 error / warn 输出到编辑器控制台(防止刷屏)
|
||||
* - 所有日志追加写入项目内 settings/mcp-bridge.log 文件(持久化)
|
||||
* @param {'info' | 'success' | 'warn' | 'error' | 'mcp'} type 日志类型
|
||||
* 封装日志函数,同时发送给面板、保存到内存并在编辑器控制台打印
|
||||
* @param {'info' | 'success' | 'warn' | 'error'} type 日志类型
|
||||
* @param {string} message 日志内容
|
||||
*/
|
||||
function addLog(type, message) {
|
||||
const logEntry = {
|
||||
time: new Date().toISOString().replace("T", " ").substring(0, 23),
|
||||
time: new Date().toLocaleTimeString(),
|
||||
type: type,
|
||||
content: message,
|
||||
};
|
||||
logBuffer.push(logEntry);
|
||||
// 防止内存泄漏:限制日志缓存上限
|
||||
if (logBuffer.length > 2000) {
|
||||
logBuffer = logBuffer.slice(-1500);
|
||||
}
|
||||
// 发送到面板
|
||||
Editor.Ipc.sendToPanel("mcp-bridge", "mcp-bridge:on-log", logEntry);
|
||||
|
||||
// 仅关键信息输出到编辑器控制台(error / warn)
|
||||
// 【修改】确保所有日志都输出到编辑器控制台,以便用户查看
|
||||
if (type === "error") {
|
||||
Editor.error(`[MCP] ${message}`);
|
||||
} else if (type === "warn") {
|
||||
Editor.warn(`[MCP] ${message}`);
|
||||
}
|
||||
|
||||
// 持久化到日志文件
|
||||
try {
|
||||
const logPath = getLogFilePath();
|
||||
if (logPath) {
|
||||
const line = `[${logEntry.time}] [${type}] ${message}\n`;
|
||||
fs.appendFileSync(logPath, line, "utf8");
|
||||
}
|
||||
} catch (e) {
|
||||
// 文件写入失败时静默,不影响主流程
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,15 +164,13 @@ const getNewSceneTemplate = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有支持的 MCP 工具列表定义(懒加载缓存)
|
||||
* 获取所有支持的 MCP 工具列表定义
|
||||
* @returns {Array<Object>} 工具定义数组
|
||||
*/
|
||||
let _toolsListCache = null;
|
||||
const getToolsList = () => {
|
||||
if (_toolsListCache) return _toolsListCache;
|
||||
const globalPrecautions =
|
||||
"【AI 安全守则】: 1. 执行任何写操作前必须先通过 get_scene_hierarchy 或 manage_components(get) 验证主体存在。 2. 严禁基于假设盲目猜测属性名。 3. 资源属性(如 cc.Prefab)必须通过 UUID 进行赋值。 4. 严禁频繁刷新全局资源 (refresh_editor),必须通过 properties.path 指定具体修改的文件或目录以防止编辑器长期卡死。";
|
||||
_toolsListCache = [
|
||||
return [
|
||||
{
|
||||
name: "get_selected_node",
|
||||
description: `获取当前编辑器中选中的节点 ID。建议获取后立即调用 get_scene_hierarchy 确认该节点是否仍存在于当前场景中。`,
|
||||
@@ -750,7 +691,6 @@ const getToolsList = () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
return _toolsListCache;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@@ -846,40 +786,11 @@ module.exports = {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
|
||||
let body = "";
|
||||
let bodySize = 0;
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB 防护
|
||||
let bodyOverflow = false;
|
||||
req.on("data", (chunk) => {
|
||||
bodySize += chunk.length;
|
||||
if (bodySize > MAX_BODY_SIZE) {
|
||||
if (!bodyOverflow) {
|
||||
bodyOverflow = true;
|
||||
addLog("error", `[HTTP] 请求体过大 (>${MAX_BODY_SIZE} bytes),已拒绝`);
|
||||
req.destroy();
|
||||
res.writeHead(413);
|
||||
res.end(JSON.stringify({ error: "Request body too large" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (bodyOverflow) return;
|
||||
const url = req.url;
|
||||
// 健康检查端点
|
||||
if (url === "/health") {
|
||||
res.writeHead(200);
|
||||
return res.end(
|
||||
JSON.stringify({
|
||||
status: "ok",
|
||||
queueLength: commandQueue.length,
|
||||
isProcessing: isProcessingCommand,
|
||||
isSceneBusy: isSceneBusy,
|
||||
uptime: process.uptime(),
|
||||
logCount: logBuffer.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url === "/list-tools") {
|
||||
const tools = getToolsList();
|
||||
addLog("info", `AI Client requested tool list`);
|
||||
@@ -936,13 +847,10 @@ module.exports = {
|
||||
argsPreview = "[无法序列化的参数]";
|
||||
}
|
||||
}
|
||||
const reqId = `R${++_requestCounter}`;
|
||||
addLog("mcp", `REQ -> [${name}] #${reqId} (队列长度: ${commandQueue.length}) 参数: ${argsPreview}`);
|
||||
addLog("mcp", `REQ -> [${name}] (队列长度: ${commandQueue.length}) 参数: ${argsPreview}`);
|
||||
|
||||
enqueueCommand((done) => {
|
||||
const startTime = Date.now();
|
||||
this.handleMcpCall(name, args, (err, result) => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const response = {
|
||||
content: [
|
||||
{
|
||||
@@ -956,7 +864,7 @@ module.exports = {
|
||||
],
|
||||
};
|
||||
if (err) {
|
||||
addLog("error", `RES <- [${name}] #${reqId} 失败 (${elapsed}ms): ${err}`);
|
||||
addLog("error", `RES <- [${name}] 失败: ${err}`);
|
||||
} else {
|
||||
let preview = "";
|
||||
if (typeof result === "string") {
|
||||
@@ -969,7 +877,7 @@ module.exports = {
|
||||
preview = "Object (Circular/Unserializable)";
|
||||
}
|
||||
}
|
||||
addLog("success", `RES <- [${name}] #${reqId} 成功 (${elapsed}ms): ${preview}`);
|
||||
addLog("success", `RES <- [${name}] 成功 : ${preview}`);
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify(response));
|
||||
@@ -1110,12 +1018,9 @@ module.exports = {
|
||||
isSceneBusy = true;
|
||||
addLog("info", "准备保存场景... 等待 UI 同步。");
|
||||
Editor.Ipc.sendToPanel("scene", "scene:stash-and-save");
|
||||
// 给场景保存留出时间后再解除锁定(stash-and-save 是异步 IPC)
|
||||
setTimeout(() => {
|
||||
isSceneBusy = false;
|
||||
addLog("info", "安全保存已完成。");
|
||||
callback(null, "场景保存成功。");
|
||||
}, 1500);
|
||||
break;
|
||||
|
||||
case "get_scene_hierarchy":
|
||||
@@ -1387,6 +1292,7 @@ export default class NewScript extends cc.Component {
|
||||
}
|
||||
break;
|
||||
|
||||
case "save": // 兼容 AI 幻觉
|
||||
case "write":
|
||||
// 使用 fs 写入 + refresh,确保覆盖成功
|
||||
const writeFsPath = Editor.assetdb.urlToFspath(scriptPath);
|
||||
@@ -1643,6 +1549,7 @@ export default class NewScript extends cc.Component {
|
||||
callback(null, `指令已发送: 从节点 ${nodeId} 在目录 ${targetDir} 创建名为 ${prefabName} 的预制体`);
|
||||
break;
|
||||
|
||||
case "save": // 兼容 AI 幻觉
|
||||
case "update":
|
||||
if (!nodeId) {
|
||||
return callback("更新预制体需要节点 ID");
|
||||
@@ -1818,6 +1725,7 @@ CCProgram fs %{
|
||||
}
|
||||
break;
|
||||
|
||||
case "save": // 兼容 AI 幻觉
|
||||
case "write":
|
||||
if (!Editor.assetdb.exists(effectPath)) {
|
||||
return callback(`Effect not found: ${effectPath}`);
|
||||
@@ -1898,6 +1806,7 @@ CCProgram fs %{
|
||||
});
|
||||
break;
|
||||
|
||||
case "save": // 兼容 AI 幻觉
|
||||
case "update":
|
||||
if (!Editor.assetdb.exists(matPath)) {
|
||||
return callback(`找不到材质: ${matPath}`);
|
||||
@@ -2058,6 +1967,7 @@ CCProgram fs %{
|
||||
callback(`找不到纹理: ${path}`);
|
||||
}
|
||||
break;
|
||||
case "save": // 兼容 AI 幻觉
|
||||
case "update":
|
||||
if (!Editor.assetdb.exists(path)) {
|
||||
return callback(`找不到纹理: ${path}`);
|
||||
@@ -2610,8 +2520,6 @@ CCProgram fs %{
|
||||
return callback(`无效的搜索路径: ${rootPathUrl}`);
|
||||
}
|
||||
|
||||
// 缓存 assets 根路径,避免在每次匹配时重复调用 urlToFspath
|
||||
const assetsRoot = Editor.assetdb.urlToFspath("db://assets");
|
||||
const mode = matchType || "content"; // content, file_name, dir_name
|
||||
const validExtensions = extensions || [".js", ".ts", ".json", ".fire", ".prefab", ".xml", ".txt", ".md"];
|
||||
const results = [];
|
||||
@@ -2713,7 +2621,10 @@ CCProgram fs %{
|
||||
lines.forEach((line, index) => {
|
||||
if (results.length >= MAX_RESULTS) return;
|
||||
if (checkMatch(line)) {
|
||||
const relativePath = pathModule.relative(assetsRoot, filePath);
|
||||
const relativePath = pathModule.relative(
|
||||
Editor.assetdb.urlToFspath("db://assets"),
|
||||
filePath,
|
||||
);
|
||||
const dbPath =
|
||||
"db://assets/" + relativePath.split(pathModule.sep).join("/");
|
||||
results.push({
|
||||
|
||||
@@ -216,7 +216,7 @@ module.exports = {
|
||||
Editor.log(`[scene-script] 更新完成。新坐标: (${node.x}, ${node.y})`);
|
||||
if (event.reply) event.reply(null, "变换信息已更新");
|
||||
} else {
|
||||
if (event.reply) event.reply(new Error(`找不到节点 (UUID: ${id})`));
|
||||
if (event.reply) event.reply(new Error("找不到节点"));
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -341,6 +341,15 @@ module.exports = {
|
||||
const compClass = component.constructor;
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
// 【防呆设计】拦截对核心只读属性的非法重写
|
||||
// 如果直接修改组件的 node 属性,会导致该引用丢失变成普通对象,进而引发编辑器卡死
|
||||
if (key === "node" || key === "uuid" || key === "_id") {
|
||||
Editor.warn(
|
||||
`[scene-script] 拒绝覆盖组件的只读/核心属性: ${key}。请勿对组件执行此操作,修改位置/激活状态等请操作 Node 节点!`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 【核心修复】专门处理各类事件属性 (ClickEvents, ScrollEvents 等)
|
||||
const isEventProp =
|
||||
Array.isArray(value) && (key.toLowerCase().endsWith("events") || key === "clickEvents");
|
||||
@@ -533,14 +542,14 @@ module.exports = {
|
||||
};
|
||||
|
||||
if (!node) {
|
||||
if (event.reply) event.reply(new Error(`找不到节点 (UUID: ${nodeId}, action: ${action})`));
|
||||
if (event.reply) event.reply(new Error("找不到节点"));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "add":
|
||||
if (!componentType) {
|
||||
if (event.reply) event.reply(new Error(`必须提供组件类型 (nodeId: ${nodeId}, action: ${action})`));
|
||||
if (event.reply) event.reply(new Error("必须提供组件类型"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -605,7 +614,7 @@ module.exports = {
|
||||
|
||||
case "remove":
|
||||
if (!componentId) {
|
||||
if (event.reply) event.reply(new Error(`必须提供组件 ID (nodeId: ${nodeId})`));
|
||||
if (event.reply) event.reply(new Error("必须提供组件 ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -627,8 +636,7 @@ module.exports = {
|
||||
Editor.Ipc.sendToAll("scene:node-changed", { uuid: nodeId });
|
||||
if (event.reply) event.reply(null, "组件已移除");
|
||||
} else {
|
||||
if (event.reply)
|
||||
event.reply(new Error(`找不到组件 (nodeId: ${nodeId}, componentId: ${componentId})`));
|
||||
if (event.reply) event.reply(new Error("找不到组件"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (event.reply) event.reply(new Error(`移除组件失败: ${err.message}`));
|
||||
|
||||
Reference in New Issue
Block a user