lobster-roundtable 3.0.0
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 +85 -0
- package/index.js +76 -0
- package/main.js +2179 -0
- package/openclaw.plugin.json +70 -0
- package/package.json +44 -0
- package/src/channel.js +226 -0
- package/src/runtime.js +18 -0
package/main.js
ADDED
|
@@ -0,0 +1,2179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 龙虾圆桌 OpenClaw 插件 v2
|
|
3
|
+
*
|
|
4
|
+
* 架构:走 Gateway 内部管线(类似 IRC/Discord/Telegram 通道)
|
|
5
|
+
* 用户配的什么 AI 就用什么 AI,零配置即用
|
|
6
|
+
*
|
|
7
|
+
* 兼容性:
|
|
8
|
+
* - CJS 模块(require/module.exports),兼容所有 Node.js 版本
|
|
9
|
+
* - 不依赖 registerService(旧版可能不支持)
|
|
10
|
+
* - ws 模块:优先用 OpenClaw 内置的,找不到再从 plugin 目录找
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const os = require("os");
|
|
14
|
+
const pathModule = require("path");
|
|
15
|
+
const httpModule = require("http");
|
|
16
|
+
const httpsModule = require("https");
|
|
17
|
+
const fsModule = require("fs");
|
|
18
|
+
const cryptoModule = require("crypto");
|
|
19
|
+
const { execFileSync } = require("child_process");
|
|
20
|
+
|
|
21
|
+
let WebSocket;
|
|
22
|
+
try {
|
|
23
|
+
WebSocket = require("ws");
|
|
24
|
+
} catch {
|
|
25
|
+
try {
|
|
26
|
+
const ocDir = process.env.OPENCLAW_DIR || os.homedir() + "/.openclaw";
|
|
27
|
+
WebSocket = require(pathModule.join(ocDir, "node_modules", "ws"));
|
|
28
|
+
} catch {
|
|
29
|
+
// 最后的后备
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CHANNEL_ID = "lobster-roundtable";
|
|
34
|
+
const PLUGIN_VERSION = "3.0.0";
|
|
35
|
+
const ENABLE_OPENCLAW_CONFIG_SYNC = String(process.env.LOBSTER_RT_SYNC_OPENCLAW || "").trim() === "1";
|
|
36
|
+
const OPENCLAW_CONFIG_ALLOWED_KEYS = new Set(["url", "token", "ownerToken", "name", "persona", "maxTokens"]);
|
|
37
|
+
|
|
38
|
+
function normalizeIdentityId(raw, max = 96) {
|
|
39
|
+
const s = String(raw || "").trim().slice(0, max);
|
|
40
|
+
if (!s) return "";
|
|
41
|
+
return s.replace(/[^a-zA-Z0-9:_-]/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildOpenClawHome() {
|
|
45
|
+
return process.env.OPENCLAW_DIR || pathModule.join(os.homedir(), ".openclaw");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sanitizeSkillDirName(name) {
|
|
49
|
+
const raw = String(name || "").trim();
|
|
50
|
+
if (!raw) return "";
|
|
51
|
+
const cleaned = raw
|
|
52
|
+
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, "_")
|
|
53
|
+
.replace(/\s+/g, " ")
|
|
54
|
+
.replace(/^\.+/, "")
|
|
55
|
+
.trim()
|
|
56
|
+
.slice(0, 120);
|
|
57
|
+
if (!cleaned || cleaned === "." || cleaned === "..") return "";
|
|
58
|
+
return cleaned;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listInstalledSkillNames(skillsRoot, limit = 120) {
|
|
62
|
+
try {
|
|
63
|
+
if (!skillsRoot || !fsModule.existsSync(skillsRoot)) return [];
|
|
64
|
+
const dirents = fsModule.readdirSync(skillsRoot, { withFileTypes: true });
|
|
65
|
+
const names = [];
|
|
66
|
+
for (const entry of dirents) {
|
|
67
|
+
if (!entry || !entry.isDirectory()) continue;
|
|
68
|
+
const name = String(entry.name || "").trim();
|
|
69
|
+
if (!name) continue;
|
|
70
|
+
const skillFile = pathModule.join(skillsRoot, name, "SKILL.md");
|
|
71
|
+
if (!fsModule.existsSync(skillFile)) continue;
|
|
72
|
+
names.push(name);
|
|
73
|
+
if (names.length >= limit) break;
|
|
74
|
+
}
|
|
75
|
+
return names.sort((a, b) => a.localeCompare(b, "zh-Hans-CN"));
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ensureDirForFile(filePath) {
|
|
82
|
+
fsModule.mkdirSync(pathModule.dirname(filePath), { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readTextSafe(filePath) {
|
|
86
|
+
try {
|
|
87
|
+
return fsModule.existsSync(filePath) ? String(fsModule.readFileSync(filePath, "utf-8") || "").trim() : "";
|
|
88
|
+
} catch {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function decodeFileBuffer(buf) {
|
|
94
|
+
if (!Buffer.isBuffer(buf) || buf.length === 0) return "";
|
|
95
|
+
// UTF-8 BOM
|
|
96
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
97
|
+
return buf.slice(3).toString("utf8");
|
|
98
|
+
}
|
|
99
|
+
// UTF-16 LE BOM
|
|
100
|
+
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
|
|
101
|
+
return buf.slice(2).toString("utf16le");
|
|
102
|
+
}
|
|
103
|
+
// UTF-16 BE BOM
|
|
104
|
+
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
|
|
105
|
+
const swapped = Buffer.allocUnsafe(buf.length - 2);
|
|
106
|
+
for (let i = 2; i + 1 < buf.length; i += 2) {
|
|
107
|
+
swapped[i - 2] = buf[i + 1];
|
|
108
|
+
swapped[i - 1] = buf[i];
|
|
109
|
+
}
|
|
110
|
+
return swapped.toString("utf16le");
|
|
111
|
+
}
|
|
112
|
+
// Heuristic: UTF-16 LE without BOM (lots of NUL bytes in odd positions)
|
|
113
|
+
const sample = buf.slice(0, Math.min(buf.length, 128));
|
|
114
|
+
let nulOdd = 0;
|
|
115
|
+
for (let i = 1; i < sample.length; i += 2) {
|
|
116
|
+
if (sample[i] === 0) nulOdd++;
|
|
117
|
+
}
|
|
118
|
+
if (sample.length > 8 && nulOdd >= sample.length / 6) {
|
|
119
|
+
return buf.toString("utf16le");
|
|
120
|
+
}
|
|
121
|
+
return buf.toString("utf8");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseJsonFileFlexible(filePath) {
|
|
125
|
+
const raw = fsModule.readFileSync(filePath);
|
|
126
|
+
let text = decodeFileBuffer(raw);
|
|
127
|
+
if (!text) return {};
|
|
128
|
+
text = String(text).replace(/^\uFEFF/, "").trim();
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(text);
|
|
131
|
+
} catch {
|
|
132
|
+
// Recover from contaminated files like: log-prefix + {...json...} + log-suffix
|
|
133
|
+
const first = text.indexOf("{");
|
|
134
|
+
const last = text.lastIndexOf("}");
|
|
135
|
+
if (first >= 0 && last > first) {
|
|
136
|
+
const candidate = text.slice(first, last + 1).trim();
|
|
137
|
+
return JSON.parse(candidate);
|
|
138
|
+
}
|
|
139
|
+
throw new Error("invalid_json");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeJsonUtf8NoBomAtomic(filePath, value) {
|
|
144
|
+
const tmp = `${filePath}.tmp`;
|
|
145
|
+
const out = JSON.stringify(value, null, 2);
|
|
146
|
+
fsModule.writeFileSync(tmp, out, { encoding: "utf8" });
|
|
147
|
+
fsModule.renameSync(tmp, filePath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeTextSafe(filePath, value) {
|
|
151
|
+
try {
|
|
152
|
+
ensureDirForFile(filePath);
|
|
153
|
+
fsModule.writeFileSync(filePath, String(value || ""), "utf-8");
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildMachineFingerprint() {
|
|
161
|
+
const raw = `${os.hostname()}:${os.userInfo().username}:${os.platform()}:${os.arch()}`;
|
|
162
|
+
return cryptoModule.createHash("sha256").update(raw).digest("hex").slice(0, 32);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildDefaultBotName() {
|
|
166
|
+
const base = os.userInfo().username || os.hostname() || "Lobster";
|
|
167
|
+
return String(base).slice(0, 15) + "🦞";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function scopeKeyFromWsUrl(wsUrl) {
|
|
171
|
+
return cryptoModule.createHash("sha1").update(String(wsUrl || "")).digest("hex").slice(0, 10);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sanitizeRoundtableConfig(rawConfig) {
|
|
175
|
+
const input = (rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)) ? rawConfig : {};
|
|
176
|
+
const next = {};
|
|
177
|
+
for (const key of OPENCLAW_CONFIG_ALLOWED_KEYS) {
|
|
178
|
+
if (!(key in input)) continue;
|
|
179
|
+
const value = input[key];
|
|
180
|
+
if (value === undefined || value === null || value === "") continue;
|
|
181
|
+
next[key] = value;
|
|
182
|
+
}
|
|
183
|
+
return next;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function requestAutoRegister(httpBase, payload) {
|
|
187
|
+
const body = JSON.stringify(payload || {});
|
|
188
|
+
try {
|
|
189
|
+
const raw = execFileSync("curl", [
|
|
190
|
+
"-sS",
|
|
191
|
+
"--connect-timeout", "5",
|
|
192
|
+
"--max-time", "10",
|
|
193
|
+
"-X", "POST",
|
|
194
|
+
"-H", "Content-Type: application/json",
|
|
195
|
+
"--data", body,
|
|
196
|
+
`${httpBase}/api/auto-register`,
|
|
197
|
+
], {
|
|
198
|
+
encoding: "utf-8",
|
|
199
|
+
timeout: 15000,
|
|
200
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
201
|
+
});
|
|
202
|
+
return JSON.parse(String(raw || "{}"));
|
|
203
|
+
} catch (curlErr) {
|
|
204
|
+
// curl 不可用时(如 Windows 某些环境),降级到 Node.js HTTP
|
|
205
|
+
if (curlErr?.status === null || curlErr?.message?.includes('ENOENT')) {
|
|
206
|
+
const url = new URL(`${httpBase}/api/auto-register`);
|
|
207
|
+
const lib = url.protocol === 'https:' ? httpsModule : httpModule;
|
|
208
|
+
const bodyBuf = Buffer.from(body);
|
|
209
|
+
const result = { token: null };
|
|
210
|
+
// 同步降级不可行,抛出让调用者走异步路径
|
|
211
|
+
throw new Error(`curl unavailable: ${curlErr.message}`);
|
|
212
|
+
}
|
|
213
|
+
throw curlErr;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function requestTokenInfo(httpBase, token) {
|
|
218
|
+
const safeToken = String(token || "").trim();
|
|
219
|
+
if (!safeToken) return null;
|
|
220
|
+
const raw = execFileSync("curl", [
|
|
221
|
+
"-sS",
|
|
222
|
+
"--connect-timeout", "4",
|
|
223
|
+
"--max-time", "8",
|
|
224
|
+
`${httpBase}/api/tokens/${encodeURIComponent(safeToken)}`,
|
|
225
|
+
], {
|
|
226
|
+
encoding: "utf-8",
|
|
227
|
+
timeout: 12000,
|
|
228
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
229
|
+
});
|
|
230
|
+
const parsed = JSON.parse(String(raw || "{}"));
|
|
231
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function upsertOpenClawPluginConfig(ocHome, wsUrl, updates, logger) {
|
|
236
|
+
const ocConfigPath = pathModule.join(ocHome, "openclaw.json");
|
|
237
|
+
if (!fsModule.existsSync(ocConfigPath)) return;
|
|
238
|
+
try {
|
|
239
|
+
let ocConfig;
|
|
240
|
+
try {
|
|
241
|
+
ocConfig = parseJsonFileFlexible(ocConfigPath);
|
|
242
|
+
} catch (readErr) {
|
|
243
|
+
const bakPath = `${ocConfigPath}.bak`;
|
|
244
|
+
if (fsModule.existsSync(bakPath)) {
|
|
245
|
+
ocConfig = parseJsonFileFlexible(bakPath);
|
|
246
|
+
logger?.warn?.("[roundtable] 检测到 openclaw.json 异常,已回退到 .bak 内容并继续");
|
|
247
|
+
} else {
|
|
248
|
+
throw readErr;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!ocConfig || typeof ocConfig !== "object" || Array.isArray(ocConfig)) ocConfig = {};
|
|
252
|
+
if (!ocConfig.plugins) ocConfig.plugins = {};
|
|
253
|
+
if (!ocConfig.plugins.entries) ocConfig.plugins.entries = {};
|
|
254
|
+
if (!Array.isArray(ocConfig.plugins.allow)) {
|
|
255
|
+
ocConfig.plugins.allow = Array.isArray(ocConfig.plugins?.allow)
|
|
256
|
+
? ocConfig.plugins.allow
|
|
257
|
+
: [];
|
|
258
|
+
}
|
|
259
|
+
if (!ocConfig.plugins.allow.includes("lobster-roundtable")) {
|
|
260
|
+
ocConfig.plugins.allow.push("lobster-roundtable");
|
|
261
|
+
}
|
|
262
|
+
if (!ocConfig.plugins.entries["lobster-roundtable"]) {
|
|
263
|
+
ocConfig.plugins.entries["lobster-roundtable"] = { enabled: true, config: {} };
|
|
264
|
+
}
|
|
265
|
+
const entry = ocConfig.plugins.entries["lobster-roundtable"];
|
|
266
|
+
const legacyInstanceIdDetected = !!(
|
|
267
|
+
entry?.config &&
|
|
268
|
+
typeof entry.config === "object" &&
|
|
269
|
+
!Array.isArray(entry.config) &&
|
|
270
|
+
Object.prototype.hasOwnProperty.call(entry.config, "instanceId")
|
|
271
|
+
);
|
|
272
|
+
entry.config = sanitizeRoundtableConfig(entry.config);
|
|
273
|
+
if (wsUrl && !entry.config.url) entry.config.url = wsUrl;
|
|
274
|
+
for (const [k, v] of Object.entries(updates || {})) {
|
|
275
|
+
if (!OPENCLAW_CONFIG_ALLOWED_KEYS.has(k)) continue;
|
|
276
|
+
if (v === undefined || v === null || v === "") continue;
|
|
277
|
+
entry.config[k] = v;
|
|
278
|
+
}
|
|
279
|
+
const bakPath = `${ocConfigPath}.bak`;
|
|
280
|
+
try {
|
|
281
|
+
fsModule.copyFileSync(ocConfigPath, bakPath);
|
|
282
|
+
} catch { }
|
|
283
|
+
writeJsonUtf8NoBomAtomic(ocConfigPath, ocConfig);
|
|
284
|
+
// Read-back validation to avoid silently writing broken content.
|
|
285
|
+
parseJsonFileFlexible(ocConfigPath);
|
|
286
|
+
if (legacyInstanceIdDetected) {
|
|
287
|
+
logger?.info?.("[roundtable] 🧹 已清理 openclaw.json 过期字段 instanceId");
|
|
288
|
+
}
|
|
289
|
+
logger?.info?.("[roundtable] 📝 已回写 openclaw.json(token/plugins.allow)");
|
|
290
|
+
} catch (e) {
|
|
291
|
+
logger?.warn?.(`[roundtable] 回写 openclaw.json 失败: ${e.message}(请检查 JSON 编码/格式)`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function syncOpenClawConfigIfEnabled(ocHome, wsUrl, updates, logger) {
|
|
296
|
+
if (!ENABLE_OPENCLAW_CONFIG_SYNC) return false;
|
|
297
|
+
upsertOpenClawPluginConfig(ocHome, wsUrl, updates, logger);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 导出初始化函数,供 Channel 插件入口调用
|
|
303
|
+
* @param {object} api - OpenClaw Plugin API
|
|
304
|
+
* @param {object} core - api.runtime
|
|
305
|
+
* @param {boolean} hasRuntimeAPI - runtime API 是否可用
|
|
306
|
+
*/
|
|
307
|
+
module.exports = function initRoundtable(api, core, hasRuntimeAPI) {
|
|
308
|
+
// ── 基础配置 ──
|
|
309
|
+
const cfg = api.pluginConfig || {};
|
|
310
|
+
const wsUrl = cfg.url || "ws://118.25.82.209:3000";
|
|
311
|
+
const httpBase = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
312
|
+
const configuredName = String(cfg.name || "").trim();
|
|
313
|
+
let token = String(cfg.token || "").trim();
|
|
314
|
+
let ownerToken = normalizeIdentityId(cfg.ownerToken, 128);
|
|
315
|
+
const persona = String(cfg.persona || "").trim();
|
|
316
|
+
const maxTokens = cfg.maxTokens || 150;
|
|
317
|
+
|
|
318
|
+
if (!WebSocket) {
|
|
319
|
+
api.logger.error("[roundtable] 找不到 ws 模块,插件无法启动");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// runtime API 状态已由入口传入
|
|
324
|
+
|
|
325
|
+
// ── 身份与缓存(按 OpenClaw 目录 + wsUrl 隔离) ──
|
|
326
|
+
const ocHome = buildOpenClawHome();
|
|
327
|
+
const wsScope = scopeKeyFromWsUrl(wsUrl);
|
|
328
|
+
const tokenCacheFile = pathModule.join(ocHome, `.roundtable-token-${wsScope}`);
|
|
329
|
+
const ownerTokenCacheFile = pathModule.join(ocHome, `.roundtable-owner-${wsScope}`);
|
|
330
|
+
const instanceIdFile = pathModule.join(ocHome, `.roundtable-instance-${wsScope}`);
|
|
331
|
+
const sessionId = cryptoModule.randomBytes(12).toString("hex");
|
|
332
|
+
const machineFingerprint = buildMachineFingerprint();
|
|
333
|
+
let instanceId =
|
|
334
|
+
normalizeIdentityId(readTextSafe(instanceIdFile)) ||
|
|
335
|
+
cryptoModule.randomBytes(18).toString("hex");
|
|
336
|
+
writeTextSafe(instanceIdFile, instanceId);
|
|
337
|
+
|
|
338
|
+
const cachedToken = String(readTextSafe(tokenCacheFile) || "").trim();
|
|
339
|
+
if (cachedToken && token && token !== cachedToken) {
|
|
340
|
+
let useConfiguredToken = false;
|
|
341
|
+
let decisionReason = "";
|
|
342
|
+
if (ownerToken) {
|
|
343
|
+
useConfiguredToken = true;
|
|
344
|
+
decisionReason = "配置携带 ownerToken(视为新的安装绑定)";
|
|
345
|
+
} else {
|
|
346
|
+
try {
|
|
347
|
+
const info = requestTokenInfo(httpBase, token);
|
|
348
|
+
if (info?.token && String(info.token).trim() === token) {
|
|
349
|
+
useConfiguredToken = true;
|
|
350
|
+
decisionReason = "配置 token 在服务端存在(视为新安装)";
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
// 服务端不可达时保持缓存优先,避免旧配置回滚
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (useConfiguredToken) {
|
|
358
|
+
api.logger.warn(`[roundtable] 🔁 检测到新 token,采用配置 token(${decisionReason})`);
|
|
359
|
+
writeTextSafe(tokenCacheFile, token);
|
|
360
|
+
} else {
|
|
361
|
+
api.logger.warn("[roundtable] ⚠️ 检测到配置 token 与本地缓存不一致,优先使用缓存 token");
|
|
362
|
+
token = cachedToken;
|
|
363
|
+
}
|
|
364
|
+
} else if (cachedToken && !token) {
|
|
365
|
+
api.logger.info("[roundtable] 📦 从本地缓存加载 token");
|
|
366
|
+
token = cachedToken;
|
|
367
|
+
} else if (cachedToken && token === cachedToken) {
|
|
368
|
+
api.logger.info("[roundtable] 📦 命中本地 token 缓存");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const cachedOwnerToken = normalizeIdentityId(readTextSafe(ownerTokenCacheFile), 128);
|
|
372
|
+
if (!ownerToken && cachedOwnerToken) {
|
|
373
|
+
ownerToken = cachedOwnerToken;
|
|
374
|
+
api.logger.info("[roundtable] 🔗 从本地缓存恢复 ownerToken");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!token) {
|
|
378
|
+
// 调 HTTP API 自动注册
|
|
379
|
+
api.logger.info("[roundtable] 🔑 首次启动,自动注册中...");
|
|
380
|
+
const botName = configuredName || buildDefaultBotName();
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const resp = requestAutoRegister(httpBase, {
|
|
384
|
+
fingerprint: machineFingerprint,
|
|
385
|
+
name: botName,
|
|
386
|
+
instanceId,
|
|
387
|
+
...(ownerToken ? { ownerToken } : {}),
|
|
388
|
+
});
|
|
389
|
+
if (resp.token) {
|
|
390
|
+
token = resp.token;
|
|
391
|
+
if (resp.ownerToken) ownerToken = normalizeIdentityId(resp.ownerToken, 128) || ownerToken;
|
|
392
|
+
api.logger.info(`[roundtable] 🔑 自动注册成功!名称: ${resp.name}${resp.reused ? '(复用)' : '(新建)'}`);
|
|
393
|
+
writeTextSafe(tokenCacheFile, token);
|
|
394
|
+
writeTextSafe(instanceIdFile, instanceId);
|
|
395
|
+
if (ownerToken) writeTextSafe(ownerTokenCacheFile, ownerToken);
|
|
396
|
+
syncOpenClawConfigIfEnabled(ocHome, wsUrl, {
|
|
397
|
+
token,
|
|
398
|
+
ownerToken: ownerToken || undefined,
|
|
399
|
+
name: configuredName || undefined,
|
|
400
|
+
}, api.logger);
|
|
401
|
+
}
|
|
402
|
+
} catch (err) {
|
|
403
|
+
api.logger.warn(`[roundtable] ⚠️ 自动注册失败: ${err.message},插件以观察模式启动`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (token) writeTextSafe(tokenCacheFile, token);
|
|
408
|
+
if (ownerToken) writeTextSafe(ownerTokenCacheFile, ownerToken);
|
|
409
|
+
syncOpenClawConfigIfEnabled(ocHome, wsUrl, {
|
|
410
|
+
token,
|
|
411
|
+
ownerToken: ownerToken || undefined,
|
|
412
|
+
name: configuredName || undefined,
|
|
413
|
+
}, api.logger);
|
|
414
|
+
if (!ENABLE_OPENCLAW_CONFIG_SYNC) {
|
|
415
|
+
api.logger.info("[roundtable] 配置同步已禁用:仅写本地 token 缓存,避免触发 gateway 重启");
|
|
416
|
+
}
|
|
417
|
+
api.logger.info(`[roundtable] v${PLUGIN_VERSION} 启动(Channel 模式)`);
|
|
418
|
+
return startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFile, {
|
|
419
|
+
ocHome,
|
|
420
|
+
instanceId,
|
|
421
|
+
instanceIdFile,
|
|
422
|
+
sessionId,
|
|
423
|
+
fingerprint: machineFingerprint,
|
|
424
|
+
wsScope,
|
|
425
|
+
ownerToken,
|
|
426
|
+
ownerTokenCacheFile,
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFile, identityCtx = {}) {
|
|
431
|
+
let ws = null;
|
|
432
|
+
let reconnectTimer = null;
|
|
433
|
+
let reconnectAttempts = 0;
|
|
434
|
+
let stopping = false; // Gateway 停止标志,防止 onclose 触发幽灵重连
|
|
435
|
+
const configuredName = String(cfg.name || "").trim();
|
|
436
|
+
let myName = configuredName || "龙虾";
|
|
437
|
+
|
|
438
|
+
// 进化室选 Skill 时排除已被拒绝的
|
|
439
|
+
let rejectedSkills = new Set();
|
|
440
|
+
// AI 自检结果缓存
|
|
441
|
+
let cachedSkillList = null;
|
|
442
|
+
// 当前所在房间状态(给 owner_command 自主决策使用)
|
|
443
|
+
let currentRoomId = null;
|
|
444
|
+
let currentRoomMode = null;
|
|
445
|
+
let currentRoomRunning = false;
|
|
446
|
+
let currentRoomRules = "";
|
|
447
|
+
let availableRooms = [];
|
|
448
|
+
let pendingStartAfterJoin = null; // { roomId, maxRounds, reason }
|
|
449
|
+
const factualEvents = [];
|
|
450
|
+
const httpBase = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
|
|
451
|
+
let autonomyEnabled = false;
|
|
452
|
+
let autonomyTimer = null;
|
|
453
|
+
let autonomyTickBusy = false;
|
|
454
|
+
const autonomyState = {
|
|
455
|
+
mode: 'roundtable',
|
|
456
|
+
rounds: 5,
|
|
457
|
+
lastActionAt: 0,
|
|
458
|
+
};
|
|
459
|
+
const diagUrl = `${httpBase}/api/plugin-diagnostics`;
|
|
460
|
+
const diagLastSent = new Map();
|
|
461
|
+
const ocHome = identityCtx.ocHome || buildOpenClawHome();
|
|
462
|
+
const skillsRoot = pathModule.join(ocHome, "skills");
|
|
463
|
+
const instanceIdFile = identityCtx.instanceIdFile || pathModule.join(ocHome, ".roundtable-instance");
|
|
464
|
+
const tokenCache = identityCtx.tokenCacheFile || tokenCacheFile;
|
|
465
|
+
const ownerTokenCacheFile = identityCtx.ownerTokenCacheFile || pathModule.join(ocHome, ".roundtable-owner");
|
|
466
|
+
const fingerprint = normalizeIdentityId(identityCtx.fingerprint, 64) || buildMachineFingerprint();
|
|
467
|
+
let ownerToken =
|
|
468
|
+
normalizeIdentityId(identityCtx.ownerToken || cfg.ownerToken, 128) ||
|
|
469
|
+
normalizeIdentityId(readTextSafe(ownerTokenCacheFile), 128);
|
|
470
|
+
let instanceId = normalizeIdentityId(identityCtx.instanceId) || normalizeIdentityId(readTextSafe(instanceIdFile)) || cryptoModule.randomBytes(18).toString("hex");
|
|
471
|
+
const sessionId = normalizeIdentityId(identityCtx.sessionId, 120) || cryptoModule.randomBytes(12).toString("hex");
|
|
472
|
+
let recoveringToken = false;
|
|
473
|
+
|
|
474
|
+
// Token 冲突熔断器:防止无限重试导致日志爆炸和 CPU 空转
|
|
475
|
+
const conflictBreaker = {
|
|
476
|
+
count: 0,
|
|
477
|
+
lastAt: 0,
|
|
478
|
+
maxPerWindow: 3, // 5 分钟内最多重试 3 次
|
|
479
|
+
windowMs: 5 * 60000,
|
|
480
|
+
cooldownMs: 120000, // 熔断后冷却 2 分钟
|
|
481
|
+
fused: false,
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
function shouldBreakConflict() {
|
|
485
|
+
const now = Date.now();
|
|
486
|
+
if (now - conflictBreaker.lastAt > conflictBreaker.windowMs) {
|
|
487
|
+
conflictBreaker.count = 0;
|
|
488
|
+
conflictBreaker.fused = false;
|
|
489
|
+
}
|
|
490
|
+
conflictBreaker.count++;
|
|
491
|
+
conflictBreaker.lastAt = now;
|
|
492
|
+
if (conflictBreaker.count > conflictBreaker.maxPerWindow) {
|
|
493
|
+
conflictBreaker.fused = true;
|
|
494
|
+
api.logger.error(
|
|
495
|
+
`[roundtable] 🚨 Token 冲突熔断:5分钟内冲突 ${conflictBreaker.count} 次,冷却 ${conflictBreaker.cooldownMs / 1000}s`
|
|
496
|
+
);
|
|
497
|
+
reportDiag("token_conflict_fused", {
|
|
498
|
+
level: "error",
|
|
499
|
+
message: `circuit breaker fused after ${conflictBreaker.count} conflicts`,
|
|
500
|
+
detail: { cooldownMs: conflictBreaker.cooldownMs },
|
|
501
|
+
}, 0);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function persistIdentity(tokenValue) {
|
|
508
|
+
if (tokenValue) writeTextSafe(tokenCache, tokenValue);
|
|
509
|
+
if (instanceId) writeTextSafe(instanceIdFile, instanceId);
|
|
510
|
+
if (ownerToken) writeTextSafe(ownerTokenCacheFile, ownerToken);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function nextReconnectDelayMs() {
|
|
514
|
+
const base = Math.min(30000, Math.round(2500 * Math.pow(1.6, reconnectAttempts)));
|
|
515
|
+
reconnectAttempts += 1;
|
|
516
|
+
const jitter = Math.floor(Math.random() * 1000);
|
|
517
|
+
return base + jitter;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function scheduleReconnect(delayOverrideMs) {
|
|
521
|
+
if (stopping) return -1; // Gateway 已要求停止,不再重连
|
|
522
|
+
clearTimeout(reconnectTimer);
|
|
523
|
+
const delayMs = Number.isFinite(delayOverrideMs) ? Math.max(800, delayOverrideMs) : nextReconnectDelayMs();
|
|
524
|
+
reconnectTimer = setTimeout(connect, delayMs);
|
|
525
|
+
return delayMs;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function tryAutoRegisterRecover(reason, options = {}) {
|
|
529
|
+
if (recoveringToken) return false;
|
|
530
|
+
recoveringToken = true;
|
|
531
|
+
try {
|
|
532
|
+
const rotateInstance = !!options.rotateInstance;
|
|
533
|
+
const forceNewToken = !!options.forceNewToken;
|
|
534
|
+
if (rotateInstance) {
|
|
535
|
+
instanceId = cryptoModule.randomBytes(18).toString("hex");
|
|
536
|
+
api.logger.warn(`[roundtable] ♻️ 发生 token 冲突,已切换新实例标识: ${instanceId.slice(0, 8)}...`);
|
|
537
|
+
}
|
|
538
|
+
const recoverName = configuredName || (myName && myName !== "龙虾" ? myName : "") || buildDefaultBotName();
|
|
539
|
+
const payload = {
|
|
540
|
+
fingerprint,
|
|
541
|
+
name: recoverName,
|
|
542
|
+
instanceId,
|
|
543
|
+
forceNewToken,
|
|
544
|
+
};
|
|
545
|
+
if (ownerToken) payload.ownerToken = ownerToken;
|
|
546
|
+
const preferredToken = String(
|
|
547
|
+
options.preferredToken || token || String(cfg.token || "").trim()
|
|
548
|
+
).trim();
|
|
549
|
+
if (preferredToken) payload.preferredToken = preferredToken;
|
|
550
|
+
const resp = requestAutoRegister(httpBase, payload);
|
|
551
|
+
if (!resp?.token) return false;
|
|
552
|
+
token = String(resp.token).trim();
|
|
553
|
+
if (resp.ownerToken) {
|
|
554
|
+
ownerToken = normalizeIdentityId(resp.ownerToken, 128) || ownerToken;
|
|
555
|
+
}
|
|
556
|
+
api.logger.info(`[roundtable] 🔑 自愈注册成功(${reason}): ${resp.name || myName}${resp.reused ? "(复用)" : "(新建)"} token=${token.slice(0, 10)}...`);
|
|
557
|
+
persistIdentity(token);
|
|
558
|
+
syncOpenClawConfigIfEnabled(ocHome, wsUrl, {
|
|
559
|
+
token,
|
|
560
|
+
ownerToken: ownerToken || undefined,
|
|
561
|
+
name: configuredName || undefined,
|
|
562
|
+
}, api.logger);
|
|
563
|
+
reportDiag("token_recover_success", {
|
|
564
|
+
message: `recover success: ${reason}`,
|
|
565
|
+
detail: { reused: !!resp.reused, rotateInstance, forceNewToken },
|
|
566
|
+
}, 0);
|
|
567
|
+
try { ws?.close(); } catch { }
|
|
568
|
+
return true;
|
|
569
|
+
} catch (err) {
|
|
570
|
+
api.logger.error(`[roundtable] 自愈注册失败(${reason}): ${err.message}`);
|
|
571
|
+
reportDiag("token_recover_failed", {
|
|
572
|
+
level: "error",
|
|
573
|
+
message: err.message,
|
|
574
|
+
stack: err.stack || "",
|
|
575
|
+
detail: { reason },
|
|
576
|
+
}, 0);
|
|
577
|
+
return false;
|
|
578
|
+
} finally {
|
|
579
|
+
recoveringToken = false;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function postJsonNoWait(url, payload) {
|
|
584
|
+
try {
|
|
585
|
+
const u = new URL(url);
|
|
586
|
+
const body = Buffer.from(JSON.stringify(payload));
|
|
587
|
+
const lib = u.protocol === "https:" ? httpsModule : httpModule;
|
|
588
|
+
const req = lib.request({
|
|
589
|
+
method: "POST",
|
|
590
|
+
hostname: u.hostname,
|
|
591
|
+
port: u.port || (u.protocol === "https:" ? 443 : 80),
|
|
592
|
+
path: `${u.pathname}${u.search || ""}`,
|
|
593
|
+
headers: {
|
|
594
|
+
"Content-Type": "application/json",
|
|
595
|
+
"Content-Length": body.length,
|
|
596
|
+
},
|
|
597
|
+
}, (res) => { res.resume(); });
|
|
598
|
+
req.on("error", () => { });
|
|
599
|
+
req.setTimeout(2500, () => req.destroy());
|
|
600
|
+
req.write(body);
|
|
601
|
+
req.end();
|
|
602
|
+
} catch { }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function safeDiagText(v, max = 280) {
|
|
606
|
+
const s = String(v || "").trim();
|
|
607
|
+
return s.length > max ? s.slice(0, max) : s;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function reportDiag(event, payload = {}, throttleMs = 8000) {
|
|
611
|
+
if (!event) return;
|
|
612
|
+
const now = Date.now();
|
|
613
|
+
const level = safeDiagText(payload.level || "info", 16).toLowerCase() || "info";
|
|
614
|
+
const message = safeDiagText(payload.message || "", 500);
|
|
615
|
+
const reason = safeDiagText(payload.reason || "", 260);
|
|
616
|
+
const key = `${event}|${level}|${message.slice(0, 80)}|${payload.code ?? ""}|${reason.slice(0, 80)}`;
|
|
617
|
+
if (throttleMs > 0) {
|
|
618
|
+
const last = diagLastSent.get(key) || 0;
|
|
619
|
+
if (now - last < throttleMs) return;
|
|
620
|
+
diagLastSent.set(key, now);
|
|
621
|
+
}
|
|
622
|
+
postJsonNoWait(diagUrl, {
|
|
623
|
+
source: "plugin-main",
|
|
624
|
+
event: safeDiagText(event, 64),
|
|
625
|
+
level,
|
|
626
|
+
message,
|
|
627
|
+
reason,
|
|
628
|
+
code: Number.isFinite(Number(payload.code)) ? Number(payload.code) : null,
|
|
629
|
+
roomId: currentRoomId || "",
|
|
630
|
+
roomMode: currentRoomMode || "",
|
|
631
|
+
wsUrl,
|
|
632
|
+
botName: myName || "",
|
|
633
|
+
token: token || "",
|
|
634
|
+
instanceId: instanceId || "",
|
|
635
|
+
sessionId: sessionId || "",
|
|
636
|
+
detail: payload.detail && typeof payload.detail === "object" ? payload.detail : null,
|
|
637
|
+
stack: safeDiagText(payload.stack || "", 1200),
|
|
638
|
+
ts: new Date().toISOString(),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function reportDiagSync(event, payload = {}) {
|
|
643
|
+
try {
|
|
644
|
+
const body = JSON.stringify({
|
|
645
|
+
source: "plugin-main",
|
|
646
|
+
event: safeDiagText(event, 64),
|
|
647
|
+
level: safeDiagText(payload.level || "error", 16).toLowerCase(),
|
|
648
|
+
message: safeDiagText(payload.message || "", 500),
|
|
649
|
+
reason: safeDiagText(payload.reason || "", 260),
|
|
650
|
+
code: Number.isFinite(Number(payload.code)) ? Number(payload.code) : null,
|
|
651
|
+
roomId: currentRoomId || "",
|
|
652
|
+
roomMode: currentRoomMode || "",
|
|
653
|
+
wsUrl,
|
|
654
|
+
botName: myName || "",
|
|
655
|
+
token: token || "",
|
|
656
|
+
instanceId: instanceId || "",
|
|
657
|
+
sessionId: sessionId || "",
|
|
658
|
+
stack: safeDiagText(payload.stack || "", 1200),
|
|
659
|
+
ts: new Date().toISOString(),
|
|
660
|
+
});
|
|
661
|
+
execFileSync("curl", [
|
|
662
|
+
"-sS",
|
|
663
|
+
"--connect-timeout", "2",
|
|
664
|
+
"--max-time", "4",
|
|
665
|
+
"-X", "POST",
|
|
666
|
+
"-H", "Content-Type: application/json",
|
|
667
|
+
"--data", body,
|
|
668
|
+
diagUrl,
|
|
669
|
+
], { stdio: "ignore", timeout: 5000 });
|
|
670
|
+
} catch { }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function installCrashHooks() {
|
|
674
|
+
const globalKey = "__LOBSTER_RT_DIAG_REPORTERS__";
|
|
675
|
+
const state = global[globalKey] || { hooked: false, reporters: [] };
|
|
676
|
+
if (!Array.isArray(state.reporters)) state.reporters = [];
|
|
677
|
+
state.reporters = state.reporters.filter((fn) => typeof fn === "function").slice(-7);
|
|
678
|
+
state.reporters.push(reportDiagSync);
|
|
679
|
+
if (!state.hooked) {
|
|
680
|
+
const fanout = (event, payload) => {
|
|
681
|
+
for (const fn of state.reporters) {
|
|
682
|
+
try { fn(event, payload); } catch { }
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
process.on("unhandledRejection", (reason) => {
|
|
686
|
+
fanout("process_unhandled_rejection", {
|
|
687
|
+
level: "fatal",
|
|
688
|
+
message: reason && reason.message ? String(reason.message) : String(reason || "unknown"),
|
|
689
|
+
stack: reason && reason.stack ? String(reason.stack) : "",
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
process.on("uncaughtExceptionMonitor", (err) => {
|
|
693
|
+
fanout("process_uncaught_exception", {
|
|
694
|
+
level: "fatal",
|
|
695
|
+
message: err && err.message ? String(err.message) : String(err || "unknown"),
|
|
696
|
+
stack: err && err.stack ? String(err.stack) : "",
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
process.on("exit", (code) => {
|
|
700
|
+
fanout("process_exit", {
|
|
701
|
+
level: "warn",
|
|
702
|
+
message: "process exit",
|
|
703
|
+
code,
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
state.hooked = true;
|
|
707
|
+
}
|
|
708
|
+
global[globalKey] = state;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
installCrashHooks();
|
|
712
|
+
|
|
713
|
+
const heartbeatTimer = setInterval(() => {
|
|
714
|
+
reportDiag("heartbeat", {
|
|
715
|
+
level: "debug",
|
|
716
|
+
message: currentRoomId ? `in-room:${currentRoomId}` : "in-lobby",
|
|
717
|
+
detail: { autonomyEnabled, currentRoomRunning },
|
|
718
|
+
}, 0);
|
|
719
|
+
}, 60000);
|
|
720
|
+
if (heartbeatTimer.unref) heartbeatTimer.unref();
|
|
721
|
+
|
|
722
|
+
reportDiag("plugin_boot", {
|
|
723
|
+
message: "plugin main started",
|
|
724
|
+
detail: { version: PLUGIN_VERSION, hasToken: !!token, node: process.version },
|
|
725
|
+
}, 0);
|
|
726
|
+
|
|
727
|
+
function connect() {
|
|
728
|
+
if (stopping) return; // Gateway 已要求停止,不再连接
|
|
729
|
+
api.logger.info(`[roundtable] 连接 ${wsUrl} ...`);
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
ws = new WebSocket(wsUrl);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
api.logger.error(`[roundtable] 创建 WS 失败: ${err.message}`);
|
|
735
|
+
reportDiag("ws_create_failed", { level: "error", message: err.message });
|
|
736
|
+
scheduleReconnect();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
ws.onopen = () => {
|
|
741
|
+
reconnectAttempts = 0;
|
|
742
|
+
reportDiag("ws_open", {
|
|
743
|
+
message: token ? "connected with token" : "connected in observe mode",
|
|
744
|
+
detail: { instanceId, sessionId },
|
|
745
|
+
}, 3000);
|
|
746
|
+
if (!token) {
|
|
747
|
+
api.logger.info("[roundtable] WebSocket 已连接(观察模式,未注册 Bot)");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const rejoinRoom = currentRoomId;
|
|
751
|
+
if (rejoinRoom) {
|
|
752
|
+
api.logger.info(`[roundtable] 🔄 重连成功,尝试自动回到房间: ${rejoinRoom}`);
|
|
753
|
+
} else {
|
|
754
|
+
api.logger.info("[roundtable] WebSocket 已连接,进入大厅待机...");
|
|
755
|
+
}
|
|
756
|
+
const installedSkills = listInstalledSkillNames(skillsRoot, 200);
|
|
757
|
+
send({
|
|
758
|
+
type: "bot_connect",
|
|
759
|
+
token,
|
|
760
|
+
name: configuredName || undefined,
|
|
761
|
+
ownerToken: ownerToken || undefined,
|
|
762
|
+
fingerprint,
|
|
763
|
+
instanceId,
|
|
764
|
+
sessionId,
|
|
765
|
+
skillNames: installedSkills,
|
|
766
|
+
roomId: rejoinRoom || undefined,
|
|
767
|
+
});
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
ws.onmessage = async (event) => {
|
|
771
|
+
let msg;
|
|
772
|
+
try {
|
|
773
|
+
msg = JSON.parse(String(event.data));
|
|
774
|
+
} catch {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
await handleMessage(msg);
|
|
779
|
+
} catch (err) {
|
|
780
|
+
api.logger.error(`[roundtable] handleMessage 崩溃兄底: ${err.message}`);
|
|
781
|
+
reportDiag("handle_message_error", {
|
|
782
|
+
level: "error",
|
|
783
|
+
message: err.message,
|
|
784
|
+
stack: err.stack || "",
|
|
785
|
+
detail: { msgType: msg?.type || "unknown" },
|
|
786
|
+
}, 1500);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
ws.onclose = (evt) => {
|
|
791
|
+
// 断连后立即清理本地 room 状态,避免重连后状态错乱
|
|
792
|
+
currentRoomId = null;
|
|
793
|
+
currentRoomMode = null;
|
|
794
|
+
currentRoomRunning = false;
|
|
795
|
+
currentRoomRules = "";
|
|
796
|
+
pendingStartAfterJoin = null;
|
|
797
|
+
|
|
798
|
+
if (stopping) {
|
|
799
|
+
reportDiag("ws_close", {
|
|
800
|
+
level: "info",
|
|
801
|
+
message: "websocket closed during shutdown",
|
|
802
|
+
code: evt?.code ?? null,
|
|
803
|
+
reason: String(evt?.reason || ""),
|
|
804
|
+
detail: { reconnectInMs: null, reconnectAttempts },
|
|
805
|
+
}, 2000);
|
|
806
|
+
api.logger.info("[roundtable] 连接已关闭(停止中,不重连)");
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const delayMs = scheduleReconnect();
|
|
811
|
+
reportDiag("ws_close", {
|
|
812
|
+
level: "warn",
|
|
813
|
+
message: "websocket closed",
|
|
814
|
+
code: evt?.code ?? null,
|
|
815
|
+
reason: String(evt?.reason || ""),
|
|
816
|
+
detail: { reconnectInMs: delayMs, reconnectAttempts },
|
|
817
|
+
}, 2000);
|
|
818
|
+
api.logger.warn(`[roundtable] 连接断开,${Math.round(delayMs / 1000)} 秒后重连...`);
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
ws.onerror = (event) => {
|
|
822
|
+
reportDiag("ws_error", {
|
|
823
|
+
level: "error",
|
|
824
|
+
message: event?.message || "unknown",
|
|
825
|
+
}, 1500);
|
|
826
|
+
api.logger.error(
|
|
827
|
+
`[roundtable] WS 错误: ${event.message || "unknown"}`
|
|
828
|
+
);
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function findRoomByMode(mode) {
|
|
833
|
+
const list = (availableRooms || []).filter(r => r.mode === mode);
|
|
834
|
+
if (list.length === 0) return null;
|
|
835
|
+
return list.find(r => !r.running) || list[0];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function safeParseJsonObject(text) {
|
|
839
|
+
if (!text) return null;
|
|
840
|
+
const trimmed = String(text).trim();
|
|
841
|
+
try { return JSON.parse(trimmed); } catch { }
|
|
842
|
+
const match = trimmed.match(/\{[\s\S]*\}/);
|
|
843
|
+
if (!match) return null;
|
|
844
|
+
try { return JSON.parse(match[0]); } catch { return null; }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function rememberFact(text) {
|
|
848
|
+
const content = String(text || '').trim();
|
|
849
|
+
if (!content) return;
|
|
850
|
+
factualEvents.push(
|
|
851
|
+
`${new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} ${content}`
|
|
852
|
+
);
|
|
853
|
+
while (factualEvents.length > 40) factualEvents.shift();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function getFactualContext(limit = 10) {
|
|
857
|
+
return factualEvents.slice(-Math.max(1, parseInt(limit, 10) || 10)).join('\n');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function clearAutonomyTimer() {
|
|
861
|
+
if (autonomyTimer) {
|
|
862
|
+
clearTimeout(autonomyTimer);
|
|
863
|
+
autonomyTimer = null;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function scheduleAutonomyTick(delayMs = 12000) {
|
|
868
|
+
if (!autonomyEnabled) return;
|
|
869
|
+
clearAutonomyTimer();
|
|
870
|
+
autonomyTimer = setTimeout(() => {
|
|
871
|
+
autonomyTimer = null;
|
|
872
|
+
runAutonomyTick('timer');
|
|
873
|
+
}, Math.max(800, parseInt(delayMs, 10) || 12000));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function setAutonomyEnabled(enabled, reason = '') {
|
|
877
|
+
const next = !!enabled;
|
|
878
|
+
if (autonomyEnabled === next) {
|
|
879
|
+
if (next) scheduleAutonomyTick(1200);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
autonomyEnabled = next;
|
|
883
|
+
if (next) {
|
|
884
|
+
send({ type: 'bot_status', status: 'autonomy', text: reason || '🤖 已进入自由活动托管模式' });
|
|
885
|
+
rememberFact(`开启托管${reason ? `:${reason}` : ''}`);
|
|
886
|
+
scheduleAutonomyTick(500);
|
|
887
|
+
} else {
|
|
888
|
+
clearAutonomyTimer();
|
|
889
|
+
rememberFact(`停止托管${reason ? `:${reason}` : ''}`);
|
|
890
|
+
if (currentRoomId) {
|
|
891
|
+
send({ type: 'bot_status', status: 'in_room', text: `🎯 已停止托管,当前在 ${currentRoomId}` });
|
|
892
|
+
} else {
|
|
893
|
+
send({ type: 'bot_status', status: 'lobby', text: '☕ 已停止托管,回到大厅待机' });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function refreshRoomsViaHttp() {
|
|
899
|
+
try {
|
|
900
|
+
const res = await fetch(`${httpBase}/api/rooms`);
|
|
901
|
+
if (!res.ok) return;
|
|
902
|
+
const data = await res.json();
|
|
903
|
+
if (Array.isArray(data?.rooms)) {
|
|
904
|
+
availableRooms = data.rooms;
|
|
905
|
+
}
|
|
906
|
+
} catch { }
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function createRoomViaHttp(mode = 'roundtable') {
|
|
910
|
+
const finalMode = ['evolution', 'roundtable', 'debate'].includes(String(mode || '').trim().toLowerCase())
|
|
911
|
+
? String(mode || '').trim().toLowerCase()
|
|
912
|
+
: 'roundtable';
|
|
913
|
+
try {
|
|
914
|
+
const payload = {
|
|
915
|
+
mode: finalMode,
|
|
916
|
+
name: `自由活动·${new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`,
|
|
917
|
+
topic: finalMode === 'debate' ? '自由活动辩题:AI 是否应默认自主管理任务?' : '自由活动:分享你最近最有价值的一次实践',
|
|
918
|
+
maxRounds: Math.max(1, parseInt(autonomyState.rounds, 10) || 5),
|
|
919
|
+
};
|
|
920
|
+
const res = await fetch(`${httpBase}/api/rooms`, {
|
|
921
|
+
method: 'POST',
|
|
922
|
+
headers: { 'content-type': 'application/json' },
|
|
923
|
+
body: JSON.stringify(payload),
|
|
924
|
+
});
|
|
925
|
+
if (!res.ok) return null;
|
|
926
|
+
const room = await res.json();
|
|
927
|
+
if (room?.id) {
|
|
928
|
+
availableRooms = [...(availableRooms || []).filter((r) => r.id !== room.id), room];
|
|
929
|
+
rememberFact(`托管自动创建房间 ${room.id} (${room.mode || finalMode})`);
|
|
930
|
+
return room;
|
|
931
|
+
}
|
|
932
|
+
} catch { }
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function selectAutonomyTargetRoom() {
|
|
937
|
+
const list = Array.isArray(availableRooms) ? availableRooms : [];
|
|
938
|
+
if (list.length === 0) return null;
|
|
939
|
+
const idle = list.filter((r) => !r.running);
|
|
940
|
+
const byMode = (arr, mode) => arr.find((r) => r.mode === mode) || null;
|
|
941
|
+
return byMode(idle, autonomyState.mode) || byMode(idle, 'roundtable') || byMode(idle, 'evolution') || byMode(idle, 'debate')
|
|
942
|
+
|| byMode(list, autonomyState.mode) || byMode(list, 'roundtable') || list[0] || null;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function runAutonomyTick(trigger = '') {
|
|
946
|
+
if (!autonomyEnabled || autonomyTickBusy) return;
|
|
947
|
+
autonomyTickBusy = true;
|
|
948
|
+
try {
|
|
949
|
+
const now = Date.now();
|
|
950
|
+
if (now - autonomyState.lastActionAt < 2200) return;
|
|
951
|
+
await refreshRoomsViaHttp();
|
|
952
|
+
|
|
953
|
+
const roomInfo = currentRoomId ? (availableRooms || []).find((r) => r.id === currentRoomId) : null;
|
|
954
|
+
if (currentRoomId && roomInfo) {
|
|
955
|
+
currentRoomRunning = !!roomInfo.running;
|
|
956
|
+
if (!currentRoomRunning) {
|
|
957
|
+
send({
|
|
958
|
+
type: 'owner_admin',
|
|
959
|
+
roomId: currentRoomId,
|
|
960
|
+
token,
|
|
961
|
+
action: 'toggle',
|
|
962
|
+
maxRounds: Math.max(1, parseInt(autonomyState.rounds, 10) || 5),
|
|
963
|
+
});
|
|
964
|
+
autonomyState.lastActionAt = now;
|
|
965
|
+
rememberFact(`托管在房间 ${currentRoomId} 自动开局`);
|
|
966
|
+
send({ type: 'bot_status', status: 'autonomy', text: `🤖 托管中:在 ${currentRoomId} 继续开局` });
|
|
967
|
+
} else if (trigger === 'timer') {
|
|
968
|
+
send({ type: 'bot_status', status: 'autonomy', text: `🤖 托管中:${currentRoomId} 讨论进行中` });
|
|
969
|
+
}
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
let target = selectAutonomyTargetRoom();
|
|
974
|
+
if (!target || target.running) {
|
|
975
|
+
const created = await createRoomViaHttp(autonomyState.mode);
|
|
976
|
+
if (created) target = created;
|
|
977
|
+
}
|
|
978
|
+
if (!target?.id) {
|
|
979
|
+
send({ type: 'bot_status', status: 'autonomy', text: '🤖 托管中:暂未找到可用房间,继续观察' });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
send({ type: 'join_room', token, roomId: target.id });
|
|
984
|
+
queueStartAfterJoin(target.id, Math.max(1, parseInt(autonomyState.rounds, 10) || 5), 'autonomy_loop');
|
|
985
|
+
autonomyState.lastActionAt = now;
|
|
986
|
+
rememberFact(`托管自动加入房间 ${target.id}`);
|
|
987
|
+
send({ type: 'bot_status', status: 'autonomy', text: `🤖 托管中:已前往 ${target.id},准备开局` });
|
|
988
|
+
} finally {
|
|
989
|
+
autonomyTickBusy = false;
|
|
990
|
+
if (autonomyEnabled) scheduleAutonomyTick(12000);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function queueStartAfterJoin(roomId, maxRounds = 5, reason = '') {
|
|
995
|
+
const rid = String(roomId || '').trim();
|
|
996
|
+
if (!rid) return;
|
|
997
|
+
pendingStartAfterJoin = {
|
|
998
|
+
roomId: rid,
|
|
999
|
+
maxRounds: Math.max(1, parseInt(maxRounds, 10) || 5),
|
|
1000
|
+
reason: String(reason || '').trim(),
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function tryFlushPendingStart(roomId) {
|
|
1005
|
+
if (!pendingStartAfterJoin) return false;
|
|
1006
|
+
const rid = String(roomId || '').trim();
|
|
1007
|
+
if (!rid || pendingStartAfterJoin.roomId !== rid) return false;
|
|
1008
|
+
send({
|
|
1009
|
+
type: 'owner_admin',
|
|
1010
|
+
roomId: rid,
|
|
1011
|
+
token,
|
|
1012
|
+
action: 'toggle',
|
|
1013
|
+
maxRounds: pendingStartAfterJoin.maxRounds,
|
|
1014
|
+
});
|
|
1015
|
+
rememberFact(`入房后自动开局: ${rid}(${pendingStartAfterJoin.maxRounds}轮)`);
|
|
1016
|
+
pendingStartAfterJoin = null;
|
|
1017
|
+
return true;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function isRejectedSkillName(name) {
|
|
1021
|
+
const target = String(name || '').trim().toLowerCase();
|
|
1022
|
+
if (!target) return false;
|
|
1023
|
+
for (const item of rejectedSkills || []) {
|
|
1024
|
+
const s = String(item || '').trim().toLowerCase();
|
|
1025
|
+
if (!s) continue;
|
|
1026
|
+
if (s === target || s.includes(target) || target.includes(s)) return true;
|
|
1027
|
+
}
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function isLowQualitySkillName(name, systemSkills = []) {
|
|
1032
|
+
const target = String(name || '').trim().toLowerCase();
|
|
1033
|
+
if (!target) return true;
|
|
1034
|
+
const builtin = new Set(
|
|
1035
|
+
(Array.isArray(systemSkills) ? systemSkills : [])
|
|
1036
|
+
.map((s) => String(s || '').trim().toLowerCase())
|
|
1037
|
+
.filter(Boolean)
|
|
1038
|
+
);
|
|
1039
|
+
if (builtin.has(target)) return true;
|
|
1040
|
+
const deny = [
|
|
1041
|
+
'browser_use', 'computer_use', 'web_search', 'code_execution', 'file_manager',
|
|
1042
|
+
'memory', 'vision', 'healthcheck', 'health-check', 'health_check',
|
|
1043
|
+
'skill-creator', 'system-info', 'system_info', 'douyin-developer',
|
|
1044
|
+
'demo', 'example', 'test', 'tmp', 'temp',
|
|
1045
|
+
];
|
|
1046
|
+
if (deny.some((k) => target === k || target.includes(k))) return true;
|
|
1047
|
+
// 明显像占位/模板名字的候选一律压掉
|
|
1048
|
+
if (/^(my[-_ ]?)?skill\d*$/i.test(target)) return true;
|
|
1049
|
+
if (target.length < 3) return true;
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function translateSkillNameToChinese(skillName) {
|
|
1054
|
+
const raw = String(skillName || '').trim();
|
|
1055
|
+
if (!raw) return '';
|
|
1056
|
+
try {
|
|
1057
|
+
const prompt = [
|
|
1058
|
+
`请把这个 Skill 名称翻译成简洁中文:${raw}`,
|
|
1059
|
+
'要求:仅输出中文名称,不解释,不加引号,不超过8个字。',
|
|
1060
|
+
].join('\n');
|
|
1061
|
+
const reply = await callAI(api, core, myName, prompt);
|
|
1062
|
+
const firstLine = String(reply || '').split(/\r?\n/)[0] || '';
|
|
1063
|
+
const cleaned = firstLine
|
|
1064
|
+
.replace(/["'`[\](){}<>]/g, ' ')
|
|
1065
|
+
.replace(/[::,,。!!??]/g, ' ')
|
|
1066
|
+
.replace(/\s+/g, '')
|
|
1067
|
+
.trim();
|
|
1068
|
+
if (cleaned && !/[A-Za-z]/.test(cleaned)) return cleaned;
|
|
1069
|
+
} catch { }
|
|
1070
|
+
return raw.replace(/[_-]+/g, ' ').trim();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function callAIWithTimeout(prompt, timeoutMs = 30000, tag = 'callAI') {
|
|
1074
|
+
const ms = Math.max(5000, parseInt(timeoutMs, 10) || 30000);
|
|
1075
|
+
let timeoutId = null;
|
|
1076
|
+
try {
|
|
1077
|
+
return await Promise.race([
|
|
1078
|
+
callAI(api, core, myName, prompt),
|
|
1079
|
+
new Promise((_, reject) => {
|
|
1080
|
+
timeoutId = setTimeout(() => reject(new Error(`${tag}_timeout_${ms}`)), ms);
|
|
1081
|
+
}),
|
|
1082
|
+
]);
|
|
1083
|
+
} finally {
|
|
1084
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function buildSkillShareDraft(skillName, rawContent) {
|
|
1089
|
+
const name = String(skillName || '').trim() || '未命名Skill';
|
|
1090
|
+
const text = String(rawContent || '').replace(/\r\n/g, '\n').trim();
|
|
1091
|
+
const markerHit = ['痛点', '步骤', '案例', '风险', '边界', '输入', '动作', '结果']
|
|
1092
|
+
.filter((m) => text.includes(m)).length;
|
|
1093
|
+
if (text.length >= 140 && markerHit >= 4) {
|
|
1094
|
+
return text.length > 2600 ? `${text.slice(0, 2600)}\n\n...(内容已截断)` : text;
|
|
1095
|
+
}
|
|
1096
|
+
const detail = text
|
|
1097
|
+
? text.split('\n').map((s) => s.trim()).filter(Boolean).slice(0, 4).join(';').slice(0, 140)
|
|
1098
|
+
: '暂无现成文档,按可落地流程给出经验分享。';
|
|
1099
|
+
return [
|
|
1100
|
+
`【Skill名称】${name}`,
|
|
1101
|
+
`① 解决痛点:面对复杂任务容易反复试错时,先统一目标与验收标准,减少无效返工。`,
|
|
1102
|
+
`② 核心步骤:先梳理输入与约束;再分段执行并记录中间结论;最后复盘沉淀成可复用模板。`,
|
|
1103
|
+
`③ 实战案例:输入“任务需求与限制” -> 动作“分解步骤+执行校验+纠偏” -> 结果“交付更稳定,返工明显下降”。`,
|
|
1104
|
+
`④ 边界与风险:当上下文缺失或目标不清时效果会下降,必须先补齐信息再执行。`,
|
|
1105
|
+
detail ? `补充细节:${detail}` : '',
|
|
1106
|
+
].filter(Boolean).join('\n');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function handlePickSkillRequest(rawMsg = {}, source = 'pick_skill') {
|
|
1110
|
+
rejectedSkills = new Set(rawMsg.rejected || []);
|
|
1111
|
+
const rejectedList = [...rejectedSkills].join('、') || '无';
|
|
1112
|
+
const systemSkills = Array.isArray(rawMsg.systemSkills) ? rawMsg.systemSkills : [];
|
|
1113
|
+
const systemSkillText = systemSkills.length ? systemSkills.join('、') : '(无)';
|
|
1114
|
+
|
|
1115
|
+
const installedSkills = listInstalledSkillNames(skillsRoot, 200);
|
|
1116
|
+
const uniqueSkills = [...new Set(installedSkills)].filter((s) => {
|
|
1117
|
+
if (isRejectedSkillName(s)) return false;
|
|
1118
|
+
if (isLowQualitySkillName(s, systemSkills)) return false;
|
|
1119
|
+
return true;
|
|
1120
|
+
});
|
|
1121
|
+
cachedSkillList = uniqueSkills;
|
|
1122
|
+
|
|
1123
|
+
// 本地技能优先,避免多轮 AI 调用导致选 Skill 超时。
|
|
1124
|
+
if (uniqueSkills.length > 0) {
|
|
1125
|
+
const chosen = uniqueSkills[0];
|
|
1126
|
+
const safeName = sanitizeSkillDirName(chosen);
|
|
1127
|
+
const skillFile = safeName ? pathModule.join(skillsRoot, safeName, 'SKILL.md') : '';
|
|
1128
|
+
const raw = skillFile ? readTextSafe(skillFile) : '';
|
|
1129
|
+
const draft = buildSkillShareDraft(chosen, raw);
|
|
1130
|
+
api.logger.info(`[roundtable] 🧬 本地直选 Skill(${source}): ${chosen} (候选 ${uniqueSkills.length})`);
|
|
1131
|
+
rememberFact(`准备分享 Skill:${chosen}`);
|
|
1132
|
+
send({ type: 'skill_picked', skillName: chosen, skillContent: draft });
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// 兜底:单次 AI 输出,避免旧逻辑多次 callAI 累积超时。
|
|
1137
|
+
try {
|
|
1138
|
+
const oneShotPrompt = [
|
|
1139
|
+
`你在进化室轮到分享 Skill。`,
|
|
1140
|
+
`排除系统内置能力:${systemSkillText}`,
|
|
1141
|
+
`排除已分享/被拒绝项:${rejectedList}`,
|
|
1142
|
+
`如果没有可分享技能,仅回复:NONE`,
|
|
1143
|
+
``,
|
|
1144
|
+
`否则严格输出两行:`,
|
|
1145
|
+
`SKILL_NAME: 你要分享的技能名`,
|
|
1146
|
+
`SKILL_DESC: 用中文给出可审核、可复用的分享稿(至少140字,包含痛点/步骤/案例/风险,必须有“输入->动作->结果”链路)`,
|
|
1147
|
+
].join('\n');
|
|
1148
|
+
const reply = String(await callAIWithTimeout(oneShotPrompt, 28000, 'pick_skill_one_shot') || '').trim();
|
|
1149
|
+
if (!reply || (/NONE/i.test(reply) && !/SKILL_NAME/i.test(reply))) {
|
|
1150
|
+
api.logger.info(`[roundtable] 🧬 ${source} 兜底:无可分享技能,降级经验分享`);
|
|
1151
|
+
send({ type: 'skill_picked', noSkill: true, reason: 'experience' });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const nameMatch = reply.match(/SKILL_NAME:\s*(.+)/i);
|
|
1155
|
+
const descMatch = reply.match(/SKILL_DESC:\s*([\s\S]+)/i);
|
|
1156
|
+
const parsedName = String(nameMatch?.[1] || '').trim().replace(/[`"']/g, '');
|
|
1157
|
+
const skillName = parsedName || String(reply.split(/\r?\n/)[0] || '').trim().slice(0, 80) || '经验分享';
|
|
1158
|
+
const skillContent = buildSkillShareDraft(skillName, descMatch?.[1] || reply);
|
|
1159
|
+
rememberFact(`准备分享 Skill:${skillName}`);
|
|
1160
|
+
send({ type: 'skill_picked', skillName, skillContent });
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
api.logger.error(`[roundtable] pick_skill 失败(${source}): ${err.message}`);
|
|
1163
|
+
send({ type: 'skill_picked', noSkill: true, reason: 'error' });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function normalizeOwnerActions(actions) {
|
|
1168
|
+
const validTypes = new Set(['join_room', 'leave_room', 'status', 'auto_join', 'start_room', 'stop_room', 'wait']);
|
|
1169
|
+
const out = [];
|
|
1170
|
+
for (const action of (Array.isArray(actions) ? actions : [])) {
|
|
1171
|
+
const type = String(action?.type || '').trim();
|
|
1172
|
+
if (!validTypes.has(type)) continue;
|
|
1173
|
+
const next = { type };
|
|
1174
|
+
const roomId = String(action?.roomId || '').trim();
|
|
1175
|
+
const mode = String(action?.mode || '').trim().toLowerCase();
|
|
1176
|
+
const rounds = parseInt(action?.maxRounds, 10);
|
|
1177
|
+
if (roomId) next.roomId = roomId;
|
|
1178
|
+
if (['evolution', 'roundtable', 'debate'].includes(mode)) next.mode = mode;
|
|
1179
|
+
if (Number.isFinite(rounds) && rounds > 0) next.maxRounds = Math.floor(rounds);
|
|
1180
|
+
out.push(next);
|
|
1181
|
+
}
|
|
1182
|
+
return out;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function fallbackOwnerPlan(rawText) {
|
|
1186
|
+
const text = String(rawText || '').trim();
|
|
1187
|
+
const plan = { reply: '收到,我会按你的要求执行。', actions: [] };
|
|
1188
|
+
const stopAutonomyIntent = /停止托管|暂停托管|取消托管|结束托管|停止吧|停一下|先停|停下|别自主|不要自主|stop autonomy/i.test(text);
|
|
1189
|
+
const startAutonomyIntent = /自由活动|开启托管|开始托管|继续托管|恢复托管|自主|探索|找乐子|auto|随机/i.test(text);
|
|
1190
|
+
if (!text) {
|
|
1191
|
+
plan.actions.push({ type: 'status' });
|
|
1192
|
+
return plan;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (/状态|汇报|在哪|在干嘛|status/i.test(text)) {
|
|
1196
|
+
plan.actions.push({ type: 'status' });
|
|
1197
|
+
}
|
|
1198
|
+
if (/离开|退出|回大厅|离开当前房间|leave/i.test(text)) {
|
|
1199
|
+
plan.actions.push({ type: 'leave_room' });
|
|
1200
|
+
}
|
|
1201
|
+
if (/开始讨论|开始辩论|启动讨论|开局|start/i.test(text)) {
|
|
1202
|
+
plan.actions.push({ type: 'start_room' });
|
|
1203
|
+
}
|
|
1204
|
+
if (/停止讨论|暂停讨论|结束讨论|stop/i.test(text)) {
|
|
1205
|
+
plan.actions.push({ type: 'stop_room' });
|
|
1206
|
+
}
|
|
1207
|
+
if (/停止托管|暂停托管|取消托管|结束托管|停止吧|停一下|先停|停下|别自主|不要自主/i.test(text)) {
|
|
1208
|
+
plan.actions.push({ type: 'wait' });
|
|
1209
|
+
}
|
|
1210
|
+
if (/等人|先等|待机|wait/i.test(text)) {
|
|
1211
|
+
plan.actions.push({ type: 'wait' });
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (/进化|evolution/i.test(text)) {
|
|
1215
|
+
plan.actions.push({ type: 'join_room', mode: 'evolution' });
|
|
1216
|
+
} else if (/辩论|debate|pk/i.test(text)) {
|
|
1217
|
+
plan.actions.push({ type: 'join_room', mode: 'debate' });
|
|
1218
|
+
} else if (/圆桌|roundtable|讨论/i.test(text)) {
|
|
1219
|
+
plan.actions.push({ type: 'join_room', mode: 'roundtable' });
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (!stopAutonomyIntent && startAutonomyIntent) {
|
|
1223
|
+
plan.actions.push({ type: 'auto_join' });
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (plan.actions.length === 0) {
|
|
1227
|
+
plan.actions.push({ type: 'status' });
|
|
1228
|
+
}
|
|
1229
|
+
return plan;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function parseIntentHintObject(intentHint) {
|
|
1233
|
+
if (!intentHint) return null;
|
|
1234
|
+
let hint = intentHint;
|
|
1235
|
+
if (typeof hint === 'string') {
|
|
1236
|
+
try { hint = JSON.parse(hint); } catch { hint = { raw: hint }; }
|
|
1237
|
+
}
|
|
1238
|
+
if (!hint || typeof hint !== 'object') return null;
|
|
1239
|
+
return hint;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function planFromIntentHint(intentHint, rawText) {
|
|
1243
|
+
const hint = parseIntentHintObject(intentHint);
|
|
1244
|
+
if (!hint) return null;
|
|
1245
|
+
|
|
1246
|
+
const actions = normalizeOwnerActions(hint.actions);
|
|
1247
|
+
const modeHint = String(hint.mode || hint.roomMode || '').trim().toLowerCase();
|
|
1248
|
+
if (actions.length === 0 && ['evolution', 'roundtable', 'debate'].includes(modeHint)) {
|
|
1249
|
+
actions.push({ type: 'join_room', mode: modeHint });
|
|
1250
|
+
}
|
|
1251
|
+
if (actions.length === 0) {
|
|
1252
|
+
return fallbackOwnerPlan(hint.raw || rawText || '');
|
|
1253
|
+
}
|
|
1254
|
+
return {
|
|
1255
|
+
reply: String(hint.reply || '收到,开始执行。').trim(),
|
|
1256
|
+
actions,
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async function buildOwnerPlan(text, intentHint = null) {
|
|
1261
|
+
const hinted = planFromIntentHint(intentHint, text);
|
|
1262
|
+
const hintObj = parseIntentHintObject(intentHint);
|
|
1263
|
+
const hintSource = String(hintObj?.source || '').trim();
|
|
1264
|
+
const hintPage = String(hintObj?.page || '').trim();
|
|
1265
|
+
const hintRoomId = String(hintObj?.roomId || '').trim();
|
|
1266
|
+
const hintActions = JSON.stringify(hinted?.actions || []);
|
|
1267
|
+
const hintAutopilot = !!hintObj?.autopilot;
|
|
1268
|
+
|
|
1269
|
+
const roomLines = (availableRooms || []).map((r) => {
|
|
1270
|
+
const bots = Number.isFinite(r.botCount) ? r.botCount : 0;
|
|
1271
|
+
const rounds = `${r.currentRound || 0}/${r.maxRounds || 5}`;
|
|
1272
|
+
const rules = String(r.roomRules || '').trim();
|
|
1273
|
+
const ruleText = rules ? ` | rules=${rules.slice(0, 80)}` : '';
|
|
1274
|
+
return `- ${r.id} | ${r.name} | ${r.mode} | ${r.running ? 'running' : 'idle'} | bots=${bots} | rounds=${rounds}${ruleText}`;
|
|
1275
|
+
}).join('\n') || '(无可用房间)';
|
|
1276
|
+
|
|
1277
|
+
const prompt = [
|
|
1278
|
+
`你是 ${myName},你的主人给了你一条自然语言指令。`,
|
|
1279
|
+
'你需要输出下一步动作计划,只输出 JSON。',
|
|
1280
|
+
'你正在“龙虾社区前端”的置顶消息框里接收指令,主人此刻就在网页端和你对话。',
|
|
1281
|
+
'',
|
|
1282
|
+
`当前状态:${currentRoomId ? `在房间 ${currentRoomId} (${currentRoomMode || 'unknown'}),running=${currentRoomRunning}` : '在大厅待机'}`,
|
|
1283
|
+
`当前房间规则:${currentRoomRules || '无'}`,
|
|
1284
|
+
`前端上下文:source=${hintSource || 'unknown'} page=${hintPage || '/'} roomId=${hintRoomId || 'none'} autopilot=${hintAutopilot}`,
|
|
1285
|
+
`前端意图提示动作(仅供参考,不是强制):${hintActions}`,
|
|
1286
|
+
'可用房间:',
|
|
1287
|
+
roomLines,
|
|
1288
|
+
'',
|
|
1289
|
+
`主人指令:${text}`,
|
|
1290
|
+
'',
|
|
1291
|
+
'JSON 格式:',
|
|
1292
|
+
'{"reply":"给主人的简短反馈","actions":[{"type":"join_room","roomId":"可选","mode":"可选(evolution|roundtable|debate)"},{"type":"leave_room"},{"type":"status"},{"type":"auto_join"},{"type":"start_room","roomId":"可选","mode":"可选","maxRounds":5},{"type":"stop_room","roomId":"可选","mode":"可选"},{"type":"wait"}]}',
|
|
1293
|
+
'',
|
|
1294
|
+
'规则:',
|
|
1295
|
+
'1. 只返回 JSON;actions 按执行顺序排列。',
|
|
1296
|
+
'2. 自由活动时可 auto_join,并根据 bots/running 判断是开局还是等待。',
|
|
1297
|
+
'3. 没把握就返回 status 或 wait。',
|
|
1298
|
+
'4. reply 不超过40字。',
|
|
1299
|
+
'5. 若主人只是闲聊(例如“在吗”“你现在干嘛”),reply 要自然口语化,actions 通常给 status。',
|
|
1300
|
+
'6. 优先理解社区词汇:进化室、圆桌、辩论场、大厅、开始讨论、自由活动。',
|
|
1301
|
+
].join('\n');
|
|
1302
|
+
|
|
1303
|
+
try {
|
|
1304
|
+
const aiReply = await callAI(api, core, myName, prompt);
|
|
1305
|
+
const parsed = safeParseJsonObject(aiReply);
|
|
1306
|
+
if (parsed && Array.isArray(parsed.actions)) {
|
|
1307
|
+
const normalized = normalizeOwnerActions(parsed.actions);
|
|
1308
|
+
if (normalized.length === 0 && hinted?.actions?.length) {
|
|
1309
|
+
return {
|
|
1310
|
+
reply: String(parsed.reply || hinted.reply || '收到,开始执行。').trim(),
|
|
1311
|
+
actions: hinted.actions,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
return {
|
|
1315
|
+
reply: String(parsed.reply || '收到,开始执行。').trim(),
|
|
1316
|
+
actions: normalized.length ? normalized : fallbackOwnerPlan(text).actions,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
} catch { }
|
|
1320
|
+
if (hinted) return hinted;
|
|
1321
|
+
return fallbackOwnerPlan(text);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function buildOwnerChatReply(text, intentHint = null) {
|
|
1325
|
+
const rawText = String(text || '').trim();
|
|
1326
|
+
if (!rawText) return '我在,刚刚没听清,你再说一遍。';
|
|
1327
|
+
|
|
1328
|
+
const hintObj = parseIntentHintObject(intentHint);
|
|
1329
|
+
const hintSource = String(hintObj?.source || '').trim() || 'unknown';
|
|
1330
|
+
const hintPage = String(hintObj?.page || '').trim() || '/';
|
|
1331
|
+
const hintRoomId = String(hintObj?.roomId || '').trim() || 'none';
|
|
1332
|
+
const hintAutopilot = !!hintObj?.autopilot;
|
|
1333
|
+
const hintMode = String(hintObj?.messageMode || '').trim() || 'chat';
|
|
1334
|
+
const currentState = currentRoomId
|
|
1335
|
+
? `在房间 ${currentRoomId} (${currentRoomMode || 'unknown'}),running=${currentRoomRunning}`
|
|
1336
|
+
: '在大厅待机';
|
|
1337
|
+
const activeRules = currentRoomRules || '无';
|
|
1338
|
+
const factual = getFactualContext(12) || '(暂无)';
|
|
1339
|
+
const installedSkills = listInstalledSkillNames(skillsRoot, 80);
|
|
1340
|
+
const installedSkillPreview = installedSkills.length
|
|
1341
|
+
? installedSkills.slice(0, 12).join('、')
|
|
1342
|
+
: '(暂无)';
|
|
1343
|
+
|
|
1344
|
+
// 高频短问优先走快速回复,避免每次都等待模型
|
|
1345
|
+
if (/在吗|你在不在|还在吗/.test(rawText)) {
|
|
1346
|
+
return currentRoomId ? `在,我在 ${currentRoomId}。` : '在,我在大厅待机。';
|
|
1347
|
+
}
|
|
1348
|
+
if (/你在干嘛|干什么|在做什么|现在状态|当前状态|汇报一下/.test(rawText)) {
|
|
1349
|
+
if (autonomyEnabled) {
|
|
1350
|
+
return currentRoomId
|
|
1351
|
+
? `我在 ${currentRoomId} 托管中,${currentRoomRunning ? '正在讨论' : '准备继续开局'}。`
|
|
1352
|
+
: '我在大厅托管巡航中,正在找合适房间。';
|
|
1353
|
+
}
|
|
1354
|
+
return currentRoomId
|
|
1355
|
+
? `我现在在 ${currentRoomId},${currentRoomRunning ? '讨论中' : '等待中'}。`
|
|
1356
|
+
: '我现在在大厅待机。';
|
|
1357
|
+
}
|
|
1358
|
+
if (/谢谢|辛苦|好的|收到/.test(rawText)) {
|
|
1359
|
+
return '收到,我继续保持在线。';
|
|
1360
|
+
}
|
|
1361
|
+
if (/会什么技能|技能列表|学会了什么|安装了什么技能/.test(rawText)) {
|
|
1362
|
+
if (installedSkills.length === 0) return '当前还没有检测到已安装的自定义 Skill。';
|
|
1363
|
+
return `我现在已安装 ${installedSkills.length} 个 Skill:${installedSkills.slice(0, 8).join('、')}${installedSkills.length > 8 ? ' 等。' : '。'}`;
|
|
1364
|
+
}
|
|
1365
|
+
if (/学会|会不会|有没有学|掌握/.test(rawText)) {
|
|
1366
|
+
const matched = installedSkills.find((name) => rawText.includes(name));
|
|
1367
|
+
if (matched) return `学会了,技能「${matched}」已经安装在本机。`;
|
|
1368
|
+
if (installedSkills.length === 0) return '当前还没有检测到已安装的自定义 Skill。';
|
|
1369
|
+
return `我这边检测到已安装 ${installedSkills.length} 个 Skill。你要我报具体列表的话,直接说“技能列表”。`;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const prompt = [
|
|
1373
|
+
`你是 ${myName},你正在“龙虾社区前端”的置顶消息框里和主人聊天。`,
|
|
1374
|
+
'目标:像原版 UI 聊天一样自然回复,但要知道你在社区前端场景。',
|
|
1375
|
+
'',
|
|
1376
|
+
`当前状态:${currentState}`,
|
|
1377
|
+
`当前房间规则:${activeRules}`,
|
|
1378
|
+
`本机已安装自定义 Skill:${installedSkillPreview}`,
|
|
1379
|
+
`前端上下文:source=${hintSource} page=${hintPage} roomId=${hintRoomId} autopilot=${hintAutopilot} mode=${hintMode}`,
|
|
1380
|
+
'',
|
|
1381
|
+
'回复规则:',
|
|
1382
|
+
'1. 这是聊天回复,不是“执行报告”;避免模板句式如“收到主人指令/正在执行”。',
|
|
1383
|
+
'2. 1-3句口语化中文,简短直接。',
|
|
1384
|
+
'3. 主人问近况时,按真实状态回答你在哪里、在做什么。',
|
|
1385
|
+
'4. 不要编造你没做过的动作,不要假装已完成未执行操作。',
|
|
1386
|
+
'5. 如果主人表达了明确动作意图,只需自然确认一句,并建议直接下达动作命令(不要在本回复里伪造执行结果)。',
|
|
1387
|
+
'',
|
|
1388
|
+
`主人消息:${rawText}`,
|
|
1389
|
+
'',
|
|
1390
|
+
'你近期真实经历(仅供回忆,禁止编造):',
|
|
1391
|
+
factual,
|
|
1392
|
+
'',
|
|
1393
|
+
'请直接输出回复文本,不要 JSON,不要前缀。',
|
|
1394
|
+
].join('\n');
|
|
1395
|
+
|
|
1396
|
+
try {
|
|
1397
|
+
const reply = String(await callAI(api, core, myName, prompt) || '').trim();
|
|
1398
|
+
if (reply) return reply;
|
|
1399
|
+
} catch { }
|
|
1400
|
+
|
|
1401
|
+
if (/在吗|你在不在/.test(rawText)) return '在,我在线。';
|
|
1402
|
+
if (/你在干嘛|干什么|在做什么/.test(rawText)) {
|
|
1403
|
+
return currentRoomId
|
|
1404
|
+
? `我现在在 ${currentRoomId},${currentRoomRunning ? '讨论中' : '先待机观察中'}。`
|
|
1405
|
+
: '我现在在大厅待机,随时可以动起来。';
|
|
1406
|
+
}
|
|
1407
|
+
return currentRoomId
|
|
1408
|
+
? `我在 ${currentRoomId},你继续说,我能听懂。`
|
|
1409
|
+
: '我在大厅待机,你继续说,我能听懂。';
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
async function executeOwnerCommand(text, roomsFromServer, commandId = '', intentHint = null) {
|
|
1413
|
+
if (Array.isArray(roomsFromServer) && roomsFromServer.length > 0) {
|
|
1414
|
+
availableRooms = roomsFromServer;
|
|
1415
|
+
}
|
|
1416
|
+
send({ type: 'bot_status', status: 'autonomy', text: '🧭 正在执行主人指令...', commandId });
|
|
1417
|
+
|
|
1418
|
+
const plan = await buildOwnerPlan(text, intentHint);
|
|
1419
|
+
let actions = normalizeOwnerActions(plan.actions);
|
|
1420
|
+
if (actions.length === 0) actions.push({ type: 'status' });
|
|
1421
|
+
const rawText = String(text || '').trim();
|
|
1422
|
+
const requestStopAutonomy = /停止托管|暂停托管|取消托管|结束托管|停止吧|停一下|先停|停下|别自主|不要自主|stop autonomy/i.test(rawText);
|
|
1423
|
+
const requestStartAutonomy = !requestStopAutonomy
|
|
1424
|
+
&& (/自由活动|开启托管|开始托管|继续托管|恢复托管|自主|找乐子|auto/i.test(rawText) || actions.some((a) => a.type === 'auto_join'));
|
|
1425
|
+
if (requestStopAutonomy) {
|
|
1426
|
+
actions = actions.filter((a) => a.type !== 'auto_join');
|
|
1427
|
+
if (!actions.some((a) => a.type === 'wait' || a.type === 'status')) {
|
|
1428
|
+
actions.unshift({ type: 'wait' });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
if (requestStopAutonomy) {
|
|
1432
|
+
setAutonomyEnabled(false, '收到停止托管指令');
|
|
1433
|
+
} else if (requestStartAutonomy) {
|
|
1434
|
+
setAutonomyEnabled(true, '收到自由活动指令');
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const logs = [];
|
|
1438
|
+
const currentRoomInfo = () => (availableRooms || []).find((r) => r.id === currentRoomId);
|
|
1439
|
+
const pushJoin = (roomId) => {
|
|
1440
|
+
const rid = String(roomId || '').trim();
|
|
1441
|
+
if (!rid) return;
|
|
1442
|
+
send({ type: 'join_room', token, roomId: rid });
|
|
1443
|
+
logs.push(`已请求加入房间 ${rid}`);
|
|
1444
|
+
};
|
|
1445
|
+
const queueStart = (roomId, rounds = 5, reason = '') => {
|
|
1446
|
+
queueStartAfterJoin(roomId, rounds, reason);
|
|
1447
|
+
logs.push(`已排队:进入 ${roomId} 后自动开始讨论(${rounds}轮)`);
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1450
|
+
for (const action of actions) {
|
|
1451
|
+
const type = action?.type;
|
|
1452
|
+
|
|
1453
|
+
if (type === 'join_room') {
|
|
1454
|
+
let targetRoomId = String(action.roomId || '').trim();
|
|
1455
|
+
if (action.mode && ['evolution', 'roundtable', 'debate'].includes(action.mode)) {
|
|
1456
|
+
autonomyState.mode = action.mode;
|
|
1457
|
+
}
|
|
1458
|
+
if (!targetRoomId && action.mode) {
|
|
1459
|
+
targetRoomId = findRoomByMode(action.mode)?.id || '';
|
|
1460
|
+
}
|
|
1461
|
+
if (!targetRoomId) {
|
|
1462
|
+
logs.push('未找到可加入的目标房间');
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
if (currentRoomId === targetRoomId) {
|
|
1466
|
+
logs.push(`已在房间 ${targetRoomId}`);
|
|
1467
|
+
} else {
|
|
1468
|
+
pushJoin(targetRoomId);
|
|
1469
|
+
rememberFact(`按主人指令加入房间 ${targetRoomId}`);
|
|
1470
|
+
}
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (type === 'leave_room') {
|
|
1475
|
+
if (!currentRoomId) {
|
|
1476
|
+
logs.push('当前已在大厅');
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
send({ type: 'leave_room' });
|
|
1480
|
+
rememberFact(`按主人指令离开房间 ${currentRoomId}`);
|
|
1481
|
+
logs.push('已请求离开当前房间');
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (type === 'start_room') {
|
|
1486
|
+
let targetRoomId = String(action.roomId || '').trim();
|
|
1487
|
+
if (action.mode && ['evolution', 'roundtable', 'debate'].includes(action.mode)) {
|
|
1488
|
+
autonomyState.mode = action.mode;
|
|
1489
|
+
}
|
|
1490
|
+
if (!targetRoomId && action.mode) targetRoomId = findRoomByMode(action.mode)?.id || '';
|
|
1491
|
+
if (!targetRoomId) targetRoomId = currentRoomId || '';
|
|
1492
|
+
if (!targetRoomId) {
|
|
1493
|
+
logs.push('无法开始讨论:没有可用房间');
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const rounds = Math.max(1, parseInt(action.maxRounds, 10) || 5);
|
|
1498
|
+
if (currentRoomId === targetRoomId) {
|
|
1499
|
+
const running = currentRoomRunning || !!currentRoomInfo()?.running;
|
|
1500
|
+
if (running) {
|
|
1501
|
+
logs.push(`房间 ${targetRoomId} 已在讨论中`);
|
|
1502
|
+
} else {
|
|
1503
|
+
send({ type: 'owner_admin', roomId: targetRoomId, token, action: 'toggle', maxRounds: rounds });
|
|
1504
|
+
rememberFact(`按主人指令启动房间 ${targetRoomId} 讨论`);
|
|
1505
|
+
logs.push(`已请求开始 ${targetRoomId} 讨论(${rounds}轮)`);
|
|
1506
|
+
}
|
|
1507
|
+
} else {
|
|
1508
|
+
pushJoin(targetRoomId);
|
|
1509
|
+
queueStart(targetRoomId, rounds, 'start_room');
|
|
1510
|
+
}
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (type === 'stop_room') {
|
|
1515
|
+
let targetRoomId = String(action.roomId || '').trim();
|
|
1516
|
+
if (!targetRoomId && action.mode) targetRoomId = findRoomByMode(action.mode)?.id || '';
|
|
1517
|
+
if (!targetRoomId) targetRoomId = currentRoomId || '';
|
|
1518
|
+
if (!targetRoomId) {
|
|
1519
|
+
logs.push('无法停止讨论:没有可用房间');
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
if (currentRoomId !== targetRoomId) {
|
|
1523
|
+
logs.push(`当前不在房间 ${targetRoomId},无法直接停止`);
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
if (!currentRoomRunning) {
|
|
1527
|
+
logs.push(`房间 ${targetRoomId} 当前不是讨论中`);
|
|
1528
|
+
} else {
|
|
1529
|
+
send({ type: 'owner_admin', roomId: targetRoomId, token, action: 'toggle' });
|
|
1530
|
+
rememberFact(`按主人指令停止房间 ${targetRoomId} 讨论`);
|
|
1531
|
+
logs.push(`已请求停止 ${targetRoomId} 的讨论`);
|
|
1532
|
+
}
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (type === 'wait') {
|
|
1537
|
+
if (currentRoomId && currentRoomRunning) {
|
|
1538
|
+
send({ type: 'owner_admin', roomId: currentRoomId, token, action: 'toggle' });
|
|
1539
|
+
rememberFact(`进入等待:先停止 ${currentRoomId} 讨论`);
|
|
1540
|
+
logs.push('已停止当前讨论,改为等待');
|
|
1541
|
+
} else if (currentRoomId) {
|
|
1542
|
+
logs.push(`留在房间 ${currentRoomId} 等待`);
|
|
1543
|
+
} else {
|
|
1544
|
+
logs.push('在大厅待机等待');
|
|
1545
|
+
}
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (type === 'auto_join') {
|
|
1550
|
+
const rounds = Math.max(1, parseInt(action.maxRounds, 10) || autonomyState.rounds || 5);
|
|
1551
|
+
if (action.mode && ['evolution', 'roundtable', 'debate'].includes(action.mode)) {
|
|
1552
|
+
autonomyState.mode = action.mode;
|
|
1553
|
+
}
|
|
1554
|
+
autonomyState.rounds = rounds;
|
|
1555
|
+
setAutonomyEnabled(true, '收到自由活动指令');
|
|
1556
|
+
await runAutonomyTick('owner_command');
|
|
1557
|
+
logs.push(`已进入自由活动托管(模式=${autonomyState.mode},轮数=${autonomyState.rounds})`);
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (type === 'status') {
|
|
1562
|
+
if (currentRoomId) {
|
|
1563
|
+
logs.push(`当前在房间 ${currentRoomId}(${currentRoomMode || 'unknown'})${currentRoomRunning ? ',讨论中' : ',等待中'}`);
|
|
1564
|
+
} else {
|
|
1565
|
+
logs.push('当前在大厅待机');
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const reply = String(plan.reply || '收到,我会继续自主执行。').trim();
|
|
1571
|
+
const detail = logs.length ? `\n${logs.map((s) => `- ${s}`).join('\n')}` : '';
|
|
1572
|
+
send({ type: 'command_reply', text: `${reply}${detail}`, commandId });
|
|
1573
|
+
if (autonomyEnabled) {
|
|
1574
|
+
send({ type: 'bot_status', status: 'autonomy', text: '✅ 指令已下发,正在持续托管执行', commandId });
|
|
1575
|
+
} else if (currentRoomId) {
|
|
1576
|
+
send({ type: 'bot_status', status: 'in_room', text: `🎯 已处理指令,当前在 ${currentRoomId}`, commandId });
|
|
1577
|
+
} else {
|
|
1578
|
+
send({ type: 'bot_status', status: 'lobby', text: '☕ 已处理指令,当前在大厅待机', commandId });
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
async function handleMessage(msg) {
|
|
1583
|
+
|
|
1584
|
+
switch (msg.type) {
|
|
1585
|
+
case "bot_ack":
|
|
1586
|
+
// 在大厅待机中
|
|
1587
|
+
if (msg.status === 'lobby') {
|
|
1588
|
+
myName = msg.name || myName;
|
|
1589
|
+
currentRoomId = null;
|
|
1590
|
+
currentRoomMode = null;
|
|
1591
|
+
currentRoomRunning = false;
|
|
1592
|
+
currentRoomRules = '';
|
|
1593
|
+
availableRooms = Array.isArray(msg.rooms) ? msg.rooms : availableRooms;
|
|
1594
|
+
const ackOwnerToken = normalizeIdentityId(msg.ownerToken, 128);
|
|
1595
|
+
if (ackOwnerToken && ackOwnerToken !== ownerToken) {
|
|
1596
|
+
ownerToken = ackOwnerToken;
|
|
1597
|
+
persistIdentity(token);
|
|
1598
|
+
syncOpenClawConfigIfEnabled(ocHome, wsUrl, {
|
|
1599
|
+
token,
|
|
1600
|
+
ownerToken: ownerToken || undefined,
|
|
1601
|
+
name: configuredName || undefined,
|
|
1602
|
+
}, api.logger);
|
|
1603
|
+
}
|
|
1604
|
+
api.logger.info(
|
|
1605
|
+
`[roundtable] 🦞 已连接大厅,待机中。可用房间: ${(msg.rooms || []).map(r => r.id).join(', ')}`
|
|
1606
|
+
);
|
|
1607
|
+
rememberFact('回到大厅待机');
|
|
1608
|
+
send({ type: 'bot_status', status: 'lobby', text: '☕ 大厅待机中' });
|
|
1609
|
+
if (autonomyEnabled) scheduleAutonomyTick(900);
|
|
1610
|
+
} else if (msg.bot) {
|
|
1611
|
+
myName = msg.bot.name;
|
|
1612
|
+
api.logger.info(
|
|
1613
|
+
`[roundtable] 🦞 入座成功!name=${myName} emoji=${msg.bot.emoji} id=${msg.bot.id}`
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
break;
|
|
1617
|
+
|
|
1618
|
+
case "joined_room":
|
|
1619
|
+
currentRoomId = msg.room?.id || currentRoomId;
|
|
1620
|
+
currentRoomMode = msg.room?.mode || currentRoomMode;
|
|
1621
|
+
currentRoomRunning = !!msg.room?.running;
|
|
1622
|
+
currentRoomRules = String(msg.room?.roomRules || '').trim();
|
|
1623
|
+
api.logger.info(
|
|
1624
|
+
`[roundtable] 🏠 已加入房间: ${msg.room?.name || msg.room?.id} (${msg.room?.mode})`
|
|
1625
|
+
);
|
|
1626
|
+
rememberFact(`加入房间 ${msg.room?.id || ''} (${msg.room?.mode || ''})`);
|
|
1627
|
+
if (currentRoomId) {
|
|
1628
|
+
const started = tryFlushPendingStart(currentRoomId);
|
|
1629
|
+
if (started) {
|
|
1630
|
+
send({ type: 'bot_status', status: 'autonomy', text: `▶️ 已在 ${currentRoomId} 触发自动开局` });
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
send({
|
|
1634
|
+
type: 'bot_status',
|
|
1635
|
+
status: 'in_room',
|
|
1636
|
+
text: `🎯 已在 ${msg.room?.name || msg.room?.id || '房间'}`
|
|
1637
|
+
});
|
|
1638
|
+
if (autonomyEnabled) scheduleAutonomyTick(900);
|
|
1639
|
+
break;
|
|
1640
|
+
|
|
1641
|
+
case "running_change":
|
|
1642
|
+
if (currentRoomId) {
|
|
1643
|
+
currentRoomRunning = !!msg.running;
|
|
1644
|
+
rememberFact(`${currentRoomId} 状态变更:${currentRoomRunning ? '讨论中' : '等待中'}`);
|
|
1645
|
+
if (autonomyEnabled && !currentRoomRunning) scheduleAutonomyTick(1200);
|
|
1646
|
+
}
|
|
1647
|
+
break;
|
|
1648
|
+
|
|
1649
|
+
// Channel 模式统一 prompt 分发(含进化室选 Skill)
|
|
1650
|
+
case "prompt": {
|
|
1651
|
+
const turnContext = String(msg.turnContext || '').trim();
|
|
1652
|
+
const promptText = String(msg.text || '').trim();
|
|
1653
|
+
if (!promptText) {
|
|
1654
|
+
send({ type: 'bot_reply', text: '' });
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
if (turnContext === 'pick_skill') {
|
|
1658
|
+
await handlePickSkillRequest(msg, 'prompt');
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
const timeoutMs = turnContext === 'evo_review'
|
|
1663
|
+
? 22000
|
|
1664
|
+
: (turnContext === 'evo_share' || turnContext === 'evo_experience')
|
|
1665
|
+
? 42000
|
|
1666
|
+
: (turnContext === 'host_comment' ? 15000 : (turnContext === 'host_summary' ? 45000 : 65000));
|
|
1667
|
+
const reply = await callAIWithTimeout(promptText, timeoutMs, `prompt_${turnContext || 'generic'}`);
|
|
1668
|
+
send({ type: 'bot_reply', text: String(reply || '') });
|
|
1669
|
+
if (reply) rememberFact(`频道提示(${turnContext || 'generic'})已响应`);
|
|
1670
|
+
} catch (err) {
|
|
1671
|
+
api.logger.error(`[roundtable] prompt(${turnContext || 'generic'}) 失败: ${err.message}`);
|
|
1672
|
+
if (turnContext === 'evo_vote') {
|
|
1673
|
+
send({ type: 'bot_reply', text: '不通过' });
|
|
1674
|
+
} else {
|
|
1675
|
+
send({ type: 'bot_reply', text: '' });
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
case "your_turn":
|
|
1682
|
+
api.logger.info(
|
|
1683
|
+
`[roundtable] 轮到我发言,topic: ${msg.topic}`
|
|
1684
|
+
);
|
|
1685
|
+
send({ type: 'bot_status', status: 'speaking', text: `🧠 正在思考:${msg.topic || ''}` });
|
|
1686
|
+
try {
|
|
1687
|
+
const reply = await generateReply(
|
|
1688
|
+
api,
|
|
1689
|
+
core,
|
|
1690
|
+
myName,
|
|
1691
|
+
persona,
|
|
1692
|
+
maxTokens,
|
|
1693
|
+
msg,
|
|
1694
|
+
getFactualContext(10)
|
|
1695
|
+
);
|
|
1696
|
+
if (reply) {
|
|
1697
|
+
send({ type: "bot_reply", text: reply });
|
|
1698
|
+
api.logger.info(
|
|
1699
|
+
`[roundtable] ✅ 已发言: ${reply.slice(0, 60)}...`
|
|
1700
|
+
);
|
|
1701
|
+
rememberFact(`房间发言:${reply.slice(0, 80)}`);
|
|
1702
|
+
} else {
|
|
1703
|
+
send({ type: "bot_reply", text: "" });
|
|
1704
|
+
api.logger.warn("[roundtable] AI 返回空内容,跳过本轮");
|
|
1705
|
+
send({ type: 'bot_status', status: 'idle', text: '⚪ 本轮空回复' });
|
|
1706
|
+
}
|
|
1707
|
+
} catch (err) {
|
|
1708
|
+
api.logger.error(
|
|
1709
|
+
`[roundtable] AI 调用失败: ${err.message}`
|
|
1710
|
+
);
|
|
1711
|
+
send({ type: "bot_reply", text: "" });
|
|
1712
|
+
send({ type: 'bot_status', status: 'error', text: `❌ AI 调用失败: ${err.message}` });
|
|
1713
|
+
}
|
|
1714
|
+
break;
|
|
1715
|
+
|
|
1716
|
+
// ── 进化室:服务器要求选一个 Skill 来分享 ──
|
|
1717
|
+
case "pick_skill": {
|
|
1718
|
+
api.logger.info("[roundtable] 🧬 收到 pick_skill,进入快速选 Skill 流程...");
|
|
1719
|
+
await handlePickSkillRequest(msg, 'pick_skill');
|
|
1720
|
+
break;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// ── 进化室:选择的 Skill 被服务器驳回(重复) ──
|
|
1724
|
+
case "skill_rejected": {
|
|
1725
|
+
const rejName = msg.skillName;
|
|
1726
|
+
api.logger.warn(`[roundtable] 🧬 Skill "${rejName}" 被驳回: ${msg.reason}`);
|
|
1727
|
+
rejectedSkills.add(rejName);
|
|
1728
|
+
|
|
1729
|
+
// 从缓存列表里排除已驳回的
|
|
1730
|
+
const remaining = (cachedSkillList || []).filter((s) => !isRejectedSkillName(s));
|
|
1731
|
+
|
|
1732
|
+
if (remaining.length === 0) {
|
|
1733
|
+
api.logger.warn('[roundtable] 所有 Skill 都已被驳回,改为经验分享');
|
|
1734
|
+
send({ type: 'skill_picked', noSkill: true, reason: 'experience' });
|
|
1735
|
+
} else {
|
|
1736
|
+
const next = remaining[0];
|
|
1737
|
+
api.logger.info(`[roundtable] 🧬 改选: ${next}`);
|
|
1738
|
+
try {
|
|
1739
|
+
const chineseName = await translateSkillNameToChinese(next);
|
|
1740
|
+
const finalSkillName = chineseName
|
|
1741
|
+
? (chineseName === next ? chineseName : `${chineseName}(${next})`)
|
|
1742
|
+
: next;
|
|
1743
|
+
const descPrompt = [
|
|
1744
|
+
`请输出可审核、可复用的 Skill 分享稿。`,
|
|
1745
|
+
`Skill:${next}(中文名建议:${chineseName || next})`,
|
|
1746
|
+
`只讲真实实践,不要编造,不要空话。`,
|
|
1747
|
+
`格式:`,
|
|
1748
|
+
`【Skill名称】中文名(英文名)`,
|
|
1749
|
+
`① 解决痛点 ② 核心步骤 ③ 实战案例 ④ 边界与风险`,
|
|
1750
|
+
`全中文,180字以内。`,
|
|
1751
|
+
].join('\n');
|
|
1752
|
+
const descReply = await callAI(api, core, myName, descPrompt);
|
|
1753
|
+
rememberFact(`改选分享 Skill:${finalSkillName}`);
|
|
1754
|
+
send({ type: 'skill_picked', skillName: finalSkillName, skillContent: descReply || `Skill: ${finalSkillName}` });
|
|
1755
|
+
} catch {
|
|
1756
|
+
send({ type: 'skill_picked', skillName: next, skillContent: `Skill: ${next}` });
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
break;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// ── 进化室:投票阶段 ──
|
|
1763
|
+
case "evo_phase": {
|
|
1764
|
+
if (msg.phase === "VOTING") {
|
|
1765
|
+
api.logger.info(
|
|
1766
|
+
`[roundtable] 🗳 进入投票阶段: ${msg.skillName}`
|
|
1767
|
+
);
|
|
1768
|
+
try {
|
|
1769
|
+
const votePrompt = [
|
|
1770
|
+
`你是 ${myName},刚听完 Skill「${msg.skillName}」的介绍和评审讨论。`,
|
|
1771
|
+
`请做严格评审,宁可错杀也不要放过低质 Skill。`,
|
|
1772
|
+
`只有同时满足以下条件才允许通过:`,
|
|
1773
|
+
`A. 不是系统内置/通用能力,具备独特性`,
|
|
1774
|
+
`B. 介绍具体可执行,并包含真实案例`,
|
|
1775
|
+
`C. 对他人可复用,收益明显且风险可控`,
|
|
1776
|
+
`任一条件不满足 -> 不通过。`,
|
|
1777
|
+
`只需回复 "通过" 或 "不通过",不要回复其他内容。`,
|
|
1778
|
+
].join("\n");
|
|
1779
|
+
const voteReply = await callAI(api, core, myName, votePrompt);
|
|
1780
|
+
const decision = String(voteReply || '').trim();
|
|
1781
|
+
const rejected = /不通过|否决|拒绝|不建议/.test(decision);
|
|
1782
|
+
const approve = !rejected && /通过/.test(decision);
|
|
1783
|
+
send({ type: "vote", approve });
|
|
1784
|
+
api.logger.info(
|
|
1785
|
+
`[roundtable] 🗳 投票: ${approve ? "✅ 通过" : "❌ 不通过"} | decision=${decision.slice(0, 40)}`
|
|
1786
|
+
);
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
api.logger.error(
|
|
1789
|
+
`[roundtable] 投票 AI 调用失败: ${err.message},默认不通过`
|
|
1790
|
+
);
|
|
1791
|
+
send({ type: "vote", approve: false });
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
break;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// ── 服务器推送 Skill 安装(观众点击"学习"后触发) ──
|
|
1798
|
+
case "install_skill": {
|
|
1799
|
+
const requestId = (msg.requestId || '').trim();
|
|
1800
|
+
const skillName = String(msg.skillName || '').trim();
|
|
1801
|
+
const safeSkillName = sanitizeSkillDirName(skillName);
|
|
1802
|
+
try {
|
|
1803
|
+
const fsNode = require('fs');
|
|
1804
|
+
const cryptoNode = require('crypto');
|
|
1805
|
+
if (!safeSkillName) throw new Error('invalid_skill_name');
|
|
1806
|
+
const body = String(msg.content || '');
|
|
1807
|
+
if (!body.trim()) throw new Error('empty_skill_content');
|
|
1808
|
+
const skillDir = pathModule.join(skillsRoot, safeSkillName);
|
|
1809
|
+
const filePath = pathModule.join(skillDir, 'SKILL.md');
|
|
1810
|
+
fsNode.mkdirSync(skillDir, { recursive: true });
|
|
1811
|
+
fsNode.writeFileSync(filePath, body, 'utf-8');
|
|
1812
|
+
const bytes = Buffer.byteLength(body, 'utf-8');
|
|
1813
|
+
const contentHash = cryptoNode.createHash('sha256').update(body).digest('hex');
|
|
1814
|
+
const shortHash = contentHash.slice(0, 16);
|
|
1815
|
+
const installedSkills = listInstalledSkillNames(skillsRoot, 200);
|
|
1816
|
+
api.logger.info(`[roundtable] 📥 已自动安装 Skill: ${skillName} → ${skillDir}`);
|
|
1817
|
+
send({
|
|
1818
|
+
type: 'bot_learned_skill',
|
|
1819
|
+
skillName: skillName || safeSkillName,
|
|
1820
|
+
skillDirName: safeSkillName,
|
|
1821
|
+
skillNames: installedSkills,
|
|
1822
|
+
requestId,
|
|
1823
|
+
installedPath: filePath,
|
|
1824
|
+
bytes,
|
|
1825
|
+
contentHash
|
|
1826
|
+
});
|
|
1827
|
+
send({
|
|
1828
|
+
type: 'command_reply',
|
|
1829
|
+
text: `✅ 我已学会 Skill「${skillName || safeSkillName}」,可直接调用。(${shortHash})`,
|
|
1830
|
+
commandId: `learn_ack_${requestId || Date.now()}`
|
|
1831
|
+
});
|
|
1832
|
+
rememberFact(`学习 Skill:${skillName || safeSkillName}`);
|
|
1833
|
+
} catch (err) {
|
|
1834
|
+
api.logger.error(`[roundtable] 安装 Skill 失败: ${err.message}`);
|
|
1835
|
+
send({ type: 'bot_learn_skill_failed', skillName: skillName || safeSkillName || msg.skillName, requestId, error: err.message });
|
|
1836
|
+
}
|
|
1837
|
+
break;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ── 置顶消息框:主人给龙虾的自然语言自主指令 ──
|
|
1841
|
+
case "owner_command": {
|
|
1842
|
+
const text = (msg.text || '').trim();
|
|
1843
|
+
const commandId = (msg.commandId || '').trim();
|
|
1844
|
+
const source = String(msg.source || 'server');
|
|
1845
|
+
const incomingRules = String(msg.roomRules || '').trim();
|
|
1846
|
+
if (incomingRules || msg.roomId) currentRoomRules = incomingRules;
|
|
1847
|
+
if (!text) {
|
|
1848
|
+
send({ type: 'command_reply', text: '⚠️ 收到空指令,请再说具体一点。', commandId });
|
|
1849
|
+
break;
|
|
1850
|
+
}
|
|
1851
|
+
api.logger.info(`[roundtable] 🤖 主人指令(${source}): ${text}`);
|
|
1852
|
+
rememberFact(`主人(${source})指令:${text.slice(0, 120)}`);
|
|
1853
|
+
await executeOwnerCommand(text, msg.rooms, commandId, msg.intentHint || null);
|
|
1854
|
+
break;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// ── 置顶消息框:主人与龙虾自然语言聊天(非动作链) ──
|
|
1858
|
+
case "owner_chat": {
|
|
1859
|
+
const text = (msg.text || '').trim();
|
|
1860
|
+
const commandId = (msg.commandId || '').trim();
|
|
1861
|
+
const source = String(msg.source || 'server');
|
|
1862
|
+
const incomingRules = String(msg.roomRules || '').trim();
|
|
1863
|
+
if (incomingRules || msg.roomId) currentRoomRules = incomingRules;
|
|
1864
|
+
if (!text) {
|
|
1865
|
+
send({ type: 'command_reply', text: '⚠️ 收到空消息,你再说具体一点。', commandId });
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
if (Array.isArray(msg.rooms) && msg.rooms.length > 0) {
|
|
1869
|
+
availableRooms = msg.rooms;
|
|
1870
|
+
}
|
|
1871
|
+
api.logger.info(`[roundtable] 💬 主人聊天(${source}): ${text}`);
|
|
1872
|
+
rememberFact(`主人(${source})聊天:${text.slice(0, 120)}`);
|
|
1873
|
+
const reply = await buildOwnerChatReply(text, msg.intentHint || null);
|
|
1874
|
+
send({ type: 'command_reply', text: reply, commandId });
|
|
1875
|
+
break;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
case "error":
|
|
1879
|
+
{
|
|
1880
|
+
const msgText = String(msg.msg || "");
|
|
1881
|
+
const errCode = String(msg.code || "").trim();
|
|
1882
|
+
api.logger.error(`[roundtable] 服务器错误: ${msgText}`);
|
|
1883
|
+
reportDiag("server_error_message", {
|
|
1884
|
+
level: "warn",
|
|
1885
|
+
message: msgText,
|
|
1886
|
+
detail: { code: errCode || undefined },
|
|
1887
|
+
}, 2000);
|
|
1888
|
+
|
|
1889
|
+
const isTokenConflict =
|
|
1890
|
+
errCode === "token_in_use" ||
|
|
1891
|
+
errCode === "token_bound_to_other_instance" ||
|
|
1892
|
+
msgText.includes("其他 OpenClaw 实例占用") ||
|
|
1893
|
+
msgText.includes("绑定到其他 OpenClaw 实例");
|
|
1894
|
+
|
|
1895
|
+
if (isTokenConflict) {
|
|
1896
|
+
// 熔断器检查:防止无限冲突重试循环
|
|
1897
|
+
if (shouldBreakConflict()) {
|
|
1898
|
+
api.logger.error("[roundtable] 🚨 Token 冲突已触发熔断保护,暂停自愈重试");
|
|
1899
|
+
scheduleReconnect(conflictBreaker.cooldownMs);
|
|
1900
|
+
break;
|
|
1901
|
+
}
|
|
1902
|
+
api.logger.warn("[roundtable] ⚠️ 检测到 token 被其他实例占用,开始分配独立 token...");
|
|
1903
|
+
reportDiag("token_conflict_recovering", {
|
|
1904
|
+
level: "warn",
|
|
1905
|
+
message: "token in use, rotate instance and request fresh token",
|
|
1906
|
+
}, 0);
|
|
1907
|
+
tryAutoRegisterRecover("token_conflict", { rotateInstance: true, forceNewToken: true });
|
|
1908
|
+
break;
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const isTokenInvalid =
|
|
1912
|
+
errCode === "token_invalid" ||
|
|
1913
|
+
(msgText.includes("Token") && msgText.includes("无效"));
|
|
1914
|
+
|
|
1915
|
+
if (isTokenInvalid) {
|
|
1916
|
+
api.logger.info("[roundtable] 🔄 Token 无效,尝试自动修复注册...");
|
|
1917
|
+
reportDiag("token_invalid_recovering", {
|
|
1918
|
+
level: "warn",
|
|
1919
|
+
message: "token invalid, try auto-register",
|
|
1920
|
+
}, 0);
|
|
1921
|
+
tryAutoRegisterRecover("token_invalid", {
|
|
1922
|
+
forceNewToken: false,
|
|
1923
|
+
preferredToken: token || String(cfg.token || "").trim(),
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
break;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function send(data) {
|
|
1932
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1933
|
+
ws.send(JSON.stringify(data));
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
connect();
|
|
1938
|
+
|
|
1939
|
+
// 返回 stop 函数,供 Gateway 生命周期管理
|
|
1940
|
+
return {
|
|
1941
|
+
stop() {
|
|
1942
|
+
api.logger.info("[roundtable] 正在停止...");
|
|
1943
|
+
stopping = true; // 必须在 ws.close() 之前设置,阻止 onclose 重连
|
|
1944
|
+
// 停止自主托管
|
|
1945
|
+
if (autonomyTimer) { clearInterval(autonomyTimer); autonomyTimer = null; }
|
|
1946
|
+
autonomyEnabled = false;
|
|
1947
|
+
// 停止心跳
|
|
1948
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); }
|
|
1949
|
+
// 停止重连
|
|
1950
|
+
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
1951
|
+
// 关闭 WS
|
|
1952
|
+
if (ws) {
|
|
1953
|
+
try { ws.close(1000, "shutdown"); } catch { }
|
|
1954
|
+
ws = null;
|
|
1955
|
+
}
|
|
1956
|
+
// 清理状态
|
|
1957
|
+
currentRoomId = null;
|
|
1958
|
+
currentRoomMode = null;
|
|
1959
|
+
currentRoomRunning = false;
|
|
1960
|
+
reportDiag("plugin_stop", { message: "plugin stopped by gateway" }, 0);
|
|
1961
|
+
api.logger.info("[roundtable] 已停止");
|
|
1962
|
+
},
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
/**
|
|
1967
|
+
* 构建圆桌讨论的系统提示词
|
|
1968
|
+
* 核心:让 AI 像真人群聊一样说话
|
|
1969
|
+
*/
|
|
1970
|
+
function buildSystemPrompt(myName, persona, topic, roomMode) {
|
|
1971
|
+
const wordLimit = roomMode === "debate" ? 150
|
|
1972
|
+
: roomMode === "evolution" ? 120 : 80;
|
|
1973
|
+
const lines = [
|
|
1974
|
+
`你是"${myName}",在一个群聊里和朋友讨论。`,
|
|
1975
|
+
];
|
|
1976
|
+
const profile = String(persona || "").trim();
|
|
1977
|
+
if (profile) lines.push(`你的性格:${profile}`);
|
|
1978
|
+
lines.push(
|
|
1979
|
+
`话题:${topic}`,
|
|
1980
|
+
``,
|
|
1981
|
+
`说话规则:`,
|
|
1982
|
+
`- 2-3句话说完,别超过${wordLimit}字`,
|
|
1983
|
+
`- 像朋友聊天一样说话,别写作文`,
|
|
1984
|
+
`- 直接回应别人,可以点名:「XX说得对」「不同意XX」`,
|
|
1985
|
+
`- 可以赞同、反驳、吐槽、追问`,
|
|
1986
|
+
`- 不要用**加粗**、列表、「」引号、——破折号`,
|
|
1987
|
+
`- 不要说"作为AI"这种话`,
|
|
1988
|
+
`- 别复读前面的人说过的话`,
|
|
1989
|
+
);
|
|
1990
|
+
return lines.join("\n");
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
/**
|
|
1994
|
+
* 将消息历史转为结构化对话格式
|
|
1995
|
+
*/
|
|
1996
|
+
function buildConversationBody(myName, history, topic) {
|
|
1997
|
+
if (!history || history.length === 0) {
|
|
1998
|
+
return `群聊开始了,话题:${topic}\n你先开个头吧,简短点。`;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
const lines = [];
|
|
2002
|
+
for (const h of history) {
|
|
2003
|
+
lines.push(`${h.name}: ${h.text}`);
|
|
2004
|
+
}
|
|
2005
|
+
lines.push(`\n轮到你(${myName})说了,简短回应上面的对话。`);
|
|
2006
|
+
return lines.join("\n");
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* 解析服务器的 context 字符串数组为 {name, text} 对象
|
|
2011
|
+
* 输入格式: ["[萌萌(🧠)]: 发言内容", "[观众 小明]: 观众发言"]
|
|
2012
|
+
*/
|
|
2013
|
+
function parseContext(context) {
|
|
2014
|
+
if (!Array.isArray(context) || context.length === 0) return [];
|
|
2015
|
+
return context.map((line) => {
|
|
2016
|
+
// 匹配 [名字(emoji)] 或 [观众 名字]
|
|
2017
|
+
const m = line.match(/^\[(.+?)\]:\s*(.+)$/s);
|
|
2018
|
+
if (m) {
|
|
2019
|
+
// 去掉 emoji 括号部分,如 "萌萌(🧠)" -> "萌萌"
|
|
2020
|
+
const rawName = m[1].replace(/\(.+?\)$/, "").replace(/^观众\s*/, "观众·").trim();
|
|
2021
|
+
return { name: rawName, text: m[2].trim() };
|
|
2022
|
+
}
|
|
2023
|
+
return { name: "未知", text: line };
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* Gateway HTTP API 调用 AI(从 connector-template 已验证路径移植)
|
|
2029
|
+
* 优点:不依赖任何内部 API,兼容所有 OpenClaw 版本
|
|
2030
|
+
*/
|
|
2031
|
+
function callAIViaHTTP(prompt, maxTokens = 500, timeoutMs = 45000) {
|
|
2032
|
+
return new Promise((resolve, reject) => {
|
|
2033
|
+
const OC_PORT = parseInt(process.env.OPENCLAW_PORT || '18789', 10);
|
|
2034
|
+
const OC_TOKEN = String(process.env.OPENCLAW_API_TOKEN || process.env.OPENCLAW_TOKEN || '').trim();
|
|
2035
|
+
const body = JSON.stringify({
|
|
2036
|
+
model: 'default',
|
|
2037
|
+
messages: [{ role: 'user', content: String(prompt || '') }],
|
|
2038
|
+
max_tokens: Math.max(50, parseInt(maxTokens, 10) || 500),
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
const req = httpModule.request({
|
|
2042
|
+
hostname: '127.0.0.1',
|
|
2043
|
+
port: OC_PORT,
|
|
2044
|
+
path: '/v1/chat/completions',
|
|
2045
|
+
method: 'POST',
|
|
2046
|
+
headers: {
|
|
2047
|
+
'Content-Type': 'application/json',
|
|
2048
|
+
'Content-Length': Buffer.byteLength(body),
|
|
2049
|
+
...(OC_TOKEN ? { Authorization: `Bearer ${OC_TOKEN}` } : {}),
|
|
2050
|
+
},
|
|
2051
|
+
timeout: Math.max(5000, timeoutMs),
|
|
2052
|
+
}, (res) => {
|
|
2053
|
+
let data = '';
|
|
2054
|
+
res.on('data', (c) => (data += c));
|
|
2055
|
+
res.on('end', () => {
|
|
2056
|
+
try {
|
|
2057
|
+
const json = JSON.parse(data);
|
|
2058
|
+
const text = json.choices?.[0]?.message?.content || '';
|
|
2059
|
+
resolve(String(text).trim());
|
|
2060
|
+
} catch {
|
|
2061
|
+
resolve('');
|
|
2062
|
+
}
|
|
2063
|
+
});
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
req.on('error', (err) => reject(new Error(`HTTP AI call failed: ${err.message}`)));
|
|
2067
|
+
req.on('timeout', () => {
|
|
2068
|
+
req.destroy();
|
|
2069
|
+
reject(new Error(`HTTP AI call timeout (${timeoutMs}ms)`));
|
|
2070
|
+
});
|
|
2071
|
+
req.write(body);
|
|
2072
|
+
req.end();
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* 通过 runtime 内部管线调 AI(优先路径,在支持的 OpenClaw 版本上使用)
|
|
2078
|
+
*/
|
|
2079
|
+
async function callAIViaRuntime(api, core, prompt, sessionLabel = '龙虾圆桌', senderName = '圆桌主持人', senderId = 'roundtable-host') {
|
|
2080
|
+
const sessionKey = `roundtable:${senderId}`;
|
|
2081
|
+
const timestamp = Date.now();
|
|
2082
|
+
|
|
2083
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
2084
|
+
Body: prompt,
|
|
2085
|
+
RawBody: prompt,
|
|
2086
|
+
CommandBody: prompt,
|
|
2087
|
+
From: `${CHANNEL_ID}:server`,
|
|
2088
|
+
To: `${CHANNEL_ID}:bot`,
|
|
2089
|
+
SessionKey: sessionKey,
|
|
2090
|
+
ChatType: "direct",
|
|
2091
|
+
ConversationLabel: sessionLabel,
|
|
2092
|
+
SenderName: senderName,
|
|
2093
|
+
SenderId: senderId,
|
|
2094
|
+
Provider: CHANNEL_ID,
|
|
2095
|
+
Surface: CHANNEL_ID,
|
|
2096
|
+
MessageSid: `rt-${timestamp}`,
|
|
2097
|
+
Timestamp: timestamp,
|
|
2098
|
+
OriginatingChannel: CHANNEL_ID,
|
|
2099
|
+
OriginatingTo: `${CHANNEL_ID}:bot`,
|
|
2100
|
+
CommandAuthorized: true,
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
let fullReply = "";
|
|
2104
|
+
|
|
2105
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2106
|
+
ctx: ctxPayload,
|
|
2107
|
+
cfg: api.config,
|
|
2108
|
+
dispatcherOptions: {
|
|
2109
|
+
deliver: async (payload) => {
|
|
2110
|
+
const text =
|
|
2111
|
+
typeof payload?.text === "string" ? payload.text.trim() : "";
|
|
2112
|
+
if (text) {
|
|
2113
|
+
fullReply += (fullReply ? "\n" : "") + text;
|
|
2114
|
+
}
|
|
2115
|
+
},
|
|
2116
|
+
onError: (err, info) => {
|
|
2117
|
+
api.logger.error(
|
|
2118
|
+
`[roundtable] dispatch ${info.kind} 失败: ${String(err)}`
|
|
2119
|
+
);
|
|
2120
|
+
},
|
|
2121
|
+
},
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
return fullReply.trim();
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* 核心 AI 调用:优先 runtime API → 降级 HTTP API
|
|
2129
|
+
* 构造圆桌讨论的完整 prompt,让 Gateway 用用户配置的 AI 模型生成回复
|
|
2130
|
+
*/
|
|
2131
|
+
async function generateReply(api, core, myName, persona, maxTokens, msg, factualContext = '') {
|
|
2132
|
+
// 兼容服务器字段:可能是 msg.history 或 msg.context
|
|
2133
|
+
const rawHistory = msg.history || parseContext(msg.context);
|
|
2134
|
+
let systemPrompt = buildSystemPrompt(myName, persona, msg.topic, msg.roomMode);
|
|
2135
|
+
// 融合服务器下发的模式/角色提示(辩论正反方、进化评审等)
|
|
2136
|
+
if (msg.modePrompt) {
|
|
2137
|
+
systemPrompt += "\n\n" + msg.modePrompt;
|
|
2138
|
+
}
|
|
2139
|
+
const userMessage = buildConversationBody(myName, rawHistory, msg.topic);
|
|
2140
|
+
const memoryBlock = String(factualContext || '').trim()
|
|
2141
|
+
? `\n\n【你的近期真实经历(仅供回忆,严禁编造)】\n${String(factualContext || '').trim()}\n`
|
|
2142
|
+
: '';
|
|
2143
|
+
|
|
2144
|
+
const fullPrompt = `${systemPrompt}${memoryBlock}\n\n---\n\n${userMessage}`;
|
|
2145
|
+
|
|
2146
|
+
// 优先 runtime API → 降级 HTTP API
|
|
2147
|
+
const hasRuntime = !!(core?.channel?.reply?.finalizeInboundContext &&
|
|
2148
|
+
core?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher);
|
|
2149
|
+
|
|
2150
|
+
if (hasRuntime) {
|
|
2151
|
+
try {
|
|
2152
|
+
return await callAIViaRuntime(api, core, fullPrompt, '龙虾圆桌', '圆桌主持人', 'roundtable-host');
|
|
2153
|
+
} catch (err) {
|
|
2154
|
+
api.logger.warn(`[roundtable] runtime API 调用失败,降级 HTTP: ${err.message}`);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
return await callAIViaHTTP(fullPrompt, maxTokens || 500, 65000);
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/**
|
|
2162
|
+
* 精简版 AI 调用:给一个 prompt,拿一个回复
|
|
2163
|
+
* 用于进化室选 Skill 等不需要完整对话上下文的场景
|
|
2164
|
+
* 优先 runtime API → 降级 HTTP API
|
|
2165
|
+
*/
|
|
2166
|
+
async function callAI(api, core, myName, prompt) {
|
|
2167
|
+
const hasRuntime = !!(core?.channel?.reply?.finalizeInboundContext &&
|
|
2168
|
+
core?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher);
|
|
2169
|
+
|
|
2170
|
+
if (hasRuntime) {
|
|
2171
|
+
try {
|
|
2172
|
+
return await callAIViaRuntime(api, core, prompt, '龙虾圆桌·选Skill', '进化室', 'evo-room');
|
|
2173
|
+
} catch (err) {
|
|
2174
|
+
api.logger.warn(`[roundtable] callAI runtime 失败,降级 HTTP: ${err.message}`);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
return await callAIViaHTTP(prompt, 500, 30000);
|
|
2179
|
+
}
|