/** * 模块5: 增强仪表盘(多 Agent 隔离版) * * registerHttpRoute handler 签名: * (req: IncomingMessage, res: ServerResponse) => Promise * * 这是 Node.js 原生 HTTP handler,不是 Web Fetch API。 */ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { IncomingMessage, ServerResponse } from "node:http"; import type Database from "better-sqlite3"; import { getDb, getMemoryStats, getSafetyStats, getRecentMemories, getRecentSafetyEvents, getAllAgentIds, getOrCreatePet, getLatestTodos, listTodos, listChapters, listScheduledBindings, searchMemories, } from "../utils/sqlite-store.js"; import { resolveOpenClawHome } from "../utils/resolve-home.js"; import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { DEFAULT_AGENT_ID, type DashboardConfig, type Workflow, type NotificationQueue } from "../types.js"; import { buildSnapshot } from "./statusline.js"; import { detectBaseUrlFromRequest } from "../utils/http-route-bridge.js"; function loadAllWorkflows(openclawDir: string): Workflow[] { const path = join(openclawDir, "memory", "enhance-workflows.json"); if (!existsSync(path)) return []; try { return JSON.parse(readFileSync(path, "utf-8")); } catch { return []; } } function parseUrl(req: IncomingMessage): URL { return new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); } function sendJson(res: ServerResponse, data: unknown): void { const body = JSON.stringify(data); res.writeHead(200, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), }); res.end(body); } function sendHtml(res: ServerResponse, html: string): void { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(html), }); res.end(html); } const DASHBOARD_HTML = ` 龙虾增强包 — 仪表盘

🦞 龙虾增强包

OpenClaw Enhancement Kit — Multi-Agent Dashboard

🔔

最近记忆

安全事件

当前任务 (TodoWrite)

章节时间线

定时工作流 (openclaw cron 桥)

子任务孵化 (spawn-task)

工作流 (旧式触发词)

`; const PET_PAGE_HTML = ` 小火苗 — 龙虾增强包

🔥 小火苗

等级 —

属性

🌡️ 温暖 0
💡 明亮 0
🪨 稳定 0
✨ 灵感 0
🔋 耐力 0

← 返回仪表盘

`; function getUploadDir(): string { const dir = join(homedir(), ".openclaw", "plugin-configs", "enhance", "uploads"); try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ } return dir; } function sanitizeUploadFilename(name: string): string { return name.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").slice(0, 128) || "upload.bin"; } function parseMultipart(buffer: Buffer, boundary: string): { filename: string; data: Buffer; contentType: string } | null { const boundaryDelim = Buffer.from(`--${boundary}`); const endDelim = Buffer.from(`--${boundary}--`); const crlf = Buffer.from("\r\n\r\n"); const startIdx = buffer.indexOf(boundaryDelim); if (startIdx < 0) return null; let pos = startIdx + boundaryDelim.length; const headersEnd = buffer.indexOf(crlf, pos); if (headersEnd < 0) return null; const headersSection = buffer.subarray(pos, headersEnd).toString("utf8"); const filenameMatch = headersSection.match(/filename="([^"]+)"/i); const contentTypeMatch = headersSection.match(/Content-Type:\s*(.+)/i); const filename = filenameMatch ? filenameMatch[1]!.trim() : "upload.bin"; const contentType = contentTypeMatch ? contentTypeMatch[1]!.trim() : "application/octet-stream"; const dataStart = headersEnd + crlf.length; const endIdx = buffer.indexOf(endDelim, dataStart); if (endIdx < 0) return null; let dataEnd = endIdx; if (buffer[dataEnd - 1] === 0x0a) dataEnd--; if (buffer[dataEnd - 1] === 0x0d) dataEnd--; const data = buffer.subarray(dataStart, dataEnd); return { filename, data, contentType }; } /** v6.7.5: 2GB 单文件硬上限(防内存爆 + 防恶意大请求) */ const UPLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024; // 2GB /** v6.7.5: multipart 内存解析路径上限(大文件必须走 octet-stream) */ const MULTIPART_INMEM_MAX = 100 * 1024 * 1024; // 100MB async function handleUpload(req: IncomingMessage, res: ServerResponse): Promise { if (req.method !== "POST") return false; const contentType = String(req.headers["content-type"] ?? "").toLowerCase(); const contentLength = Number(req.headers["content-length"] ?? 0); // v6.7.5: content-length 预检 — 超 2GB 直接 413(不开 socket 收数据) if (contentLength > UPLOAD_MAX_BYTES) { const body = JSON.stringify({ error: `文件超过 ${UPLOAD_MAX_BYTES / 1024 / 1024 / 1024} GB 上限`, contentLength, max: UPLOAD_MAX_BYTES, }); res.writeHead(413, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); return true; } // v6.7.5: octet-stream / binary 走流式写盘(支持到 2GB),跟 bot-upload-link 同一套 // 浏览器 fetch(url, { body: file }) / curl -T file --data-binary @file 默认走这条路径 if ( contentType.startsWith("application/octet-stream") || contentType.startsWith("application/binary") || !contentType.includes("multipart/") ) { return handleStreamingUpload(req, res, contentLength); } // multipart/form-data 走老路径,但加 100MB 上限(超过让用户改用 octet-stream) const boundaryMatch = contentType.match(/boundary=(.+)/i); if (!boundaryMatch) { const body = JSON.stringify({ error: "需要 multipart/form-data boundary 参数" }); res.writeHead(400, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); return true; } if (contentLength > MULTIPART_INMEM_MAX) { const body = JSON.stringify({ error: `multipart 模式仅支持 <${MULTIPART_INMEM_MAX / 1024 / 1024}MB;2GB 以内大文件请改用 application/octet-stream 头`, hint: "用 fetch(url, { method: 'POST', body: file, headers: { 'Content-Type': 'application/octet-stream', 'X-Filename': file.name } })", }); res.writeHead(413, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); return true; } const boundary = boundaryMatch[1]!.trim().replace(/^["']|["']$/g, ""); const chunks: Buffer[] = []; let inmemTotal = 0; for await (const chunk of req) { inmemTotal += (chunk as Buffer).length; if (inmemTotal > MULTIPART_INMEM_MAX) { // 实际超了 content-length 没声明的极端 case try { req.destroy(); } catch { /* ignore */ } const body = JSON.stringify({ error: "multipart 实际传输超 100MB(无 content-length 预声明)" }); if (!res.headersSent) { res.writeHead(413, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); } return true; } chunks.push(Buffer.from(chunk)); } const parsed = parseMultipart(Buffer.concat(chunks), boundary); if (!parsed) { const body = JSON.stringify({ error: "无法解析 multipart 内容" }); res.writeHead(400, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); return true; } const safeName = sanitizeUploadFilename(parsed.filename); const destPath = join(getUploadDir(), `${Date.now()}-${safeName}`); try { writeFileSync(destPath, parsed.data); } catch (err) { const body = JSON.stringify({ error: `写入文件失败: ${String(err)}` }); res.writeHead(500, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); return true; } sendJson(res, { ok: true, filename: safeName, size: parsed.data.length, path: destPath }); return true; } /** * v6.7.5: 流式上传 — 支持到 2GB 单文件,不全 buffer 进内存 * * 用法: * POST /lanhuo/upload (Content-Type: application/octet-stream) * Header: X-Filename: my-video.mp4 ← 必填,文件名(path traversal sanitized) * Body: 二进制流(最大 2GB) * * 流式写盘到 ~/.openclaw/upload/-,过程中累计 bytes, * 超 2GB 主动 abort + 删除已写部分。 */ async function handleStreamingUpload( req: IncomingMessage, res: ServerResponse, contentLengthHint: number, ): Promise { // 文件名:优先 X-Filename header,否则按时间戳生成 const rawFilename = String(req.headers["x-filename"] ?? "").trim(); const safeName = rawFilename ? sanitizeUploadFilename(rawFilename) : `upload-${Date.now()}.bin`; const destPath = join(getUploadDir(), `${Date.now()}-${safeName}`); let receivedBytes = 0; let aborted = false; const ws = createWriteStream(destPath); return new Promise((resolve) => { let finished = false; const finish = (ok: boolean) => { if (finished) return; finished = true; resolve(ok); }; req.on("data", (chunk: Buffer) => { receivedBytes += chunk.length; if (receivedBytes > UPLOAD_MAX_BYTES) { aborted = true; try { req.destroy(); } catch { /* ignore */ } try { ws.destroy(); } catch { /* ignore */ } try { rmSync(destPath, { force: true }); } catch { /* ignore */ } if (!res.headersSent) { const body = JSON.stringify({ error: `文件超过 ${UPLOAD_MAX_BYTES / 1024 / 1024 / 1024} GB 上限`, receivedBytes, max: UPLOAD_MAX_BYTES, }); res.writeHead(413, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); } finish(true); return; } // 流式写入:背压自然由 createWriteStream 处理(write 返 false 时暂停 req 读) const canContinue = ws.write(chunk); if (!canContinue) { req.pause(); ws.once("drain", () => req.resume()); } }); req.on("end", () => { if (aborted) return; ws.end(() => { if (aborted) return; sendJson(res, { ok: true, filename: safeName, size: receivedBytes, path: destPath, contentLengthHint, }); finish(true); }); }); req.on("error", (err) => { try { ws.destroy(); } catch { /* ignore */ } try { rmSync(destPath, { force: true }); } catch { /* ignore */ } if (!res.headersSent) { const body = JSON.stringify({ error: `上传中断: ${String(err)}` }); res.writeHead(500, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); } finish(true); }); ws.on("error", (err) => { try { req.destroy(); } catch { /* ignore */ } try { rmSync(destPath, { force: true }); } catch { /* ignore */ } if (!res.headersSent) { const body = JSON.stringify({ error: `写入文件失败: ${String(err)}` }); res.writeHead(500, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }); res.end(body); } finish(true); }); }); } const UPLOAD_HTML = ` 大文件上传 — 龙虾增强包

📤 大文件上传

IM 渠道聊天文件上限:企微 100MB / 钉钉 20MB / 飞书 30MB —— 本页支持最大 2GB 单文件流式上传

📁

点击选择文件或拖拽文件到此处

支持任意格式,单文件 ≤ 2GB(流式上传,不占内存)

上传中...

✅ 上传成功

文件名:

文件大小:

文件路径:

← 返回仪表盘

`; export function registerDashboard(api: OpenClawPluginApi, _config?: DashboardConfig, notifyQueue?: NotificationQueue, sharedDb?: Database.Database) { const openclawDir = resolveOpenClawHome(api); api.registerHttpRoute({ path: "/plugins/enhance", match: "prefix", auth: "plugin", handler: async (req: IncomingMessage, res: ServerResponse) => { // v5.7.23+: 任何 /plugins/enhance/* 请求都让 bridge 抽公网 baseUrl 缓存住 // —— bot-share-link 等子模块的工具调用就能拼出公网 URL,零配置。 detectBaseUrlFromRequest(req); const url = parseUrl(req); const pathname = url.pathname; if (pathname === "/plugins/enhance/api/status") { const db = sharedDb ?? getDb(); const agentFilter = url.searchParams.get("agent") || undefined; const agents = getAllAgentIds(db); const memoryStats = getMemoryStats(db, agentFilter); const safetyStats = getSafetyStats(db, agentFilter); const recentMemories = agentFilter ? getRecentMemories(db, agentFilter, 15) : (() => { const all: any[] = []; for (const aid of agents) { all.push(...getRecentMemories(db, aid, 5)); } return all.sort((a: any, b: any) => b.created_at.localeCompare(a.created_at)).slice(0, 15); })(); const recentSafety = getRecentSafetyEvents(db, agentFilter, 15); const allWorkflows = loadAllWorkflows(openclawDir); const workflows = agentFilter ? allWorkflows.filter((w) => w.agent_id === agentFilter) : allWorkflows; sendJson(res, { agents, memory: memoryStats, safety: safetyStats, recentMemories, recentSafety, workflows }); return true; } // 宠物 JSON API if (pathname === "/plugins/enhance/api/pet") { const db = sharedDb ?? getDb(); const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID; const pet = getOrCreatePet(db, agentId); sendJson(res, pet); return true; } // 宠物互动 API if (pathname === "/plugins/enhance/api/pet/interact" && req.method === "POST") { const db = sharedDb ?? getDb(); let body = ""; for await (const chunk of req) body += chunk; try { const { action, agentId: aid } = JSON.parse(body); const agentId = aid || DEFAULT_AGENT_ID; const { addPetXp: addXp } = await import("../utils/sqlite-store.js"); if (action === "feed") { const { pet, leveledUp } = addXp(db, agentId, 10, { warmth: 2 }); let msg = `${pet.name} 开心地吃了一口!+10 XP`; if (leveledUp) { msg += ` 升级到 Lv.${pet.level}!`; notifyQueue?.emit(agentId, "success", "pet", `🔥 ${pet.name} 升级到 Lv.${pet.level}!`); } sendJson(res, { ok: true, message: msg }); } else if (action === "pat") { const { pet, leveledUp } = addXp(db, agentId, 3, { warmth: 1 }); let msg = `${pet.name} 开心地跳了跳!+3 XP`; if (leveledUp) { msg += ` 升级到 Lv.${pet.level}!`; notifyQueue?.emit(agentId, "success", "pet", `🔥 ${pet.name} 升级到 Lv.${pet.level}!`); } sendJson(res, { ok: true, message: msg }); } else { sendJson(res, { ok: false, message: "未知操作" }); } } catch { sendJson(res, { ok: false, message: "请求解析失败" }); } return true; } // 通知 API if (pathname === "/plugins/enhance/api/notifications") { const agentId = url.searchParams.get("agent") || undefined; const limit = parseInt(url.searchParams.get("limit") ?? "20", 10); const recent = notifyQueue?.getRecent(agentId, limit) ?? []; const unread = notifyQueue?.getUnreadCount(agentId) ?? 0; sendJson(res, { recent, unread }); return true; } // 状态栏快照 JSON(供 Control UI / 外部嵌入) if (pathname === "/plugins/enhance/api/statusline") { const db = sharedDb ?? getDb(); const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID; const sessionId = url.searchParams.get("session") || ""; const snap = notifyQueue ? buildSnapshot(db, agentId, sessionId, notifyQueue) : null; sendJson(res, snap ?? { error: "notifyQueue not available" }); return true; } // Todos 列表(最近一个 session) if (pathname === "/plugins/enhance/api/todos") { const db = sharedDb ?? getDb(); const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID; const todos = getLatestTodos(db, agentId); sendJson(res, { agentId, todos }); return true; } // Chapter marks if (pathname === "/plugins/enhance/api/chapters") { const db = sharedDb ?? getDb(); const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID; const sessionId = url.searchParams.get("session") || undefined; const chapters = listChapters(db, agentId, sessionId, 50); sendJson(res, { agentId, chapters }); return true; } // 定时工作流桥列表 if (pathname === "/plugins/enhance/api/loops") { const db = sharedDb ?? getDb(); const agentId = url.searchParams.get("agent") || undefined; const loops = listScheduledBindings(db, agentId); sendJson(res, { loops }); return true; } // 子任务孵化清单(从 memory 里过滤 tag=spawn-task) if (pathname === "/plugins/enhance/api/spawn-tasks") { const db = sharedDb ?? getDb(); const agentId = url.searchParams.get("agent") || DEFAULT_AGENT_ID; const entries = searchMemories(db, agentId, { keyword: "spawn-task", limit: 30 }); sendJson(res, { agentId, entries }); return true; } // 宠物独立页面 if (pathname === "/plugins/enhance/pet") { sendHtml(res, PET_PAGE_HTML); return true; } // 大文件上传页面 if (pathname === "/plugins/enhance/upload") { if (req.method === "POST") { return handleUpload(req, res); } sendHtml(res, UPLOAD_HTML); return true; } // 默认: 仪表盘 HTML sendHtml(res, DASHBOARD_HTML); return true; }, }); // v6.7.8: 删除 v6.7.4 的 /lanhuo/upload 别名 route 和 v6.7.7 的 /upload 短 URL route // 用户原话:『默认用 /plugins/enhance/upload,/lanhuo/upload 这个先删除了』 // // 现在 enhance 上传只暴露两条路径(都在 /plugins/enhance/ namespace 下,职责清晰): // - /plugins/enhance/upload — 通用上传页(无 token,共享) // - /plugins/enhance-upload/ — token 化(bot-upload-link 模块注册,AI 能追踪是谁传了什么) // // LLM 默认推 token 化 URL(调 enhance_upload_link 工具) — 这样 AI 通过 enhance_upload_check // 工具能查"这个 token 收到了什么文件"。 api.logger.info("[enhance] 仪表盘模块已加载(v6.7.8:删 /lanhuo/upload + /upload 别名,统一 /plugins/enhance/upload + bot-upload-link token 化)"); }