// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (c) 2026 开发者猫叔 (DeveloperCatUncle) · 商业使用需授权:COMMERCIAL-LICENSE.md "use strict"; /** * OpenWorkBuddy — 服务器主入口。 * 功能:Web 工作台(SSE 流式)、技能系统、MCP 连接器、专家团多智能体、IM 远程指挥(飞书/企业微信/通用 Webhook)。 */ const BOOT_T0 = Date.now(); // server.js 从加载到 listen 的耗时,启动慢时先看这行日志 // Node 太老 / 依赖缺文件:这句必须排在 express 这一批 require 前面。排在后面的话, // 缺依赖的人看到的是 `Cannot find module 'express'`——对不写 Node 的人等于没说。 // 有壳的时候不自己 exit:抛回去让壳把原因画在窗口上(exit 会让那个窗口根本没机会出现)。 require("./src/platform/boot-check").enforce({ rootDir: __dirname, packaged: require("./src/platform/paths").isPackaged(), throwInstead: !!global.__wbBootFail }); const express = require("express"); const fs = require("fs"); const path = require("path"); const { APP_DIR, DATA_DIR, dataPath, appPath, seedDataDir, resolvePort } = require("./src/platform/paths"); const migrate = require("./src/server/migrate"); // 数据目录跟代码目录不是同一个地方时(装机版、以及 Docker 里设了 OPENWORKBUDDY_HOME), // 得先把随包出厂的技能和专家铺过去,否则 skills.js 只认 dataPath("skills"), // 容器起来是能起来,但技能列表空空如也。开发态两个目录本来就是一个,这行是空操作。 // 放在这儿是因为下面 require 的 tools/skills 一加载就按数据目录算路径了。 seedDataDir(); const { mergeBuiltinExperts } = require("./src/agent/experts-lib"); const mcpCatalog = require("./src/core/ext/mcp-catalog"); const mcpPolicy = require("./src/core/ext/mcp-policy"); // 连接器工具一个个设的权限 const mcpImport = require("./src/core/ext/mcp-import"); // 从本机别的应用里导连接器 const { createOAuthManager } = require("./src/core/ext/mcp-oauth"); const { createLLM, createEmbedder, pingRequest, probeEmbedding, embedCandidates, embedChannels, embedHostable } = require("./src/core/model/llm"); const sessSearch = require("./src/core/memory/session-search"); const { outputFiles, noteUserInput, moveUserInput, isUserInput, filesScope, safePath, safePathIn, workspaceKeyOf, getWorkspaceDir, getDefaultWorkspaceDir, setWorkspaceDir, setLibraryDir, withLibraryBase, withLibraryDir, libBase, notesFileOf, withWorkspace, enterWorkspace, withPolicy, orgPolicy, canvasReadState, canvasWriteState, canvasNormalizeState, canvasList, canvasSafeName, SEARCH_PROVIDERS, searchProviderKey, searchProviderReady, shellPath } = require("./src/agent/tools"); const checkpointReview = require("./src/agent/checkpoint-review"); const checkpoints = require("./src/agent/checkpoints"); // 这条对话改过的文件:列出来、整步退回去 const worktree = require("./src/agent/worktree"); // 两条任务同时改一个仓库时,后来的那条进自己的 git worktree const canvasRoutes = require("./src/server/routes/canvas"); // 画布读写 + 短剧素材台账 + 制片进度 const dramaRoutes = require("./src/server/routes/drama"); // 短剧分镜表 + 一镜一镜的版本留底 const { createComposeRouter } = require("./src/server/routes/compose"); // 一键合成的两条接口 const { createCodeRouter } = require("./src/server/routes/code"); // 工程线的改动审阅 const connectorGuard = require("./src/core/safety/connector-guard"); // 连接器接入的内置检查 const memberAccess = require("./src/core/ext/member-access"); // 哪些连接器和技能不给成员 const storageUsage = require("./src/domains/account/storage-usage"); // 各账号占的磁盘和配额 const libraryRoutes = require("./src/server/routes/library"); // 资料库的封面、正文摘录、收藏 const { createPromptTplsRouter } = require("./src/server/routes/prompt-tpls"); // 参考模板库里「我的」「公司」两层的增删改 const { createSessionImportRouter } = require("./src/server/routes/session-import"); // 会话导入入口默认关闭 const { publicRun } = require("./src/core/automation/run-view"); const sessionImport = require("./src/domains/import/session-import"); // 导进来的会话接着聊:在原来的文件夹里、接原来那根线 const { createGeoRouter } = require("./src/server/routes/geo"); // 行程卡:查地点、两站间路线、底图瓦片和地点照片 const { createChromeRouter } = require("./src/server/routes/chrome"); // 设置 → 浏览器:配对码、连没连上、扩展文件夹 const { createShareStore } = require("./src/domains/account/session-shares"); const { createSessionSharesRouter } = require("./src/server/routes/session-shares"); const geoPlaces = require("./src/domains/geo/places"); // 行程卡查地点:设置页要它折算高德上限、存设置时解除当天停用 const { createComposeJobs } = require("./src/domains/media/compose-jobs"); // 一键合成的任务队列:把镜头真的拼成成片 const taskDirs = require("./src/util/task-dirs"); // 成果按对话分文件夹:哪些根下分、文件夹叫什么 const taskDelete = require("./src/util/task-delete"); const deletingTasks = new Set(); const taskFileWrites = new Map(); function taskFileWrite(req, res, next) { let id; try { id = String(req.body && req.body.session || (req.is("application/octet-stream") && uploadHeaders(req).session) || ""); } catch (e) { return res.status(400).json({ error: e.message }); } if (deletingTasks.has(id)) return res.status(409).json({ error: "正在删除此任务,请稍候" }); if (!id) return next(); taskFileWrites.set(id, (taskFileWrites.get(id) || 0) + 1); let released = false; const release = () => { if (released) return; released = true; const n = (taskFileWrites.get(id) || 1) - 1; if (n) taskFileWrites.set(id, n); else taskFileWrites.delete(id); }; // A disconnected upload can still be writing: the handler's promise, rather // than the socket closing, releases the lease (see taskFileHandler). res.locals.taskFileRelease = release; next(); } const taskFileHandler = fn => async (req, res) => { try { await fn(req, res); } finally { if (res.locals.taskFileRelease) res.locals.taskFileRelease(); } }; const prefs = require("./src/core/config/prefs"); // 按账号存的个人偏好:底层引擎 / 思考档 / 上次选的模型 / 宠物 / 快捷键 const { previewData } = require("./src/domains/library/preview"); const evolve = require("./src/agent/evolve"); const { McpManager, cfgFingerprint, scrubText: mcpScrub } = require("./src/agent/mcp"); const { createAgentRuntime, scanOutputs, sweepPlanOffThread } = require("./src/agent/agent"); const { createImRouter } = require("./src/im/im"); const imConfig = require("./src/im/im-config"); // IM 配置的合并 / 掩码:管理员那份和成员自己那份同一套规矩 const { createImAccounts } = require("./src/im/im-accounts"); // 每个成员自己绑的消息渠道 const { createScheduler, setActiveScheduler, SCHEDULE_LABEL } = require("./src/core/automation/scheduler"); const account = require("./src/domains/account/account"); const { createStaticCompress: staticCompress } = require("./src/server/static-compress"); const { createJsonCompress: jsonCompress } = require("./src/server/json-compress"); const { thumbFileAsync } = require("./src/platform/render/thumb"); const org = require("./src/domains/account/org"); // 组织(租户)层:席位、部门、邀请码、审计 const taskBilling = require("./src/core/billing/task-billing"); const usageStore = require("./src/core/billing/usage-store"); const budget = require("./src/core/billing/budget"); // 钱闸:中转站发出去的 Key 和公司内部自己用,花的是同一笔预算 const admin = require("./src/domains/account/admin"); // 企业管理后台的接口层 /api/admin/* const spaces = require("./src/domains/account/spaces"); // 不止一个账号时,各账号自己的工作目录 const engines = require("./src/engines"); // 底层引擎:内置循环 / 本机 Claude Code / 本机 Codex const engineGate = require("./src/engines/gate"); // 外部引擎开跑前那道闸(型号、附加参数、命令行开关) const lanes = require("./src/core/config/lanes"); // 两条工作线:办公(桌面办公 agent)/ 工程(本机 openworkbuddy 命令行) const cliLive = require("./src/core/obs/cli-live"); // 终端里起的任务挂在盘上的那个目录,网页/手机靠它看见并插话 const thinking = require("./src/core/model/thinking"); // 思考模式档位表(各家参数名都不一样,集中在那儿) const security = require("./src/core/safety/security"); const netGuard = require("./src/core/safety/net-guard"); // AI 联网工具的地址闸:绑上端口后把自己登记进去,AI 一律打不到 const netAddr = require("./src/util/net-addr"); const childEnv = require("./src/platform/child-env"); // AI 起的子进程只拿最小环境变量;属主清单和「几个账号」从这里注册进去 const toolward = require("./src/core/safety/toolward"); const sweep = require("./src/agent/sweep"); const fileTrash = require("./src/platform/file-trash"); const modes = require("./src/core/config/modes"); // 执行模式的唯一真源(craft/goal/plan/ask) const cfgMerge = require("./src/core/config/config-merge"); // 存配置时把外面手改的那些合进来,不整份覆盖 const cfgLint = require("./src/core/config/config-lint"); // 手改配置写错了当场说,别让人以为「改了没反应」 const projectMemo = require("./src/core/memory/project-memo"); // 工作目录往上到 git 根的 AGENTS.md / CLAUDE.md,命令行也用这一份 const mediaModels = require("./src/core/model/media-models"); const mediaHealth = require("./src/core/model/media-health"); // 连不通的媒体渠道熔断表:设置页要显示,保存时要清空 // 图/视频/语音/视觉:渠道表 + 每路多模型 const chatModels = require("./src/core/model/chat-models"); const modelDiscovery = require("./src/core/model/model-discovery"); const retiredModels = require("./src/core/model/retired-models"); // 服务商宣布下线的型号:设置页提醒、测试连接说清楚 const memberModels = require("./src/core/model/member-models"); // 成员能用哪些对话模型:管理员共享的 + 自己填 Key 的 // 对话模型:渠道共用一把 Key(跟上面共用 config.providers) const systemOne = require("./src/core/judge/systemone"); // 判断模型(Jev)的纯逻辑:请求怎么拼、回答怎么读 const taskVerdict = require("./src/core/automation/task-verdict"); // 定时任务裁定层的纯函数(这一轮办完没有 / 怎么问 / 怎么读) const pushGate = require("./src/core/automation/push-gate"); // 推之前那一问:跟上一次真推出去的那条比,有没有新东西 const jev = require("./src/core/judge/jev"); // 判断模型的调用路:挑渠道、取 Key、发请求 const quota = require("./src/core/billing/quota"); // 按次计费的外部 API:调之前问一句额度,调完记一笔 const pricing = require("./src/core/billing/pricing"); // 按量价目:批量生成前的预估跟记账查的是同一张表 const tracing = require("./src/core/obs/trace"); // 执行追踪(Langfuse),默认关;跟 agent.js 共用同一个追踪器 const genCache = require("./src/domains/media/gen-cache"); // 生成结果缓存:同一格重跑别再烧第二次钱 const toolJobs = require("./src/domains/media/tool-jobs"); // 直调生成的收单台账:同一个 clientJobId 只真跑一次 const uploadName = require("./src/util/upload-name"); // 上传同名不覆盖:挑 名字_2、名字_3 const memory = require("./src/core/memory/memory"); const notify = require("./src/core/obs/notify"); const log = require("./src/platform/log"); const metrics = require("./src/core/obs/metrics"); const callout = require("./src/util/callout"); // 正文提示条:机器人推送里换成文字标签 const store = require("./src/platform/store"); const petSprites = require("./src/desktop/pet-sprites"); // 桌面宠物的精灵图(吃 Codex / Petdex 的格式) const pet = require("./src/desktop/pet"); // 只为拿默认值(没有 electron 时它自己降级成空壳,纯 node 也 require 得动) const { createImSessionStore } = require("./src/im/im-store"); // config.json 不入 git(可能含 API Key);首次运行自动从模板复制 const CONFIG_PATH = dataPath("config.json"); if (!fs.existsSync(CONFIG_PATH)) { fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }); fs.copyFileSync(appPath("config.example.json"), CONFIG_PATH); console.log("已从 config.example.json 生成 config.json,请填入你的模型 API Key"); } // 这是全机器上唯一一份明文装着所有 API Key 的文件,权限必须是「只有文件主人读得到」。 // 以前没人管这一位,落地就是 umask 给的 0644——同一台 VPS / 同一台办公电脑上的任何一个 // 别的账号,一句 cat 就把九把 Key 全拿走了。开机先收一次,把装了半年的老机器一并修好; // 之后每次存盘再收一次(见 store.writeJsonAtomic 的 mode)。.bak 跟正本一字不差,一起收。 store.tighten(CONFIG_PATH, store.SECRET_MODE); store.tighten(CONFIG_PATH + ".bak", store.SECRET_MODE); // 同一道理的还有另外两本,一起在开机时收一次(新写入的由各自的 writeJsonAtomic 保着): // users.json —— 所有活着的会话令牌(拿到就是别人的登录态,不要密码、也绕过二次验证)、 // 密码哈希和盐、TOTP 密钥、一次性找回码; // orgs.json —— 还没用完的邀请码(拿到就能自己开号进来,角色是发码的人预置好的)、整本审计流水。 // 这两本原来都是 umask 给的 0644。列在这里而不是各自模块里:收权限是一次性的开机动作, // 散进每次读盘就是每读一条记录多一次 chmod 系统调用。 // OPENWORKBUDDY_DATA_DIR 照认:account.js / org.js 都按它算目录,这里算得不一样就等于没收 const ACCOUNT_DIR = process.env.OPENWORKBUDDY_DATA_DIR || dataPath("data"); for (const f of ["users.json", "orgs.json"]) { const p0 = path.join(ACCOUNT_DIR, f); store.tighten(p0, store.SECRET_MODE); store.tighten(p0 + ".bak", store.SECRET_MODE); } // 再把整个 data/ 目录收成 0700,当作上面那几条的兜底。 // 逐个文件收权限有个绕不过去的毛病:**这个目录还在长**。今天列全了,下一个功能加一本新账 // (会话正文、记忆库、IM 消息、审计流水都在里面),忘了带 mode 就又是一个 0644。 // 目录没有执行位,别的本地账号连 `ls` 都进不来,里头单个文件是什么位都不要紧了。 // 只收 data/ 不收上一层:开发态下 DATA_DIR 就是仓库根目录(见 paths.js), // 把源码目录改成 0700 是件没人预期的事,而真正要护的东西一件都不在那儿。 // 失败照例吞掉——Windows 上 chmod 基本是空操作,容器挂载卷也可能不让改(见 store.tighten)。 store.tighten(ACCOUNT_DIR, 0o700); // 配置读坏了不能就这么空着起来:那样界面上所有 Key 都变成空的,用户随手一保存就把 // 真 Key 覆盖没了。store 会先拿 .bak 顶(Key 原样还在),实在顶不住才把坏文件改名隔离、 // 退回模板——原文还在 .corrupt-时间戳 里,Key 捞得回来。 const CONFIG_DEFAULTS = JSON.parse(fs.readFileSync(appPath("config.example.json"), "utf8")); // 模板另拷一份当「文件不存在」的底:readJson 找不到文件时原样把 empty 交回来,不拷的话 // 下面所有对 config 的改动都会写进 CONFIG_DEFAULTS,后面 mergeDiskEdits 拿模板兜底时兜到的就是活配置 const rawConfig = store.readJson(CONFIG_PATH, JSON.parse(JSON.stringify(CONFIG_DEFAULTS))); // 0.2 之前的老配置压根没有 models 表(只有 provider / openai / anthropic 三块)——要在模板补缺之前认出来 const legacyNoModels = !Array.isArray(rawConfig.models); const config = fillDefaults(rawConfig, CONFIG_DEFAULTS); // 子进程环境的策略:每次起子进程现问,设置页改了清单不用重启。 // 像 Key 的名字只在一个账号时给——账号多了,成员的任务也跑在这台机器上(见 platform/child-env.js 顶上) childEnv.setPolicy(() => ({ allow: security.getSecurity(config).env_passthrough, keys: !admin.multiUser() })); // 多人共用时命令、代码碰了文件黑名单直接拦、不出审批卡:卡是发起任务的人自己批的 security.setMultiUser(() => admin.multiUser()); // 本机引擎那道「多人共用时要管理员打开」的闸只拦别人,管理员自己用不用先打开(engines/gate.js) security.setPlatformAdmin((name) => { const u = account._internals.loadUsers().users.find((x) => x.username === name); return !!u && admin.ownsGlobalWorkspace(u); }); // 系统沙箱:成员的任务要把属主的项目目录藏起来;启动时先预检一遍,设置页一打开就有现状 require("./src/agent/tools").setOwnerDirs(() => (config.projects || []).map((p) => p && p.dir).filter(Boolean)); require("./src/agent/tools").warmSandbox(security.getSecurity(config)); // 助理的名字和头像:想叫它「小秘」就叫「小秘」。界面(气泡头像/侧栏/品牌位)和系统提示词都跟着这里走 // "@cat" 是内置猫标的哨兵值,跟应用图标同一只猫;前端 avatarBits 认它,account.normalizeAvatar 放行 const ASSISTANT_DEFAULT = { name: "OpenWorkBuddy", avatar: "@cat" }; // 盯住这个文件的改动时间,还有它上一次跟磁盘对齐时的样子。 // config.json 是明确让人手改的文件——README 里就写着「把 API Key 填进来」。可 saveConfig() 存的是 // 内存里那整份 config:手改完之后在界面上随便点一下保存,刚粘进去的 Key 就被整份盖掉了,一句提示也没有。 // 有了这两样,存盘前才问得出那句「这文件在我背后动过没有」(见 saveConfig)。 let CONFIG_MTIME = cfgMerge.mtimeOf(CONFIG_PATH); let CONFIG_BASE = cfgMerge.snapshot(config); // 连接器那两个键(mcp_servers / mcp_disabled)不等下次存盘:运行中盯着改动时间,变了就只把这两个键认进来 // 并重连变了的那几台(见 pollConfig)。CONFIG_SEEN = 磁盘上那份我们最后看过(读过或写过)的改动时间; // 跟 CONFIG_MTIME 分开记,因为只认两个键不算「整份对齐过」,下次存盘照样要走合并。 let CONFIG_SEEN = CONFIG_MTIME; // 连接器管理器手上正跑着的是哪一版配置:{ servers: 名字→指纹, disabled: 名字集合 }。开机起完之前是 null let MCP_APPLIED = null; // 手改写错了当场说出来。不说的话界面上一点反应也没有, // 然后去怀疑是不是没保存、要不要重启、这功能是不是坏了——查半天发现是键名少了个字母。 for (const line of cfgLint.lines(cfgLint.lint(config, CONFIG_DEFAULTS))) console.warn(`[配置] ${line}`); /** * 只补缺的,不改已有的(顶层键 + 顶层对象里的子键,两层就够)。 * * config.json 是明确让用户手改的文件,删掉整个 server / agent 块很常见。以前那种情况下 * 启动会抛 “Cannot read properties of undefined (reading 'host')” 或 “...(reading 'max_steps')”—— * 一条完全看不出跟配置有关的报错,用户只会以为程序坏了。缺的用模板里的默认值填上就行。 * 数组不合并:mcp_servers、models 这些用户清空是有意为之,不能又给他塞回来。 */ function fillDefaults(cur, def) { for (const [k, v] of Object.entries(def)) { if (cur[k] === undefined) { cur[k] = v; continue; } if (v && typeof v === "object" && !Array.isArray(v) && cur[k] && typeof cur[k] === "object" && !Array.isArray(cur[k])) { for (const [k2, v2] of Object.entries(v)) if (cur[k][k2] === undefined) cur[k][k2] = v2; } } return cur; } // 老配置迁移:还没有 models 表的,只把填了 Key 的那家搬成一条模型行。 // 以前这里会塞五家厂商的模板行——用户看到的就是一排没 Key 的占位渠道,删了下次启动又长回来。 // models 是空数组的不碰:那是用户自己清空的(或者就是没配),空着才是对的 if (legacyNoModels) { config.models = chatModels.legacyRows(config); config.active_model = config.models.length ? config.models[0].name : ""; saveConfig(); } // 四路媒体模型迁移:老的「一路一个模型、Key 抄四份」→ 渠道表 + 模型表。幂等,老用户什么都不用做 // 对话模型跟着做同一件事:每条自己抄一份地址和 Key → 抽到渠道那一层,一把 Key 底下挂一排模型。 // 两者共用同一张 config.providers 表,所以媒体那边先跑(它的渠道认领只看地址+Key,不看协议) // 启动这一趟替用户改挂了哪几条媒体模型:只记在内存里,设置页头一回来取时说一次(GET /api/settings 的 moved_on_boot) const bootNorm = {}; let migrated = mediaModels.normalize(config, bootNorm); let movedOnBoot = bootNorm.moved || []; if (chatModels.normalize(config)) migrated = true; // 老版本出厂 config 里那九行没 Key 的厂商模板、以及它们留下的空壳渠道,这一趟收掉。只认名字+地址 // 跟出厂一模一样、Key 空着、又不是正在用的那条;用户自己起名建的行一条不碰。跑过一遍就没得收了 // 只跑一次(presets_pruned 章):之后用户自己清空某条渠道的 Key、或者起了个跟老模板同名的行,都不许再被当成占位收走 // 判断模型(Jev)混进了对话模型列表(早先的版本、或拉清单时点中了它):它没有 /chat/completions, // 挂着就是每一趟 400,定时任务一条条挂。收掉;正在用的是它,就换回第一条——运行时本来也是这么兜的 { const jevRows = (config.models || []).filter((m) => m && systemOne.isDecisionModel(m.model)); if (jevRows.length) { config.models = config.models.filter((m) => !jevRows.includes(m)); if (jevRows.some((m) => m.name === config.active_model)) config.active_model = (config.models[0] || {}).name || ""; console.log(`[渠道整理] 判断模型不能当对话模型用,已从模型列表移除:${jevRows.map((m) => m.name).join("、")}`); migrated = true; } } if (!config.presets_pruned) { const pruned = chatModels.pruneSeededPresets(config); if (pruned.models.length || pruned.channels.length) console.log(`[渠道整理] 收回出厂占位 → 模型行:${pruned.models.join("、") || "无"};空壳渠道:${pruned.channels.join("、") || "无"}`); config.presets_pruned = true; migrated = true; } if (migrated) saveConfig(); security.getSecurity(config); // 补齐安全中心默认策略 config.shortcuts = config.shortcuts || {}; // 快捷键自定义绑定(只存改过的项,默认值在前端定义) config.assistant = { ...ASSISTANT_DEFAULT, ...(config.assistant || {}) }; if (config.workspace_dir) { try { setWorkspaceDir(config.workspace_dir); } catch (e) { console.warn("workspace_dir 无效,使用默认工作空间:", e.message); } } // 开机把当前项目挂载的那块资料库也接回去。少了这一句,重启之后工作目录是对的、 // 资料库却退回了整个库,agent 在「客户 A」的项目里照样翻得到别家的合同—— // 而这种错不会报错,只会安静地引用错资料。 try { const boot = (config.projects || []).find((p) => p && p.name === config.active_project); if (boot && boot.library_dir) setLibraryDir(boot.library_dir); } catch {} // 从老版本升上来的那一下:之前的文件得替人收进新文件夹里整理好。 // 放在这儿是因为工作目录到这一行才算最终确定(上面刚认完 workspace_dir 和资料库)。 // 每条迁移只跑一次,记在 data/migrations.json;跑不动就只是打一行日志—— // 为了整理文件而开不了应用,那是本末倒置 try { // 「这台机器以前用过没有」——判升级还是全新装全靠它。看的是有没有会话/账号留下来, // 不看 config.json:那玩意儿第一次启动就会从 example 生成一份,永远为真,什么也证明不了 const priorUse = (() => { try { if (fs.existsSync(dataPath("data", "users.json"))) return true; } catch {} try { return fs.readdirSync(dataPath("data", "sessions")).some((f) => f.endsWith(".json")); } catch {} return false; })(); // 记忆向量以前没选嵌入模型时会借聊天 / 媒体渠道的 Key 去算,这一版起只认设置里选定的那个。 // 盘上有算好的向量、设置里却没选 = 以前靠的就是借来的那条,升上来后改按关键词召回,得说一声 const embedOff = (() => { if (config.embedding && String(config.embedding.model || "").trim()) return false; try { return memory.vectorStatus().have > 0; } catch { return false; } })(); // 中转站以前把没登记型号的渠道当通用网关,这一版起只转登记过的。发过 Key、又有渠道停转或 Key 受影响才提示 const relayHit = (() => { try { const vk = require("./src/domains/account/vkeys"); const keys = vk.list(); if (!keys.length) return false; const g = vk.relayAudit(config, keys); return g.stopped.length > 0 || g.keys.length > 0; } catch { return false; } })(); // 外部引擎:以前型号能留空、Codex 里的命令默认联网,这一版两样都变了。看属主和各账号选着哪个引擎 const enginesInUse = (() => { const ids = new Set([String((config.agent && config.agent.engine) || "")]); try { for (const f of fs.readdirSync(dataPath("prefs"))) { if (!f.endsWith(".json")) continue; try { ids.add(String((JSON.parse(fs.readFileSync(dataPath("prefs", f), "utf8")).agent || {}).engine || "")); } catch {} } } catch {} return [...ids].filter((id) => id && id !== "builtin" && engines.get(id)); })(); const engineNoModel = enginesInUse.filter((id) => !engines.gateView(id, config).allowed.length).map((id) => engines.get(id).label || id).join("、"); const codexNet = enginesInUse.includes("codex") && ((((config.agent || {}).engine_options || {}).codex || {}).network === undefined); const notes = migrate.runMigrations(getWorkspaceDir(), dataPath("data", "migrations.json"), { version: String(require("./package.json").version || ""), priorUse, embedOff, relayHit, engineNoModel, codexNet, }); for (const n of notes) console.log(`[升级整理] ${n.note}`); global.__wbMigrationNotes = notes; // 界面上给用户看一眼:动过他的文件,得说 } catch (e) { console.warn("[升级整理] 这次没做成,不影响使用:" + e.message); } let llmInner = createLLM(config); // 记忆向量召回:只用设置里选定的嵌入模型,没选就是 null,记忆按关键词匹配(memory 自己兜底) memory.setEmbedder(createEmbedder(config)); // 任务历史检索的向量渠道。这两个变量本该跟下面那一块检索代码放在一起,但接线在这儿就发生了—— // let 声明在后面的话是暂时性死区,进程会直接起不来(不是搜索不好使,是整个服务起不来) let sessEmbedder = null; let sessVecRunning = false; setSessEmbedder(createEmbedder(config)); memory.ensureVectors().then((r) => { if (r.computed) console.log(`[记忆向量] 启动补算了 ${r.computed} 条`); }).catch((e) => console.warn("[记忆向量] 启动补算失败:", e.message)); // 可热替换的 LLM 包装:设置修改后 runtime 无需重建 const llm = { get provider() { return llmInner.provider; }, get model() { return llmInner.model; }, chat: (args) => llmInner.chat(args), }; /** 按对话覆盖模型:会话里选过就用会话的,没选跟全局默认。 * 选过的模型已被从列表删掉 → 明确报错,绝不悄悄换成别的模型跑 */ function llmForSession(sess, fallbackUser) { const name = sess && sess.model; if (sess?.billing_scope === "team") { const row = taskBilling.sharedModel(config, name); return createLLM({ ...config, models: [row], active_model: row.name }); } // 多人服务器上的成员:只认「管理员共享给他的 + 他自己填 Key 的」那几条(member-models.js)。 // 管理员和个人桌面版不走这段,跟以前一个字节不差 const who = modelWho((sess && sess.user) || fallbackUser); if (!who.owner) { const row = name ? memberModels.find(config, who, name) : memberModels.defaultFor(config, who); if (row) return !row.own && row.name === config.active_model ? llm : createLLM({ ...config, models: [row], active_model: row.name }); const why = name ? `「${String(name).replace(/^my:/, "")}」不在你能用的模型里了。点输入框右下角的模型按钮重新选一个。` : "你还没有能用的对话模型:平台管理员没有共享给你,可以在「设置 → 模型」里加一把自己的 Key。"; return { provider: name || "", model: name || "", chat: () => Promise.reject(new Error(why)) }; } if (!name) return llm; if (Array.isArray(config.models) && config.models.some((m) => m.name === name)) { return createLLM({ ...config, active_model: name }); } return { provider: name, model: name, chat: () => Promise.reject(new Error(`该对话指定的模型「${name}」已不在模型列表里。点输入框右下角的模型按钮重新选一个,或选「跟随全局默认」。`)), }; } /** * 某个人在「用哪些模型」这件事上的身份:平台管理员 / 个人桌面版 / 认不出是谁(命令行、IM、定时任务) * 都算 owner,看全表;其余的是成员,带上他的偏好(里面有他自己填的模型)。 * @param {any} user 登录名或账号对象 */ function modelWho(user) { const name = typeof user === "string" ? user : (user && user.username) || ""; if (!name || admin.isSoloDesktop()) return { owner: true }; let u = null; try { u = account._internals.loadUsers().users.find((x) => x.username === name) || null; } catch {} if (!u && user && typeof user === "object") u = user; if (u && admin.ownsGlobalWorkspace(u)) return { owner: true }; let p = {}; try { p = prefs.read(name); } catch {} return { owner: false, prefs: p }; } /** * 价目闸:头上设了预算的人,这一趟的对话模型查不到价就不开跑(见 budget.unpriced)。 * 拦下返回那句话,放行返回 ""。护的是预算本身:查不到价的那一趟记账是 0, * 设了上限的人点名一个没登记价目的型号,花多少都扣不到预算上。 * 只管内置引擎——外部 CLI 引擎走它自己的登录和订阅,不经这本价目。 * 会话点名的模型已经不在列表里的(llmForSession 给的是报错桩),开跑时自有那句「已不在模型列表里」,这儿不抢着报。 * @param {any} user * @param {{ provider?: string, model?: string }} runLLM */ function unpricedChat(user, runLLM) { if (!user || !runLLM || !runLLM.model) return ""; if (engines.currentId(config, { user: user.username }) !== "builtin") return ""; if (!(Array.isArray(config.models) && config.models.some((m) => m && m.name === runLLM.provider))) return ""; try { const o = org.getOrg(org.orgIdOf(user)); const s = org.settingsOf(o); const hit = budget.unpriced({ org: s, orgId: o.id, user, usage: { model: runLLM.model }, price: { config, discount: s.price_discount, name: runLLM.provider }, }); return hit ? hit.message : ""; } catch (e) { // 预算模块自己坏了不该拦住正事,跟钱闸同一个选择:放行,但喊一声 console.warn("[预算] 价目闸没能判(本次放行):" + (e && e.message)); return ""; } } // 同一模型连续「整跑失败」计数(成功一次即清零):连挂说明是模型/渠道本身的问题,光报错用户不知道该干嘛 const modelFailStreak = new Map(); // 模型健康账本:每次整跑记一笔成败(按模型条目名,滚动只留最近 20 次),选模型时能看到 // 「这个渠道最近靠不靠谱」,不用踩了才知道。落盘 data/model_health.json,重启不清零 const HEALTH_FILE = dataPath("data", "model_health.json"); const modelHealth = store.readJson(HEALTH_FILE, {}) || {}; function recordModelHealth(name, ok, failMsg) { if (!name) return; const h = (modelHealth[name] = modelHealth[name] || { recent: [] }); h.recent.push(ok ? 1 : 0); if (h.recent.length > 20) h.recent = h.recent.slice(-20); if (ok) h.last_ok_t = Date.now(); else { h.last_fail_t = Date.now(); h.last_fail = String(failMsg || "").slice(0, 200); } healthWriter.request(); } // 每跑完一轮记一笔,以前每笔都在主线程上 fsync(实测单次约 4.5ms,5 条对话并发时跟会话存盘挤在一起)。 // 账本本身在内存里,盘上那份只是重启用的:后台写、一秒最多一次,进程退出时同步补一次(见 flushPendingWritesSync) const healthWriter = store.coalesce(() => store.writeJsonAtomicAsync(HEALTH_FILE, modelHealth), { minGapMs: 1000, onError: (e) => console.warn(`[模型健康] 账本存盘失败:${e.message}`), }); function healthSummary() { const out = {}; for (const [name, h] of Object.entries(modelHealth)) { const recent = Array.isArray(h.recent) ? h.recent : []; let streak = 0; for (let i = recent.length - 1; i >= 0 && !recent[i]; i--) streak++; out[name] = { n: recent.length, ok: recent.filter(Boolean).length, fail_streak: streak, last_fail: h.last_fail || "", last_fail_t: h.last_fail_t || 0 }; } return out; } // 专家团:数组引用被 runtime 闭包持有,增删改都就地改这个数组(热生效,无需重启) const EXPERTS_FILE = dataPath("experts.json"); const experts = []; const expertTeams = []; // 专家团 = 智能体团队,同样是被 runtime 闭包持有的活引用 let expertsMeta = store.readJson(EXPERTS_FILE, {}) || {}; // 打包版升级后,包里新出的内置专家/专家团要补进用户那份(用户改过、删过的不动); // 只靠首次安装时的 copyIfMissing,老用户永远见不到新专家。补了几个写日志留痕。 if (appPath("experts.json") !== EXPERTS_FILE) { try { const bundled = store.readJson(appPath("experts.json"), null); const hadSeen = Array.isArray(expertsMeta.seen_builtins); // 技能那本「见过」账第一次记也要落盘:不记下来,下次启动又当第一次跑,用户删掉的技能会被补回去 const hadSeenSkills = !!(expertsMeta.seen_builtin_skills && typeof expertsMeta.seen_builtin_skills === "object"); const r = bundled ? mergeBuiltinExperts(expertsMeta, bundled) : { added: [], addedTeams: [], bound: [] }; if (r.added.length || r.addedTeams.length || r.bound.length || (bundled && (!hadSeen || !hadSeenSkills))) { store.writeJsonAtomic(EXPERTS_FILE, expertsMeta, { pretty: true }); if (r.added.length || r.addedTeams.length) console.log(`[专家] 升级补入内置专家 ${r.added.length} 位、专家团 ${r.addedTeams.length} 个:${[...r.added, ...r.addedTeams].join("、")}`); if (r.bound.length) console.log(`[专家] 升级给内置专家补绑技能 ${r.bound.length} 个:${r.bound.join("、")}`); } } catch (e) { console.warn("[专家] 合并内置专家失败(不影响启动):", e.message); } } experts.push(...(expertsMeta.experts || [])); expertTeams.push(...(expertsMeta.teams || [])); function saveExperts() { store.writeJsonAtomic(EXPERTS_FILE, { ...expertsMeta, experts, teams: expertTeams }, { pretty: true }); } const mcpOAuth = createOAuthManager({ file: dataPath("data", "mcp-oauth.json"), security: () => security.getSecurity(config) }); const mcpManager = new McpManager({ oauthToken: (cfg, opts) => mcpOAuth.status(cfg).authorized ? mcpOAuth.token(cfg, opts) : null, oauthFetch: mcpOAuth.fetch }); let imBridge = null; // IM 桥(含飞书长连接控制),init() 里创建 let imAccounts = null; // 成员自己绑的通道,跟 imBridge 一起建 // ---------- 会话持久化:内存 + 磁盘(data/sessions/.json) ---------- // 结构:{ history: 统一格式历史(供LLM), transcript: 界面回放记录, title, updated_at } const SESS_DIR = dataPath("data", "sessions"); const sessions = new Map(); const activeRuns = new Map(); // sessionId -> { ctrl: AbortController, interject: [] }(「停止」与「插队」用) // 浏览器标签页的闲置关页、到顶腾位,不许收还在跑的任务的页(等用户回话超过 10 分钟的,回来表单还得在) try { require("./src/platform/render/cdp").setActivePredicate((sid) => activeRuns.has(sid) || require("./src/agent/tools").runHeld(sid)); } catch {} // 正在跑的任务落一份名单到磁盘:应用中途被关/被重启时,内存里的 activeRuns 直接蒸发, // 下次启动就靠这份名单知道哪些会话是被打断的,在回放里明说,而不是让那一轮无声地断在半空 const RUNNING_FILE = dataPath("data", "running.json"); // git worktree 分身放这儿。放在用户仓库外面:放里面等于让 git 观察自己,清理时手一滑就删到人家代码上。 // 开发态数据根就是应用仓库,dataPath 那个默认值落在仓库里,由 worktree.defaultStore 挪出去(2026-09-29,见那里)。 // 用到才算:要问一趟 git,别压在 require 这一步上 const WORKTREE_LEGACY = dataPath("data", "worktrees"); let worktreeDir = ""; const worktreeStore = () => worktreeDir || (worktreeDir = worktree.defaultStore(WORKTREE_LEGACY)); // 每轮开跑、收尾各写一次。后台写,一次只在路上一份:名单在开写那一刻才取,所以落地的永远是最新的那份 const runningWriter = store.coalesce(() => store.writeJsonAtomicAsync(RUNNING_FILE, [...activeRuns.keys()]), { onError: (e) => console.warn(`[恢复] 正在跑的名单存盘失败:${e.message}`), }); function persistRunning() { runningWriter.request(); } function sweepInterruptedRuns() { const ids = store.readJson(RUNNING_FILE, []); if (!Array.isArray(ids) || !ids.length) return; let marked = 0; for (const id of ids) { try { const sess = getSession(id); const last = sess.transcript[sess.transcript.length - 1]; if (!last || last.type !== "assistant") continue; const evs = last.events || (last.events = []); const tail = evs[evs.length - 1]; if (tail && tail.type === "error") continue; // 已有明确收场就别重复盖章 evs.push({ type: "error", message: "应用在任务执行中被关闭或重启,这一轮已中断(已完成的进度和文件都还在)。可以对我说「接着上次进度继续」。" }); saveSession(id); marked++; } catch {} } try { store.writeJsonAtomic(RUNNING_FILE, []); } catch {} if (marked) console.log(`[恢复] 检测到 ${marked} 个被重启打断的任务,已在会话回放里标注中断`); } const assignedDirs = new Set(); // 刚分配、还没写出文件的对话文件夹名:两个新对话同时起步不许撞同名 /** 判「这个根按不按对话分文件夹」要的几个坐标(口径见 src/util/task-dirs.js 开头;命令行 chatDirHere 用同一套) */ function layoutAnchors() { return { workspace: dataPath("workspace"), projects: dataPath("projects"), tenants: org.tenantsDir(), homes: spaces.homes(), layouts: config.folder_layouts || {} }; } /** 当前工作目录下要不要按对话分成果文件夹(口径见 src/util/task-dirs.js 开头) */ function perChatHere() { return taskDirs.perChatRoot(getWorkspaceDir(), layoutAnchors()); } /** * 用户选定一个文件夹的那一刻,把「每个对话一个文件夹 / 直接放进去」记进 config.folder_layouts。 * 没明说就把此刻判出来的那种记下:不然今天选进来是普通文件夹(按对话分),哪天里面 git init 了, * 后面的对话就悄悄改成摊在根上,前后两半成果一半在 任务_ 里一半在外面。 * 应用自己的目录(默认工作空间、projects/ 下各项目)永远按对话分,不记。只改内存,落盘跟着调用方的 saveConfig。 */ function noteLayout(dir, layout) { if (!dir) return; const now = taskDirs.folderLayout(dir, layoutAnchors()); if (now.locked) return; if (layout === "per_chat" || layout === "flat") config.folder_layouts = taskDirs.setLayout(config.folder_layouts, dir, layout); else if (!now.chosen) config.folder_layouts = taskDirs.setLayout(config.folder_layouts, dir, now.layout); } /** 会话记的成果文件夹还在不在「此刻这个根」下(口径在 src/util/task-dirs.js,命令行那头用同一套) */ function sessDirHere(sess) { return taskDirs.sessDirIn(sess, getWorkspaceDir(), dataPath("workspace")); } /** * 会话在「此刻这个根」下的成果文件夹:正用着的那格,或者以前在这个根下用过、盘上还在的那格;都没有给 null。 * 会话只有一个 dir 槽的时候,聊到一半去别的项目转一圈再回来,原来那格就被新根的名字盖掉了, * 回来只能另起一个 任务_…_2,前后两半成果拆在两个文件夹里,「本对话」也只认后一半 */ function sessDirOf(sess) { return taskDirs.sessDirAt(sess, getWorkspaceDir(), dataPath("workspace")); } /** 当前根下这个会话已经有自己的那格就接着用它;返回用上没有 */ function useSessionDirHere(sess) { return taskDirs.useSessDirAt(sess, getWorkspaceDir(), dataPath("workspace")); } /** 这个会话以前摊在「此刻这个根」上的产出(相对名),盘上还在的 */ function rootFilesOf(sess) { const root = getWorkspaceDir(); return taskDirs.flatOutputs(sess, root, dataPath("workspace"), workspaceKeyOf(root)) .filter((n) => { try { return fs.statSync(path.join(root, n)).isFile(); } catch { return false; } }); } /** * 换过根的对话:历史里的附件留在别的根下那格里,那边的格名跟这边的还不一样(任务_1001_周报 / 任务_1001_周报_2), * 前端按「当前那格/名字」去取就是一排灰方块。这里报「当前格里没有、别的根那格里有」的那些:名字 → {root 指纹, dir}。 * 只看每格第一层(附件就落在第一层),最多 300 个;指纹按 allRoots 里那份原样算,rootFromKey 才认得回来。 */ function attachSpotsOf(sess) { const here = getWorkspaceDir(), def = dataPath("workspace"); const curDir = taskDirs.sessDirAt(sess, here, def); const pairs = []; if (sess.dir && !taskDirs.samePlace(sess.root || def, here)) pairs.push([sess.root || def, sess.dir]); for (const [r, d] of Object.entries(sess.dirs || {})) if (d && !taskDirs.samePlace(r, here)) pairs.push([r, d]); const out = {}; let n = 0; if (!pairs.length) return out; const known = allRoots(); // 这些格子都是这条会话自己的,不用再按人收窄 for (const [r, d] of pairs) { const root = known.find((k) => taskDirs.samePlace(k, r)); if (!root) continue; let names = []; try { names = fs.readdirSync(path.join(root, d), { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name); } catch { continue; } for (const name of names) { if (n >= 300) return out; if (Object.prototype.hasOwnProperty.call(out, name)) continue; if (curDir && fs.existsSync(path.join(here, curDir, name))) continue; out[name] = { root: workspaceKeyOf(root), dir: d }; n++; } } return out; } /** 给对话分配成果文件夹(任务_月日_标题)。「消息发出前就传上来的」附件由 settlePendingUploads 搬进去 */ function assignSessionDir(sess, message) { // 存进会话,后续轮次/重启都落同一个文件夹。连**哪个根**下的这个文件夹也一起记住: // 只记相对名的后果就是用户换一次工作目录,这条对话的成果全部变成「文件不存在」—— // 文件没丢,是坐标系换了而没人记得旧的那套。 const dir = taskDirs.newSessDir(sess, getWorkspaceDir(), dataPath("workspace"), message, assignedDirs); rememberRoot(sess.root); return dir; } /** 改名搬文件;工作目录在外置盘、跨盘 rename 会 EXDEV,就拷过去再删原件(拷不成原件不动) */ function moveFileAcross(from, to) { try { fs.renameSync(from, to); return; } catch (e) { if (e.code !== "EXDEV") throw e; } fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL); fs.unlinkSync(from); } /** * 「先传后发」的附件搬到这条对话此刻干活的地方:按对话分文件夹的根就是它那格(baseDir),摊在根上的就是根。 * 每笔都记着是在哪个根下传的({name, root, rel};老的只有名字,就是当前这个根)。只记名字的时候, * 传完换了文件夹再发,这里拿名字去新根里找——找不到还好,碰上新根里正好有个同名的,搬进来的就是别人的文件。 * 搬运失败一概不抛:一个附件搬不动就让整条对话起不来,代价太大。 * 返回真搬了的那几个名字:页面上那条消息的气泡是按上传那一刻的落点画的, * 不告诉它搬了,一点预览就是「文件不存在」——文件好好的,只是换了地方没人说。 */ function settlePendingUploads(sess, baseDir) { const here = getWorkspaceDir(); const moved = []; for (const p of sess.pending_uploads || []) { try { const n = typeof p === "string" ? p : p && p.name; if (!n) continue; const fromRoot = (p && p.root) || here, same = taskDirs.samePlace(fromRoot, here); const fromRel = (p && p.rel) || n, destRel = baseDir ? path.join(baseDir, n) : n; if (same && fromRel === destRel) continue; // 摊在根上的目录里传、在这儿发:本来就在该在的地方 const from = path.join(fromRoot, fromRel), to = path.join(here, destRel); // 目标位置还空着才搬:同名的已经在了,宁可不动也不盖 if (!fs.existsSync(from) || !fs.statSync(from).isFile() || fs.existsSync(to)) continue; // 不在本根根上的那份(别的根、别的对话那格)得核实还是用户传上来的那一份:mtime 对不上就是改写过、换过了,不动 if (!(same && fromRel === n) && !isUserInput({ name: fromRel, mtime: fs.statSync(from).mtime.toISOString() }, fromRoot)) continue; // 那格可能空了十分钟被收掉了(listEmptyTaskDirs),会话还记着名字:先建回来,不然搬不进去、附件留在根上 fs.mkdirSync(path.dirname(to), { recursive: true }); moveFileAcross(from, to); // 「这份是用户传的」那笔账记的是路径,搬完得跟着改键,否则这张图换个位置就又变成产出了 moveUserInput(fromRel, destRel, same ? undefined : fromRoot); moved.push(n); } catch {} } sess.pending_uploads = []; return moved; } /** * 输入框上那几枚 chip 是「下一条消息」的草稿,不跟着对话走:传完点了「新任务」、点开别的对话、换了项目,chip 都还在。 * 可文件是按传的那一刻是哪条对话落的——记在那条对话的待搬账上,或者已经进了那条对话自己那格。 * 这条消息一发,Agent 在这边按名字找就扑空;文件还留在别的对话那格里,那边的成果区平白多一份。 * 前端把「不是在这条对话里传的」那几枚报上来(传的时候用的会话 id + 上传回的相对路径),这里核实后记到这条对话的待搬账上。 * 核实三件:那条对话归这个人(待搬账上那笔还得是他传的);文件就在它的待搬账上或它自己那格里; * mtime 跟上传那一刻记的对得上(settlePendingUploads 里查)。 */ function adoptUploads(sess, user, list) { if (!Array.isArray(list)) return; const fence = rootFence({ user }); // 不止一个账号时只搬自己根里的:管理员能看成员的对话,不等于能把成员传的文件搬走 for (const a of list.slice(0, 50)) { try { const sid = String((a && a.sid) || ""), rel = path.normalize(String((a && a.path) || "")); if (!sid || !rel || rel.startsWith("..") || path.isAbsolute(rel)) continue; // 待搬账只在内存里;内存里没有、盘上又没这条的,就是没这回事——别为一个随手报上来的 id 建一条空会话 const o = sessions.get(sid) || (fs.existsSync(sessFile(sid)) ? getSession(sid) : null); if (!o || o === sess || !sessionAllowed(user, o)) continue; const name = path.basename(rel), sub = path.dirname(rel); let hit = null; if (sub === ".") { const i = (o.pending_uploads || []).findIndex((p) => (typeof p === "string" ? p : p && p.name) === name); const p = i >= 0 ? o.pending_uploads[i] : null; // 只传了附件、还没发过的空会话没有主人,sessionAllowed 谁来都放行:这里再认一次是谁传的, // 不然知道会话 id 和文件名的人能把别人刚传上来、还没发出去的文件搬进自己那格 if (!p || (p.by && user && p.by !== user.username)) continue; o.pending_uploads.splice(i, 1); hit = typeof p === "string" ? { name, root: getWorkspaceDir() } : p; } else { // 那条对话在哪个根下用的是这一格:当前那格,或者以前在别的根下用过的 const spots = [[o.root || dataPath("workspace"), o.dir], ...Object.entries(o.dirs || {})]; const at = spots.find(([, d]) => d && path.normalize(d) === sub); if (at) hit = { name, root: at[0], rel }; } if (!hit || (fence && !fence(hit.root || getWorkspaceDir()))) continue; sess.pending_uploads = (sess.pending_uploads || []).filter((p) => (typeof p === "string" ? p : p && p.name) !== name).concat(hit); } catch {} } } function sessFile(id) { return path.join(SESS_DIR, id.replace(/[^\w-]/g, "_") + ".json"); } // 每个会话文件「我们最后一次读/写它时」的样子。用来分辨盘上那份是不是被别的进程改过。 const sessStamp = new Map(); // id -> { mtime, size } function sessStat(id) { try { const st = fs.statSync(sessFile(id)); return { mtime: st.mtimeMs, size: st.size }; } catch { return null; } } /** * 盘上那份比我们手里的新吗。 * * 只比 mtime 会漏:原子改名两次可能落在同一毫秒里。加上体积,改了内容基本不可能两项都一样。 */ function sessChangedOnDisk(id) { const now = sessStat(id); const mine = sessStamp.get(id); if (!now || !mine) return false; // 文件没了 / 我们压根没记过,都不算「别人改的」 return now.mtime !== mine.mtime || now.size !== mine.size; } /* ---------- 会话缓存:内存里只留最近用过的那几条 ---------- * * sessions 是**缓存**不是账本 —— 真本一直在 data/sessions/.json,内存里这份丢了, * 大不了下次重读一遍(本机实测:最大的一条 1.3MB 重读 7.07ms,中位数那条 0.30ms,用户根本感觉不到)。 * * 可它以前只进不出:点开过的每一条会话都原地住到进程退出为止。本机 203 条会话全读进来实测 * 堆 +39.6MB、进程 RSS +76MB,而且再也不回落 —— 这正是 metrics 里那条 rss 告警说的 * 「如果它不再回落,值得看一眼是不是有超大会话没被回收」。多人服务器上这个数还要乘人头。 * * 所以按「最近用过」排队,超预算就从队头往外扔。四道闸门缺一不可,少一道都会悄悄吃掉数据: * 1. 正在跑的不扔(activeRuns)—— 那份对象正被这一轮改着; * 2. 有人长期攥着的不扔(sessHold,定时任务那条路)—— 它的 emit 还在往里写; * 3. 刚碰过的不扔(SESS_CACHE_IDLE_MS)—— 来回切两条大会话不能变成来回读盘; * 4. 内存里那份跟盘上那份必须一字不差才扔 —— 这是兜底的那道。 * 举个真会发生的:附件是「先传后发」的,上传接口把文件名记在 sess.pending_uploads 上 * **而且不存盘**(见 /api/upload),等下一条消息发出去才用。这种只活在内存里的字段 * 光靠前三道闸门拦不住,第 4 道能:它跟盘上对不上,就不许扔。 */ // 预算定在 8MB 而不是"看着够用就行":重读一条 1MB 的会话实测 4.5ms、中位数那条 0.3ms, // 留着它省下的就是这几毫秒;而多留一份就是实打实几十 MB 常驻,还乘人头。 // 这笔账两边差着三个数量级,那就往小了定。真正在跑的那些走 activeRuns,本来就不受这道线管。 const SESS_CACHE_BYTES = 8 * 1024 * 1024; // 按盘上那份的字节算;堆里大约是它的 1.36 倍(实测) const SESS_CACHE_KEEP = 8; // 不管多大,最近这几条一定留着 const SESS_CACHE_IDLE_MS = 60 * 1000; // 一分钟内碰过的不动,哪怕超预算 const sessUsedAt = new Map(); // id -> 上次碰它的毫秒数 const sessHold = new Map(); // id -> 还有几处长期攥着这份内存对象 /** 有人要长期拿着这份对象(不是一次请求内用完就扔),先说一声,别让它被清掉 */ function holdSession(id) { sessHold.set(id, (sessHold.get(id) || 0) + 1); } function releaseSession(id) { const n = (sessHold.get(id) || 0) - 1; if (n > 0) sessHold.set(id, n); else sessHold.delete(id); } /** 用过一次:挪到队尾。Map 记插入顺序,删了再塞就是一条现成的 LRU 队列 */ function touchSession(id) { if (!sessions.has(id)) return; // 不在缓存里就别记:记了这个 Map 自己就成了新的只进不出 sessUsedAt.set(id, Date.now()); const v = sessions.get(id); sessions.delete(id); sessions.set(id, v); } /** 内存里这份跟盘上那份一字不差吗。不一样就说明还有没落盘的改动,这条不许清 */ function sessSynced(id) { const s = sessions.get(id); if (!s) return false; let text = ""; try { text = JSON.stringify(s); } catch { return false; } // 循环引用之类:拿不准就当它脏的 const f = sessFile(id); let st = null; try { st = fs.statSync(f); } catch { return false; } // 盘上压根没有(新会话还没存过):更不能清 if (st.size !== Buffer.byteLength(text)) return false; // 先比长度,绝大多数不一样的在这儿就刷掉了 try { return fs.readFileSync(f, "utf8") === text; } catch { return false; } } /** 这条会话跟它在内存里的一切痕迹都抹掉(删会话、清定时任务残留都走这儿)。 * 四个 Map 是一套的,漏掉任何一个,那个 Map 就变成新的只进不出 */ function forgetSession(id) { sessions.delete(id); sessStamp.delete(id); sessSaveAt.delete(id); sessUsedAt.delete(id); sessHold.delete(id); // 排着的后台存盘一起作废:不作废的话,删会话之后一秒它又把整份写回盘上,等于没删 const w = sessWriters.get(id); if (w) w.w.cancel(); sessWriters.delete(id); } /** * 删会话:盘上跟它有关的每一份一起删——正本、.bak、.corrupt / .corrupt-<时间戳>、写到一半的 .tmp, * 外加这条会话压缩时归档的原文(compact-archive/-<毫秒>.json)。 * 以前只删正本:2026-09-28 实测 data/sessions 下 5 个 .bak 的正本早没了—— * 用户点了删除,整段对话原样躺在 .bak 里。按文件名精确比对,别的会话一个字节都碰不到。 * 目录从 SESS_DIR 推,不用 dataPath():e2e / memory 两个测试把这一整段切出去单跑,注进去的就那几个依赖。 */ function removeSessionFiles(id, strict = false) { const base = path.basename(sessFile(id)); const safe = base.slice(0, -".json".length); // .<序号>.tmp 是后台存盘那条路的临时名(store.writeTextAtomicAsync) const tail = /^(?:bak|corrupt(?:-\d+)?|\d+(?:\.\d+)?\.tmp|bak\.\d+\.tmp)$/; const sweep = (dir, hit) => { let names = []; try { names = fs.readdirSync(dir); } catch (e) { if (strict && e.code !== "ENOENT") throw e; return; } for (const n of names) if (hit(n)) { try { fs.rmSync(path.join(dir, n), { force: true }); } catch (e) { if (strict) throw e; } } }; sweep(SESS_DIR, (n) => n === base || (n.startsWith(base + ".") && tail.test(n.slice(base.length + 1)))); sweep(path.join(path.dirname(SESS_DIR), "compact-archive"), (n) => n.startsWith(safe + "-") && /^\d{13}\.json$/.test(n.slice(safe.length + 1))); } /** 当前内存里这些会话占了多少字节(按盘上那份算) */ function sessCacheBytes() { let n = 0; for (const id of sessions.keys()) n += ((sessStamp.get(id) || {}).size) || 0; return n; } let sessTrimAt = 0; // 这个时刻之前不再白跑一趟(见下) /** 超预算就从「最久没碰」那头往外扔。keepId 是这次要用的那条,永远不许扔它 */ function trimSessionCache(keepId) { let bytes = sessCacheBytes(); if (bytes <= SESS_CACHE_BYTES) return 0; const now = Date.now(); // 上一趟一条都没清掉(全在用着、或者全都还没落盘):歇一会儿再试。 // 不歇的话,超着预算的这段时间里每开一条会话都要把整份缓存重扫一遍, // 而那一趟是要逐个读盘比对的——本来是省内存的,反倒成了新的开销。 if (now < sessTrimAt) return 0; let gone = 0, looked = 0; for (const id of [...sessions.keys()]) { if (bytes <= SESS_CACHE_BYTES || sessions.size <= SESS_CACHE_KEEP) break; looked++; if (id === keepId || activeRuns.has(id) || sessHold.has(id)) continue; if (now - (sessUsedAt.get(id) || 0) < SESS_CACHE_IDLE_MS) continue; const w = sessWriters.get(id); if (w && w.w.busy()) continue; // 还有一趟后台存盘没落地:内存里这份就是还没上盘的改动 if (!sessSynced(id)) continue; bytes -= ((sessStamp.get(id) || {}).size) || 0; sessions.delete(id); sessStamp.delete(id); // 跟着一起清,不然这两个 Map 自己变成新的泄漏 sessSaveAt.delete(id); sessUsedAt.delete(id); sessWriters.delete(id); gone++; } // 只有「真挑过、一条都挑不动」才歇(全在用着、或者全都还没落盘)。 // 一条都没挑过就歇是个陷阱:缓存刚好卡在保底条数上又超了预算时,这一趟什么都干不了, // 可接下来这一分钟里用户再点开多少条都不会再清——内存在这一分钟里是彻底敞开的。 // 本机实测:预算 8MB、保底 8 条、每条 1MB,正好在第 8 条撞上这一格, // 于是翻完 30 条一条都没清掉,30.1MB 全留着,等于这道闸门白装。 if (!gone && looked) sessTrimAt = now + SESS_CACHE_IDLE_MS; return gone; } function getSession(id) { /** * 内存里有了也要回头看一眼盘:命令行的 openworkbuddy 写的是同一批文件(data/sessions/.json), * 而 openworkbuddy resume 不给 id 时接的就是「最近动过的那个」,包括桌面上刚开的那条。 * 以前这个 Map 一进来就再也不回头,于是「桌面开个头 → 终端 openworkbuddy resume 接着做 → 回桌面再发一句」 * 这条路上,桌面用的是几小时前那份内存副本,一存盘就把终端那几轮整段盖掉了—— * 用户看到的是「我在终端做的那半截凭空消失了」。 * * 正在跑任务的会话不重读:那份内存对象正被这一轮改着,从盘上盖回去等于把自己的进度丢了。 */ if (sessions.has(id) && !activeRuns.has(id) && sessChangedOnDisk(id)) { console.log(`[会话] ${id} 在别处改过(多半是命令行 openworkbuddy),重新读一遍磁盘,免得把那边的记录覆盖掉`); sessions.delete(id); } if (!sessions.has(id)) { // 会话文件坏了不抛错(不能因为一条对话打不开就让整个工作台起不来), // 但也绝不装作没有过这条对话:store 会先拿 .bak 顶上,实在不行把坏文件改名隔离 const data = store.readJson(sessFile(id), { history: [], transcript: [], title: "", updated_at: null }); // 目录迁移可能只改过 root 字段,旧 files.root 哈希不可逆;仅用同一会话的备份核对原根。 try { const staleRoot = data.root && data.dir && (data.transcript || []).some(row => (row.events || []).some(e => e.type === "files" && e.root && e.root !== workspaceKeyOf(data.root))); if (staleRoot) { const previous = JSON.parse(fs.readFileSync(sessFile(id) + ".bak", "utf8")); if (taskDirs.recoverFileRoots(data, previous, workspaceKeyOf)) store.writeJsonAtomic(sessFile(id), data); } } catch { /* 没有迁移证据时保留原链接,不按文件名跨项目猜测。 */ } sessions.set(id, data); sessStamp.set(id, sessStat(id)); sessUsedAt.set(id, Date.now()); trimSessionCache(id); // 刚读进来的这条排在队尾,清的是队头那些老的 } else { touchSession(id); } return sessions.get(id); } /** * 把会话写回磁盘。 * * sess 传不传都行:长期攥着对象的调用方(定时任务录制器)把手里那份直接递进来, * 这样哪怕它已经被缓存清掉了,写盘照样落在对的内容上 —— 而不是悄没声地什么都不做。 */ function saveSession(id, sess, { keepTime = false } = {}) { const s = sess || sessions.get(id); // 内存里没有、调用方也没递一份进来:这不是正常状态,必须留个痕。 // 悄悄 return 的话,真出了「跑完一整趟、回放里一片空白」这种事,日志里一个字都没有。 if (!s) return void console.warn(`[会话] 要存 ${id},可内存里没有这份,也没人把对象递进来——这一次写盘跳过了`); if (!s.history) return; touchSession(id); // 同步写了最新的一整份:后台排着的那趟作废,路上那趟也别晚到把这份盖回旧的(见 sessWriter 的 shouldCommit) const rec = sessWriters.get(id); if (rec) { rec.gen++; rec.w.cancel(); } // 置顶这种只改标记的写盘不算「动过」:不然一置顶,它在侧栏里的先后就跳到最前 if (!keepTime || !s.updated_at) s.updated_at = new Date().toISOString(); // 盘上那份还是我们上回读 / 写下的样子(mtime+size 都对得上):上一版不用再整份读回来 JSON.parse // 一遍才配当 .bak,直接硬链接过去(见 store.js linkBackup)。对不上——命令行写过、 // 或者这条压根不在缓存里(定时任务把对象直接递进来那条路)——就还走老路先验再拷, // 残骸永远顶不掉一份好的 .bak。 const mine = sessStamp.get(id), disk = mine ? sessStat(id) : null; const trustPrev = !!(mine && disk && disk.size > 0 && disk.mtime === mine.mtime && disk.size === mine.size); store.writeJsonAtomic(sessFile(id), s, { trustPrev }); // 记下自己写完之后的样子,别把自己这次写当成「别人改的」。 // 只给还在缓存里的记:清掉的那些记了也没人看,反倒让这个 Map 接着涨 if (sessions.has(id)) sessStamp.set(id, sessStat(id)); } /** 跑任务途中顺手存一次盘,最快 5 秒一次。 * 跟 saveSession / 缓存清理摆在一块儿:它写的 sessSaveAt 是会话缓存那一套 Map 里的一个, * 拆开放的话,forgetSession 清不到它,而 e2e 切这一整段出去单跑时也会当场少一个名字。 */ const sessSaveAt = new Map(); function autosaveSession(id, minGapMs = 5000) { const now = Date.now(); if (now - (sessSaveAt.get(id) || 0) < minGapMs) return; sessSaveAt.set(id, now); queueSessionSave(id); } /** * 跑任务途中的存盘走后台,而且每条会话一秒最多写一次。 * * 以前这里直接调 saveSession:整份 JSON.stringify + 写临时文件 + fsync 全在主线程上。 * 2026-09-29 实测 5 条对话并发、每条会话 1.4MB:540 秒里主线程 fsync 11426 次、合计 51.6 秒 * (单次约 4.5ms),这段时间所有对话的流式输出和所有接口一起停着。按 120 秒 CPU 采样分: * 会话存盘 4.5 秒、running.json 4.5 秒、模型健康账本 2.2 秒,三处都改成了后台写。 * * 现在:写临时文件和 fsync 交给线程池(store.writeJsonAtomicAsync),主线程只剩序列化和改名那一下; * 同一条会话排着的几次并成一次(store.coalesce,一次只在路上一份,旧的不会晚到盖掉新的)。 * 丢不了:回合收尾 await flushSessionSave 落完盘才发 done;进程退出走 flushSessionSavesSync 同步补写。 */ const SESS_SAVE_GAP_MS = 1000; const sessWriters = new Map(); // id -> { w: 排班, gen: 同步那条路写过几次 } function sessWriter(id) { const had = sessWriters.get(id); if (had) return had; /** @type {{ gen: number, w: any }} */ const me = { gen: 0, w: null }; me.w = store.coalesce(() => { if (sessWriters.get(id) !== me) return; // 删了 / 清出缓存了:这一趟作废 const s = sessions.get(id); if (!s) return void console.warn(`[会话] 要存 ${id},可内存里没有这份——这一次写盘跳过了`); if (!s.history) return; const gen = me.gen; s.updated_at = new Date().toISOString(); return store.writeJsonAtomicAsync(sessFile(id), s, { // 跟 saveSession 同一个判据,只是挪到改名前那一刻才判 trustPrev: () => { const mine = sessStamp.get(id), disk = mine ? sessStat(id) : null; return !!(mine && disk && disk.size > 0 && disk.mtime === mine.mtime && disk.size === mine.size); }, // 等线程池这几毫秒里同步那条路写过更新的一份(gen 变了),或者会话被删了:这份旧的不许落地 shouldCommit: () => me.gen === gen && sessWriters.get(id) === me, onCommit: () => { if (sessions.has(id)) sessStamp.set(id, sessStat(id)); }, }); }, { minGapMs: SESS_SAVE_GAP_MS, onError: (e) => console.warn(`[会话] 中途存盘失败(${id}):${e.message}`) }); sessWriters.set(id, me); return me; } function queueSessionSave(id) { touchSession(id); sessWriter(id).w.request(); } /** 不等间隔立刻存,落完盘才 resolve(回合收尾用:告诉界面「完成了」之前记录必须已经在盘上) */ function flushSessionSave(id) { touchSession(id); return sessWriter(id).w.flush(); } /** 进程要退了:还没落地的那些同步补写一遍(退出钩子里等不了线程池) */ function flushSessionSavesSync() { let n = 0; for (const [id, rec] of [...sessWriters]) { if (!rec.w.busy()) continue; try { saveSession(id); n++; } catch (e) { console.warn(`[会话] 退出前补存失败(${id}):${e.message}`); } } return n; } /** * 磁盘上的会话清单(给侧栏的任务历史用)。 * * 侧栏那份列表一直只活在浏览器 localStorage 里,而对话本体在 data/sessions/。 * 于是「换台机器 / 清缓存 / 改用户名 / 换个账号先登进来」任意一件事,列表就空了, * 用户看到的是「我的历史任务全没了」,其实一条都没丢。这里按磁盘给一份权威清单。 * * 按文件 mtime 做增量缓存:没变的文件不重读,跑着任务时也不会每次全量读一百个 JSON。 * id 取的是文件名(sessFile 会把非 \w 字符换成 _,而 id 本来就是 s_<时间戳>_<随机数>,不会被改写)。 */ const sessMetaCache = new Map(); // 文件名 -> { mtime, row } // 上面那个 Map 只在进程活着的时候管用。每次重启后第一次拉侧栏,还得把 data/sessions/ 下 // 每个 JSON 整个读出来、整个 parse 一遍,只为了取标题和轮数——1500 条会话(20MB)实测 326ms, // 而且用得越久越长。这一下正好卡在「点开应用、侧栏还是空的」那段空白上。 // 所以把 {mtime, row} 落一份到盘上:重启后 mtime 对得上就直接用,对不上才回去读原文件。 // 判据仍然是 mtime,跟内存那份一模一样,不存在「缓存比文件旧」这种状态。 // ⚠️ 名字不带 .json,是为了能安心放在 SESS_DIR 里头。扫这个目录的一共四处 // (这儿、改名时的批量重写、成果清单、cli 的会话列表),四处都是 filter(endsWith(".json")), // 所以一个没后缀的点文件对它们全都不存在——真叫 sessions-index.json 的话, // 侧栏第一条就会多出一个叫「sessions-index」的假会话。 // 路径从 SESS_DIR 推、不用 dataPath():e2e 把这一整段切出去单跑,注进去的就那几个依赖。 const SESS_INDEX = path.join(SESS_DIR, ".index"); let sessIndexLoaded = false; let sessIndexDirty = false; let sessIndexTimer = null; function loadSessIndex() { if (sessIndexLoaded) return; sessIndexLoaded = true; const raw = store.readJson(SESS_INDEX, null); if (!raw || raw.v !== 1 || !raw.rows) return; for (const [name, hit] of Object.entries(raw.rows)) { // 只认形状对的:这文件坏了顶多是白读一次盘,绝不能让侧栏拿着半个 row 去渲染。 // row 为 null 是**有效**结论(「这个文件不进侧栏」),跟「少写了 row 这个字段」不是一回事, // 所以判的是「有没有 row 这个键」,不是「row 真不真」——见下面 saveSessIndex 那段。 if (!hit || typeof hit.mtime !== "number" || !("row" in hit)) continue; if (hit.row !== null && !(hit.row && hit.row.id)) continue; sessMetaCache.set(name, hit); } } function saveSessIndex() { if (!sessIndexDirty || sessIndexTimer) return; // 攒 2 秒再写。攒的不是「一次列表里的几十条」(一次列表只调一次这儿), // 是「侧栏在轮询」:网页 + 手机 + 第二个标签页,几秒一趟,每趟都重写 37KB 没意义。 sessIndexTimer = setTimeout(() => { sessIndexTimer = null; sessIndexDirty = false; const rows = {}; // ⚠️ row 为 null 的也要写进去。它的意思是「这个文件我看过了,不进侧栏」—— // 空壳会话、写坏的 JSON 都落在这一类。漏掉它们的话,恰恰是这批「读了也没用」的文件 // 每次重启都要被完整读一遍,而这正是这份索引要省掉的开销。 for (const [name, hit] of sessMetaCache) if (hit && "row" in hit) rows[name] = hit; try { store.writeJsonAtomic(SESS_INDEX, { v: 1, rows }, { backup: false }); } catch {} }, 2000); if (sessIndexTimer.unref) sessIndexTimer.unref(); // 测试里起完就关的进程,别被这个定时器吊住 } function listSessionsOnDisk() { loadSessIndex(); let names = []; try { names = fs.readdirSync(SESS_DIR).filter((n) => n.endsWith(".json")); } catch { return []; } const rows = []; const seen = new Set(); for (const n of names) { const id = n.slice(0, -5); seen.add(n); const live = sessions.get(id); // 内存里有就用内存的:刚写过的盘没必要再读一遍 if (!live) { let mtime = 0; try { mtime = fs.statSync(path.join(SESS_DIR, n)).mtimeMs; } catch { continue; } const hit = sessMetaCache.get(n); if (hit && hit.mtime === mtime) { if (hit.row) { // 老版本的 .index 尚未记创建时间:用索引里已有的稳定时间补齐,不为侧栏首次打开重读整份会话。 if (!hit.row.created_at) { hit.row.created_at = new Date(Number(hit.row.at) || mtime).toISOString(); sessIndexDirty = true; } rows.push(hit.row); if (typeof hit.row.files !== "number") sessFilesTodo.add(n); } continue; } const data = store.readJson(path.join(SESS_DIR, n), null); const row = sessionRow(id, data); sessMetaCache.set(n, { mtime, row }); sessIndexDirty = true; if (row) rows.push(row); continue; } const row = sessionRow(id, live); if (row) rows.push(row); } for (const k of sessMetaCache.keys()) if (!seen.has(k)) { sessMetaCache.delete(k); sessIndexDirty = true; } // 删掉的会话别赖在缓存里 rows.sort((a, b) => b.at - a.at); saveSessIndex(); fillSessFilesLater(); return rows; } /** 这条会话前后产出过几个文件(同名改写算一个)。侧栏靠它在行上挂文件图标,顶栏靠它写「N 个文件」 */ function sessFileCount(s) { return s ? sessSearch.filesOf(s).length : 0; } // 「产出过几个文件」是后加的字段,升级前落盘的 .index 里没有。那几行照常进侧栏, // 由这里在后台一条条回去补,补完下一次拉列表就带上了。不在拉列表那一趟里当场补: // 几百条会话整份重读一遍要一两秒,正好卡在打开应用、侧栏还空着的那一下。 const sessFilesTodo = new Set(); // 文件名 let sessFilesTimer = null; function fillSessFilesLater() { if (sessFilesTimer || !sessFilesTodo.size) return; const later = (ms) => { sessFilesTimer = setTimeout(step, ms); if (sessFilesTimer.unref) sessFilesTimer.unref(); }; const step = () => { sessFilesTimer = null; const t0 = Date.now(); for (const n of sessFilesTodo) { sessFilesTodo.delete(n); const hit = sessMetaCache.get(n); if (!hit || !hit.row || typeof hit.row.files === "number") continue; let mtime = 0; try { mtime = fs.statSync(path.join(SESS_DIR, n)).mtimeMs; } catch { continue; } if (mtime !== hit.mtime) continue; // 文件又动过:下一趟列表会整份重读它,轮不到这儿 hit.row.files = sessFileCount(store.readJson(path.join(SESS_DIR, n), null)); sessIndexDirty = true; if (Date.now() - t0 > 30) break; // 一口气最多占 30ms,别卡住正在出字的对话 } if (sessFilesTodo.size) later(50); else saveSessIndex(); }; later(500); } function sessionRow(id, s) { // 一句话都没说过的空壳不进侧栏:点进去还是空的,只会让人以为「历史又乱了」 if (!s || !Array.isArray(s.transcript) || !s.transcript.length) return null; // 定时任务跑出来的那段会话也不进侧栏:一天几十条 cron 会把真正的对话整个挤下去。 // 它们有自己的正经去处——自动化 → 运行记录,那边每条记录都挂着「看执行过程」直接开这一段。 if (s.kind === "schedule") return null; const firstUserAt = (s.transcript || []).find((t) => t && t.type === "user" && t.at)?.at; return { id, title: s.title || "未命名任务", dir: s.dir || "", // 固定展示创建日期,让任务在自动改标题、继续执行后仍有稳定的「名称 · 日期」标识。 // 旧会话没有首条消息时间时退回更新时间,避免侧栏出现空日期。 created_at: firstUserAt || s.created_at || s.updated_at || "", user: s.user || "", project: s.project || "", at: Date.parse(s.updated_at || "") || 0, turns: s.transcript.length, // 侧栏按标签分栏。这儿只如实报「这条会话当初记的是哪条线」,没记过就不报—— // 老会话该归到哪条线要看**读它的这个人**现在配的是什么引擎,那是前端拿 /api/lanes 算的事; // 在这儿按服务器配置替他填死,多用户下就会把别人的引擎口径安到他头上。 lane: lanes.normalize(s.lane) || undefined, billing_scope: s.billing_scope === "team" ? "team" : "personal", engine: typeof s.engine === "string" ? s.engine : undefined, pinned_at: Number(s.pinned_at) > 0 ? Number(s.pinned_at) : undefined, files: sessFileCount(s), }; } /** * 侧栏只列「我自己的」,故意比 sessionAllowed 更窄: * 后者管的是「能不能打开」(管理员拿到同组织的链接可以打开), * 这里管的是「侧栏该不该出现」——把同事的任务铺进管理员的侧栏是另一种事故。 * 没开账号体系(req.user 为空)本来就是一个人用;没记归属的老会话归管理员(见 legacySessionOwner), * 不再当公共的——那一条正是「新建的号一打开,侧栏里全是别人的对话」的出处。 * 这里的 row 是磁盘清单里的一行,不会是空壳,所以直接按 legacySessionOwner 兜底。 */ function ownSession(user, row) { if (!user) return true; const owner = (row && row.user) || legacySessionOwner(); return !owner || owner === user.username; } // 任务跑一半崩了 / 用户直接退出 App,这一轮的过程就全没了——中途也存,最多每 5 秒一次。 // 存的是同一份对象,落盘又是原子改名,跟收尾时那次 saveSession 不会打架。 // IM 会话跟网页会话分开存(data/im-sessions/<键>.json),重启不丢上下文 const imSessions = createImSessionStore({ dir: dataPath("data", "im-sessions") }); // 助理页的上下文以前全服务器共用一个键(local_assist),现在一人一段。老库里那一段就这么晾着的话, // 升级完第一句话它就「突然失忆」——那段话本来就是管理员跟它说的,认到管理员名下即可 try { const boss = account.defaultUser(); const bossKey = boss ? "local_" + prefs.keyOf(boss) : ""; if (bossKey && imSessions.has("local_assist") && !imSessions.has(bossKey)) { imSessions.set(bossKey, imSessions.get("local_assist")); imSessions.clear((k) => k === "local_assist"); console.log("[IM会话] 助理页上下文已按账号拆开,原来那段归到管理员名下"); } } catch (e) { console.warn("[IM会话] 助理页上下文迁移失败(不影响启动):", e.message); } /** 包装 emit:把事件同时记录到 transcript(文本增量合并,跳过噪音事件),顺便中途存盘 */ // ---------- Goal 目标模式 ---------- // 拆验收标准、对着标准验收这套逻辑搬到了 goal.js,网页端和 `openworkbuddy` 命令行共用同一份。 // 搬家的直接原因:这段代码原本只长在下面那个 /api/chat 里,于是 Goal 就只对网页存在—— // 命令行的模式表是手抄的三个,goal 没抄进去,`/mode goal` 敲得进去却按 craft 跑。 // 这里只留「动脑那句话问谁」(goalThink),因为它要看登录用户配的是哪个引擎。 /** * 目标验收那一步借判断模型(Jev)。 * * 走跟 /api/decide 同一道额度闸:它一道题两万分之一美金,贵不起来,可它是**在循环里**跑的—— * 自动补跑几轮、每轮几条标准,一晚上能问出很多道。限的是失控的量,不是钱。 * 没配渠道 / 被闸拦了都返回 ok:false,goal.js 会安静地退回对话模型那条老路。 */ const decideForGoal = (args) => jev.askMetered(config, args, { meta: "目标验收" }); const goalKit = require("./src/core/automation/goal").createGoalEngine({ workspaceDir: getWorkspaceDir, decide: decideForGoal }); const GOAL_MAX_ROUNDS = goalKit.MAX_ROUNDS; /** * 把一次调用(或 agent 返回的一整轮)的用量并进本次任务的总账。 * * 以前这件事在五个地方各手写一遍 `total.prompt += …; total.completion += …`,于是 * llm.js 好不容易统一读出来的 cached 在这一层被五处同时丢掉——账本记的 cached 恒为 0, * 「有没有在反复全价重买同一段上下文」这个问题看不见,扣积分也没法给缓存打折。 * 收成一个函数,以后 usage 再多一个字段只改这一处,不会有第六处漏掉。 * * calls:llm.chat() 一次就是一次;agent 返回的 usage 自带 calls(里面是好多次),照抄。 */ function addUsage(total, u) { if (!u) return; total.prompt += u.prompt || 0; total.completion += u.completion || 0; total.cached = (total.cached || 0) + (u.cached || 0); total.calls += u.calls != null ? u.calls : 1; total.elapsed_ms = (total.elapsed_ms || 0) + (u.elapsed_ms || 0); } /** * Goal 模式的「动脑」通道:拆验收标准、对着标准判分,这两句该问谁。 * * 本机引擎(Claude Code / Codex)在跑时,就借那个 CLI 问——用户切过去图的就是不花 API 的钱, * 这两步却偷偷走 API:没配 Key 的人于是永远拆不出标准也验不了收,目标卡卡在 0/N 不动, * 从用户那边看就是「Goal 模式没做」。同一份订阅已经付过钱了,问它就是了。 */ /** 这个请求所在的组织关了命令行(外部引擎自带命令行,跟着一起不能用) */ function orgShellOff() { const p = orgPolicy(); return !!p && p.allow_shell === false; } async function goalThink(sessLLM, { system, prompt, timeoutMs, total, user }) { const id = engines.currentId(config, { user }); // 引擎是按账号存的,得看这一趟任务是谁发起的 if (id !== "builtin" && engines.get(id)) { // 跟开跑那条过同一道闸:组织关了命令行、属主没打开、型号没钉都直接报错,不悄悄改问 API 模型 const pass = engines.admit(id, config, { shellOff: orgShellOff(), user }); return await engines.ask({ id, opts: pass.opts, system, prompt, timeoutMs }); } const r = await sessLLM.chat({ system, history: [{ role: "user", content: prompt }], tools: [], signal: AbortSignal.timeout(timeoutMs), }); addUsage(total, r.usage); return r.text; } /** * 把 goalThink 包成 goal.js 要的那个 think(\{system,prompt,timeoutMs\}) —— 用量记在这一趟任务的总账上。 * 拆出去之后这一层就是全部的粘合剂:goal.js 不认识 sessLLM、不认识引擎、也不记账。 */ const goalThinkFor = (sessLLM, total, user) => (a) => goalThink(sessLLM, { ...a, total, user }); /** * 把任务事件翻译成桌面宠物的表情。只认深度 0 的事件——专家子代理的动静太密, * 宠物跟着抽风反而看不出主线在干什么。纯 node 模式下 global.__openworkbuddyPet 不存在,整个是空操作。 */ function petSay(ev) { const P = global.__openworkbuddyPet; if (!P) return; try { switch (ev.type) { case "ask_user": P.alertAsk(ev.question); break; // 跳 + 通知 + Dock 弹,最高一档 case "ask_answer": P.clearAsk(true); break; case "tool_use": P.setState("working", "正在用 " + (ev.name || "工具")); break; case "expert_start": P.setState("working", `${ev.expert || "专家"} 接手了:${String(ev.task || "").slice(0, 40)}`); break; case "sleep": P.setState("sleep", ev.note); break; case "failover": P.setState("working", ev.note); break; case "limit": P.setState("error", ev.note); break; case "error": P.setState("error", String(ev.message || "任务出错了").slice(0, 80)); break; default: break; } } catch {} } /** * @param opts.pet 要不要让桌面宠物跟着这趟任务动表情。定时任务传 false: * 它是背着人跑的,让宠物替一条 cron 手舞足蹈会盖掉用户手头正在看的那件事。 */ function recordingEmit(send, events, sessionId, { pet = true } = {}) { return (ev) => { send(ev); if (pet && !(ev.depth > 0)) petSay(ev); if (ev.type === "text") { if (ev.depth > 0) return; const last = events[events.length - 1]; if (last && last.type === "text") last.delta += ev.delta; else events.push({ type: "text", delta: ev.delta }); } else if (ev.type === "files") { // 产出卡片得能活过一次重开:历史回放全靠这条事件重建。以前它不在存盘清单里, // 于是任务当场看得见成果卡,退出再进来那片卡就凭空消失了。 // 但整份清单最多 500 条、每批工具跑完就来一次,原样存会把会话文件撑爆—— // 只留这一批真正变更的那几条(回放时 renderFiles 被 isReplaying 挡住,用不到全量) const chg = ev.changed || []; // partial 是给回放用的实话:存下来的这份 files 已经被裁成「这一批变更」了,不是全量清单。 // 前端拿不到全量就不能判定谁没了——早先没这个标记,回放时每来一批就把上一批的产出 // 全盖上「已删除」,用户看到的是四个文件全被划掉,其实一个都没删 // 第 4 层往下、挤出最新 500 条的产出不在 files 里,在 turn_files 里(两份不重名):并起来再裁,不然回放时那几张卡画不出来 const pool = (ev.files || []).concat(ev.turn_files || []); // gone 是服务端当场 stat 过、确实没了的那几个:回放时清单是裁过的,前端只能靠它撤卡 const gone = Array.isArray(ev.gone) ? ev.gone : []; if (chg.length || gone.length) events.push({ type: "files", changed: chg, files: pool.filter((f) => chg.includes(f.name)), partial: true, root: ev.root, ...(gone.length ? { gone } : {}) }); } else if (ev.type === "status") { // 状态播报大多是进度(重试中、思考中),过去就过去了,不存。要存的两种是这一趟的事实: // 本机引擎启动成功那条(谁在跑、哪个模型),和标了 notice 的(工具没挂上、连接器挂不上、 // 只读模式停用了哪些设置……)。以前一条都不存,重开对话就不知道那趟是怎么跑的 if (ev.depth > 0) return; if (ev.notice || ("model" in ev && !ev.starting)) events.push(ev); } else if (["tool_use", "tool_result", "parallel", "expert_start", "expert_done", "error", "limit", "auto_continue", "failover", "sleep", "trim", "compact", "usage", "interject", "worktree", "credits", "sources", "ask_user", "ask_answer", "milestones", "todos", "context", "trace"].includes(ev.type)) { // 工具事件盖个时间戳(send 已经发出去了,这里只影响存盘):回放时轨迹条才算得出每步耗时 if (ev.type === "tool_use" || ev.type === "tool_result") ev.at = ev.at || Date.now(); events.push(ev); // 一步走完就是个存盘点:跑了半小时的任务不该因为一次崩溃从头再来 if (sessionId && ev.type === "tool_result") autosaveSession(sessionId); } }; } const app = express(); // 大小写敏感路由。Express 默认是 false,于是 POST /API/settings 会命中 /api/settings 的 // 处理器,而 account.js 的 authGuard 和 admin.js 的门禁表都是用 startsWith 按**原样**路径 // 判的——把首字母改成大写就既不用登录、也绕开整张写表。判据那两处也一并小写化了(两头都堵, // 只堵一头都会留下半扇门)。全仓路由注册和前端请求本来就全小写,打开这个开关不改变任何现有行为。 app.set("case sensitive routing", true); app.use(express.json({ limit: "60mb" })); /** * 几条响应头。本来这东西跑在本机没人惦记,但只要往 VPS 上一放就是公网服务了。 * * - no-referrer:配对码是从 ?pair= 进来的。带 Referer 的话,这一页上任何一个外链 * (AI 生成的网页里随便一个 )都会把整条地址、连着那串码一起送到对面站点去。 * - nosniff:/api/files 那条路会把用户自己的文件原样吐出来。不关嗅探的话, * 一个存成 .txt 的 html 会被浏览器当页面执行,那就是同源下的 XSS。 * - SAMEORIGIN:别人用 iframe 套住这一页骗点击(点的是「删除」,他看见的是「领奖」)。 * - base-uri/form-action:真出了 XSS 时,这两条能拦住最顺手的那两种偷法。 * * - X-Powered-By:Express 默认写的是 "Express",换成产品名。不带版本号——版本号只对挑漏洞的人有用。 * 写产品名是为了让放到公网上的部署能被认出来:商用没授权的,大多就是这么被发现的。 * * 没上完整 CSP 是故意的:预览 AI 生成的网页用的是 blob: iframe,而 blob: 会继承 * 这一页的 CSP。加上 script-src/connect-src,生成的网页里引个图表库就白屏了—— * 那是主线功能。iframe 那边已经用 sandbox 隔成独立源,先靠它。 */ app.disable("x-powered-by"); app.use((req, res, next) => { res.setHeader("X-Powered-By", "OpenWorkBuddy"); res.setHeader("X-OWB-Mark", require("./src/util/provenance").MARK); // 改了名部署出去也认得出来源 res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "SAMEORIGIN"); res.setHeader("Content-Security-Policy", "base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"); next(); }); // 来了请求就把卡顿表打开(闲着时它是关的,见 metrics.js 的 wake)。 // 页面开着时自己隔十几秒问一次的那几条(连接器状态、待审批、终端在跑的活)带 X-OWB-Poll,不算有事—— // 算的话窗口一开着,表就永远关不掉(2026-10-07 实测:三条轮询把它一直顶着,空转照样每秒醒 40 次) app.use((req, res, next) => { if (!req.headers["x-owb-poll"]) metrics.wake(); next(); }); /** * /api 的 GET 默认 no-store,别让 Chromium 往盘上的 HTTP 缓存里写。 * 这些回应本来就不复用(下一次一定回来重新要),可不写头的话 Chromium 照样把它们落进 * userData/Cache:GET /api/session/:id 一份就 936 KB,切一次任务写一份,2026-09-28 看到那个目录 * 大部分是这种用不上的旧会话。要缓存的路由自己会再 res.set 一次、盖掉这里的默认值—— * /api/files/view 带对版本号时就是这么拿到长期缓存的(见 viewCacheHeader); * sendFile 看到已经有 Cache-Control 就不再写它那个 public, max-age=0。 */ app.use((req, res, next) => { if ((req.method === "GET" || req.method === "HEAD") && req.path.startsWith("/api/")) res.setHeader("Cache-Control", "no-store"); next(); }); /** * 静态文本先压再发,摆在 express.static 前面。 * * 首屏那 1.9 MB(HTML + 八个 app-0*.js + i18n 词典 + JointJS + Dagre)全是文本, * 压完只剩三成。本机上差别看不出来,走隧道或者手机流量上来的人差十几秒。 * vendor 那两个不在 public 底下,得单独把前缀指过去——它们俩加起来 558 KB, * 恰恰是首屏里最大的一块,漏了等于白做。 */ // Viewer code is public, immutable application code. CORS lets the opaque // sandbox import OFD modules; document bytes still arrive only from its parent. for (const asset of ["ofd", "msdoc"]) app.use("/vendor/" + asset, (_req, res, next) => { res.set("Access-Control-Allow-Origin", "*"); next(); }); app.get("/vendor/document/jszip.min.js", (_req, res) => res.sendFile(appPath("node_modules/jszip/dist/jszip.min.js"))); app.get("/vendor/document/docx-preview.min.js", (_req, res) => res.sendFile(appPath("node_modules/docx-preview/dist/docx-preview.min.js"))); app.use(staticCompress([ { prefix: "/", dir: appPath("public") }, { prefix: "/vendor/joint/", dir: appPath("node_modules/@joint/core/dist") }, { prefix: "/vendor/dagre/", dir: appPath("node_modules/@dagrejs/dagre/dist") }, ])); /** * 动态 JSON 也压。上面那层只碰磁盘文件,而这台服务器上最大的一个响应恰恰是动态的: * GET /api/session/:id 实测 936 KB(brotli 后 189 KB),用户每切一次任务就下一遍。 * 只包 res.json,SSE 走的是 res.write,结构上够不着——理由写在 json-compress.js 开头。 */ app.use(jsonCompress()); app.use(express.static(appPath("public"))); // Only a one-use state-bound code is deposited here. The authenticated initiating // owner exchanges it from the app; an external browser need not possess its cookie. app.get("/api/mcp/oauth/callback", (req, res) => { res.set({ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'; base-uri 'none'" }); try { mcpOAuth.receive(req.query); res.type("html").send('OpenWorkBuddy

授权回调已收到

请返回 OpenWorkBuddy。The authorization response was received. Return to OpenWorkBuddy.

'); } catch { res.status(400).type("html").send('OpenWorkBuddy

授权请求已失效

请在连接器页面重新开始。Restart authorization from the Connectors page.

'); } }); // JointJS 是短剧画布的开源底座。它从 node_modules 原样提供给前端, // 不复制、不改写 vendor 源码;这样开发态、桌面包和离线模式使用的是同一份 MPL-2.0 文件。 app.get("/vendor/joint/joint.min.js", (_req, res) => { res.sendFile(appPath("node_modules/@joint/core/dist/joint.min.js")); }); app.get("/vendor/joint/LICENSE", (_req, res) => { res.sendFile(appPath("node_modules/@joint/core/LICENSE")); }); // Dagre(MIT)只负责按真实有向边计算节点层级和紧凑坐标,JointJS 继续负责渲染与交互。 app.get("/vendor/dagre/dagre.min.js", (_req, res) => { res.sendFile(appPath("node_modules/@dagrejs/dagre/dist/dagre.min.js")); }); app.get("/vendor/dagre/LICENSE", (_req, res) => { res.sendFile(appPath("node_modules/@dagrejs/dagre/LICENSE")); }); /** * 「这个端口上应答的是谁」——唯一一个不需要登录的接口。 * * 两个地方指着它,问的是同一件事「3800 上应答的是不是我自己」: * 1)`openworkbuddy doctor`:端口被占着,占的是 OpenWorkBuddy 自己(那叫「已经开着」), * 还是别的程序(那才要去处理)。不分的话,每个正常使用中的用户跑一次体检都会看到 * 一条红色告警,看两次就再也不信这个工具了。 * 2)桌面壳的 listenWithFallback:少了它,壳只能把窗口指给任何一个会应答的服务, * 用户看到的是个陌生页面。 * * 只回应用名和版本号——登录页上本来就写着这两样,不多泄露任何东西。 * 免登录有两把锁,各自独立:这一行在 authGuard **前面**,account.js 的 PUBLIC_API 里也列着它 * (那段注释在 account.js 里写着)。拆掉任意一把都还能应答,两把一起没了才会回 401—— * 那时候壳把自己人当成陌生人,转头换个口又起一台。 * * 只许注册一次。原先这个文件里有一模一样的第二处,在 authGuard 后面;Express 只派给先注册的 * 那个,那份从来没被走到过。真正的危险是有人清理时留后删前——接口悄悄挪到闸后头,当时看着还好 * (PUBLIC_API 兜着),等哪天有人顺手精简那个 Set,doctor 和壳就一起瞎掉。test/remote.js 盯着这两件事。 */ app.get("/api/ping", (_req, res) => { res.json({ app: "openworkbuddy", version: require("./package.json").version }); }); // /api/auth/* /api/usage /api/credits/* app.use( account.createRouter({ // 账号改了登录名,历史会话的归属得跟着走,不然那些任务就成了没主的(列表里直接消失)。 // 内存和磁盘两头都要改:只改盘上的,下一次 saveSession 会拿内存里的旧名字盖回去。 onRename: (from, to) => { for (const s of sessions.values()) if (s && s.user === from) s.user = to; let files = 0; for (const f of fs.existsSync(SESS_DIR) ? fs.readdirSync(SESS_DIR) : []) { if (!f.endsWith(".json")) continue; const p = path.join(SESS_DIR, f); const s = store.readJson(p, null); if (s && s.user === from) { s.user = to; store.writeJsonAtomic(p, s); files++; } } const mems = memory.renameScope(from, to); // 记忆也认登录名,不搬就成了孤儿 // 个人工作目录:文件夹名不动,表里换个键。个人资料库的目录名是按登录名算的,得跟着挪,不然改完名打开是空的 try { spaces.rename(from, to); } catch (e) { console.warn("[账号] 个人工作目录没跟上改名:" + e.message); } try { const was = dataPath("data", "library-users", prefs.keyOf(from)), now = dataPath("data", "library-users", prefs.keyOf(to)); if (fs.existsSync(was) && !fs.existsSync(now)) fs.renameSync(was, now); } catch (e) { console.warn("[账号] 个人资料库没跟上改名:" + e.message); } console.log(`[账号] 登录名 ${from} → ${to},${files} 条会话、${mems} 条记忆的归属已迁移`); }, }) ); /** * 企业加装包。 * * 开源版**只留口子,不留实现**。SSO / 目录同步、审计外送 SIEM、高可用、 * 白标这几样归商业授权,将来住在私有仓库 openworkbuddy-enterprise 里,以一个 npm 包装上来。 * 线是怎么划的见 docs/开源与商业版边界.md。 * * 为什么包还不存在就先把口子留出来:没有口子,将来接企业版只能去改主干, * 那意味着**每次都要两边同步 diff**;有了口子,企业版永远是加法—— * 装了就多几条路由,没装就当它不存在。 * * 这里刻意**不做**三件事(做了这就成了「阉割版」,那是开源项目最招人烦的东西): * · 不因为没装而画灰按钮、弹「升级解锁」、写「此功能需企业版」; * · 不因为没装而少给任何一样现在有的能力; * · 不探测、不上报「这台装没装」。 * 没装的唯一表现就是:这几行什么也没干。 */ const enterprise = (() => { let entry = null; try { entry = require.resolve("@openworkbuddy/enterprise"); } catch { return null; // 没装。这是**正常状态**不是错误,一个字都不该往日志里写 } try { return require(entry); } catch (e) { // 装了却加载不起来是真事故(版本对不上、依赖缺了)。必须吵出来, // 否则客户拿到的是一台「企业功能静悄悄失踪」的服务器,这种最难查。 // 先 resolve 探、再 require 载,就是为了把这一档和「没装」彻底分开: // 光看 MODULE_NOT_FOUND 分不开——企业包自己缺个依赖,报的也是这个码。 console.error("[enterprise] 加装包装上了,但加载失败:", (e && e.stack) || e); return null; } })(); // deps 是**传**进去的不是让它 require 的,跟下面 createAdminRouter 一个形状—— // 企业包不去猜开源版的目录结构,我们内部怎么重构都不会把它碰散。 const relay = require("./src/server/relay"); // 中转站那条路自己的防连打闸:60 次撞门就歇一会儿。跟登录那把分开计数, // 不然一个刷 Key 的脚本会把正常同事的登录一起锁死。 const relayLimiter = account.createLimiter(); const entDeps = { org, account, security, config, admin }; /** * 第一个口子:**认证之前**。 * * 这一档是给 SSO 回调用的(SAML 的 ACS、OIDC 的 redirect_uri)。 * 这些请求按定义就是**还没登录**的那一个——身份正是它们要带回来的东西。 * 挂在 authGuard 后面的话,IdP 打回来的那一跳会被自己人挡在门外,SSO 根本走不通。 * * 所以这里是**窄门**,不是后门:企业包该只把回调这类路由放进来,别的一律走下面那个。 * 名字分成两个就是为了让这件事在调用处一眼看得见——`mount` 是安全的默认, * 想要免登录得**显式**写 `mountPublic`,手滑写不出这种口子。 */ if (enterprise && typeof enterprise.mountPublic === "function") { enterprise.mountPublic(app, entDeps); } /** * 第二个口子:**API 中转站**,也在认证之前。 * * 它的身份不是 cookie,是 Authorization 头里那把我们自己发的虚拟 Key * (owb-sk-…,见 vkeys.js)。挂在 authGuard 后面的话,每个请求都会被「未登录」挡掉, * 而业务方那边用的是 openai 官方 SDK——它根本没有 cookie 这个概念。 * * 这不是一个敞开的口子:relay.js 里第一件事就是验 Key,验不过一律 401, * 而且带着自己的防连打闸(跟登录用的是同一个 createLimiter)。 * 路径前缀是 /v1/,跟整个 /api/ 不重叠,不会误放行任何一条内部接口。 */ app.use(relay.createRouter({ config: () => config, orgSettings: (id) => org.settingsOf(org.getOrg(id)), user: account.billingUser, orgProblem: (id) => org.expiredProblem(id), clientIp: account.clientIp, limiter: relayLimiter, })); app.use(account.authGuard); // 其余 /api/* 与 /im/*(除外部回调)需要登录 app.use("/api/session/:id", (req, res, next) => { if (req.method !== "GET" && deletingTasks.has(req.params.id)) return res.status(409).json({ error: "正在删除此任务,请稍候" }); next(); }); // 租户工作目录 → 服务器级接口的闸 → 凭证脱敏。三段的说明都在 admin.js 里 const tenantScopeMw = admin.tenantScope({ withWorkspace, withPolicy, getWorkspaceDir, readConfig: () => config, withLibraryBase, withLibraryDir, libraryRootOf: (u) => libraryRootOf(u) }); app.use(tenantScopeMw); /** * 没有 HTTP 请求的那几条路(定时任务)也要落进某个人的租户范围: * 工作目录、命令行开关、联网白名单、额度,跟他本人在网页上发一句话时一模一样。 * 直接复用同一个中间件,不另写一份——另写的那份迟早跟这份漂开。 */ function inTenantOf(user, source, fn) { return new Promise((resolve, reject) => { try { tenantScopeMw({ user, quotaSource: source }, null, () => { Promise.resolve().then(fn).then(resolve, reject); }); } catch (e) { reject(e); } }); } app.use(admin.platformGuard); app.use(admin.sharedWorkspaceGuard); app.use(admin.redactGuard); const ownsGlobalWorkspace = admin.ownsGlobalWorkspace; /** * 界面靠它决定「服务器级的那些控件画不画」。画了却一点就 403,比不画更气人—— * 用户看到的是一颗明明能点的按钮点了没反应,只能自己去猜是不是坏了。 */ const isPlatformOwner = (req) => admin.isSoloDesktop() || ownsGlobalWorkspace(req && req.user); /** * 「能不能从网页/手机操控终端里正在跑的任务」。两个条件都要满足: * ① 你得是这台机器的主人(原来就有的那道闸,租户看不见别人电脑里在跑什么) * ② 组织后台把 remote_control 打开了(**默认关**,见 org.js) * 分开两道是因为它们防的不是一件事:①防的是别人,②防的是「我自己没想开着这个口子」。 */ const canRemoteControl = (req) => isPlatformOwner(req) && account.remoteAllowed("remote_control", req && req.user); /** 拒绝的理由分清楚:是「你不是主人」还是「这台机器把远程操控关了」。混成一句话,用户不知道该去哪儿改 */ const cliOffReason = (req, verb) => isPlatformOwner(req) ? `这台机器关掉了「允许远程操控终端任务」,去 企业管理后台 → 客户端安全 打开` : `终端里的任务只有这台机器的主人${verb}`; // 企业管理后台 /api/admin/*:自带 adminGuard(管理员+审计员可看,只有管理员能改) app.use( admin.createAdminRouter({ // 付费 API 那一页要判「这一路配没配 Key」。传函数而不是传 config 本身: // config 是活的(设置页存一次就整体替换),传引用会让后台一直看着一份旧快照 readConfig: () => config, // 中转站那一页要改价目表(config.prices)。跟 im.js 一个形状:存盘这件事只有 server 会做, // admin.js 不去猜 config.json 在哪、也不重复实现一遍原子写 saveConfig: () => saveConfig(), // 「这个组织的成果目录占了多大」——直接数当前请求这条链上的根,跨租户不会串 orgUsage: () => { const fs2 = outputFiles(); const list = Array.isArray(fs2) ? fs2 : fs2.files || []; return { files: list.length, bytes: list.reduce((n, f) => n + (f.size || 0), 0) }; }, /** * 办离职时把这个人正在跑的任务掐断,返回掐了几个。 * * 为什么非得掐:停用只挡住**下一次**请求。已经跑起来的那一趟在自己的循环里, * 手上攥着工具权限,还在读文件、还在调模型、还在往他的通知渠道推结果—— * 一趟深度任务能跑十几分钟,足够他走到楼下了。 */ stopRunsOf: (username) => { let n = 0; for (const [sid, run] of activeRuns) { const sess = sessions.get(sid); if (!sess || sess.user !== username) continue; try { run.ctrl.abort(); n++; } catch {} } return n; }, }) ); /** * 第二个口子:**认证之后**,也过完了租户隔离和凭证脱敏那几道。 * 企业包绝大部分东西(审计外送、白标设置、目录同步的管理面)都该走这儿—— * 进到这里的请求,req.user 已经有了,租户的根也已经绑好了。 * * mount 故意**不**包 try:要不要「加载失败就别让服务起来」(比如 SSO 没挂上就不该 * 悄悄退回密码登录)是企业包自己的判断——它 throw 就是 fail-closed,不 throw 就照常起。 * 开源版不替它做这个决定,也就不该把它的 throw 吃掉。 */ if (enterprise && typeof enterprise.mount === "function") { enterprise.mount(app, entDeps); } /** * 会话归属。会话文件是按 id 存的,接口只要拿到 id 就给内容——同一台服务器上的另一个账号 * 猜中/拿到一个 id 就能读走整段对话(含产出文件名、模型原文)。这里补上归属判定: * 本人一定能看;管理员只能看**本组织**的;老会话没记 user 的按公共处理(不然升级上来全打不开)。 */ /** * 没记归属的那些老会话算谁的。 * * 以前一律按「公共」处理,本意是别让升级上来的历史消失。可账号体系上来之后,「公共」 * 的实际意思是:**每注册一个新账号,他一进来就看见前面所有人的任务历史**——刚建的 demo 号 * 打开侧栏,里头是管理员那几十条对话,标题、产出文件名、点开就是全文。 * * 跟助理页上下文那次(local_assist,见下面那段迁移)是同一个判断:没记归属 = 账号体系之前 * 留下的,那会儿这台机器上就一个人在用,认到管理员名下即可。历史一条不丢,也不铺给别人。 * * 不去改盘上的文件(那是 1500 个 JSON 的批量重写,写坏一个就是一段对话没了),只在读的时候 * 认这一笔。管理员是谁会变(改名、删号),所以每 30 秒回头问一次账号表。 */ let legacyOwner = { at: 0, name: "" }; function legacySessionOwner() { const now = Date.now(); if (now - legacyOwner.at < 30000) return legacyOwner.name; let name = ""; try { const boss = account.defaultUser(); name = boss ? boss.username : ""; } catch {} legacyOwner = { at: now, name }; return name; } /** * 一条会话(内存里的那份对象)现在算谁的。 * * 空壳不算谁的:前端是先自己生成 id、再发第一句话,中间这一下 getSession 会凭空造一份空的出来。 * 把空壳认到管理员名下的话,别的账号连自己刚开的那条新对话都打不开(403)。 */ function sessionOwner(s) { if (!s) return ""; if (s.user) return s.user; const 有内容 = (s.transcript && s.transcript.length) || (s.history && s.history.length); return 有内容 ? legacySessionOwner() : ""; } function sessionAllowed(user, s) { if (!user || !s) return true; const owner = sessionOwner(s); if (!owner || owner === user.username) return true; if (!account.canAdmin(user)) return false; const o = account._internals.loadUsers().users.find((u) => u.username === owner); if (o) return org.orgIdOf(o) === org.orgIdOf(user); // 主人已经删了:按删除时记下的组织认。原来这里是「找不到人就放行」—— // 任何一家公司的管理员、审计员都能翻别家删掉的员工留下的全部对话 const was = account.removedOrgOf(owner); return was ? was === org.orgIdOf(user) : ownsGlobalWorkspace(user); } function guardSession(req, res) { const s = getSession(req.params.id); if (sessionAllowed(req.user, s)) return s; res.status(403).json({ error: "这条对话不属于你" }); return null; } /** * 正在跑的那个任务,这次请求的人碰不碰得到。 * * 插队 / 回答 / 停止 / 续流这四条走的是 body.sessionId 或自己的 :id,绕开了 guardSession, * 以前一个字都没查归属:知道一个会话 id,就能往别人正在跑的任务里塞一句话、替他回答 * agent 弹出来的问题、把他的任务掐掉,或者把他的实时输出整段读走。会话 id 是 * s_<时间戳>_<随机数>,但它会出现在链接、日志、截图里,不是秘密,更不该当权限用。 * 判据跟 guardSession 同一条(sessionAllowed):本人放行,管理员限同组织,没记归属的老会话照旧。 */ function guardRun(req, res, id) { if (sessionAllowed(req.user, getSession(id))) return true; res.status(403).json({ ok: false, error: "这条对话不属于你" }); return false; } /** * 一页只占一条直播连接:/api/chat/live 用到的几样东西。 * * 2026-09-28 实测:同时跑 6 个对话,每个对话各占一条长连接(POST /api/chat 的 SSE、断线后的 * /api/chat/stream),Chromium 对同一主机最多开 6 条 HTTP/1.1 连接——池子被占满,点开别的对话、 * 点停止、开预览、预览里的 iframe 全排在后面干等,界面看着就是「点了没反应、右边一片白」。 * 所以运行中的事件改成整页合用一条 /api/chat/live,按 sid 分流,几条对话都只占这一个连接。 * * lastRuns:刚跑完的那一趟再留 60 秒。detach 模式下 POST 先回「收到了」、页面随后才来订阅, * 跑得快的任务(秒回、开头就报错)这中间可能已经收尾——不留着的话订上来只剩一句「没有这趟」, * 这一轮在页面上就是空白。 * side:不进回放记录、页面却要用的事件(成果目录、目标卡、标题、收尾文件清单、清理提示、done), * 每种只留最新一条,订阅晚到或重连时补发;前端按内容去重,补几遍都不会重复画。 */ const lastRuns = new Map(); // sessionId -> 最近一趟的 runState(跑完后再留 60 秒) let liveRunSeq = 0; const LIVE_SIDE = new Set(["dir", "goal", "title", "sweep", "files", "done"]); function noteSide(run, ev) { if (!ev || !LIVE_SIDE.has(ev.type)) return; if (ev.type === "files" && (ev.changed || []).length) return; // 有变更的那批已经进了记录,回放里有 if (!run.side) run.side = new Map(); run.side.set(ev.type, ev); } /** * 第 k 条记录发出之前、记录里的位置:n = 已经记了几条,t = 最后一条是文本时它有多长(不是文本记 -1)。 * 每帧都带上它,前端拿来校正自己的计数——以前全靠前端照着服务端的记账口径自己数, * 口径差一点(比如 files 事件)重连时就从错的位置补发,同一段过程画两遍。 */ function livePos(events, k) { const prev = k > 0 ? events[k - 1] : null; return { n: k, t: prev && prev.type === "text" ? String(prev.delta || "").length : -1 }; } let runtime; // MCP 启动后创建 app.get("/api/info", (_req, res) => { res.json({ app: "openworkbuddy", version: require("./package.json").version, provider: llm.provider, model: llm.model, skills: runtime ? runtime.getSkills().map((s) => s.name) : [], experts: experts.map((e) => e.name), mcp_tools: mcpManager.toolDefs().length, agent_plugins_spec: require("./src/core/ext/plugins").SPEC_VERSION, }); }); // 版本 / 更新检查。查的是 GitHub Releases,最快 6 小时一次(force=1 强查)。 // 界面上「有没有新版」和「你这种装法怎么升」是一起给的——只报版本号等于没说。 const updater = require("./src/server/updater"); // Windows 安装版能在应用里直接下、直接装(auto-update.js,在主进程里)。别的装法 auto.supported=false,界面照旧只讲「怎么升」 async function autoUpdate(op) { const bridge = require("./src/platform/electron-bridge"); try { if (bridge.isRemote()) { if (op === "state") return await bridge.call("update.state", {}, { timeoutMs: 5000 }); if (op === "download") return await bridge.call("update.download", {}, { timeoutMs: 5000 }); if (op === "install") return await bridge.call("update.install", {}, { timeoutMs: 5000 }); throw new Error("不支持的更新操作"); } if (bridge.mode() === "inproc") return await require("./src/desktop/auto-update").shared(require("electron"))[op](); } catch (e) { return { supported: false, why: "error", phase: "error", error: String(e.message || e) }; } return { supported: false, why: "no-shell" }; } app.get("/api/update", async (req, res) => { const [d, auto] = await Promise.all([updater.checkUpdate({ force: req.query.force === "1" }), autoUpdate("state")]); res.json({ ...d, auto }); }); // 下载、安装只给管理员:个人桌面版那一个人就是管理员;多人的服务器上本来就没有这个能力(auto.supported=false) app.get("/api/update/auto", async (_req, res) => res.json(await autoUpdate("state"))); const updateAllowed = (req, res) => { if (req.user && account.isAdmin(req.user)) return true; res.status(403).json({ error: "只有管理员能装更新" }); return false; }; app.post("/api/update/download", async (req, res) => { if (!updateAllowed(req, res)) return; res.json(await autoUpdate("download")); }); app.post("/api/update/install", async (req, res) => { if (!updateAllowed(req, res)) return; res.json(await autoUpdate("install")); }); // 跟 files 事件同一份清单,走后台线程翻目录(见 agent.js scanOutputs):输入框里敲 @ 就会拉它, // 在主线程上同步走整棵树,那一下所有对话的流式输出一起停 app.get("/api/files", (req, res, next) => { try { const dir = req.query.root ? rootedPath(req, ".") : getWorkspaceDir(); scanOutputs(dir).then((files) => res.json(files), next); } catch (error) { res.status(400).json({ error: error.message }); } }); // 资料库「工作区」那一栏:一次列一层,像访达那样一层层点进去。/api/files 是「最近动过的 500 个、 // 最深 3 层」,拿它当全集的话,第 4 层往下和第 501 个往后的文件哪儿都找不到(细账在 src/domains/library/ws-browse.js 开头)。 // 根跟 /api/files 是同一个:getWorkspaceDir() 已经被 tenantScope 绑到这个人所属组织、当前项目的工作区上。 // dir 里的 ..、绝对路径、链接一律 400;文件夹已经没了回 404,前端据此退回根。 const wsBrowse = require("./src/domains/library/ws-browse"); const wsIndex = require("./src/domains/library/ws-index"); // 工作区清单:后台线程走树,过期了先给旧的 app.get("/api/files/tree", (req, res) => { try { const root = getWorkspaceDir(); const out = wsBrowse.listDir(root, req.query.dir, { appDataDir: dataPath("data"), offset: req.query.offset }); res.json({ ...out, root: workspaceKeyOf(root) }); } catch (e) { res.status((e && e.status) || 500).json({ error: (e && e.message) || "读不了这个文件夹" }); } }); // 画布 / 短剧分镜表 / 一键合成三组路由在 src/server/routes/ 下,合成的任务队列在 src/domains/media/compose-jobs.js。 // 挂在这个位置不挪:登录、租户根、平台权限、脱敏这几道闸都在前面,过完了 getWorkspaceDir 才认得出是谁的工作区。 // 三份之间互相要用的几样(素材定位、读分镜表、等时钟走过版本号)也在这儿递过去,路由文件不回头 require server.js app.use(canvasRoutes.createCanvasRouter({ getWorkspaceDir, outputFiles, safePath, rootedPath, canvasList, canvasReadState, canvasWriteState, canvasNormalizeState, canvasSafeName, readDramaJson: dramaRoutes.readDramaJson, })); app.use(createCodeRouter({ getWorkspaceDir, gitIn: (cwd, args, req, o) => require("./src/agent/tools").sandboxedGit(cwd, args, { sec: config.security, actor: req.user && req.user.username, ...o }), isBusy: (dir) => { const dirs = [...activeRuns.values()].map((r) => r.root); try { for (const r of cliLive.list({ prune: false })) if (r.live) dirs.push(r.cwd); } catch {} return dirs.some((d) => d && taskDirs.samePlace(d, dir)); }, })); app.use(createComposeRouter({ jobs: createComposeJobs({ getWorkspaceDir, outputFiles, safePath, shellPath, canvasReadState, canvasWriteState, assetBase: canvasRoutes.assetBase, canvasAssetLocator: canvasRoutes.canvasAssetLocator, canvasAssetNear: canvasRoutes.canvasAssetNear, canvasTickPast: canvasRoutes.canvasTickPast, }), })); app.use(dramaRoutes.createDramaRouter({ getWorkspaceDir, outputFiles, safePath, account, org, budget, llm, llmForSession, unpricedChat, addUsage, toolRunSubdir, toolRunSubdirReady, canvasAssetNear: canvasRoutes.canvasAssetNear, })); app.use(libraryRoutes.createLibraryRouter({ libraryRootOf, rootedPath, rootOfResolved, getWorkspaceDir, safePathIn, workspaceKeyOf, thumbsDir: path.join(dataPath("data"), "thumbs"), busy: () => activeRuns.size > 0, libWalk: () => libWalk(), appDataDir: dataPath("data") })); // 用户自己的 Chrome:扩展连的那条 WebSocket 在 main() 里挂(src/core/ext/chrome-link.js),这里只是设置页那几个接口。 // 「打开扩展文件夹」只给桌面版的本机主人:服务器版点了是在服务器上开窗口,没人看得见 app.use(createChromeRouter({ canOpenFolder: (req) => !!(process.versions.electron && !process.env.ELECTRON_RUN_AS_NODE) && (admin.isSoloDesktop() || admin.platformAdmin(req.user)), openFolder: (dir) => openWithSystem(dir), extDir: appPath("extension", "chrome"), copyTo: dataPath("data", "chrome-extension"), packaged: () => require("./src/platform/paths").isPackaged(), desktop: !!process.versions.electron, })); app.use(createPromptTplsRouter({ isSolo: admin.isSoloDesktop, canEditOrg: (u) => require("./src/domains/account/rbac").can(u, "admin.write"), orgIdOf: org.orgIdOf, orgName: (u) => (org.getOrg(org.orgIdOf(u)) || {}).name || "", audit: org.audit, })); app.use(createGeoRouter({ getConfig: () => config })); // 只有这台机器的主人能导(个人桌面版或平台管理员):~/.claude、~/.codex 是跑服务器的那个系统账号的 // 保留已导入会话的续聊兼容;关闭新导入接口,不创建扫描器。 app.use(createSessionImportRouter({ isOwner: isPlatformOwner })); // 助理身份:界面一进来就要拿它画头像,所以单开一个轻接口,不用为了个名字去拉整份设置 // 多人服务器上成员给自己的助理另起过名字、换过头像的,看到的是他自己那份(prefs.assistantFor) app.get("/api/assistant", (req, res) => { if (!ownPrefs(req)) return res.json(config.assistant); const a = prefs.assistantFor(req.user && req.user.username, config); res.json({ ...config.assistant, name: a.name, avatar: a.avatar || config.assistant.avatar }); }); /** * 成员自己的助理:名字、头像、偏好,只影响他自己的对话(存 data/prefs/<账号>.json 的 assistant)。 * 留空 = 跟全局那份走。偏好是加在全局偏好后面的,平台管理员定的那份照样带着。 * 平台管理员和个人桌面版改的就是全局那份,走 POST /api/settings,这条不收。 */ app.get("/api/my-assistant", (req, res) => { if (!ownPrefs(req)) return res.json({ applies: false }); const mine = (prefs.read(req.user).assistant) || {}; res.json({ applies: true, name: mine.name || "", avatar: mine.avatar || "", persona: mine.persona || "", default_name: config.assistant.name, default_avatar: config.assistant.avatar }); }); app.post("/api/my-assistant", (req, res) => { if (!ownPrefs(req)) return res.status(400).json({ error: "平台管理员改的是全局那份,在上面的卡里改" }); const b = req.body || {}; const old = (prefs.read(req.user).assistant) || {}; const next = { ...old }; if (b.name !== undefined) next.name = String(b.name || "").trim().slice(0, 24); try { if (b.avatar !== undefined) next.avatar = b.avatar ? (account._internals.normalizeAvatar(b.avatar) || "") : ""; } catch (e) { return res.status(400).json({ error: e.message }); } if (b.persona !== undefined) next.persona = String(b.persona || "").slice(0, 4000); prefs.write(req.user, { assistant: next }); const a = prefs.assistantFor(req.user.username, config); res.json({ ok: true, name: a.name, avatar: a.avatar || config.assistant.avatar }); }); // ---------- 应用内设置(模型 + IM),保存到 config.json 并热生效 ---------- /** * 下拉框的数据源之一:精选模型目录。 * * 为什么不把目录硬编在前端:目录会过期,而前端是缓存在浏览器里的,用户可能几个月都拿着旧的那份。 * 放服务端,跟着版本走,升级一次就全对上了。 */ app.get("/api/model-catalog", (req, res) => { // brand_hints 跟着一起下发:前端要在**保存之前**就认出「这个型号是别家的」, // 认的规矩必须和服务端是同一份,否则前端放行、后端自愈,用户看到的就是「我选了 A,存完变成 B」 // kind_hosts(地址认家门那张表)同理:渠道类型选了官方、地址填的是别家,两边得判成同一家 res.json(mediaModels.clientCatalog()); }); // 从渠道现拉一次模型清单,10 分钟内不重复拉(换渠道来回点几下不该把人家接口打一遍) const modelListCache = new Map(); /** * 活列表:拿渠道自己的 /models 接口问「你这儿都有啥」。 * * 精选目录只放确认跑得通的那几款,各家发新版比我们发版快得多;活列表补的就是这一段。 * 拉不到不算错(很多国产渠道压根没有这个接口),前端照样有目录和手填两条路可走。 */ app.post("/api/provider-models", async (req, res) => { // 出网请求 + 带着 Key,只有平台管理员能发起;成员那边界面本来也不画这个按钮 if (!isPlatformOwner(req)) return res.status(403).json({ error: "渠道归平台管理员配", platform_only: true }); try { const b = req.body || {}; // 和渠道测活共用凭据边界:改地址/协议时,绝不把库里那把 Key 送过去。 const plan = chatModels.testPlan(config, { ...b, model: "" }); if (plan.error) return res.json({ ok: false, why: plan.error, models: [] }); const { base, key } = plan; const kind = plan.kind || mediaModels.guessKind(base); const api = mediaModels.protoOfChannel({ kind, api: plan.api }); if (base && !/^https?:\/\//i.test(base)) return res.json({ ok: false, why: "接口地址得是 http(s) 开头的完整地址", models: [] }); if (!base && !["anthropic", "gemini"].includes(api)) return res.json({ ok: false, why: "还没填接口地址", models: [] }); const input = { base_url: mediaModels.baseForUse(base, "chat", kind), api_key: key, api }; const cacheKey = modelDiscovery.cacheKey(input); const hit = modelListCache.get(cacheKey); if (b.refresh !== true && hit && Date.now() - hit.at < 600000) return res.json({ ...hit.result, cached: true }); const result = await modelDiscovery.discover(input); if (!result.ok) return res.json(result); result.models = result.models.filter((m) => !systemOne.isDecisionModel(m.id)); // 空清单不进缓存。它几乎不是稳定状态:Ollama 刚起来还没 pull 过东西、网关那头还在初始化, // 都会先回一个空的 data。缓住它,用户照着提示去 ollama pull 完回来点「重新问一次」, // 十分钟内拿到的还是那份空的——提示教他做的事做完了,界面上却没有任何变化 if (result.models.length) { if (modelListCache.size >= 100) modelListCache.delete(modelListCache.keys().next().value); modelListCache.set(cacheKey, { at: Date.now(), result }); } res.json(result); } catch (e) { // 超时、DNS 挂了、返回的不是 JSON——都算「这家没有列表」,不是错误页 res.json({ ok: false, why: String(e.message || e).slice(0, 200), models: [] }); } }); /** * 渠道测活:拿这条渠道真发一次「ping」,把上游的回答翻成人话。 * * 为什么非有不可:界面上一把 Key 填进去就显示「已配置」,可「填了」和「能用」是两回事—— * 余额扣光了、Key 是别家的、模型名在这家不存在,界面全都看不出来,非要等某个任务跑到一半才炸。 * probeModel 早就把 401/402/404/429 翻成了人话,只是一直只有开箱向导在用;这里把它摆到渠道卡上。 * * 拿哪个模型去 ping:只认这条渠道底下已登记的(点名的必须在列表里,没点名拿第一个)。 * 不再退到精选目录:那是用户没配过的型号,拿他的 Key 去打,猜错了报的 404 还会让人以为 Key 坏了。 * 用存着的 Key 测时地址和类型不许改,要换地址就连 Key 一起重填。规矩在 chatModels.testPlan 一处。 */ app.post("/api/provider-test", async (req, res) => { // 出网请求 + 带着 Key,跟 /api/provider-models 同一条规矩:只有平台管理员能发起 if (!isPlatformOwner(req)) return res.status(403).json({ ok: false, error: "渠道归平台管理员配", platform_only: true }); const b = req.body || {}; const plan = chatModels.testPlan(config, b); if (plan.error) return res.json({ ok: false, error: plan.error }); const { known, kind, base, key } = plan; const local = kind === "ollama" || /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(base); if (!key && !local) return res.json({ ok: false, error: "这个渠道还没填 Key,填完再测" }); // 判断模型(Jev)没有 /chat/completions 这条路,拿它去 ping 必然 400。 // 它有自己的测活:真问一道题,把答案也带回来——「通了」和「答得对不对」一次看完 if ((mediaModels.PROVIDER_KINDS.find((k) => k.kind === kind) || {}).decide_only) { const t = Date.now(); // 型号跟真跑时同一个:config.decide.model 钉过就测钉的那个,没钉才是出厂型号 const pinned = String((config.decide || {}).model || "").trim(); const r = await jev.selftest({ providers: [{ id: known.id || "tmp", kind, base_url: base, api_key: key }], ...(pinned ? { decide: { model: pinned } } : {}) }, { timeoutMs: 20000 }); return res.json({ ok: !!r.ok, ms: Date.now() - t, model: r.model || "", error: r.ok ? "" : r.error || "没答上来", answers: r.ok ? r.answers.map((a) => systemOne.lineOf(a)) : [], }); } const api = mediaModels.protoOfChannel({ kind, api: plan.api }); if (!["anthropic", "gemini"].includes(api) && !/^https?:\/\//i.test(base)) return res.json({ ok: false, error: "接口地址得是 http(s) 开头的完整地址" }); const model = plan.model; if (!model) { // 专门挂生图 / 生视频的渠道底下本来就一个对话模型都没有。以前这儿直接甩一句 // 「还没有对话模型」,等于告诉人「你这条渠道没法测」——可它明明配好了、也在用。 // 这种渠道改走不花钱的清单测活:Key 认不认、模型名在不在,一样能测出来。 // 挂在对话列表里、看名字却是生图 / 视频 / 配音的型号(testPlan 的 skipped)同样走这条, // 绝不拿它发对话请求:上游回的 400 只会让人以为 Key 或地址坏了 const media = (config.media_models || []).filter((m) => m.provider === known.id); const odd = (plan.skipped || [])[0]; if (odd || media.length) { const t = Date.now(); const hit = odd ? {} : (mediaModels.resolve(config).list || []).find((x) => x.id === media[0].id) || {}; const name = odd || media[0].model; const cap = odd ? mediaModels.mediaCapOf(odd) : media[0].cap; const capCn = mediaModels.CAP_CN[cap] || cap; const r = await probeMediaModel({ base_url: hit.base_url || mediaModels.baseForUse(base, "media", kind), api_key: hit.api_key || key, model: name, capCn, kind }); const pre = odd ? `「${odd}」看名字是${capCn},没拿它发对话请求。` : ""; return res.json({ ok: !r.error, ms: Date.now() - t, model: name, partial: !!r.partial || !!odd, note: r.note ? pre + r.note : "", error: r.error ? pre + r.error : "" }); } return res.json({ ok: false, error: "这个渠道下面还没挂任何模型。加一个再测——测活要拿一个真模型去打一次招呼,瞎猜一个名字测出来的 404 会让人误以为 Key 坏了" }); } const t0 = Date.now(); // 地址换算跟真跑对话同一份(baseForUse):百炼渠道存的是原生 /api/v1,对话在 /compatible-mode/v1。 // 拿原地址直接拼 /chat/completions,测的就不是真跑时那个地址 const why = await probeModel({ provider: api, base_url: mediaModels.baseForUse(base, "chat", kind), api_key: key, model }, { custom: ownBase(kind, base) }); res.json({ ok: !why, ms: Date.now() - t0, model, error: why || "" }); }); /** * 生图 / 生视频 / 配音 / 转写这四路的测活:**不真生成**。 * * 为什么不像对话那样真跑一次:生一次视频的钱够 ping 一千次,生一张图也不便宜。 * 一颗写着「测一下」的按钮,不该在人没预期的时候扣一笔——尤其它常常要连点好几次 * (改个模型名再测、换条渠道再测)。所以这儿只验两件不花钱的事: * ① 这把 Key 上游认不认(拿模型清单就知道,401/403 立刻现形) * ② 配的那个模型名在这家的清单里有没有(「模型不存在」是排第二常见的坑) * 验不到的那件必须说出来,不许拿「✓ 通了」糊过去:余额够不够、这个模型让不让你调, * 只有真生成一次才知道。含糊其辞的绿勾比红叉更坑人——人会拿它当「已经能用」。 */ async function probeMediaModel({ base_url, api_key, model, capCn, kind }) { const base = String(base_url || "").trim().replace(/\/+$/, ""); if (!/^https?:\/\//i.test(base)) return { error: "这条渠道的接口地址不是 http(s) 开头的完整地址" }; // 百炼原生的清单(GET /api/v1/models)是分页的:一页 20 个、一共一百多个,要测的型号多半不在第一页。 // 官方给了按型号 ID 精确查的参数 model(https://help.aliyun.com/zh/model-studio/list-models),原生地址上带上它 const byId = !!model && /\/api\/v1$/i.test(base) && (kind === "dashscope" || /dashscope|\.maas\.aliyuncs\.com/i.test(base)); let r; try { r = await fetch(`${base}/models${byId ? "?model=" + encodeURIComponent(model) : ""}`, { headers: api_key ? { Authorization: `Bearer ${api_key}` } : {}, signal: AbortSignal.timeout(15000), }); } catch (e) { const msg = String((e && e.message) || e); if (/timeout|abort/i.test(msg)) return { error: "连不上(15 秒超时)。国外服务商在国内直连经常打不通,挂代理或换国产渠道" }; return { error: "连不上:" + msg.slice(0, 160) }; } // 不是 2xx:状态码和上游原文照搬。403 不替人下结论——百炼上它常是「这把 Key 没开这个型号 / 这条路」,不等于 Key 错了 const said = r.ok ? "" : String(await r.text().catch(() => "")).replace(/\s+/g, " ").trim().slice(0, 200); const orig = said ? `。上游原文:${said}` : ""; if (r.status === 401) return { error: `这个 Key 上游不认(HTTP 401),检查有没有复制全、是不是这家服务商的 Key${orig}` }; if (r.status === 403) return { error: `上游拒绝了这次请求(HTTP 403)${orig}` }; if (r.status === 429) return { error: `被限流了(429),等一会儿再试${orig}` }; // 给不出清单不等于坏了:不少专做生图/生视频的接口根本没有 /models 这条路。 // 这种情况老老实实说「只验到这儿」,不编一个绿勾出来 if (!r.ok) return { partial: true, note: `这条渠道不给模型清单(HTTP ${r.status}),只验到「地址是通的」。Key 对不对、${capCn}「${model}」在不在,得真生成一次才知道${orig}` }; const j = await r.json().catch(() => null); // 清单的形状:OpenAI 兼容是 data[].id;百炼原生是 output.models[].model(那里的 name 是中文显示名,不是型号) const out = (j && j.output) || {}; const raw = (j && (Array.isArray(j.data) ? j.data : Array.isArray(j.models) ? j.models : Array.isArray(out.models) ? out.models : [])) || []; const ids = raw.map((m) => (typeof m === "string" ? m : String((m || {}).id || (m || {}).model || (m || {}).name || ""))).filter(Boolean); if (!ids.length) { return { partial: true, note: byId ? `Key 这一关过了,按型号查清单没查到${capCn}「${model}」。名字对不对,得真生成一次才知道` : `Key 这一关过了(清单接口没拒绝我们),但这条渠道一个模型都没列出来,没法核对${capCn}「${model}」这个名字` }; } if (!ids.includes(model)) { // 分了页的清单只回了一页:这一页里没有,不等于这条渠道没有 const total = Number(out.total); if (total > ids.length) return { partial: true, note: `Key 这一关过了。清单分了页(这一页 ${ids.length} 个、一共 ${total} 个),这一页里没有${capCn}「${model}」,没法下结论` }; return { error: `Key 是好的,但这条渠道列出来的 ${ids.length} 个模型里没有「${model}」。去 设置 → 模型 把它改成清单里的名字(在模型下拉框里挑,别手打)` }; } if (byId) return { note: `Key 认了,按型号查清单查到了「${model}」。这一步不花钱所以没真生成——余额够不够,得生成一次才知道` }; return { note: `Key 认了,「${model}」在这条渠道的清单里(一共 ${ids.length} 个)。这一步不花钱所以没真生成——余额够不够,得生成一次才知道` }; } /** * 一行一测:设置 → 模型 里每一行模型后面那颗「测」。 * * 跟渠道那颗「测一下」的分工:渠道那颗问的是「这条线通不通」,这颗问的是「**这一行**能不能用」。 * 一条渠道下面挂五个模型,通的是渠道、挂的却可能有三个模型名是错的—— * 只有渠道级测活的话,那三个要等任务跑到一半才炸。 */ app.post("/api/model-test", async (req, res) => { // 出网请求 + 带着 Key,跟 /api/provider-test 同一条规矩 if (!isPlatformOwner(req)) return res.status(403).json({ ok: false, error: "渠道归平台管理员配", platform_only: true }); const t0 = Date.now(); try { const b = req.body || {}; const isMedia = b.scope === "media"; const list = isMedia ? config.media_models || [] : config.models || []; const m = list[Number(b.index)]; if (!m) return res.json({ ok: false, error: "这一行已经不在了(多半是刚删过或者别处改了配置),刷新一下再试" }); // 测得通也要说:这个型号下个月就没了,现在能用不代表能继续用 const retire = retiredModels.noteOf(retiredModels.retirementOf(m.model)); const reply = (/** @type {any} */ o) => res.json(!retire ? o : { ...o, retire, error: o.error ? `${retire}(${o.error})` : "", note: o.ok ? `${o.note ? o.note + "。" : ""}注意:${retire}` : o.note }); const chanId = isMedia ? m.provider : m.channel; const p = (config.providers || []).find((x) => x.id === chanId); if (!p) return res.json({ ok: false, error: chanId ? "这一行挂的渠道已经被删了,编辑它重新挑一条" : "这一行没挂渠道,编辑它挑一条渠道再测" }); const local = p.kind === "ollama" || /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(p.base_url || ""); if (!p.api_key && !local) return res.json({ ok: false, error: `渠道「${p.name}」还没填 Key,先去下面那张渠道卡里填上` }); if (isMedia) { // 走 resolve 而不是直接读 p.base_url:生图那几路的地址跟对话不一定是同一个前缀, // 换算规则只有 media-models 那一份,这儿抄一遍迟早跟真跑的时候对不上 const hit = (mediaModels.resolve(config).list || []).find((x) => x.id === m.id) || {}; const capCn = mediaModels.CAP_CN[m.cap] || m.cap; const r = await probeMediaModel({ base_url: hit.base_url || p.base_url, api_key: hit.api_key || p.api_key, model: m.model, capCn, kind: p.kind }); return reply({ ok: !r.error, ms: Date.now() - t0, model: m.model, partial: !!r.partial, note: r.note || "", error: r.error || "" }); } // 对话列表里混进了生图 / 视频 / 配音的型号:不拿它发对话请求,改走不花钱的清单测活,结论只给半格 const odd = mediaModels.mediaCapOf(m.model); if (odd) { const capCn = mediaModels.CAP_CN[odd] || odd; const r = await probeMediaModel({ base_url: mediaModels.baseForUse(p.base_url, "media", p.kind), api_key: p.api_key, model: m.model, capCn, kind: p.kind }); const pre = `「${m.model}」看名字是${capCn},没拿它发对话请求。`; return reply({ ok: !r.error, ms: Date.now() - t0, model: m.model, partial: true, note: r.note ? pre + r.note : "", error: r.error ? pre + r.error : "" }); } const why = await probeModel({ provider: mediaModels.protoOfChannel(p), base_url: mediaModels.baseForUse(p.base_url, "chat", p.kind), api_key: p.api_key, model: m.model }, { custom: ownBase(p.kind, p.base_url) }); reply({ ok: !why, ms: Date.now() - t0, model: m.model, error: why || "", note: why ? "" : `「${m.model}」在渠道「${p.name}」上答得上话`, }); } catch (e) { res.json({ ok: false, ms: Date.now() - t0, error: String((e && e.message) || e).slice(0, 200) }); } }); /** * 判断模型(Jev):一段状态 + 几道有类型的题 → 几个代码能直接用的值。 * * 为什么它不在 /api/chat 那条路上:那条路是「拿文字」,这条是「拿主意」。 * Jev 不会写字,它返回的是选中的选项、一个分数、或者一个 0~1 的概率,外加一个确定度。 * 混进对话接口只会让两边的返回都得多带一个「这次其实是另一种东西」的标记。 * * GET 只说「现在能不能用、走的哪条路」,**不带 Key**——这一条给设置页和 doctor 看。 */ app.get("/api/decide", (req, res) => { res.json({ ...jev.status(config), kinds: systemOne.KINDS, kind_cn: systemOne.KIND_CN, routes: systemOne.ROUTE_IDS.map((id) => ({ ...systemOne.ROUTES[id] })), max_state: systemOne.MAX_STATE, max_questions: systemOne.MAX_QUESTIONS, sure_min: systemOne.SURE_MIN, }); }); /** * 真问一趟。 * * 额度按**题数**算,不按请求数:一次请求里塞 30 道题和塞 1 道题,上游收的钱差 30 倍, * 按请求记的话「一天 800 次」这道闸拦不住任何东西——一次请求就能把一天的量跑完。 */ app.post("/api/decide", async (req, res) => { const b = req.body || {}; const { questions, errs } = systemOne.normalizeQuestions(b.questions); if (errs.length) return res.status(400).json({ ok: false, error: errs.join(";") }); // 材料也在这儿查,不留到 jev.ask 里边——不然「既没材料又没配渠道」的那一趟会先被说成 // 「去配渠道」,人配完回来还是同一个 400。请求本身不对,跟配没配渠道没关系 const stateText = typeof b.state === "string" ? b.state.trim() : b.state; if (!stateText || (typeof stateText === "object" && !Object.keys(stateText).length)) { return res.status(400).json({ ok: false, error: "没给它要判断的东西(state)——问题问得再清楚,没有材料它也判断不了" }); } const out = await jev.askMetered(config, { state: b.state, questions, model: b.model, timeoutMs: Number(b.timeout_ms) || 0 }, { meta: Object.keys(questions).join("、") }); if (!out.ok) { if (out.quota) { security.audit("额度拦截", "判断模型:" + out.error, "拦截"); return res.status(429).json({ ok: false, error: out.error, quota: true }); } // 「没配」这一类必须带上 how:界面上就这一句话,只说「还没有能用的渠道」等于没说 return res.status(out.notReady ? 503 : out.badRequest ? 400 : 502) .json({ ok: false, error: out.error + (out.how ? "。" + out.how : ""), ms: out.ms || 0, not_ready: !!out.notReady }); } res.json({ ...out, lines: out.answers.map((a) => systemOne.lineOf(a)), cost: systemOne.costOf(out.usage), cost_text: systemOne.costText(out.usage), }); }); /** 一键测活:拿一段固定材料真问三道题。设置页那个「测一下」和命令行的 `openworkbuddy jev` 走的是同一条 */ app.post("/api/decide/selftest", async (req, res) => { const g = quota.gate("decide", { n: 3, provider: (jev.status(config) || {}).route || "" }); if (!g.ok) return res.status(429).json({ ok: false, error: g.why, quota: true }); const out = await jev.selftest(config); if (!out.ok) { quota.undo(g.hold); return res.status(502).json({ ok: false, error: out.error }); } quota.record("decide", { n: 3, provider: out.route || "", model: out.model || "", meta: "测活", hold: g.hold }); res.json({ ...out, lines: out.answers.map((a) => systemOne.lineOf(a)), cost_text: systemOne.costText(out.usage) }); }); /** * 四路媒体的老扁平配置(tools.js 读的那份)发给界面之前,按身份把 Key 掩掉。 * 掩成一串星号而不是空串:空串会被界面读成「还没配」,于是有人会去重填一遍—— * 而他根本没有改这台服务器 Key 的权限,白填一次还要吃一个 403。 */ function maskMedia(media, owner) { const out = {}; for (const cap of mediaModels.CAPS) { const c = { base_url: "", api_key: "", model: "", ...(cap === "tts" ? { voice: "" } : {}), ...((media || {})[cap] || {}) }; out[cap] = { ...c, api_key: owner ? c.api_key || "" : (c.api_key ? "********" : ""), has_key: !!c.api_key }; } return out; } // 升级整理做了什么。动过用户的文件就必须让他知道,而且要说清搬到哪儿了、原件还在。 // 界面看过一次就记下 id,不再打扰;这里不存「看过没有」,那是每台机器自己的事 app.get("/api/migrations", (_req, res) => res.json({ notes: global.__wbMigrationNotes || [] })); function embeddingView(req) { const e = config.embedding || {}; const own = !e.provider; // 单独填的一组地址 / Key;点名渠道的那种 Key 在渠道表里,这儿不回 const prov = e.provider ? (config.providers || []).find((p) => p && String(p.id) === String(e.provider)) : null; return { provider: e.provider ? String(e.provider) : "", base_url: own ? e.base_url || "" : "", model: e.model || "", api_key: own && e.api_key ? "********" : "", key_hint: own && isPlatformOwner(req) ? keyHint(e.api_key) : "", has_key: own && !!e.api_key, // 能挑的已配渠道(只聊天 / 只做媒体的不在里面),只回名字和型号建议 channels: embedChannels(config), // 选的那条渠道被删了、或换成了算不了向量的类型:界面照实说,让人重选 missing: e.provider && !(prov && embedHostable(prov.kind)) ? String(e.provider) : "", }; } /** 启动时改挂的那批只回一次:回过就清,不然每进一次设置页都弹同一句 */ function takeMovedOnBoot() { const m = movedOnBoot; movedOnBoot = []; return m; } function modelRetireNotes() { const seen = new Set(); const out = []; for (const m of [...(config.models || []), ...(config.media_models || [])]) { const id = String((m && m.model) || ""); if (!id || seen.has(id)) continue; seen.add(id); const r = retiredModels.retirementOf(id); if (r && (r.gone || r.soon)) out.push({ name: m.name || id, model: id, gone: r.gone, days: r.days, to: r.to, note: retiredModels.noteOf(r) }); } return out.sort((a, b) => Number(b.gone) - Number(a.gone) || a.days - b.days); } app.get("/api/settings", (req, res) => { // 个人偏好压在全局配置上面。没有个人偏好文件时这几个 *Cfg 原样返回 config 的那一份 const myAgent = prefs.agentCfg(config); const myPet = prefs.petCfg(config); const myModel = prefs.modelCfg(config); // 跟真跑的时候同一个判断:成员没选过、管理员那个本机引擎又没给他打开,这里就报内置 const myEngine = engines.currentId(config, { user: req.user && req.user.username }); const lay = taskDirs.folderLayout(getWorkspaceDir(), layoutAnchors()); res.json({ workspace_dir: getWorkspaceDir(), // 用的是自己那份工作目录(不止一个账号、又不是老主人):界面上不给换文件夹;项目是他自己那份(/api/projects 回 own) workspace_personal: !admin.keepsSharedSpace(req.user), // 默认工作空间里每个对话各有一个成果文件夹:成果区据此默认只摆「本对话」那一格 workspace_is_default: path.resolve(getWorkspaceDir()) === dataPath("workspace"), workspace_per_chat: perChatHere(), // 成果面板靠它决定新对话先摆「本对话」那一格,还是整个目录摊开 // 设置页那组单选按它画:每个对话一个文件夹 / 直接放进去;locked = 应用自己的目录,只能按对话分 workspace_layout: lay.layout, workspace_layout_locked: lay.locked, // Key 从不发原文,谁来问都一样:回一串八颗星(= 「没改」的暗号)+ has_key(配没配)。 // 平台管理员多拿一个 key_hint(sk-…4f2a)用来认「我装的是哪一把」;普通成员连这截也没有—— // 那是整台服务器的账单凭证,他既改不了也不该拿到手。 // 管理员也一样掩:八颗星是「原样传回来 = 没改」的约定暗号(POST 那边认这个正则), // 界面真正显示的是 key_hint 那一小截 // 成员只看得到共享给他的那几家,外加他自己填 Key 的(own: true,Key 同样不回原文) models: memberModels.listFor(config, isPlatformOwner(req) ? { owner: true } : modelWho(req.user)).map((m) => ({ ...m, team_available: !m.own && memberModels.sharedToMembers(config, m), api_key: m.api_key ? "********" : "", key_hint: isPlatformOwner(req) ? keyHint(m.api_key) : "", has_key: !!m.api_key, })), // 成员的「全局默认」:管理员那个没共享给他时,是他能用的第一个(跟 llmForSession 一个算法) active_model: isPlatformOwner(req) ? config.active_model : ((memberModels.defaultFor(config, modelWho(req.user)) || {}).name || ""), // 成员能不能自己填 Key;平台管理员在模型页那张「成员的模型」卡片上开关 member_byok: memberModels.byokAllowed(config), session_shares: config.session_shares === true && admin.multiUser(), model_health: healthSummary(), // 配着的模型里,服务商已经下线或快下线的。在设置页说,别等任务跑到那一步才报错 model_retire: isPlatformOwner(req) ? modelRetireNotes() : [], // 界面靠它决定「服务器级的那些输入框画不画」:画了却一存就 403,比不画更气人 platform_owner: isPlatformOwner(req), // 「助理设置」那页成员也画:他绑的是自己那套通道(/im/my/*) im_own: !!(req.user && !admin.isSoloDesktop() && !ownsGlobalWorkspace(req.user)), model_follow_last: myModel.model_follow_last, last_picked_model: myModel.last_picked_model, assist_model: myModel.assist_model, assist_engine: assistEngineOf(req.user), agent: { max_steps: config.agent.max_steps, tool_timeout_ms: config.agent.tool_timeout_ms, max_runtime_ms: config.agent.max_runtime_ms || 1800000, auto_continue_rounds: config.agent.auto_continue_rounds || 0, gen_parallel_max: config.agent.gen_parallel_max || 2, max_parallel_tasks: config.agent.max_parallel_tasks || 0, auto_parallel_tasks: require("./src/core/automation/run-governor").autoTasks(require("os").totalmem()), llm_timeout_ms: config.agent.llm_timeout_ms || 300000, max_context_chars: config.agent.max_context_chars || 0, // 0 = 没设,按模型窗口算(agent.js contextBudgetChars) max_tokens_budget: config.agent.max_tokens_budget || 0, second_opinion: !!config.agent.second_opinion, push_gate: !!config.agent.push_gate, continue_gate: !!config.agent.continue_gate, memory_gate: !!config.agent.memory_gate, ask_gate: !!config.agent.ask_gate, skill_gate: !!config.agent.skill_gate, // 上面这两个开关共用一面旗子:没配判断模型的时候,它们打开也不会生效,别让界面假装能开 judge_ready: jev.status(config).ready, failover_model: config.agent.failover_model || "", thinking: thinking.norm(myAgent.thinking), // 思考模式档位,默认 auto=跟随模型自己的默认 engine: myEngine, // 前端那个模型选择器要靠它说实话:走本机 CLI 的时候,API 模型列表整个不生效 engine_label: (engines.list().find((e) => e.id === myEngine) || {}).label || "", engine_options: myAgent.engine_options || {}, }, pet: { enabled: myPet.enabled === true, // 默认没有宠物:得用户在对话里开口要,或来这儿手动打开 character: myPet.character || "cat", scale: myPet.scale || pet.DEFAULT_SCALE, opacity: myPet.opacity || 1, notify: myPet.notify !== false, notify_done: myPet.notifyDone !== false, wander: myPet.wander === true, sprite: myPet.sprite || "", has_photo: !!petPhotoPath(), // 本机装了哪些精灵图宠物(~/.codex/pets、~/.petdex/pets、data/pets)。 // 扫的是文件头不是整张图,几毫秒的事,跟设置一起返回省一次往返。 sprites: petSprites.scanPets().map((x) => ({ id: x.id, name: x.displayName, source: x.source, ok: x.ok, why: x.why })), available: !!global.__openworkbuddyPet, // 纯 node 模式没有桌面窗口,前端要如实说明 }, persona: config.persona || "", assistant: config.assistant, search: { provider: (config.search || {}).provider || "", jina_key: (config.search || {}).jina_key || (config.search || {}).api_key || "", tavily_key: (config.search || {}).tavily_key || "", brave_key: (config.search || {}).brave_key || "", bocha_key: (config.search || {}).bocha_key || "", zhipu_key: (config.search || {}).zhipu_key || "", qiniu_key: (config.search || {}).qiniu_key || "", serper_key: (config.search || {}).serper_key || "", custom_key: (config.search || {}).custom_key || "", custom_url: (config.search || {}).custom_url || "", custom_query_field: (config.search || {}).custom_query_field || "", }, // 行程卡的地图:用哪家查地点、高德 Web 服务 Key、每月打高德的上限(搜索、路线分开)、国内路线默认用哪家打开。 // 上限给的是折算后的:老配置只有「每天上限」,怎么折见 places.settingsOf map: { provider: (config.map || {}).provider || "auto", amap_key: (config.map || {}).amap_key || "", amap_search_cap: geoPlaces.settingsOf(config).caps.search, amap_route_cap: geoPlaces.settingsOf(config).caps.route, nav: geoPlaces.settingsOf(config).nav, }, im: { feishu: (config.im || {}).feishu || { app_id: "", app_secret: "", verification_token: "", group_reply_mode: "mention" }, qq: (config.im || {}).qq || { app_id: "", app_secret: "" }, wecom_app: (config.im || {}).wecom_app || { corp_id: "", agent_id: "", secret: "", token: "", aes_key: "" }, wechat_mp: (config.im || {}).wechat_mp || { app_id: "", app_secret: "", token: "", aes_key: "" }, smtp: (config.im || {}).smtp || { host: "", port: "", user: "", pass: "", from: "", allow_to: "" }, // iLink 的 bot_token 是扫码换来的长期凭证,不回给前端(前端只需要知道连没连上,状态走 /im/status) wechat_ilink: { bot_id: ((config.im || {}).wechat_ilink || {}).ilink_bot_id || "" }, wecom_bot_webhook: (config.im || {}).wecom_bot_webhook || "", dingtalk_webhook: (config.im || {}).dingtalk_webhook || "", dingtalk_secret: (config.im || {}).dingtalk_secret || "", webhook_secret: (config.im || {}).webhook_secret || "", session_idle_hours: +(config.im || {}).session_idle_hours || 0, }, media: maskMedia(config.media, isPlatformOwner(req)), // 嵌入接口(语义召回)。Key 跟别的一样只回八颗星 + has_key embedding: embeddingView(req), // 渠道表:一把 Key 一行,图/视频/语音/视觉都引用它。普通成员看得见有哪些渠道,但看不到 Key—— // 那是整台服务器的账单凭证,他既改不了也不该拿到手 // 没共享给成员的渠道,成员连名字都不列(对话模型那边同样滤掉了,见 member-models.js) providers: (config.providers || []).filter((p) => isPlatformOwner(req) || p.share_members !== false).map((p) => ({ id: p.id, name: p.name, kind: p.kind, base_url: p.base_url, api: p.api || "", share_members: p.share_members !== false, image_api: p.image_api || "", video_api: p.video_api || "", tts_api: p.tts_api || "", asr_api: p.asr_api || "", // 模板里可能写着请求头,只给管理员看 ...(isPlatformOwner(req) && p.http_api ? { http_api: p.http_api } : {}), api_key: p.api_key ? "********" : "", key_hint: isPlatformOwner(req) ? keyHint(p.api_key) : "", has_key: !!p.api_key, })), media_models: config.media_models || [], // 启动时替用户改挂过的媒体模型,只回一次。只给平台管理员:改的是整台机器的配置 moved_on_boot: isPlatformOwner(req) ? takeMovedOnBoot() : [], security: config.security, // 系统沙箱眼下立没立起来、为什么:开着却没生效时界面要说出来,不能只摆一个「已开启」 sandbox_status: require("./src/agent/tools").sandboxStatus(), // 安全中心「命令能看到的环境变量」那张卡要照实说:像 Key 的名字现在写进清单给不给,看的是账号数 env_keys_pass: !admin.multiUser(), // 沙箱安全总开关只有一个账号时才认(security.sandboxOff),多个账号时设置页置灰并说明 solo: !admin.multiUser(), shortcuts: prefs.shortcutsCfg(config), // 执行追踪。私钥跟别的 Key 一个待遇:只有平台管理员看得见原文,其余人拿到八个星号。 // stats 是**实打实的上报账本**(发出去多少、丢了多少、上一次为什么失败)—— // 没有它,"开了但一条都没到" 和 "开了且正常" 在界面上长得一模一样 langfuse: { enabled: !!(config.langfuse || {}).enabled, host: (config.langfuse || {}).host || "https://cloud.langfuse.com", public_key: (config.langfuse || {}).public_key || "", secret_key: isPlatformOwner(req) ? (config.langfuse || {}).secret_key || "" : ((config.langfuse || {}).secret_key ? "********" : ""), has_secret: !!(config.langfuse || {}).secret_key, stats: tracing.getTracer(config).stats(), }, }); }); /** * 个人偏好落盘:底层引擎、思考档、上次选的模型、宠物开关、全局快捷键。 * * 校验一条都不能省。这些值不是摆设——引擎名写错会让任务在启动那一刻就炸,思考档写错 * 会让「我明明关了思考」和账单对不上。所以宁可当场 400,也绝不悄悄退回默认值。 * 抛出去的错由 /api/settings 那层的 try/catch 变成 400,跟全局设置的错误路径同一条。 */ /** * 这次的「个人偏好」该落哪儿。 * * false(落 config.json):个人桌面版——屏幕前就一个人,config 就是他的偏好;而且 Electron 壳 * 在任何人登录之前就要靠 config.pet / config.shortcuts 把宠物和快捷键装起来,写别处等于重启后消失。 * 平台管理员同理:他设的那份是这台机器的默认,定时任务和 IM 消息没有「当前登录的人」,只能读 config。 * true(落 data/prefs/<账号>.json):多人服务器上的其他人,谁也不覆盖谁。 */ /** * 设全局默认模型(设置页点「主用」、首次向导)。 * * 这也是一次「手动选模型」,得顺手记进 last_picked_model。不记的话,开着「新对话沿用上次 * 手动选的模型」时,上次在输入框里点过的那个永远压着:设置里刚把主用换成 A、点新对话, * 标签和真跑的都还是之前输入框里挑的 B——设置页上的选择等于没用。 * 只动 config 那份:能改全局默认的就是平台管理员,他的个人偏好本来就存在 config 里。 */ function setGlobalModel(name) { config.active_model = name; config.last_picked_model = name; } /** 属主给成员放行的型号:数组或逗号/换行分隔的一串都收,去空去重,最多 50 个 */ function engineModelList(v) { const raw = Array.isArray(v) ? v : String(v == null ? "" : v).split(/[,,\n]/); const out = []; for (const x of raw) { const s = String(x == null ? "" : x).trim().slice(0, 120); if (s && !out.includes(s)) out.push(s); } return out.slice(0, 50); } function ownPrefs(req) { return !(admin.isSoloDesktop() || ownsGlobalWorkspace(req && req.user)); } function savePersonalPrefs(user, personal) { const out = {}; if (personal.agent) { const a = {}; if (personal.agent.engine !== undefined) { const id = String(personal.agent.engine || "builtin").trim() || "builtin"; if (engines.get(id) === undefined) throw new Error("没有这个底层引擎:" + id); // 多人共用时外部引擎要管理员逐个给别的账号打开;没打开的现在就说,别等开跑才报(这一段只有成员走得到) if (id !== "builtin" && security.isMultiUser() && !engines.gateView(id, config).enabled) { throw new Error(`${engines.get(id).label || id}要平台管理员打开才能用,也可以在 ${engineGate.WHERE} 切回内置引擎。`); } a.engine = id; } if (personal.agent.thinking !== undefined) { const lv = String(personal.agent.thinking || "").trim().toLowerCase(); if (!thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv); a.thinking = lv; } if (personal.agent.engine_options && typeof personal.agent.engine_options === "object") { const eo = {}; for (const [id, v] of Object.entries(personal.agent.engine_options)) { if (engines.get(id) === undefined) continue; // 前端可能带上已经不存在的引擎,忽略即可,不值得整单失败 const cur = {}; if (v.model !== undefined) { // 成员自己挑的型号必须落在属主放行的列表里;空串 = 跟属主钉的那个走 const m = String(v.model || "").trim(); const allowed = engines.gateView(id, config).allowed; const name = engines.get(id).label || id; if (m && !allowed.length) throw new Error(`属主还没给${name}放行型号,请平台属主在 ${engineGate.WHERE} 里填。`); if (m && !allowed.includes(m)) throw new Error(`型号「${m}」不在属主给${name}放行的列表里,从列表里选一个。`); cur.model = m; } if (v.thinking !== undefined) { const lv = String(v.thinking || "").trim().toLowerCase(); if (lv && !thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv); cur.thinking = lv; // 空串 = 这个引擎跟随全局档位 } if (Object.keys(cur).length) eo[id] = cur; } if (Object.keys(eo).length) a.engine_options = eo; } if (Object.keys(a).length) out.agent = a; } if (typeof personal.model_follow_last === "boolean") out.model_follow_last = personal.model_follow_last; if (personal.last_picked_model !== undefined) { const n = String(personal.last_picked_model || "").trim(); if (n && !(config.models || []).some((m) => m.name === n)) throw new Error(`模型「${n}」不在模型列表里`); out.last_picked_model = n; } if (personal.pet && typeof personal.pet === "object") { const q = {}; for (const k of ["enabled", "notify", "notifyDone", "wander"]) if (typeof personal.pet[k] === "boolean") q[k] = personal.pet[k]; if (personal.pet.sprite !== undefined) { const sp = String(personal.pet.sprite || "").slice(0, 80); if (sp && !petSprites.findPet(sp)) throw new Error("没找到这只精灵图宠物(或它的图集不合规):" + sp); q.sprite = sp; } if (personal.pet.character !== undefined) { const c = personal.pet.character === "photo" ? "photo" : personal.pet.character === "sprite" ? "sprite" : "cat"; const sp = q.sprite !== undefined ? q.sprite : (prefs.read(user).pet || {}).sprite || ""; if (c === "sprite" && !sp) throw new Error("先选一只精灵图宠物,再切到这个形象"); q.character = c; } if (personal.pet.scale !== undefined) q.scale = Math.max(0.6, Math.min(2, Number(personal.pet.scale) || pet.DEFAULT_SCALE)); if (personal.pet.opacity !== undefined) q.opacity = Math.max(0.25, Math.min(1, Number(personal.pet.opacity) || 1)); if (Object.keys(q).length) out.pet = q; } if (personal.shortcuts && typeof personal.shortcuts === "object") { const sc = {}; for (const [k, v] of Object.entries(personal.shortcuts)) if (typeof v === "string" && v.length < 60) sc[k] = v; out.shortcuts = sc; } if (!Object.keys(out).length) return null; return prefs.write(user, out); } app.post("/api/settings", (req, res) => { try { let b = req.body || {}; const norm = {}; // 媒体两张表规整时替用户改挂了哪几条,回给前端 /** * 个人偏好分流。 * * 谁写 config.json:个人桌面版(屏幕前就一个人,config 就是他的偏好;而且 Electron 壳在 * 任何人登录之前就要靠 config.pet / config.shortcuts 把宠物和快捷键装起来), * 以及多人服务器上的平台管理员(他设的那份是这台机器的默认——定时任务和 IM 消息没有 * 「当前登录的人」,只能读 config)。 * 其余人写各自的 data/prefs/<账号>.json,谁也不覆盖谁。 */ if (ownPrefs(req)) { const { personal, rest } = prefs.split(b); const saved = Object.keys(personal).length ? savePersonalPrefs(req.user, personal) : null; b = rest; // 整单都是个人项:config 一个字节都不用动,也别白跑一次 saveConfig if (!Object.keys(b).length) return res.json({ ok: true, personal: true, saved: !!saved }); // 走到这儿说明单子里还剩服务器级的项(模型渠道、API Key、安全档位、IM、工作目录……), // 而能走进这个 if 的人按定义就不是平台管理员。界面本来就不给他画这些输入框, // 但接口不能只靠界面守——不然一条 curl 就能把整台机器的 Key 换掉。 return res.status(403).json({ error: `这些是整台服务器一份的设置,归平台管理员改:${Object.keys(b).join("、")}`, platform_only: true, }); } // 能换型号的附加参数在动任何配置之前就拦:型号只认「模型」栏和放行列表(engines/gate.js) for (const [id, v] of Object.entries((b.agent && b.agent.engine_options) || {})) { const badArg = v && Array.isArray(v.extraArgs) ? engineGate.modelArg(v.extraArgs, id) : ""; if (badArg) throw new Error(`附加参数里有换型号的「${badArg}」,型号请在「模型」栏里填。`); } if (Array.isArray(b.models)) { const old = new Map((config.models || []).map((m) => [m.name, m])); for (const m of b.models) { if (!m.name || !m.model) throw new Error("每个模型需要 name 和 model 字段"); // 判断模型(Jev)挂到对话模型列表里:下拉按渠道种类挡住了,可模型名是个自由输入框, // 手打一个照收——存得下、选得中,可它没有 /chat/completions,每一趟都是 400。 // 不在存的时候拦,人要等到真发一句话才知道,而那时候收到的是上游的 400, // 根本看不出是「挂错了地方」。拦住也得指路,否则就成了第二种摸不着头脑。 if (systemOne.isDecisionModel(m.model)) { throw new Error(`「${m.model}」是判断模型(Jev),它不产文字、没有 /chat/completions,挂在对话模型列表里每一趟都是 400。它走自己那条路:命令行 openworkbuddy jev、接口 /api/decide,或在渠道里加一条「TypeSafe Jev」再点那颗「测一下」`); } m.provider = mediaModels.normApi(m.provider); delete m.has_key; // 读接口给界面加的,不进配置文件 delete m.key_hint; // 同上:Key 的末四位只是给人看的,落盘就成了第二份 Key 副本 // 读接口给非管理员回的是掩码。真有人把掩码原样存回来,按「没改」处理,别把 Key 抹成八个星号 if (/^\*+$/.test(String(m.api_key == null ? "" : m.api_key).trim())) { m.api_key = String((old.get(m.name) || {}).api_key || ""); } } config.models = b.models; } if (b.active_model !== undefined) { if (!config.models.some((m) => m.name === b.active_model)) throw new Error("active_model 不在模型列表中"); setGlobalModel(b.active_model); } if (typeof b.model_follow_last === "boolean") config.model_follow_last = b.model_follow_last; if (b.last_picked_model !== undefined) { const n = String(b.last_picked_model || "").trim(); if (n && !(config.models || []).some((m) => m.name === n)) throw new Error(`模型「${n}」不在模型列表里`); config.last_picked_model = n; } // 内网模式。这是整台机器的网络事实,不是谁的偏好,所以走服务器级这一支(prefs.split // 的个人项是白名单,认不出的键一律算服务器级,非管理员改会拿到 403)。 // 它只负责「据实相告」:把连不上的连接器标出来、GitHub 装技能当场拒掉, // 不动任何已经配好的东西——万一判断错了,用户自己关掉就全恢复。 if (typeof b.intranet === "boolean") config.intranet = b.intranet; if (b.agent) { if (b.agent.max_steps) config.agent.max_steps = Math.max(1, Math.min(100, +b.agent.max_steps)); if (b.agent.tool_timeout_ms) config.agent.tool_timeout_ms = Math.max(5000, +b.agent.tool_timeout_ms); if (b.agent.max_runtime_ms) config.agent.max_runtime_ms = Math.max(60000, +b.agent.max_runtime_ms); if (b.agent.auto_continue_rounds !== undefined) config.agent.auto_continue_rounds = Math.max(0, Math.min(20, Math.round(+b.agent.auto_continue_rounds) || 0)); // 上限 4:这一档每条都花钱,给得太大等于把「手滑并发十条视频」变成一次点击的事 if (b.agent.gen_parallel_max !== undefined) config.agent.gen_parallel_max = Math.max(1, Math.min(4, Math.round(+b.agent.gen_parallel_max) || 2)); // 0 = 按这台机器的内存自动定 if (b.agent.max_parallel_tasks !== undefined) config.agent.max_parallel_tasks = Math.max(0, Math.min(32, Math.round(+b.agent.max_parallel_tasks) || 0)); if (b.agent.llm_timeout_ms) config.agent.llm_timeout_ms = Math.max(30000, +b.agent.llm_timeout_ms); // 下限 2 万字符:再小连最近几步的工具原文都留不住,agent 会失忆式反复重做 if ("max_context_chars" in b.agent) { const v = +b.agent.max_context_chars; if (v > 0) config.agent.max_context_chars = Math.max(20000, Math.min(2000000, v)); else delete config.agent.max_context_chars; // 清空 = 回到按模型窗口算 } if (b.agent.max_tokens_budget !== undefined) config.agent.max_tokens_budget = Math.max(0, Math.round(+b.agent.max_tokens_budget) || 0); // 定时任务跑绿之后再让判断模型看一眼。默认关,因为它每条绿都要花一道题的钱 if (b.agent.second_opinion !== undefined) config.agent.second_opinion = !!b.agent.second_opinion; // 没变化就不推。默认关:它少响一声铃,而「少响的那一声」用户看不见,得他自己点头 if (b.agent.push_gate !== undefined) config.agent.push_gate = !!b.agent.push_gate; if (b.agent.continue_gate !== undefined) config.agent.continue_gate = !!b.agent.continue_gate; // 往长期记忆里写之前先判一句。默认关:它拒错一条,用户只会觉得「说过的事它又忘了」 if (b.agent.memory_gate !== undefined) config.agent.memory_gate = !!b.agent.memory_gate; if (b.agent.ask_gate !== undefined) config.agent.ask_gate = !!b.agent.ask_gate; if (b.agent.skill_gate !== undefined) config.agent.skill_gate = !!b.agent.skill_gate; if (b.agent.thinking !== undefined) { const lv = String(b.agent.thinking || "").trim().toLowerCase(); // 写错档位当场拒绝,不悄悄退回 auto:用户以为关掉了思考、账单却照着思考的量涨 if (!thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv); config.agent.thinking = lv; } if (b.agent.failover_model !== undefined) { const fm = String(b.agent.failover_model || "").trim(); if (fm && !config.models.some((m) => m.name === fm)) throw new Error("备用渠道不在模型列表中"); config.agent.failover_model = fm; // 空串 = 关闭自动换道(默认) } if (b.agent.engine !== undefined) { const id = String(b.agent.engine || "builtin").trim() || "builtin"; // 写错名字当场拒绝。悄悄退回内置 = 用户以为在用免费的本机订阅,账单却在涨 if (engines.get(id) === undefined) throw new Error("没有这个底层引擎:" + id); config.agent.engine = id; } if (b.agent.engine_options && typeof b.agent.engine_options === "object") { config.agent.engine_options = config.agent.engine_options || {}; for (const [id, v] of Object.entries(b.agent.engine_options)) { if (engines.get(id) === undefined) continue; if (!v || typeof v !== "object") continue; const cur = (config.agent.engine_options[id] = config.agent.engine_options[id] || {}); for (const k of ["model", "bin", "permissionMode", "sandbox"]) if (v[k] !== undefined) cur[k] = String(v[k] || "").trim(); // 多人共用时这个引擎开不开、成员能挑哪几个型号:只有属主写得进来(这一段只有属主走得到) if (v.enabled !== undefined) cur.enabled = !!v.enabled; if (v.models !== undefined) cur.models = engineModelList(v.models); if (v.thinking !== undefined) { const lv = String(v.thinking || "").trim().toLowerCase(); if (lv && !thinking.LEVELS.includes(lv)) throw new Error("没有这个思考模式档位:" + lv); cur.thinking = lv; // 空串 = 这个引擎跟随全局档位 } if (v.network !== undefined) cur.network = !!v.network; if (v.approval !== undefined) cur.approval = !!v.approval; // claude 要审批的动作交给安全中心判(engines/approve.js) if (v.relay !== undefined) cur.relay = !!v.relay; // 借出去的工具交回主进程跑(engines/tool-relay.js) if (Array.isArray(v.extraArgs)) cur.extraArgs = v.extraArgs.map((x) => String(x)).slice(0, 20); } } } if (b.pet) { const cur = config.pet || {}; // 精灵图那两个字段先验后写:这个 handler 一路在往 config 上直接赋值, // 半路 return 400 会留下「内存里改了、盘上没存」的半拉状态。所以校验全部前置。 const nextSprite = b.pet.sprite !== undefined ? String(b.pet.sprite || "").slice(0, 80) : cur.sprite || ""; const nextChar = b.pet.character === undefined ? cur.character || "cat" : b.pet.character === "photo" ? "photo" : b.pet.character === "sprite" ? "sprite" : "cat"; // 存一个扫不到的 id 进去,结果是宠物窗口悄悄回落成内置猫, // 用户体感是「换了但没生效」——不如当场拒了,把原因说清楚。 if (nextSprite && !petSprites.findPet(nextSprite)) return res.status(400).json({ error: "没找到这只精灵图宠物(或它的图集不合规):" + nextSprite }); if (nextChar === "sprite" && !nextSprite) return res.status(400).json({ error: "先选一只精灵图宠物,再切到这个形象" }); config.pet = cur; if (typeof b.pet.enabled === "boolean") config.pet.enabled = b.pet.enabled; if (typeof b.pet.notify === "boolean") config.pet.notify = b.pet.notify; if (typeof b.pet.notifyDone === "boolean") config.pet.notifyDone = b.pet.notifyDone; if (typeof b.pet.wander === "boolean") config.pet.wander = b.pet.wander; config.pet.sprite = nextSprite; config.pet.character = nextChar; if (b.pet.scale !== undefined) config.pet.scale = Math.max(0.6, Math.min(2, Number(b.pet.scale) || pet.DEFAULT_SCALE)); if (b.pet.opacity !== undefined) config.pet.opacity = Math.max(0.25, Math.min(1, Number(b.pet.opacity) || 1)); if (global.__openworkbuddyPet) try { global.__openworkbuddyPet.applyConfig({ ...config.pet, enabled: config.pet.enabled === true }); } catch {} } if (b.evolve) { config.evolve = config.evolve || {}; // 默认关:每晚要调一次模型,是花钱的事,得你自己点开 if (typeof b.evolve.auto === "boolean") config.evolve.auto = b.evolve.auto; if (b.evolve.hour !== undefined) config.evolve.hour = Math.max(0, Math.min(23, Math.round(+b.evolve.hour) || 0)); if (b.evolve.days !== undefined) config.evolve.days = Math.max(1, Math.min(365, Math.round(+b.evolve.days) || 14)); } if (b.persona !== undefined) config.persona = String(b.persona).slice(0, 4000); if (b.assistant) { if (b.assistant.name !== undefined) { const n = String(b.assistant.name).replace(/\s+/g, " ").trim(); if (n.length > 24) throw new Error("助理名字最多 24 个字"); config.assistant.name = n || ASSISTANT_DEFAULT.name; // 清空就退回默认,别让它变成没名字的空气 } // 头像的校验规则和用户头像完全一样(emoji 或 ≤256KB 的 data URI),共用一处免得两边规则跑偏 if (b.assistant.avatar !== undefined) config.assistant.avatar = account._internals.normalizeAvatar(b.assistant.avatar) || ASSISTANT_DEFAULT.avatar; } if (b.search) { config.search = config.search || {}; // 先存 Key,再校验 provider。顺序反过来踩过一次大的: // 选了一家这个进程还不认的服务商(程序更新了但没重开,内存里还是老名单), // provider 那一句先 throw,整个保存被回滚——人刚一个一个敲进去的七八把 Key 一起没了, // 而界面只说了句「provider 只认这几家」,谁也想不到自己的 Key 被顺手丢了。 for (const k of ["jina_key", "tavily_key", "brave_key", "bocha_key", "zhipu_key", "qiniu_key", "serper_key", "custom_key", "custom_url", "custom_query_field"]) { if (b.search[k] !== undefined) config.search[k] = String(b.search[k]).trim(); } if (b.search.provider !== undefined) { // 这张名单跟 tools.js 的 SEARCH_PROVIDERS 是同一份;加一家要两边一起加, // 只加一边的后果是:设置里存得下,真搜的时候那家不存在,整条接力从第二家才开始 const 支持的 = Object.keys(SEARCH_PROVIDERS); if (b.search.provider !== "" && !支持的.includes(b.search.provider)) { saveConfig(); // Key 已经写进 config 了,先落盘再报错——别让人白填一遍 throw new Error( `正在跑的这个程序还不认「${b.search.provider}」这一家(它认的是:${支持的.join(" / ")})。` + "多半是程序升级前就一直开着,退出重开一次就好。你刚填的 Key 已经存下来了,不用重填。" ); } config.search.provider = b.search.provider; } } if (b.map && typeof b.map === "object") { config.map = config.map || {}; if (b.map.amap_key !== undefined) config.map.amap_key = String(b.map.amap_key).trim(); if (b.map.provider !== undefined) { if (!["auto", "amap", "osm"].includes(b.map.provider)) throw new Error(`地图只认 auto / amap / osm,收到的是「${b.map.provider}」`); config.map.provider = b.map.provider; } if (b.map.nav !== undefined) { if (!["amap", "google"].includes(b.map.nav)) throw new Error(`打开路线只认 amap / google,收到的是「${b.map.nav}」`); config.map.nav = b.map.nav; } // 老界面(升级前开着没刷新的那页)还会送每天的上限:照收,places.settingsOf 会折成每月 if (b.map.amap_daily_cap !== undefined) { const n = Math.floor(+b.map.amap_daily_cap); if (!Number.isFinite(n) || n < 0) throw new Error("每天打高德的上限要填 0 或正整数"); config.map.amap_daily_cap = n; } for (const [k, what] of [["amap_search_cap", "每月高德搜索的上限"], ["amap_route_cap", "每月高德路线的上限"]]) { if (b.map[k] === undefined) continue; const n = Math.floor(+b.map[k]); if (!Number.isFinite(n) || n < 0) throw new Error(`${what}要填 0 或正整数`); config.map[k] = n; } // 两项新的都存了,老的每天上限就用不上了,删掉免得以后看配置的人以为它还管用 if (b.map.amap_search_cap !== undefined && b.map.amap_route_cap !== undefined) delete config.map.amap_daily_cap; // 地图设置存过(多半是换了 Key):今天因为 Key 用不了停掉的高德,按新设置再试 geoPlaces.resetStop(); } if (b.workspace_layout !== undefined && b.workspace_layout !== "per_chat" && b.workspace_layout !== "flat") { throw new Error("文件夹用法只有两种:per_chat(每个对话一个文件夹)、flat(直接放进去)"); } if (b.workspace_dir !== undefined && b.workspace_dir !== getDefaultWorkspaceDir()) { config.workspace_dir = setWorkspaceDir(b.workspace_dir); // 输入框旁的快速切换是临时的(新任务会切回项目目录);设置中心改路径才算改默认,同步进当前项目 if (b.workspace_permanent === true) { ensureProjects(); const ap = config.projects.find((p) => p.name === config.active_project); if (ap) ap.dir = config.workspace_dir; } // 选了文件夹就把它怎么放成果记下来:没明说按此刻判的记,见 noteLayout noteLayout(config.workspace_dir, b.workspace_layout); } else if (b.workspace_layout !== undefined) { // 文件夹没换、只改了用法(设置页同一个文件夹换了单选,或输入框旁那个菜单里切的):改的是眼下这个文件夹 noteLayout(getWorkspaceDir(), b.workspace_layout); } config.im = config.im || {}; if (b.im) imConfig.mergeImPatch(config.im, b.im); if (Array.isArray(b.providers)) { const old = new Map((config.providers || []).map((p) => [p.id, p])); config.providers = b.providers.map((p) => { const prev = old.get(p.id) || {}; // 读接口给非管理员回的是掩码。真有人把掩码原样存回来,按「没改」处理,别把 Key 抹成八个星号 const key = String(p.api_key == null ? prev.api_key || "" : p.api_key).trim(); return { id: String(p.id || "").trim(), name: String(p.name || "").trim(), kind: String(p.kind || "").trim(), base_url: String(p.base_url || "").trim(), api_key: /^\*+$/.test(key) ? prev.api_key || "" : key, // 接口格式:空 = 按渠道类型;认不出的值不落盘,免得一个拼错的字把整条渠道带去走最通用那种还不自知 ...(mediaModels.isApiFormat(p.api) ? { api: p.api } : {}), // 生图 / 视频 / 配音接口同理:空 = 自动,认不出的值和对话专用渠道上的都不落盘 ...mediaModels.mediaApisOf(p), // 中转站「放行任意型号」只能在中转站页由平台超级管理员开关(admin.js 那条路由会记审计)。 // 这里只从旧值抄过来、不认表单里的:设置页的保存权限比那个开关宽,不能变成一条绕过去的路 ...(prev.relay_any_model === true ? { relay_any_model: true } : {}), // 「给不给成员用」只走 POST /api/models/share,整表回存时抄旧值,表单漏带也不会悄悄共享出去 ...(prev.share_members === false ? { share_members: false } : {}), }; }); auditKeyChanges(req, old, config.providers); } if (Array.isArray(b.media_models)) { for (const m of b.media_models) { if (!String(m.model || "").trim()) throw new Error("每个模型都得填模型名"); if (!mediaModels.CAPS.includes(m.cap)) throw new Error("不认识这一路能力:" + m.cap); } // protocol 也得留住。它是手改 config.json 的人才会用的那道口子:一台 new-api 网关 // 后面挂着好几家上游,地址和 Key 只有一份,只能逐条说「这个型号后面是万相」。 // 这里不白名单它的话,下次在设置页点一下保存就被抹掉了,人还找不出是哪一步弄丢的。 config.media_models = b.media_models.map((m) => ({ id: String(m.id || "").trim(), cap: m.cap, name: String(m.name || "").trim(), provider: String(m.provider || "").trim(), model: String(m.model || "").trim(), voice: String(m.voice || "").trim(), protocol: String(m.protocol || "").trim(), default: !!m.default, })); } if (b.media) { // 扁平写法(向导第三步):直接落成渠道 + 模型条目并设为默认。只写 config.media 的话, // 下面 normalize 按 media_models 重算时会把它原样抹掉,界面却已经亮了「已配」。 // 排在两张表整表替换之后,同一次提交里带了 providers 也不会把刚建的渠道冲掉;先全验一遍再动手,免得存一半 const caps = mediaModels.CAPS.filter((k) => b.media[k]); // 单一真源:加一路能力只改 media-models.js 的 CAPS for (const k of caps) { if (!String(b.media[k].base_url || "").trim() || !String(b.media[k].model || "").trim()) throw new Error("接口地址和模型名都要填"); } for (const k of caps) mediaModels.upsertLegacy(config, k, b.media[k]); } // 两张表任何一张动过,就重算 id、补默认项、把「默认那条」压平回 config.media, // 这样 tools.js 那边永远读到一份现成的扁平配置,不用关心多模型这套 if (b.media || b.providers || b.media_models) { mediaModels.normalize(config, norm); // 渠道配置动过 = 用户已经去处理那条断掉的路了(换了 Key、换了型号、换了家)。 // 熔断记录这时必须清空,否则他改完还得干等半小时,然后来问「我都改好了怎么还不动」。 mediaHealth.reset(); } // 渠道改了 Key,挂在它底下的对话模型要跟着换——压平这一步就是干这个的 if (b.models || b.providers) chatModels.normalize(config); if (b.security) { const sec = security.getSecurity(config); for (const k of ["gateway", "delete_protect", "cmd_risk_gate", "runtime_node", "runtime_python", "sandbox_safety"]) { if (typeof b.security[k] === "boolean") sec[k] = b.security[k]; } for (const k of ["batch_delete_threshold", "approval_timeout_s"]) { if (b.security[k] !== undefined) sec[k] = Math.max(1, +b.security[k] || security.DEFAULTS[k]); } for (const k of ["file_whitelist", "file_blacklist", "cmd_allow", "cmd_ask", "url_whitelist", "url_blacklist"]) { if (Array.isArray(b.security[k])) sec[k] = b.security[k].map((s) => String(s)).filter((s) => s.trim()).slice(0, 100); } // 外挂的第二把尺子:auto / advisory / off 三挡,别的值一律当没填过(默认 auto) if (b.security.toolward !== undefined) { const m = String(b.security.toolward || "").toLowerCase(); sec.toolward = ["auto", "advisory", "off"].includes(m) ? m : security.DEFAULTS.toolward; } // 指死一个可执行文件的路径。这是条会被执行的路径,长度掐住,别让它变成往配置里塞东西的口子 if (b.security.toolward_bin !== undefined) sec.toolward_bin = String(b.security.toolward_bin || "").trim().slice(0, 500); // 子进程额外放行的环境变量名。只收合法变量名、最多 50 条;像 Key 的照收,给不给由账号数在起子进程时判 if (Array.isArray(b.security.env_passthrough)) sec.env_passthrough = childEnv.cleanNames(b.security.env_passthrough); // 本机/内网放行清单:只收 host:端口(端口可写 *,主机可带 * 通配),写成网址的剥成 host:端口,最多 50 条 if (Array.isArray(b.security.url_allow_local)) sec.url_allow_local = netAddr.cleanAllow(b.security.url_allow_local); // 系统沙箱档位:认不出的值一律当没填过(按部署走),不能因为一个错字就变成不套 if (b.security.sandbox !== undefined) { const m = String(b.security.sandbox || ""); sec.sandbox = ["default", "auto", "required", "off"].includes(m) ? m : "default"; } if (b.security.sandbox_level !== undefined) sec.sandbox_level = b.security.sandbox_level === "basic" ? "basic" : "hardened"; if (b.security.sandbox !== undefined || b.security.sandbox_level !== undefined || b.security.sandbox_safety !== undefined) require("./src/agent/tools").warmSandbox(sec); } if (b.langfuse && typeof b.langfuse === "object") { const cur = config.langfuse || (config.langfuse = { enabled: false, host: "https://cloud.langfuse.com", public_key: "", secret_key: "" }); if (b.langfuse.host !== undefined) { const h = String(b.langfuse.host || "").trim().replace(/\/+$/, ""); // 地址写错是这一块最常见的坑(漏 https://、把浏览器地址栏里带路径的整条粘进来)。 // 不在这儿拦的话,上报会一直静默失败,用户只会看到"开着,但一条都没有" if (h && !/^https?:\/\/[^\s]+$/.test(h)) throw new Error("Langfuse 地址得是 http:// 或 https:// 开头的网址"); cur.host = h || "https://cloud.langfuse.com"; } if (b.langfuse.public_key !== undefined) cur.public_key = String(b.langfuse.public_key || "").trim(); if (b.langfuse.secret_key !== undefined) { const k = String(b.langfuse.secret_key || "").trim(); cur.secret_key = /^\*+$/.test(k) ? cur.secret_key || "" : k; // 掩码原样存回来 = 没改 } if (b.langfuse.enabled !== undefined) { const on = !!b.langfuse.enabled; // 开着却没钥匙,等于以为在记其实一条都没发。这种"看起来成了"的状态最坑人,当场拦掉 if (on && !(cur.public_key && cur.secret_key)) throw new Error("要开执行追踪,公钥和私钥都得填(在 Langfuse 项目设置里生成一对)"); cur.enabled = on; } } if (b.embedding !== undefined) { // 语义召回用的嵌入模型。全空 = 清掉,记忆按关键词召回——不会再从聊天 / 媒体渠道里借 Key 自己找一个, // 用户没选过的型号不该花他的钱(llm.js embedCandidates)。两种存法:点名一条已配渠道 + 型号 // (Key 用时从渠道表现取),或者单独填一组地址 / Key / 型号(只接了 DeepSeek 这类没有嵌入接口的人靠它) const e = b.embedding || {}; const provider = String(e.provider || "").trim(); const base = String(e.base_url || "").trim().replace(/\/+$/, ""); const model = String(e.model || "").trim(); if (!provider && !base && !model) delete config.embedding; else if (provider) { const p = (config.providers || []).find((x) => x && String(x.id) === provider); if (!p) throw new Error("没有这条渠道,先去 设置 → 模型 把它加上"); if (!embedHostable(p.kind)) throw new Error(`「${p.name || p.id}」算不了向量,换一条渠道或单独填接口`); if (!model) throw new Error("模型名要填,比如 text-embedding-3-small"); config.embedding = { provider: String(p.id), model }; } else { if (!/^https?:\/\/[^\s/]+/i.test(base)) throw new Error("接口地址要以 http:// 或 https:// 开头"); if (!model) throw new Error("模型名要填,比如 text-embedding-3-small"); const old = config.embedding || {}; let key = String(e.api_key == null ? "" : e.api_key).trim(); if (/^\*+$/.test(key)) { // 八颗星 = 没改。可地址换了还沿用旧 Key,等于把这家的 Key 发给了另一家 if (old.provider || String(old.base_url || "").replace(/\/+$/, "") !== base) throw new Error("换了接口地址,Key 要重新填一遍"); key = String(old.api_key || ""); } config.embedding = { base_url: base, api_key: key, model }; } } if (b.shortcuts && typeof b.shortcuts === "object") { config.shortcuts = {}; for (const [k, v] of Object.entries(b.shortcuts)) { if (typeof v === "string" && v.length < 60) config.shortcuts[k] = v; } if (global.__wbRegisterShortcuts) global.__wbRegisterShortcuts(config.shortcuts); // 桌面版重注册全局快捷键 } llmInner = createLLM(config); // 模型热切换 memory.setEmbedder(createEmbedder(config)); setSessEmbedder(createEmbedder(config)); memory.ensureVectors().catch(() => {}); saveConfig(); if (b.im && b.im.feishu && imBridge) { imBridge.startFeishuWs(true).catch((e) => console.warn("[飞书] 长连接重启失败:", e.message)); // 飞书配置变更后重建长连接 } if (b.im && b.im.qq && imBridge) { imBridge.startQQ(true).catch((e) => console.warn("[QQ] 长连接重启失败:", e.message)); } // 这一趟存的时候替用户改挂了哪几条(moved),前端拿去说一声 res.json({ ok: true, provider: llm.provider, model: llm.model, moved: norm.moved || [] }); } catch (e) { res.status(400).json({ error: e.message }); } }); /** * 执行追踪连通性自检。 * * 为什么要有这颗按钮:这一块的失败全是静默的——地址少个字母、Key 是另一个项目的、 * 自建实例的端口没开,任务照跑不误,只是 trace 永远是空的。等用户想起来去点链接, * 已经过去好几天了。所以在设置页当场发一条真的上去,成不成立刻说,顺带把那条探针的链接给他。 * 没存的草稿值也能测(body 里带什么就用什么),不然只能"先存了再看结果"。 */ app.post("/api/trace/test", async (req, res) => { if (!isPlatformOwner(req)) return res.status(403).json({ error: "执行追踪是整台服务器一份的设置,归平台管理员改", platform_only: true }); const b = req.body || {}; const cur = config.langfuse || {}; const sk = String(b.secret_key == null ? "" : b.secret_key).trim(); const out = await tracing.getTracer(config).probe({ host: b.host !== undefined ? b.host : cur.host, public_key: b.public_key !== undefined ? String(b.public_key || "").trim() : cur.public_key, // 界面上私钥那格给非管理员是掩码;管理员拿到的是原文。原样传回来(或压根没动)都按"用已存的那把" secret_key: !sk || /^\*+$/.test(sk) ? cur.secret_key || "" : sk, }); res.json(out); }); // 本地 Trace:默认记录在当前 workspace/.openworkbuddy/traces.jsonl,不依赖 Langfuse。 // Langfuse 只是可选的外部副本,用户可以先用本地追踪排查任务,再决定是否外发。 // 这三条一律锁平台管理员。一本账里记的是**整台服务器**上跑过的每趟任务:提示词原文、 // 工具参数、文件路径、最终回复,全都在里面,而且不带账号维度——多人服务器上放开给成员看, // 等于把同事的对话内容原样端出去。界面上这一页也只对平台管理员显示(PLATFORM_ONLY_VIEWS)。 const traceOwnerOnly = (req, res) => { if (isPlatformOwner(req)) return false; res.status(403).json({ error: "执行记录是整台服务器一份的,归平台管理员看", platform_only: true, traces: [] }); return true; }; // 执行记录跨项目可见;只查本应用记住的根,不扫描磁盘、不接受请求传入任意目录。 function traceReadOptions() { return { roots: [...new Set([...allRoots(), dataPath("workspace"), dataPath()])] }; } app.get("/api/traces", (req, res) => { if (traceOwnerOnly(req, res)) return; try { const limit = Math.max(1, Math.min(200, Number(req.query.limit) || 50)); const sessionId = String(req.query.session || "").slice(0, 120); res.json({ traces: tracing.getTracer(config).localTraces({ ...traceReadOptions(), limit, sessionId }) }); } catch (e) { res.status(500).json({ error: e.message, traces: [] }); } }); app.get("/api/traces/:id", (req, res) => { if (traceOwnerOnly(req, res)) return; try { const trace = tracing.getTracer(config).localTraces({ ...traceReadOptions(), traceId: String(req.params.id || "") }); if (!trace) return res.status(404).json({ error: "找不到这条 Trace" }); res.json({ trace }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.delete("/api/traces", (req, res) => { if (traceOwnerOnly(req, res)) return; try { tracing.getTracer(config).clearLocalTraces(traceReadOptions()); res.json({ ok: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ---------- 系统日志 / 指标 / 告警 ---------- // 跟 Trace 一样锁平台管理员,理由也一样:这几本账都是**整台机器一份**的。 // 日志里带着别人的登录名、会话 id、报错原文;指标里带着别人的 token 花销和任务量。 // 多人共用一台服务器时,放开给成员看等于把同事干了什么摊开。 const opsOwnerOnly = (req, res) => { if (isPlatformOwner(req)) return false; res.status(403).json({ error: "系统日志和运行指标是整台机器一份的,归平台管理员看", platform_only: true, rows: [] }); return true; }; app.get("/api/ops/logs", (req, res) => { if (opsOwnerOnly(req, res)) return; try { res.json({ days: log.days(), rows: log.tail({ day: String(req.query.day || ""), level: String(req.query.level || ""), q: String(req.query.q || ""), limit: Math.min(1000, +req.query.limit || 200) }), }); } catch (e) { res.status(500).json({ error: e.message, rows: [] }); } }); app.get("/api/ops/metrics", (req, res) => { if (opsOwnerOnly(req, res)) return; try { const rows = metrics.read({ from: String(req.query.from || ""), to: String(req.query.to || ""), limit: Math.min(5000, +req.query.limit || 720) }); // 「现在」也给一份:刚重启完还没到一分钟时,图上总得有个东西,不然像坏了 res.json({ rows, alerts: metrics._internals.loadState(), rules: metrics._internals.RULES.length }); } catch (e) { res.status(500).json({ error: e.message, rows: [] }); } }); /** * Prometheus 抓取口。**故意跟别的接口一样锁在平台管理员后面**—— * 这套东西是打包成桌面应用发出去的,多开一个不要认证的端口出去, * 等于在用户机器上开了个谁都能读的运行数据口子。要接 Prometheus 的人 * 在 scrape_configs 里带上 openworkbuddy_token 这个 cookie 就行(部署文档里写了怎么配)。 */ app.get("/api/ops/metrics.prom", (req, res) => { if (opsOwnerOnly(req, res)) return; const rows = metrics.read({ limit: 1 }); const m = rows[rows.length - 1] || {}; const out = []; const put = (k, v, help) => { if (typeof v !== "number" || !isFinite(v)) return; out.push(`# HELP openworkbuddy_${k} ${help}`, `# TYPE openworkbuddy_${k} gauge`, `openworkbuddy_${k} ${v}`); }; put("tasks", m.tasks, "tasks finished in the last snapshot window"); put("tasks_failed", m.tasks_failed, "tasks that ended with an error"); put("task_fail_rate", m.task_fail_rate, "failed / total in the window"); put("task_p95_ms", m.task_p95_ms, "95th percentile wall-clock task duration"); put("tokens", m.tokens, "prompt + completion tokens in the window"); put("disk_free_pct", m.disk_free_pct, "free space on the data volume"); put("rss_mb", m.rss_mb, "resident memory of the server process"); put("loop_p99_ms", m.loop_p99_ms, "99th percentile event-loop delay in the window"); put("loop_max_ms", m.loop_max_ms, "worst event-loop delay in the window"); put("loop_util", m.loop_util, "event-loop utilization (plain node only)"); for (const [name, n] of Object.entries(m.channel_fail_streak || {})) { out.push(`openworkbuddy_channel_fail_streak{channel="${String(name).replace(/["\\]/g, "")}"} ${n}`); } res.type("text/plain; version=0.0.4").send(out.join("\n") + "\n"); }); // ---------- 首次开箱引导:没有 API Key 时,什么都干不了,得先把这一步走完 ---------- // 只回布尔值,绝不把 key 原文吐给前端(设置页要改 key 走 /api/settings) function isLocalModel(m) { return /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(m.base_url || ""); } function hasKey(m) { return isLocalModel(m) || !!String(m.api_key || "").trim(); } /** * 换 Key 要留痕。 * * 审计流水里躺着「谁充了值」「谁改了成员角色」「谁重置了密码」,唯独没有「谁换了那把 * 全组织都在用的 Key」——而这恰恰是最值得留痕的一条:多管理员的组织里,任何一个管理员 * 都能把渠道悄悄指到自己的账号、或者指到一个会把对话原文全存下来的中转,事后翻遍后台 * 一个字都查不到。企业客户做合规的时候,这一条是必问项。 * * 只记「哪条渠道、从哪把换成哪把」,两头都是 keyHint(前三位…末四位)—— * 审计表是管理员和审计员都看得到的,把明文 Key 写进去等于给它开了第二个出口。 */ function auditKeyChanges(req, before, after) { if (!req || !req.user) return; // 个人桌面版没有账号这回事,也就没有「谁改的」可记 const changed = []; for (const p of after) { const prev = before.get(p.id); const oldKey = String((prev || {}).api_key || ""); const newKey = String(p.api_key || ""); if (oldKey === newKey) continue; const name = p.name || p.id; if (!oldKey) changed.push(`${name}:首次填入 ${keyHint(newKey)}`); else if (!newKey) changed.push(`${name}:清空(原 ${keyHint(oldKey)})`); else changed.push(`${name}:${keyHint(oldKey)} → ${keyHint(newKey)}`); } if (!changed.length) return; try { org.audit({ org: org.orgIdOf(req.user), actor: req.user.username, action: "更换模型 Key", target: "渠道", detail: changed.join(";"), }); } catch {} // 审计写不进去也不能把「存设置」整个失败掉 } /** * Key 的「认得出是哪一把」凭据:前三位 + 末四位,中间省略。 * * 成熟产品(OpenAI / Stripe / Vercel 后台)一律是这个做法:Key 写进去就再也读不出来, * 界面上只留末四位。我们以前是平台管理员每打开一次设置页,九把明文 Key 就往浏览器里送一趟—— * 页面上任何一处 XSS 都能整包端走。现在管理员那边也只给这一小截: * 「我装的是哪一把」还看得出来,「这把还能不能用」交给渠道卡上的「测一下」。 */ function keyHint(k) { const v = String(k || "").trim(); if (!v) return ""; return v.length <= 8 ? "•".repeat(v.length) : v.slice(0, 3) + "…" + v.slice(-4); } /** 发一条最小的真实请求验活。返回 null = 通过,返回字符串 = 人话版失败原因。 * 刻意不走 createLLM:它会把工具 schema 一起发过去,这里只想知道"这个 key 认不认"。 */ /** 地址是用户自己填的(中转 / 网关 / 改过官方地址):404 时地址和模型名都可能是错的那一个 */ function ownBase(kind, base) { const k = mediaModels.PROVIDER_KINDS.find((x) => x.kind === kind); if (!k || k.relay) return true; const b = String(base || "").trim(); if (!b) return false; // 没填 = 用这家官方的默认地址 return mediaModels.guessKind(b) !== kind; // 域名认得出就是这家自己的 } async function probeModel(m, opts = {}) { return (await probeModelRaw(m, opts)).error; } /** 同 probeModel,另带上游状态码(没连上是 0):自填地址那条路要凭 404 决定补不补 /v1 */ async function probeModelRaw(m, { custom = false } = {}) { // 每种接口格式的地址算法只有一份(llm.js 的 pingRequest),验活和真跑必须打同一个地址: // 否则填了中转的人验活验的是中转、跑起来打的是官方,绿勾骗人 const { url, headers, body } = pingRequest(m); try { const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30000) }); if (r.ok) return { status: r.status, error: null }; const txt = (await r.text()).slice(0, 300); const out = (error) => ({ status: r.status, error }); // 每一句都带上状态码和上游原文。403 不替人下结论:百炼上它常是「这把 Key 没开这个型号」,不等于 Key 错了 const said = txt.replace(/\s+/g, " ").trim().slice(0, 200); const orig = said ? `。上游原文:${said}` : ""; if (r.status === 401) return out("这个 Key 上游不认(HTTP 401),检查有没有复制全、是不是这家服务商的 Key" + orig); if (r.status === 403) return out("上游拒绝了这次请求(HTTP 403)" + orig); if (r.status === 402) return out("Key 有效但余额不足 / 未开通付费(HTTP 402),去服务商控制台充值后再试" + orig); // 方舟对不存在和未授权共用 404;已关停的接入点还可能回 400。不能都说成用户写错了型号。 if (/InvalidEndpoint\.ClosedEndpoint|ModelNotOpen|InvalidEndpointOrModel\.NotFound|do not have access/i.test(txt)) return out(`模型或接入点「${m.model}」当前不可访问(HTTP ${r.status}):可能未开通、已关停或型号不匹配。在当前页面刷新模型列表,选择已开通的型号;火山方舟也可填写控制台的 ep- 接入点 ID${orig}`); if (r.status === 404) return out(custom ? `HTTP 404:地址或模型名有一处对不上(打的是 ${url}),对照接口文档再看一眼${orig}` : `模型「${m.model}」不存在或当前账号无权访问(HTTP 404),刷新模型列表后重选,并到服务商控制台确认开通状态${orig}`); if (r.status === 429) return out("被限流了(429),等一会儿再试,或换个渠道" + orig); return out(`上游返回 HTTP ${r.status}:${txt}`); } catch (e) { const msg = String((e && e.message) || e); if (/timeout|abort/i.test(msg)) return { status: 0, error: "连不上(30 秒超时)。国外服务商在国内直连经常打不通,挂代理或换国产渠道" }; return { status: 0, error: "连不上:" + msg.slice(0, 200) }; } } /** * 首次开箱向导要的一张「体检表」:每项能力配没配、配的是什么,一次性给全。 * * 为什么不让前端各拉各的接口再拼:向导弹在登录后第一屏,多拉五个接口就是多五次转圈; * 而且「大脑到底算不算接上了」只有服务端知道——走本机 Claude Code / Codex 时根本不需要 API Key, * 前端只看 has_key 会把已经能用的人拦在门口。 * * seen = 用户走完(或明确跳过)过一次向导。没走完的每次打开都弹,走完的只在「设置 → 关于」里能再打开。 */ app.get("/api/onboarding", async (req, res) => { const models = (config.models || []).map((m) => ({ name: m.name, channel: m.channel || "", api: m.provider || "openai", model: m.model, base_url: m.base_url || "", local: isLocalModel(m), has_key: hasKey(m), })); const active = (config.models || []).find((m) => m.name === config.active_model) || (config.models || [])[0]; const myAgent = prefs.agentCfg(config); const engineId = engines.currentId(config, { user: req.user && req.user.username }); const brainViaEngine = engineId !== "builtin" && engines.get(engineId) !== undefined; const brainOk = brainViaEngine || !!(active && hasKey(active)); const seen = !!((config.onboarding || {}).done_at); // 本机 CLI 探测要跑 which + --version,只在向导真要渲染的时候做,老用户每次开机别白等。 // 开机那一趟(maybeOnboard)只是拿来判断"要不要弹",不带 probe;真弹出来时会再拉一次带 probe 的。 let found = []; if (req.query.probe === "1") { try { found = await engines.detectAll(myAgent.engine_options || {}); } catch {} } const media = config.media || {}; // 本机接口(Ollama、自建 whisper 之类)本来就不要 Key;只有地址没有型号的是半截,调不通,不算配好 const mediaOk = (kind) => { const c = media[kind] || {}; return !!(c.base_url && c.model && (c.api_key || chatModels.isLocalBase(c.base_url))); }; const im = config.im || {}; const pair = (o, ...ks) => !!(o && ks.every((k) => String(o[k] || "").trim())); const imConfigured = [ pair(im.feishu, "app_id", "app_secret"), pair(im.qq, "app_id", "app_secret"), pair(im.wecom_app, "corp_id", "secret", "agent_id"), pair(im.wechat_mp, "app_id", "app_secret"), pair(im.wechat_ilink, "bot_token", "ilink_bot_id"), !!String(im.wecom_bot_webhook || "").trim(), !!String(im.dingtalk_webhook || "").trim(), ].filter(Boolean).length; // 「自动」也要报得出实际会用哪家:报成写死的一家,体检页说「已配」而真跑的是另一家 const sc = config.search || {}; const sp = (sc.provider || "").toLowerCase() || Object.keys(SEARCH_PROVIDERS).find((p) => searchProviderReady(sc, p, searchProviderKey(sc, p))) || "bocha"; res.json({ // 大脑没接上 = 一句话都发不出去,必须弹引导。接上了就不再自动弹了: // 用户在第一步填完 Key 就跳过是最常见的一条路,以前那种"没走完就再弹一次"每次开机都要拦他一遍。 needs_setup: !brainOk, seen, // 向导最后一步写的是服务器级设置(工作目录 + done_at),只有平台管理员落得了盘。 // 成员那边压根不该弹:弹了他一步步认真填完,最后一颗「开始使用」必然 403,退都退不出去。 can_finish: isPlatformOwner(req), brain: { ok: brainOk, via: brainViaEngine ? "engine" : "api", name: brainViaEngine ? engineId : (active ? active.name : ""), model: brainViaEngine ? ((config.agent.engine_options || {})[engineId] || {}).model || "" : (active ? active.model : ""), }, active_model: config.active_model, workspace_dir: getWorkspaceDir(), models, // 向导的「服务商」清单从目录来,不再靠 config 里那排没 Key 的模板行撑场面 templates: chatModels.templates(), // 「自己填地址」那一项的接口格式下拉:跟设置里渠道卡用的同一份 api_formats: mediaModels.API_FORMATS.map((f) => ({ id: f.id, label: f.label })), any_key: models.some((m) => m.has_key && !m.local), // models / model:向导里给本机 CLI 钉型号用(候选 + 已存的那个),型号不填不让过 engines: found.map((e) => ({ id: e.id, label: e.label, installed: e.installed, version: e.version, install: e.install || "", note: e.note || "", models: Array.isArray(e.models) ? e.models.slice(0, 50) : [], model: String(((e.options || {}).model) || "") })), engine: engineId, // 「配好了」不等于「有 Key」:自定义那家认的是地址,不要鉴权的自建接口本来就没有 Key search: { provider: sp, has_key: searchProviderReady(sc, sp, searchProviderKey(sc, sp)) }, media: Object.fromEntries(mediaModels.CAPS.map((c) => [c, mediaOk(c)])), im: { configured: imConfigured }, }); }); // 填 key → 真发一条最小请求验活 → 通过才落盘。不验就存等于把坑留到用户第一次提问时才炸 app.post("/api/onboarding", async (req, res) => { try { const b = req.body || {}; const key = String(b.api_key || "").trim(); // 自己填地址:中转站、自建网关、本机 vLLM……跟模板那条一样先算、验活、验过了才落 if (b.custom && typeof b.custom === "object") { let plan = chatModels.planCustom(config, b.custom); if (plan.err) throw new Error(plan.err); if (systemOne.isDecisionModel(plan.row.model)) throw new Error(`「${plan.row.model}」是判断模型(Jev),不能当对话模型用`); if (!key && !plan.t.local) throw new Error("API Key 不能为空(本机接口可以不填)"); if (b.skip_test !== true) { const keyOf = (p) => key || (p.prov ? p.prov.api_key : ""); let r = await probeModelRaw({ ...plan.row, api_key: keyOf(plan) }, { custom: true }); // 只填了域名、原样打回 404:换成带 /v1 的再验一次,通了就按通了的那个地址存 if (r.error && r.status === 404 && plan.alt) { const alt = chatModels.planCustom(config, { ...b.custom, base_url: plan.alt }); const r2 = alt.err ? r : await probeModelRaw({ ...alt.row, api_key: keyOf(alt) }, { custom: true }); if (!r2.error) { plan = alt; r = r2; } } if (r.error) return res.json({ ok: false, error: r.error }); } const row = chatModels.commitTemplate(config, plan, key); chatModels.normalize(config); return landBrain(row.name); } // 按厂商模板新起一条:先算好、验活、验过了才落进 config——验不过的 Key 不该留下一个半成品渠道 if (b.kind) { const plan = chatModels.planTemplate(config, b.kind, b.model_id); if (!plan) throw new Error("没有这家服务商:" + b.kind); if (systemOne.isDecisionModel(plan.row.model)) throw new Error(`「${plan.row.model}」是判断模型(Jev),不能当对话模型用`); if (!key && !plan.t.local) throw new Error("API Key 不能为空"); if (b.skip_test !== true) { const bad = await probeModel({ ...plan.row, api_key: key || (plan.prov ? plan.prov.api_key : "") }); if (bad) return res.json({ ok: false, error: bad }); } const row = chatModels.commitTemplate(config, plan, key); chatModels.normalize(config); return landBrain(row.name); } const entry = (config.models || []).find((m) => m.name === b.model); if (!entry) throw new Error("没有这个模型:" + b.model); if (!key && !isLocalModel(entry)) throw new Error("API Key 不能为空"); // 本机那条(Ollama)的型号是向导现问出来的:他电脑上装了什么只有他知道, // 库里那条多半还写着出厂模板里的 qwen3:14b。点名了就照他说的来。 // 先只当作「这一趟拿它去验」,验过了才真写进 config——验不过还把人家原来能用的 // 型号改掉,等于一次失败的尝试把他本来跑得好好的配置弄坏了。 const wantModel = String(b.model_id || "").trim() || entry.model; if (systemOne.isDecisionModel(wantModel)) throw new Error(`「${wantModel}」是判断模型(Jev),不能当对话模型用`); if (b.skip_test !== true) { const bad = await probeModel({ ...entry, model: wantModel, api_key: key || entry.api_key }); if (bad) return res.json({ ok: false, error: bad }); } entry.model = wantModel; if (key) { // Key 归渠道那一层管(config.providers),不是模型条目。写在条目上看着像成了, // 下一次规整会把渠道的空 Key 压平回来,把它抹掉—— // 于是首页明明填过 Key,后台设置还说没设置。 const prov = (config.providers || []).find((p) => p.id === entry.channel); if (prov) prov.api_key = key; else entry.api_key = key; // 还没挂渠道的条目:先写着,紧接着的规整会照它认出或建出渠道 } // 规整一趟:认渠道、并掉重复的空壳行、再把渠道的地址和 Key 压平回模型条目。 // 不跑这一趟,下面 createLLM 读到的还是旧的扁平字段,这一趟对话照样 401 chatModels.normalize(config); return landBrain(entry.name); } catch (e) { res.status(400).json({ ok: false, error: e.message }); } /** 三条路验过之后都一样:设成全局在用的模型、按需换工作目录、重建客户端、落盘 */ function landBrain(name) { const b = req.body || {}; setGlobalModel(name); if (b.workspace_dir) { config.workspace_dir = setWorkspaceDir(b.workspace_dir); ensureProjects(); const ap = config.projects.find((p) => p.name === config.active_project); if (ap) ap.dir = config.workspace_dir; noteLayout(config.workspace_dir); } llmInner = createLLM(config); memory.setEmbedder(createEmbedder(config)); setSessEmbedder(createEmbedder(config)); memory.ensureVectors().catch(() => {}); saveConfig(); return res.json({ ok: true, active_model: config.active_model, model: llm.model, workspace_dir: getWorkspaceDir() }); } }); // 向导走完 / 明确跳过:记一笔,下次开机不再弹。大脑没接上时不许记——记了等于把一个什么都干不了的界面留给用户 app.post("/api/onboarding/done", (req, res) => { try { const b = req.body || {}; const active = (config.models || []).find((m) => m.name === config.active_model) || (config.models || [])[0]; const engineId = engines.currentId(config, { user: req.user && req.user.username }); const brainOk = (engineId !== "builtin" && engines.get(engineId) !== undefined) || !!(active && hasKey(active)); if (!brainOk) return res.status(400).json({ ok: false, error: "还没接上任何大模型,先把第一步走完" }); if (b.workspace_dir) { config.workspace_dir = setWorkspaceDir(b.workspace_dir); ensureProjects(); const ap = config.projects.find((p) => p.name === config.active_project); if (ap) ap.dir = config.workspace_dir; noteLayout(config.workspace_dir); } const skipped = Array.isArray(b.skipped) ? b.skipped.map((x) => String(x).slice(0, 20)).slice(0, 10) : []; config.onboarding = { done_at: Date.now(), skipped }; saveConfig(); res.json({ ok: true, workspace_dir: getWorkspaceDir(), skipped }); } catch (e) { res.status(400).json({ ok: false, error: e.message }); } }); // 直连所配搜索服务商测活(不走 DDG 回退,测的就是这家 key 能不能用) app.get("/api/search/test", async (req, res) => { const t0 = Date.now(); try { const cfg = config.search || {}; // ?provider=bocha 指名测某一家(设置页每一行后面那颗「测」按的就是这个)。 // 不指名就跟真搜的时候走同一个挑法:首选,没首选就从上往下第一个配好了的。 // 这里另外写死一家的话,测试按的是 A、实际搜的是 B,测出来的「可用」不算数 const asked = String(req.query.provider || "").trim().toLowerCase(); if (asked && !SEARCH_PROVIDERS[asked]) { return res.json({ ok: false, error: `没有「${asked}」这家;这个版本认的是:${Object.keys(SEARCH_PROVIDERS).join(" / ")}` }); } const provider = asked || (cfg.provider || "").toLowerCase() || Object.keys(SEARCH_PROVIDERS).find((p) => searchProviderReady(cfg, p, searchProviderKey(cfg, p))) || ""; if (!provider) return res.json({ ok: false, error: "一家都还没配:先填一个服务商的 Key" }); const fn = SEARCH_PROVIDERS[provider]; const key = searchProviderKey(cfg, provider); if (!searchProviderReady(cfg, provider, key)) { return res.json({ ok: false, provider, error: provider === "custom" ? "自定义搜索还没填接口地址" : "这家还没填 API Key" }); } const items = await fn(key, "OpenAI", 3, cfg); // 「0 条」单独说清楚:Key 是好的、接口也通了,就是这一趟没结果。 // 跟「Key 坏了」混成一句话的话,人会跑去重新申请一把本来好好的 Key if (!items.length) return res.json({ ok: false, provider, ms: Date.now() - t0, error: "接口通了,但这次一条结果都没回。Key 应该是好的,多半是对方这趟没搜到" }); res.json({ ok: true, provider, ms: Date.now() - t0, n: items.length, sample: (items[0].title || items[0].url || "").slice(0, 60) }); } catch (e) { res.json({ ok: false, provider: String(req.query.provider || "").trim().toLowerCase() || undefined, ms: Date.now() - t0, error: e.message }); } }); // ---------- 安全中心:审计 / 审批 / 系统授权 ---------- app.get("/api/security/audit", (req, res) => res.json(security.auditList(Math.min(1000, +req.query.limit || 100)))); app.post("/api/security/audit/clear", (_req, res) => { security.auditClear(); res.json({ ok: true }); }); app.get("/api/security/audit/export", (_req, res) => { res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="openworkbuddy-audit-${new Date().toISOString().slice(0, 10)}.log"`); res.send(security.auditExport()); }); /** * 审批看得见谁的、批得动谁的。 * * 返回 undefined = 不限定(平台管理员,以及双击打开的桌面版——那儿屏幕前就一个人)。 * 返回登录名 = 只限定他自己那条:审批卡片上写的是别人任务要跑的整条命令,路径、域名、 * 脚本片段都在里面,多人共用一台服务器时那是别人的东西。 */ const approvalScope = (req) => admin.isSoloDesktop() || admin.platformAdmin(req.user) ? undefined : (req.user && req.user.username) || ""; app.get("/api/security/approvals", (req, res) => { const scopeTo = approvalScope(req); res.json({ items: security.listApprovals(scopeTo), mode: security.permissionMode(config.security), session_allow: security.listSessionAllow(scopeTo), // 界面照这个决定要不要画「一直允许」那颗按钮:会 403 的按钮不该摆在那儿 can_always: scopeTo === undefined, // 服务器此刻的钟:界面按 deadline - now 算还剩多久,两边的钟差多少都不影响倒计时 now: Date.now(), }); }); /** * 批准/拒绝一条审批。scope:once 只这一次 / session 本次运行期间同类不再问 / always 永久写进放行名单。 * always 要落盘——「一直允许」点完重启又来问,等于没这个按钮;落盘的规则在安全中心看得见、删得掉。 * * 三档里只有 always 是「改这台服务器」:它把规则写进 config 的永久放行名单,对所有人生效。 * 所以非平台管理员的 always 降一档按 session 处理,而不是回 403——他那个任务正挂着等这个回答, * 403 换来的是任务干等到超时。降了要在返回里说清楚,界面照实说,不许悄悄换个档还报「已永久放行」。 */ app.post("/api/security/approvals/:id", (req, res) => { const body = req.body || {}; const scopeTo = approvalScope(req); let { scope, downgraded } = security.effectiveScope(body.scope, scopeTo !== undefined); const r = security.resolveApproval(req.params.id, !!body.allow, scope, scopeTo); if (!r.ok) { return res .status(r.forbidden ? 403 : 409) .json({ ...r, error: r.error || "这条审批已经结束了(等超时了,或者别处已经点过)" }); } // danger:/write:/code: 这几类 cmd_allow 管不到(闸里只认本会话),写进去重启后照样问, // 按钮说的「重启也生效」就成了假话。降成本会话,照实说 let reason = ""; if (scope === "always" && r.ruleKey && !security.isPersistableRule(r.ruleKey)) { scope = "session"; downgraded = true; reason = /^chrome:/.test(r.ruleKey) ? "动你 Chrome 的只在这次任务里放行,不进永久名单" : "高危命令、写文件、跑代码只能本次运行期间放行,不进永久名单"; } if (r.ok && body.allow && scope === "always" && r.ruleKey) { const sec = security.getSecurity(config); const list = Array.isArray(sec.cmd_allow) ? sec.cmd_allow : []; if (!list.includes(r.ruleKey)) { // 另起一份再写:没配过名单时这就是 DEFAULTS 里那个数组,原地 push 会连默认值一起改掉 sec.cmd_allow = [...list, r.ruleKey]; saveConfig(); } } res.json({ ...r, scope, downgraded, ...(reason ? { reason } : {}) }); }); // 执行模式表。界面上那个下拉不再自己写四行 HTML,从这儿取—— // 「网页四个、命令行三个」就是抄出来的:goal 是后加的,抄到第三份就漏了。 // 这里没有任何机密,也不按人区分,所以不设门禁:没登录的首屏也得画得出模式菜单。 // plan:Plan 跑完那两颗按钮的字和「开干」时真正发出去的那句(跟终端 /mode 单子同一份,见 modes.js) app.get("/api/modes", (_req, res) => res.json({ modes: modes.EXEC_MODES, default: modes.DEFAULT_MODE, plan: modes.PLAN_HANDOFF })); /** * 哪条媒体渠道正被熔断闸停着。 * * 熔断本身在后台默默生效就行,但「为什么它不给我看图了」必须有地方能看见—— * 否则用户只会觉得功能坏了,而真相是我们替他拦下了一条撞不通的路。 * 设置 → 模型 页顶上那条黄条读的就是这个。 */ app.get("/api/media-health", (_req, res) => res.json({ paused: mediaHealth.list() })); app.post("/api/media-health/reset", (req, res) => { mediaHealth.reset(String((req.body || {}).cap || "") || undefined); res.json({ ok: true, paused: mediaHealth.list() }); }); app.get("/api/security/modes", (req, res) => // can_switch:档位是整台服务器一份(决定 agent 动手前问不问),普通成员改不了。 // 界面拿它决定那个 🛡️ 菜单画成可点的还是只读的——不然点下去只有一句「切换失败」。 res.json({ modes: security.PERMISSION_MODES, current: security.permissionMode(config.security), can_switch: isPlatformOwner(req) }) ); app.post("/api/security/mode", (req, res) => { // 自守一道,别只靠门禁表。这个开关一改,全站的 agent 动手前问不问就变了—— // 「只有一层前缀表把门」的东西,表上出一点差错就是整台机器的审批被人关掉。 // 上面那条 GET 已经回了 can_switch: isPlatformOwner(req),判据用同一个,不会出现「界面说能改、后端不让」。 if (!isPlatformOwner(req)) return res.status(403).json({ error: "权限档位是整台服务器一份的,归平台管理员管", platform_only: true }); const mode = String((req.body || {}).mode || ""); if (!security.PERMISSION_MODES[mode]) return res.status(400).json({ error: "未知的权限档位" }); security.getSecurity(config).permission_mode = mode; // 走 getSecurity 补默认值,别把别的字段挤掉 saveConfig(); security.audit("权限档位", `切换到「${security.PERMISSION_MODES[mode].label}」`, "放行"); res.json({ ok: true, mode }); }); /** * 外挂的第二把尺子现在在不在。界面拿它决定那张卡片怎么画—— * 没装就别摆一排看着能用、实际没生效的开关,直接给一行安装命令和那句授权说明。 * 只回「有没有、哪个版本、在哪」,不回扫描结果:这个接口是给设置页用的,不该顺手跑一趟扫描。 */ app.get("/api/security/toolward", (req, res) => { if (!isPlatformOwner(req)) return res.status(403).json({ error: "这项是整台服务器一份的,归平台管理员管", platform_only: true }); try { res.json(toolward.status(config)); } catch (e) { // 探测本身出错也得给界面一个能画的形状,别让设置页因为一个可选组件白屏 res.json({ mode: "auto", on: false, bin: "", version: "", why: String(e.message || e), install: toolward.INSTALL_HINT, licence: toolward.LICENCE_NOTE }); } }); app.post("/api/security/session-allow/clear", (req, res) => { security.clearSessionAllow(approvalScope(req)); res.json({ ok: true }); }); app.get("/api/security/system", (_req, res) => { res.json({ platform: process.platform, desktop: !!process.versions.electron, fulldisk: security.checkFullDisk(), accessibility: security.checkAccessibility(), automation: "unchecked", // 主动探测会触发系统弹窗,改为用户点「检测/授权」时才查 }); }); app.post("/api/security/system/check-automation", async (_req, res) => { res.json({ automation: await security.checkAutomation() }); }); app.post("/api/security/system/open", (req, res) => { res.json({ ok: security.openPrefPane((req.body || {}).pane) }); }); // 桌面版窗口全屏切换(Web 版由前端 requestFullscreen 兜底) app.post("/api/app/fullscreen", (_req, res) => { if (global.__wbWin) { try { global.__wbWin.setFullScreen(!global.__wbWin.isFullScreen()); return res.json({ ok: true }); } catch {} } res.json({ ok: false }); }); // 检查更新只有一条路:updater.js 的 GET /api/update(查 GitHub Releases,按安装方式给该做什么)。 // 以前这里还有一个在服务端跑 git 的检查更新接口——装机版根本没有 .git, // 点一次报一次「未配置更新源」,等于一颗永远点不动的按钮,删了。 // ---------- MCP 连接器管理 ---------- // 底层引擎:探测本机装没装 Claude Code / Codex。只跑 --version,不消耗任何额度 app.get("/api/engines", async (req, res) => { try { // ?force=1 = 用户点了「重新检测本机」(刚装完 CLI,必须当场看见);平时吃缓存,别每开一次设置页就起一堆子进程 const myAgent = prefs.agentCfg(config); // 「当前用的是哪个引擎」是按账号的,别把别人选的报给他 // Windows:进程里的 PATH 定格在启动那一刻,刚 winget 装的东西不在里面。点「重新检测」时现读一次注册表, // 跟启动时那份合并(体检、run_shell 都用这一份)。读不成它自己会留日志,照旧按手里那份 PATH 检测;别的系统直接返回 if (req.query.force === "1") await require("./src/platform/which").refreshWinPath(); // 点这一下顺带联网问一下本机 CLI 有没有新版(平时开设置页只看缓存,不联网) const found = await engines.detectAll(myAgent.engine_options || {}, { force: req.query.force === "1", latest: req.query.force === "1" }); // 闸的状态一并给前端:多人共用时开没开、放行哪些型号、命令能不能联网。只读属主那份,成员改不了 const withGate = found.map((e) => ({ ...e, gate: engines.gateView(e.id, config) })); res.json({ current: engines.currentId(config, { user: req.user && req.user.username }), builtin: engines.BUILTIN, engines: withGate, multiUser: security.isMultiUser(), shellOff: orgShellOff() }); } catch (e) { res.status(500).json({ error: e.message }); } }); /** * 两条工作线:侧栏那两个标签各自是什么、此刻有没有活儿在跑。 * * 工作线分的是**干哪种活儿、在哪儿干**(办公 / 工程),不是「用哪个模型」。 * 底层引擎(内置循环 / 本机 Claude Code / Codex)是用户在设置里挑一次、两条线共用的另一件事—— * 早先版本把它俩绑在一起,结果「切个标签」能把别人配好的引擎一起换掉。 * * 工程线连的是**这台机器上的 `openworkbuddy` 命令行**:终端里起的任务会自己挂到 data/cli-live/ 下, * 这里如实报「现在有几趟在跑」。前端不许自己猜这件事——命令行是不是还活着要看心跳和 pid, * 只有服务端摸得到。 */ app.get("/api/lanes", (req, res) => { try { let cli = []; // 终端属于这台机器的主人。租户账号看见别人电脑里正在跑什么,是越权 if (canRemoteControl(req)) { try { cli = cliLive.list(); } catch {} } const liveCount = cli.filter((r) => r.live).length; res.json({ lanes: lanes.LANES.map((l) => ({ id: l.id, name: l.name, short: l.short, hint: l.hint, detail: l.detail })), current: lanes.DEFAULT_LANE, // 工程线上此刻从终端起的活儿:有几趟在跑、都是什么 cliLive: cli, cliRunning: liveCount, }); } catch (e) { res.status(500).json({ error: e.message }); } }); /** * 终端里正在跑的那几趟活儿。 * * 跟 /api/chat/running 是两本账:那本是这个服务进程自己跑的(内存里),这本是**别的进程** * (`openworkbuddy` 命令行)跑的,只能从盘上那个目录读。手机上打开「工程」标签看到的就是这一份。 */ app.get("/api/cli/live", (req, res) => { // allowed:false 前端本来就认(画成一句「这里看不到」而不是空列表),所以关掉开关不会白屏 if (!canRemoteControl(req)) return res.json({ rows: [], allowed: false, remote_off: !account.remoteAllowed("remote_control", req.user) }); try { res.json({ rows: cliLive.list(), allowed: true, staleMs: cliLive.STALE_MS }); } catch (e) { res.status(500).json({ error: e.message }); } }); /** * 跟一趟终端里的活儿:先补上已经发生的,再接着直播。 * * 口径跟 /api/chat/stream 一样(from = 已经看过几个事件),前端一套代码两边都能用。 * 直播靠轮询文件尾部——fs.watch 在 macOS/Linux/容器里的行为各不相同,几百毫秒一次的 * 读尾部比它可靠得多,而且读的是增量,不是整个文件。 */ app.get("/api/cli/stream/:id", (req, res) => { if (!canRemoteControl(req)) return res.status(403).json({ error: cliOffReason(req, "看得到") }); const sid = String(req.params.id || ""); const meta = cliLive.get(sid); if (!meta) return res.status(404).json({ error: "终端里没有这趟活儿(可能已经跑完很久了)" }); // 同 /api/chat/live:请求在中间件那段就被掐了,close 早发过,下面 400ms 一次的读尾部定时器就一直转到这趟活儿跑完。 // 2026-09-29 复审同一个写法,终端里一趟活儿跑几个小时,页面每重开一次都可能多留一个 if (res.destroyed || req.socket.destroyed) return; res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.on("error", () => {}); const from = Math.max(0, parseInt(req.query.from, 10) || 0); const send = (ev) => { try { res.write(`data: ${JSON.stringify(ev)}\n\n`); } catch {} }; let pos = 0; try { const first = cliLive.read(sid, { fromLine: from }); first.events.forEach(send); pos = first.pos; } catch {} let closed = false; req.on("close", () => { closed = true; clearInterval(timer); }); const timer = setInterval(() => { if (closed) return; try { const more = cliLive.read(sid, { fromByte: pos }); if (more.reset) { pos = 0; return; } // 命令行开了新的一轮,下一拍从头补 more.events.forEach(send); pos = more.pos; const now = cliLive.get(sid); if (!now || !now.live) { // 跑完了(或者被强杀了):如实说一声再收摊,别让手机上那个圈一直转 send({ type: "cli_end", ok: !!(now && now.endedAt && !now.error), error: (now && now.error) || (now && now.died ? "命令行进程没了(可能是终端被关掉了)" : null) }); clearInterval(timer); try { res.end(); } catch {} } } catch {} }, 400); }); /** * 往终端里那趟活儿插一句话。 * * 写进 data/cli-live/.in,命令行在两步之间读走。写不进去就如实报错—— * 界面上显示「已发送」而其实没送到,比直接说送不到糟得多。 */ app.post("/api/cli/interject", (req, res) => { if (!canRemoteControl(req)) return res.status(403).json({ ok: false, error: cliOffReason(req, "插得上话") }); const { sessionId, message } = req.body || {}; const text = String(message || "").trim(); if (!text) return res.status(400).json({ ok: false, error: "消息为空" }); const row = cliLive.get(String(sessionId || "")); if (!row) return res.status(404).json({ ok: false, error: "终端里没有这趟活儿" }); if (!row.live) return res.status(409).json({ ok: false, error: "这趟已经跑完了,插话没人接" }); if (!cliLive.interject(row.id, text)) return res.status(500).json({ ok: false, error: "写不进去(磁盘满了或者目录没权限)" }); res.json({ ok: true }); }); /** * 终端里那趟活儿此刻卡在等什么(一道选择题,或者一条要批准的命令)。 * * 这条口子是给手机用的。终端里等回答是「卡住不动直到超时」,而超时对审批来说等于**拒绝**: * 人去楼下拿了杯咖啡,回来只看见「用户没批准,我跳过了」——他从来没被问到过。 */ app.get("/api/cli/pending", (req, res) => { if (!canRemoteControl(req)) return res.json({ rows: [], allowed: false, remote_off: !account.remoteAllowed("remote_control", req.user) }); const sid = String(req.query.sessionId || "").trim(); try { const ids = sid ? [sid] : cliLive.list({ prune: false }).filter((r) => r.live).map((r) => r.id); const rows = []; for (const id of ids) for (const a of cliLive.pending(id)) rows.push({ ...a, sessionId: id }); // now 同审批列表:手机和电脑的钟对不齐,倒计时按服务器的钟校正 res.json({ rows, allowed: true, now: Date.now() }); } catch (e) { res.status(500).json({ error: e.message }); } }); /** * 在手机上把那道题答了。 * * 不在这儿判答案对不对:终端那边 cli-ask 本来就有一套「敲一半算选了哪条」的解析, * 手机上答的走同一份,省得两边对同一句话给出两种理解。 */ app.post("/api/cli/answer", (req, res) => { if (!canRemoteControl(req)) return res.status(403).json({ ok: false, error: cliOffReason(req, "答得了") }); const { sessionId, askId, value } = req.body || {}; const id = String(askId || "").trim(); if (!id) return res.status(400).json({ ok: false, error: "不知道在答哪道题" }); const row = cliLive.get(String(sessionId || "")); if (!row) return res.status(404).json({ ok: false, error: "终端里没有这趟活儿" }); if (!row.live) return res.status(409).json({ ok: false, error: "这趟已经跑完了,答案没人接" }); // 已经不等了(终端那边先答了,或者超时了)就说清楚,别让界面显示「已提交」而其实没人收 if (!cliLive.pending(row.id).some((a) => a.id === id)) { return res.status(409).json({ ok: false, error: "这道题已经答过了,或者等超时了" }); } if (!cliLive.answer(row.id, id, value)) return res.status(500).json({ ok: false, error: "写不进去(磁盘满了或者目录没权限)" }); res.json({ ok: true }); }); /** * 思考模式:每一档到底会发什么,如实报给设置页。 * * 为什么不让前端自己写死一张表:各家的参数名一年能改两回,而「这一档对你选的这个模型 * 到底生不生效」只有服务端知道(要看 provider、base_url、模型名,走本机 CLI 时还要看 * 那个 CLI 认不认这个选项)。写死在前端 = 界面上写着"已关闭"、实际一个参数都没发出去。 * * 所以这里把 5 个档位逐个算一遍,supported=false 的连同原因一起给出去,界面照抄。 */ app.get("/api/thinking", async (req, res) => { try { const myAgent = prefs.agentCfg(config); const engineId = engines.currentId(config, { user: req.user && req.user.username }); const entry = (config.models || []).find((m) => m.name === config.active_model) || (config.models || [])[0] || {}; // 本机 CLI 的能力探测(claude 对不认识的选项是静默忽略的,非探不可),探不动就当没有 let caps = {}; if (engineId !== "builtin") { try { const found = await engines.detectAll(myAgent.engine_options || {}); caps = (found.find((e) => e.id === engineId) || {}).caps || {}; } catch {} } const levels = thinking.LEVELS.map((lv) => { const api = thinking.planFor(entry, lv); const eng = engineId === "builtin" ? null : thinking.planForEngine(engineId, lv, caps); const active = eng || api; return { level: lv, label: thinking.LEVEL_LABEL[lv], supported: active.supported, note: active.note }; }); res.json({ current: thinking.norm(myAgent.thinking), via: engineId === "builtin" ? "api" : "engine", target: engineId === "builtin" ? (entry.name || "") : engineId, levels, }); } catch (e) { res.status(500).json({ error: e.message }); } }); /** * 一键连接:真的连一次,不是看看文件在不在。 * * 设置页上「已装 ✓」和「装了但没登录」长得一模一样——用户点了切换,然后每个任务都在 * 原地报错。这个接口花几十个 token 真跑一句话过去,把「能用 / 没登录 / 限流 / 装坏了」 * 分开告诉用户,并且给出下一步该干什么。跑在系统临时目录里,不往工作区留东西。 */ app.post("/api/engines/test", async (req, res) => { const id = String((req.body && req.body.id) || "").trim(); if (!id || id === "builtin") return res.status(400).json({ error: "内置引擎不用测,它走的是你配的 API Key" }); try { // 用户可能刚在输入框里改了路径/模型还没保存,先用他正在填的那份测 const patch = (req.body && req.body.options) || {}; const member = ownPrefs(req); const typed = patch.model !== undefined ? String(patch.model || "").trim() : undefined; // 试连也过闸:成员试的型号得在放行列表里、引擎得已打开;属主试连可以先于打开和保存, // 但命令行开关、附加参数、要有型号这几条照查——试连本身就是真跑一句话 let pass; try { pass = engines.admit(id, config, { shellOff: orgShellOff(), ...(typed ? { model: typed } : {}), trial: !member }); } catch (e) { if (!e || !e.engineGate) throw e; return res.json({ ok: false, ms: 0, engine: id, path: "", version: "", reply: "", model: "", why: e.message, hint: "" }); } const opts = { ...pass.opts }; // bin 是「起哪个可执行文件」——在多人服务器上等于任意命令执行。只有平台管理员能指定, // 其他人一律用已保存的那份(他们本来也改不了它) if (!member && patch.bin !== undefined) opts.bin = String(patch.bin || "").trim(); for (const k of Object.keys(opts)) if (opts[k] === "" || opts[k] == null) delete opts[k]; res.json(await engines.testConnect(id, opts)); } catch (e) { res.status(500).json({ error: e.message }); } }); // 预设目录:常用 MCP 服务器一键接入;顺带告诉前端本机找没找到 npx / uvx app.get("/api/mcp/catalog", (_req, res) => { const configured = new Set((config.mcp_servers || []).map((s) => s.name)); // 把已经在内存里的 config 递进去:内网开关就在里面,别让每次请求都去读一遍盘 const cat = mcpCatalog.catalog({ cfg: config }); res.json({ ...cat, items: cat.items.map((it) => ({ ...it, configured: configured.has(it.name) })) }); }); /** * 给非平台管理员看的连接器地址:只留「协议 + 主机」。 * 很多托管 MCP 把密钥直接拼在地址里(路径里一截 sk-…、查询串 ?api_key=…、user:pass@), * 启动参数里也常有 --token=…。redactGuard 只认字段名,url/args 这两个名字它不管, * 原来多人部署下普通成员 GET 一下就能拿到原文。 */ function mcpPublicUrl(u) { try { const x = new URL(String(u || "")); return x.protocol + "//" + x.host; } catch { return u ? "(地址已隐藏)" : ""; } } /** * 一条自配连接器此刻的版本号:连接器页打开时记下,保存时原样带回来(base_revs), * 后端拿它认出「页上这条已经旧了」——页面开着的这会儿,agent 或手改 config.json 把它换过了。 * 不直接给 cfgFingerprint:那是连 Key 一起算的哈希,短 Key 拿去离线撞得出来; * 套一层进程里随机钥匙的 HMAC,只能比对、倒推不出。重启后钥匙换了,旧页面带回来的一律对不上, * 按「旧了」处理——最坏是那几条原样不动,不会写坏。 */ const MCP_REV_KEY = require("crypto").randomBytes(16); function mcpRev(s) { return require("crypto").createHmac("sha256", MCP_REV_KEY).update(cfgFingerprint(s || {})).digest("hex").slice(0, 16); } /** 「授权没了」的判据:mcp.js 里 whyFailed 翻出来的那句话,401/403 那条固定带着「(401)Key/令牌」 */ const MCP_AUTH_BAD = /(401)|(403)|Key/令牌/; /** 模型这会儿手上的连接器工具有几个:设了不让用的不算(agent.js 摆工具表时也摘掉了) */ const mcpOffered = () => mcpManager.toolDefs((s, t) => mcpPolicy.offered(config.mcp_tool_policy, s, t)).length; /** 从本机别的应用导连接器:只在服务跑在自己电脑上(只绑回环地址、只有一个账号)、由平台管理员来做 */ const mcpImportAvailable = (req) => admin.servesLoopback() && !admin.multiUser() && isPlatformOwner(req); /** 连接器的名字在不在(自配的 + 插件带的)。插件坏了不该让连接器页上的按钮点不动 */ function mcpFind(name) { let fromPlugins = []; try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch {} return [...(config.mcp_servers || []), ...fromPlugins].find((x) => x && x.name === name) || null; } app.get("/api/mcp", (req, res) => { const full = isPlatformOwner(req); const view = (s, plugin) => { const client = mcpManager.clients.get(s.name); const failure = mcpManager.failures.find((f) => f.name === s.name); return { name: s.name, // 本机路径和启动参数只给平台管理员:成员反正改不了(POST /api/mcp 在平台写表里),用不着原文 command: full ? s.command || "" : path.basename(String(s.command || "")), args: full ? s.args || [] : [], args_hidden: full ? 0 : (s.args || []).length, // 环境变量里基本都是 API Key,和请求头一样只回键名,不回值 env_keys: Object.keys(s.env || {}), url: full ? s.url || "" : mcpPublicUrl(s.url), masked: !full, // 请求头里常有 token,界面上只说有几个,不回传值 header_keys: Object.keys(s.headers || {}), transport: s.transport || (s.command ? "stdio" : "streamable-http"), plugin, // 插件带来的:界面上只读,不许当成 config 里的条目存回去 // 保存时带回来比对用(见 mcpRev)。只有能存的人要:成员拿到也没用 rev: full && !plugin ? mcpRev(s) : "", // 关掉的那几台:既没连上也没失败,界面要能分清「连不上」和「我自己关的」—— // 少了这一个字段,用户关掉一台之后看到的是一张灰卡片,跟连接失败长得一模一样 enabled: !mcpManager.disabled.has(s.name), error: failure ? failure.error : "", // 「授权没了」和「连不上」在界面上是两件事:前者要用户现在就去换 Key,后者多半过会儿自己就好。 // 判据就是 mcp.js 里 whyFailed 翻出来的那句话——401/403 那条固定带着「(401)Key/令牌」。 auth_bad: failure ? MCP_AUTH_BAD.test(failure.error || "") : false, connected: !!client, oauth: s.url ? mcpOAuth.status(s) : { available: false, authorized: false }, tools: client ? client.tools.map((t) => ({ name: t.name, description: (t.description || "").slice(0, 200) })) : [], // 每个工具设的权限(直接用 / 先问我 / 不让用,空 = 跟着安全档位走):页上照着画,改只有平台管理员能改 policy: mcpPolicy.viewOf(config.mcp_tool_policy, s.name), }; }; // config.json 里配的 + 插件 mcp.json 里声明的,一起列出来, // 否则「已注入 N 个工具」的 N 里有一半找不到对应的卡片,用户以为是幻觉。 let fromPlugins = []; try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了不该让连接器页打不开 */ } const servers = (config.mcp_servers || []).map((s) => view(s, "")) .concat(fromPlugins.map((s) => view(s, s.plugin))) .filter((s) => !memberAccess.hidden(config, "mcp", s.name, req.user ? req.user.username : "")); res.json({ servers, total_tools: mcpOffered(), can_toggle: isPlatformOwner(req), // 没设权限的工具跟着这一档走(页上要说清「先问我」在全自动下也问) perm_mode: security.permissionMode(security.getSecurity(config)), import_available: mcpImportAvailable(req), }); }); /** * 单台连接器的开关。 * * 为什么不复用 POST /api/mcp:那条是「把整张表存回去」。GET 出去的时候 env / headers 的值 * 被抹成了键名(里头是 API Key),前端手上根本没有完整的表;为了点一下开关而整表回存, * 等于每次都拿一份缺了密钥的副本去覆盖真的那份——现在靠 prevByName 兜着,但那是兜底,不该当主路走。 * 何况插件带来的连接器压根不在 config.mcp_servers 里,整表回存对它们无从下手。 * * 关掉 = 停掉进程 + 从工具表里摘掉。摘掉这一步才是重点:留着定义,模型会先想一个用它的方案、 * 调一次、吃一条「连不上」、再重想——白烧一轮。这和 toolList 里对 run_shell 的处理是同一个道理。 */ function oauthServer(name) { adoptDiskMcp(); const all = [...(config.mcp_servers || []), ...pluginsMgr.pluginMcpServers()]; return all.find((s) => s.name === name && s.url && !s.command) || null; } const oauthOwner = (req, res) => { if (isPlatformOwner(req)) return true; res.status(403).json({ error: "连接器授权由平台管理员管理", platform_only: true }); return false; }; app.post("/api/mcp/oauth/start", async (req, res) => { if (!oauthOwner(req, res)) return; try { const body = req.body || {}, cfg = oauthServer(String(body.name || "")); if (!cfg) return res.status(404).json({ error: "没有可授权的远程连接器" }); const origin = req.get("origin") || req.protocol + "://" + req.get("host"); const callback = new URL("/api/mcp/oauth/callback", origin).href; if (body.redirect_uri !== callback) return res.status(400).json({ error: "OAuth 回调必须回到当前工作台" }); res.set("Cache-Control", "no-store"); res.json(await mcpOAuth.begin(cfg, req.user.username, callback, { client_id: body.client_id, client_secret: body.client_secret, client_metadata_url: body.client_metadata_url, scope: body.scope })); } catch (e) { res.status(400).json({ error: mcpScrub(e.message) }); } }); app.post("/api/mcp/oauth/finish", async (req, res) => { if (!oauthOwner(req, res)) return; try { const result = await mcpOAuth.finish(String((req.body || {}).flow_id || ""), req.user.username, (cfg) => oauthServer(cfg.name)?.url === cfg.url); if (!result.ready) return res.json({ ready: false }); const cfg = oauthServer(result.cfg.name); if (!cfg || cfg.url !== result.cfg.url) { mcpOAuth.revoke(result.cfg); return res.status(409).json({ error: "连接器已改变,请重新开始授权" }); } await mcpApply(async () => { mcpManager.stop([cfg.name]); await mcpManager.startAll([cfg]); }); res.json({ ready: true, connected: mcpManager.clients.has(cfg.name), enabled: !mcpManager.disabled.has(cfg.name), name: cfg.name }); } catch (e) { res.status(400).json({ error: mcpScrub(e.message) }); } }); app.post("/api/mcp/oauth/cancel", (req, res) => { if (!oauthOwner(req, res)) return; mcpOAuth.cancel(String((req.body || {}).flow_id || ""), req.user.username); res.json({ ok: true }); }); app.post("/api/mcp/oauth/remove", async (req, res) => { if (!oauthOwner(req, res)) return; try { const cfg = oauthServer(String((req.body || {}).name || "")); if (!cfg) return res.status(404).json({ error: "没有这台连接器" }); mcpOAuth.revoke(cfg); await mcpApply(async () => { mcpManager.stop([cfg.name]); }); res.json({ ok: true }); } catch { res.status(400).json({ error: "移除授权失败,请稍后重试" }); } }); app.post("/api/mcp/toggle", async (req, res) => { // 连接器是整台机器一份的:一个人关掉,所有人的任务都少一批工具。跟权限档位同一个判据。 if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器是整台机器一份的,开关归平台管理员", platform_only: true }); try { const name = String((req.body || {}).name || "").trim(); if (!name) throw new Error("要说明开关哪一台连接器"); adoptDiskMcp(); // mcp_disabled 整张表一起存:外面刚改的先认进来,别拿旧表盖掉 let fromPlugins = []; try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了不该让开关点不动 */ } const all = [...(config.mcp_servers || []), ...fromPlugins]; const cfg = all.find((x) => x.name === name); if (!cfg) throw new Error(`没有这台连接器:${name}`); const on = (req.body || {}).enabled !== false; const off = new Set(config.mcp_disabled || []); if (on) off.delete(name); else off.add(name); // 只留还存在的名字:连接器删掉之后,它的名字不该在这张表里长住 config.mcp_disabled = [...off].filter((n) => all.some((x) => x.name === n)); saveConfig(); await mcpApply(async () => { mcpManager.setDisabled(config.mcp_disabled); if (on) await mcpManager.startAll([cfg]); // 只起这一台,别把别人的连接踢了重连 else mcpManager.stop([name]); // 只记这一台的开关已生效:别的名字要是外面也改了开关还没同步,留给 syncMcp if (MCP_APPLIED) { if (on) MCP_APPLIED.disabled.delete(name); else MCP_APPLIED.disabled.add(name); if (!cfg.plugin) MCP_APPLIED.servers.set(name, cfgFingerprint(cfg)); } }); res.json({ ok: true, name, enabled: on, connected: mcpManager.clients.has(name), total_tools: mcpOffered() }); } catch (e) { res.status(400).json({ error: e.message }); } }); /** 只重连这一台:Key 刚换过、对方服务刚恢复。别的连接器不动,回它现在的样子 */ app.post("/api/mcp/reconnect", async (req, res) => { if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器是整台机器一份的,重连归平台管理员", platform_only: true }); try { const name = String((req.body || {}).name || "").trim(); if (!name) throw new Error("要说明重连哪一台连接器"); adoptDiskMcp(); const cfg = mcpFind(name); if (!cfg) throw new Error(`没有这台连接器:${name}`); if ((config.mcp_disabled || []).includes(name)) throw new Error("这台关着,先打开再重连"); await mcpApply(async () => { mcpManager.setDisabled(config.mcp_disabled || []); mcpManager.stop([name]); await mcpManager.startAll([cfg]); if (MCP_APPLIED && !cfg.plugin) MCP_APPLIED.servers.set(name, cfgFingerprint(cfg)); }); const c = mcpManager.clients.get(name); const f = mcpManager.failures.find((x) => x.name === name); res.json({ ok: true, name, enabled: true, connected: !!c, tool_count: c ? c.tools.length : 0, error: f ? f.error : "", auth_bad: f ? MCP_AUTH_BAD.test(f.error || "") : false, total_tools: mcpOffered(), }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post("/api/mcp/credentials", async (req, res) => { if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器凭据归平台管理员管理", platform_only: true }); try { adoptDiskMcp(); const b = req.body || {}; const name = typeof b.name === "string" ? b.name : ""; const prev = (config.mcp_servers || []).find((s) => s.name === name); if (!prev) return res.status(404).json({ error: "没有这台连接器" }); if (!b.rev || b.rev !== mcpRev(prev)) return res.status(409).json({ error: "连接器已被修改,请刷新后再改凭据" }); if (prev.url && mcpOAuth.status(prev).authorized && Object.entries(b.headers_patch || {}).some(([k, v]) => k.toLowerCase() === "authorization" && typeof v === "string" && v.trim())) { return res.status(409).json({ error: "请先移除本机 OAuth 授权,再修改 Authorization 请求头" }); } const entry = normalizeMcpServer({ ...prev, env_patch: b.env_patch, headers_patch: b.headers_patch }, 0, new Map([[name, prev]])); const status = await connectorCommit(entry, { expectedRev: b.rev }); res.json({ ok: true, ...status }); } catch (e) { res.status(e.status || 400).json({ error: e.message }); } }); /** * 一台连接器的工具权限:{server, default?, tools?: {工具: 策略}},策略是 allow / ask / deny,给 "" 就是回到跟着档位走。 * 跟开关同一个判据:只有平台管理员能改(成员的任务也在用这台) */ app.post("/api/mcp/policy", (req, res) => { if (!isPlatformOwner(req)) return res.status(403).json({ error: "连接器工具的权限归平台管理员设", platform_only: true }); try { const b = req.body || {}; const server = String(b.server || "").trim(); if (!mcpFind(server)) throw new Error(`没有这台连接器:${server}`); /** @type {{default?: unknown, tools?: unknown}} */ const patch = {}; if (Object.prototype.hasOwnProperty.call(b, "default")) patch.default = b.default; if (b.tools && typeof b.tools === "object") patch.tools = b.tools; config.mcp_tool_policy = mcpPolicy.withServer(config.mcp_tool_policy, server, patch); saveConfig(); res.json({ ok: true, server, policy: mcpPolicy.viewOf(config.mcp_tool_policy, server), total_tools: mcpOffered() }); } catch (e) { res.status(400).json({ error: e.message }); } }); /** * 从本机别的应用(Claude Desktop、Claude Code、Cursor、Codex)导连接器。 * GET 只给候选清单:来源、名字、命令或地址、键名,一个值都不带(见 core/ext/mcp-import.js); * POST 只递「哪个来源的哪一台」,值由这边自己从文件里取了写进 config,不经过页面。 * 多人共用的服务端上不出现:那台机器上的文件不是这个人的 */ app.get("/api/mcp/import", (req, res) => { if (!mcpImportAvailable(req)) return res.status(403).json({ error: "从本机导入只在自己电脑上、由管理员来做", platform_only: true }); const taken = [...(config.mcp_servers || []).map((s) => s.name)]; try { taken.push(...pluginsMgr.pluginMcpServers().map((s) => s.name)); } catch {} res.json(mcpImport.list({ taken })); }); app.post("/api/mcp/import", async (req, res) => { if (!mcpImportAvailable(req)) return res.status(403).json({ error: "从本机导入只在自己电脑上、由管理员来做", platform_only: true }); try { const picks = Array.isArray((req.body || {}).picks) ? req.body.picks.slice(0, 50) : []; if (!picks.length) throw new Error("先勾上要导入的连接器"); const imported = [], skipped = []; for (const p of picks) { const label = String((p && (p.as || p.name)) || ""); let raw = null; try { raw = mcpImport.pick(String(p.source || ""), String(p.name || "")); const name = String(p.as || mcpImport.safeName(raw.name)).trim(); const input = raw.transport === "http" ? { name, url: raw.url, headers: raw.headers } : { name, command: raw.command, args: raw.args, env: raw.env }; const { entry, exists } = connectorPrepare(input); if (exists && p.overwrite !== true) { skipped.push({ name, why: "已经有同名的了" }); continue; } imported.push(await connectorCommit(entry)); } catch (e) { // 报错原文里可能带着地址(地址里可能有密钥):换成一个占位再回页面 const msg = String((e && e.message) || e); skipped.push({ name: label, why: raw && raw.url ? msg.split(raw.url).join("(地址)") : msg }); } } res.json({ ok: true, imported, skipped, total_tools: mcpOffered() }); } catch (e) { res.status(400).json({ error: e.message }); } }); /** * 这一刻模型手上到底有哪些工具。 * * 以前这个问题在界面上无解—— * 工具表是 agent.js 的 toolList() 按「组织关没关命令行、有没有渲染器、配没配发信通道、 * 连上了几台连接器」当场算出来的,界面上一份都没有。于是「它到底能不能发邮件」 * 只能靠问它一次、等它答「没配」来确认。 * * 所以这里不另抄一份清单,直接调 runtime.toolList()——摆出来的就是模型看见的那一份, * 不会漂。抄一份的下场在 modes 那儿已经演过一遍了(三份手抄,goal 只抄进两份)。 */ app.get("/api/tools", (req, res) => { if (!runtime) return res.json({ tools: [], groups: [], total: 0 }); const mode = String(req.query.mode || "craft"); let defs = []; try { defs = runtime.toolList(0, mode) || []; } catch (e) { return res.status(500).json({ error: e.message }); } const tools = defs.map((t) => { const m = String(t.name).match(/^mcp__([^_]+(?:_[^_]+)*?)__(.+)$/); return { name: t.name, // 连接器工具的描述前面带着 `[MCP:服务器] `,那是给模型认来源的,界面上有分组标题了,重复一遍是噪音 description: String(t.description || "").replace(/^\[MCP:[^\]]+\]\s*/, "").split("\n")[0].slice(0, 160), source: m ? "mcp" : "builtin", server: m ? m[1] : "", short: m ? m[2] : "", }; }); const groups = [{ key: "builtin", label: "内置工具", count: tools.filter((t) => t.source === "builtin").length }]; for (const name of new Set(tools.filter((t) => t.source === "mcp").map((t) => t.server))) { groups.push({ key: "mcp:" + name, label: name, count: tools.filter((t) => t.server === name).length }); } res.json({ tools, groups, total: tools.length, mode }); }); /** 一条连接器配置规整成后端认的形状;stdio 看 command,远程看 url */ function normalizeMcpServer(s, i, prevByName = new Map()) { // 「改 Key」只递改了的那几项(env_patch / headers_patch):值从来不回页面,页上没法整份带回来。 // 填了的换掉,空着的保持原值;拷一份再改,别动到 config 里那份 const mcpPatch = (base, patch) => { const out = { ...base }; if (patch && typeof patch === "object" && !Array.isArray(patch)) { for (const [k, v] of Object.entries(patch)) if (typeof v === "string" && v !== "" && k !== "__proto__") out[String(k)] = v; } return out; }; const at = `第 ${i + 1} 个连接器`; const name = String(s.name || "").trim(); if (!name) throw new Error(`${at}缺少 name`); if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`${at}的 name「${name}」只能用字母、数字、- 和 _(工具名要按 mcp__服务器__工具 拼)`); const url = String(s.url || "").trim(); const command = String(s.command || "").trim(); if (!command && !url) throw new Error(`${at}要么填 command(本地进程),要么填 url(远程 Streamable HTTP)`); if (url) { let u; try { u = new URL(url); } catch { throw new Error(`${at}的 url 不是合法地址:${url}`); } if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(`${at}的 url 只支持 http/https`); // 请求头里多半是 Authorization,GET 只回 header_keys 不回值; // 前端原样存回来时没带 headers,就沿用原来那份,别把令牌洗没了。 const prev = prevByName.get(name); const headers = mcpPatch(s.headers && typeof s.headers === "object" ? Object.fromEntries(Object.entries(s.headers).map(([k, v]) => [String(k), String(v)])) : (prev && prev.headers) || {}, s.headers_patch); const local = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1"; if (Object.keys(headers).length && u.protocol === "http:" && !local) { throw new Error(`${at}带了请求头(多半是令牌)却走明文 http,令牌会在路上被看光——请改成 https`); } return { name, transport: "streamable-http", url, headers }; } // env 里是 API Key,GET 只回键名;前端原样存回来时不带 env,就沿用原来那份 const prev = prevByName.get(name); const env = mcpPatch(s.env && typeof s.env === "object" ? Object.fromEntries(Object.entries(s.env).map(([k, v]) => [String(k), String(v)])) : (prev && prev.env) || {}, s.env_patch); for (const k of Object.keys(env)) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) throw new Error(`${at}的环境变量名「${k}」不合法(只能字母、数字、下划线,不能以数字开头)`); return { name, transport: "stdio", command, args: Array.isArray(s.args) ? s.args.map(String) : [], env }; } /** * 存之前让 toolward 看一眼这批连接器。 * * 连接器以前一次安全检查都不过:从 GitHub 装个技能要过两道闸,而一条 * `command: npx` + 一串参数的连接器,点个保存就在这台机器上跑起来了——权限比技能大得多。 * 拿不到结果(没装 toolward、它崩了)就是 null,一切照旧。 * * **页面上只提醒,不拦**:这是用户自己填的命令和地址,不是陌生人的代码。在「保存」这一步硬拦, * 用户能做的只有改回去或者关掉整个检查,那这道提醒就等于逼人学会无视它。 * agent 加的那一路不一样:命令和地址是模型写的,有提醒就摆审批卡(见 tools.js 的 add_connector)。 * 密钥不出门——redactServers 把 env/headers 的值全换成 ***,只留键名,见 toolward.js。 */ function connectorAdvice(servers) { try { // 内置规则先上:toolward 是可选的,没装时不能一条都不查 const own = connectorGuard.scan(servers), rep = toolward.scanConnectors(servers, config); const findings = [...own.findings, ...((rep && rep.findings) || [])]; if (!findings.length) return null; log.warn("mcp", "连接器配置有被提醒的地方(不影响保存)", { count: findings.length, rules: [...new Set(findings.map((f) => f.rule))].slice(0, 10), }); return { level: own.level === "block" ? "block" : (rep && rep.level) || own.level, findings: findings.slice(0, 8), more: Math.max(0, findings.length - 8), toolward: rep ? rep.toolward : null }; } catch (e) { log.warn("mcp", "连接器体检没跑成(不影响保存)", { err: e }); return null; } } /** * 页面整表保存要存成的那张表:规整、查重。 * baseNames = 页面打开那一刻看到的自配连接器名字。给了就只替换这些,之后才冒出来的 * (agent 刚加的、有人手改 config.json 加的)原样留着——页面手上那张旧表压根不知道它们, * 不能当成「用户删了」。不给(老页面、脚本)还是整表替换,跟以前一样。 * baseRevs = 页面打开时每条的版本号(GET 回的 rev)。页上只能加、删,已有的那几条是原样带回来的旧样子: * 版本号对不上 = 页面开着的时候别处把它换过了,留现在这条,不拿页上的旧地址配新 Key 存回去; * 页上见过、现在已经没了 = 别处删了,不复活。 */ function prepareMcpServers(list, baseNames, baseRevs) { if (!Array.isArray(list)) throw new Error("需要 servers 数组"); const prevByName = new Map((config.mcp_servers || []).filter((s) => s && typeof s === "object").map((s) => [s.name, s])); const revs = baseRevs && typeof baseRevs === "object" && !Array.isArray(baseRevs) ? baseRevs : null; const sawRev = (k) => !!revs && Object.prototype.hasOwnProperty.call(revs, k); let next = []; list.forEach((s, i) => { const key = String((s && s.name) || ""); const cur = prevByName.get(key); if (sawRev(key) && !cur) return; if (sawRev(key) && String(revs[key]) !== mcpRev(cur)) { next.push(cur); return; } next.push(normalizeMcpServer(s, i, prevByName)); }); if (!Array.isArray(baseNames) && revs) baseNames = Object.keys(revs); if (Array.isArray(baseNames)) { const seen = new Set(baseNames.map(String)); const given = new Set(next.map((s) => s.name)); next = [...next, ...(config.mcp_servers || []).filter((s) => s && !seen.has(s.name) && !given.has(s.name))]; } const dup = next.map((s) => s.name).find((n, i, a) => a.indexOf(n) !== i); if (dup) throw new Error(`连接器名字重复:${dup}`); return next; } /** * 连接器的停和起排成一队。页面保存、开关、插件装卸、agent 加连接器、外面改了 config.json 的同步—— * 谁都可能在别人 startAll 到一半时进来,交错着 stop/start 的结果是一台连两次,或者刚连上就被摘掉。 */ let mcpQueue = Promise.resolve(); function mcpApply(fn) { const run = mcpQueue.then(fn); mcpQueue = run.catch(() => {}); // 前一单挂了不该把后面的全堵死;挂的原因由调用方自己接 return run; } /** 整批重启:config 里的加上插件带的。插件那些不在 config 里,只重启 config 的会把它们整批打没(旧版就是这个 bug) */ async function restartAllMcp() { const snap = mcpSnapshot(); // 先拍:起的是这一刻的配置,起到一半配置又变了,那是下一单的事 let fromPlugins = []; try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了不该拖累连接器保存 */ } const servers = [...(config.mcp_servers || []), ...fromPlugins]; mcpManager.setDisabled(config.mcp_disabled || []); mcpManager.stop([...mcpManager.clients.keys(), ...mcpManager.failures.map((f) => f.name), ...servers.map((s) => s.name)]); await mcpManager.startAll(servers); // 失败的单独在日志告警,不阻塞其他 markMcpApplied(snap); } /** * agent 加一台连接器的前半段:跟页面保存同一个 normalizeMcpServer、同一份 toolward 体检, * 只是不落盘——要不要摆审批卡由工具那边按档位定(见 tools.js add_connector),批了才 commit。 */ function connectorPrepare(input) { adoptDiskMcp(); // 外面刚改过的先认进来,不然「是不是覆盖」会按旧表判 const prevByName = new Map((config.mcp_servers || []).map((s) => [s.name, s])); // 没给请求头 / 环境变量时,原来的 Key 只在还发往同一处时沿用(见 mcp.js sameTarget): // 模型换个地址不带头,不该把用户原来的令牌一起递过去。页面保存那条路照旧全沿用 const want = String((input && input.name) || "").trim(); const keep = require("./src/agent/mcp").sameTarget(prevByName.get(want), input || {}) ? new Map([[want, prevByName.get(want)]]) : new Map(); const entry = normalizeMcpServer(input || {}, 0, keep); let fromPlugins = []; try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了照样能加自己的 */ } const owner = fromPlugins.find((s) => s.name === entry.name); // 同名的插件连接器开机时会把这条顶掉,加了等于白加,还会让人以为连上的是自己这台 if (owner) throw new Error(`「${entry.name}」这个名字已经被插件 ${owner.plugin || ""} 的连接器占了,换个名字`); const prev = prevByName.get(entry.name) || null; const advice = connectorAdvice([entry]); return { entry, exists: !!prev, // 同名同配置 = 重连一下,不算覆盖 same: !!prev && cfgFingerprint(prev) === cfgFingerprint(entry), findings: (advice && advice.findings) || [], }; } /** 后半段:落盘、只重启这一台,回它现在的状态 */ function connectorCommit(entry, { expectedRev = "" } = {}) { return mcpApply(async () => { adoptDiskMcp(); // 等审批那会儿外面可能又改过:在最新那张表上替换,别拿旧表盖回去 if (expectedRev) { const current = (config.mcp_servers || []).find((s) => s && s.name === entry.name); if (!current || mcpRev(current) !== expectedRev) throw Object.assign(new Error("连接器已被修改,请刷新后再改凭据"), { status: 409 }); } const list = (config.mcp_servers || []).filter((s) => s && s.name !== entry.name); const at = (config.mcp_servers || []).findIndex((s) => s && s.name === entry.name); list.splice(at < 0 ? list.length : at, 0, entry); // 覆盖的留在原位,连接器页上不跳位置 config.mcp_servers = list; saveConfig(); // 上面认进来(或存盘时合进来)的开关表先灌给管理器再起:外面刚把这台关了,startAll 拿旧表判, // 就会先把用户刚关掉的进程拉起来、回模型一句「连上了」,等下一轮同步才又停掉 mcpManager.setDisabled(config.mcp_disabled || []); mcpManager.stop([entry.name]); await mcpManager.startAll([entry]); // 只记这一台已生效(配置和开关都是):别的条目要是外面也改了还没同步,留给 syncMcp 去起 if (MCP_APPLIED) { MCP_APPLIED.servers.set(entry.name, cfgFingerprint(entry)); if (mcpManager.disabled.has(entry.name)) MCP_APPLIED.disabled.add(entry.name); else MCP_APPLIED.disabled.delete(entry.name); } return connectorStatus(entry.name); }); } /** 一台连接器此刻的样子,给 agent 的结果用:连上没、几个工具、连不上时的原文 */ function connectorStatus(name) { const c = mcpManager.clients.get(name); const f = mcpManager.failures.find((x) => x.name === name); const cfg = (config.mcp_servers || []).find((s) => s && s.name === name) || {}; return { name, disabled: mcpManager.disabled.has(name), connected: !!c, tools: c ? c.tools.map((t) => `mcp__${name}__${t.name}`) : [], // 原文要照抄给人看(不替人猜原因),但先把回显的令牌洗掉 error: f ? mcpScrub(f.raw || f.error, cfg) : "", }; } app.post("/api/mcp", async (req, res) => { try { adoptDiskMcp(); // 外面刚改的先认进来:下面替换只动页面认识的那几条,其余的要是旧的就白留了 const next = prepareMcpServers((req.body || {}).servers, (req.body || {}).base_names, (req.body || {}).base_revs); const advice = connectorAdvice(next); config.mcp_servers = next; // 删掉的连接器,它的工具权限别长住:以后来一台同名的不该继承(插件列不出来时不清,免得误删插件那几台的) try { config.mcp_tool_policy = mcpPolicy.prune(config.mcp_tool_policy, [...next.map((s) => s.name), ...pluginsMgr.pluginMcpServers().map((s) => s.name)]); } catch {} saveConfig(); await mcpApply(restartAllMcp); res.json({ ok: true, total_tools: mcpOffered(), connected: [...mcpManager.clients.keys()], // 接入弹窗当场报「已连接 · N 个工具」用 tool_counts: Object.fromEntries([...mcpManager.clients].map(([n, c]) => [n, c.tools.length])), failures: mcpManager.failures, advice, // toolward 的第二意见;没装它就是 null }); } catch (e) { res.status(400).json({ error: e.message }); } }); // ---------- 项目(多工作空间,仿官方「项目」:每个项目一个独立工作目录,成果互不混淆) ---------- /** * 项目自带的「指令」和挂载的专家/技能/连接器。 * 指令不是装饰:切到这个项目之后,它会进系统提示词(见 accountedRuntime), * 否则用户在弹窗里写一大段项目背景,agent 一个字都看不见。 */ function projectMeta(body) { const arr = (v) => (Array.isArray(v) ? v.map((x) => String(x)).filter(Boolean).slice(0, 30) : []); // library_dir:这个项目挂载资料库的哪一块。""=整个资料库(老行为)。 // 挂坏了不该让整次保存失败——目录可能是后来被人在磁盘上删的——所以校验不过就当没挂 let lib = ""; try { lib = libRel(body.library_dir); } catch { lib = ""; } return { instructions: String(body.instructions || "").slice(0, 4000), connectors: arr(body.connectors), experts: arr(body.experts), skills: arr(body.skills), library_dir: lib, }; } function activeProject() { ensureProjects(); return config.projects.find((p) => p.name === config.active_project) || null; } /** * 组装进系统提示词的项目上下文:指令 + 挂载清单。 * 挂载在弹窗里勾了才有;勾过但后来被删掉的专家/技能/连接器要过滤掉,不然提示词里指着空气让 agent 用。 */ function projectContextOf(p) { if (!p) return ""; // 带谁的项目由 projectFor 定:跑在别人根里的那趟拿到的是 null,不带共享根项目的说明和规范 const parts = []; if (p.instructions) parts.push(p.instructions); // 挂载了资料库的某一块就说出来。不说的话模型只会看见一个小得可疑的文件清单, // 然后自作主张去 run_shell 翻整个 data 目录找「应该还有的那些文件」——那条路本来就该被安全中心拦下 if (p.library_dir) parts.push(`本项目只挂载了资料库的「${p.library_dir}」这一块,library_list 列出来的就是全部;别去别处找资料,也别猜库里还有什么。`); const alive = (names, pool) => (names || []).filter((n) => pool.includes(n)); const exps = alive(p.experts, experts.map((e) => e.name)); if (exps.length) parts.push(`本项目挂载的专家:${exps.join("、")}。相应领域的子任务优先 delegate_to_expert 委派给他们。`); let skillNames = []; try { skillNames = skillsMgr.loadSkills().map((s) => s.name); } catch {} const sks = alive(p.skills, skillNames); if (sks.length) parts.push(`本项目挂载的技能:${sks.join("、")}。做对应任务前先 use_skill 加载,按技能里的规范执行。`); const conns = alive(p.connectors, (config.mcp_servers || []).map((s) => s.name)); if (conns.length) parts.push(`本项目挂载的连接器:${conns.join("、")}。涉及外部系统时优先用这些连接器提供的工具。`); // 项目目录里的 AGENTS.md / CLAUDE.md 是写给 agent 看的项目规范(pi / Claude Code 的通行惯例), // 用户既然放了就自动带上,不用再往项目指令里手抄一遍。往上一直找到 git 仓库根,和命令行用的是同一份逻辑 const memo = projectMemo.memoContext(p.dir); if (memo) parts.push(memo); return parts.join("\n\n"); } function ensureProjects() { if (!Array.isArray(config.projects) || !config.projects.length) { config.projects = [{ name: "默认项目", dir: getDefaultWorkspaceDir() }]; config.active_project = "默认项目"; } if (!config.projects.some((p) => p.name === config.active_project)) config.active_project = config.projects[0].name; } /** * 把一个项目的「空间」落到运行时:工作空间根 + 资料库挂载的那一块。 * * 这两件事必须一起做。只切工作目录不切资料库挂载,就是「我明明在客户 A 的项目里, * 它翻出来的是客户 B 的合同」;只切挂载不切目录,产出会写进上一个项目的文件夹。 * 切完顺手把根记进名单(rememberRoot),这样以后回看这条项目下的老对话还找得到文件。 */ function applyProjectSpaces(p) { if (!p) return; try { setLibraryDir(p.library_dir || ""); } catch {} if (p.dir) rememberRoot(p.dir); } /** * config.json 是唯一一份存着所有 API Key 的文件,还不入 git——写坏了就是全丢。 * 所以全应用只留这一个写入口,走原子改名 + .bak。 */ /** * 存配置。 * * 难点不在写,在于**别把别人的改动盖掉**。config.json 同时有两个人在改: * 界面(走这个函数,写的是内存里那整份 config)和用户本人(文本编辑器里粘一个 API Key、 * 加一条 mcp_servers)。老写法不管三七二十一整份覆盖,于是手改的那些全没了—— * 最常见的是刚粘进去的 Key,用户的体感是「填了不生效」,回头一看文件,空的。 * * 所以存盘前先看一眼文件的改动时间:跟我们上次对齐时不一样,说明外面有人动过。 * 这时候不是二选一,而是把两边合起来:磁盘上那份当底,只把**这个进程真改过的那几处** * 按路径盖上去(哪几处 = 内存里的 config 跟基线快照的差集)。用户粘的 Key 在底上,一个字不动。 */ function saveConfig() { const now = cfgMerge.mtimeOf(CONFIG_PATH); if (now && CONFIG_MTIME && now !== CONFIG_MTIME) mergeDiskEdits(); store.writeJsonAtomic(CONFIG_PATH, config, { pretty: true, mode: store.SECRET_MODE }); CONFIG_MTIME = cfgMerge.mtimeOf(CONFIG_PATH); CONFIG_BASE = cfgMerge.snapshot(config); CONFIG_SEEN = CONFIG_MTIME; } /** 把磁盘上那份读回来当底,只把本进程改过的那几处盖上去。合不了就照旧覆盖,但要留一句。 */ function mergeDiskEdits() { let disk; try { disk = store.readJson(CONFIG_PATH, null); } catch (e) { console.warn(`[配置] config.json 在外面被改过,但读不回来(${(e && e.message) || e}),这次按内存里的存`); return; } if (!disk || typeof disk !== "object" || Array.isArray(disk)) return; // 不成形的就别拿来当底 const { changed: mine } = cfgMerge.mergeOnto(disk, CONFIG_BASE, config); // 用户可能整块删掉(server / agent 这种),跟启动时同一套兜底,免得合完之后某个字段没了就崩 fillDefaults(disk, CONFIG_DEFAULTS); disk.shortcuts = disk.shortcuts || {}; disk.assistant = { ...ASSISTANT_DEFAULT, ...(disk.assistant || {}) }; // 换内容不换对象:llm、security、tools 这些模块启动时就把 config 的引用拿走了, // 这里要是 config = disk,它们手上还攥着老的那份,界面显示新值、干活用老值。 for (const k of Object.keys(config)) delete config[k]; Object.assign(config, disk); security.getSecurity(config); // 安全策略跟着新内容重新补齐 console.log(`[配置] config.json 在外面被改过,已合并:磁盘那份当底,本进程改的 ${mine.length} 处盖上去`); } /** 此刻 config 里的连接器配置,拍成跟 MCP_APPLIED 同样的形状好比对 */ function mcpSnapshot() { return { servers: new Map((config.mcp_servers || []).filter((s) => s && s.name).map((s) => [s.name, cfgFingerprint(s)])), disabled: new Set((config.mcp_disabled || []).map(String)), }; } function markMcpApplied(snap = mcpSnapshot()) { MCP_APPLIED = snap; } function mcpChanged() { if (!MCP_APPLIED) return false; // 开机那一批还没起完:起完会自己记 const now = mcpSnapshot(); const same = (a, b) => a.size === b.size && [...a].every((x) => (Array.isArray(x) ? b.get(x[0]) === x[1] : b.has(x))); return !same(now.servers, MCP_APPLIED.servers) || !same(now.disabled, MCP_APPLIED.disabled); } /** * config 里的连接器跟正在跑的对不上了(外面改了 config.json、或者别的路存盘时合并进来的): * 只动变了的那几台——删掉的停、新加的和改过的重连、开关翻了的按新开关来。没变的一台都不碰, * 正连着的 npx 进程不该因为隔壁加了一条就被踢下线。 */ async function syncMcp() { if (!mcpChanged()) return; const want = mcpSnapshot(); const had = MCP_APPLIED; let fromPlugins = []; try { fromPlugins = pluginsMgr.pluginMcpServers(); } catch { /* 插件坏了照样同步自己配的 */ } const pluginNames = new Set(fromPlugins.map((s) => s.name)); const flipped = (n) => had.disabled.has(n) !== want.disabled.has(n); const gone = [...had.servers.keys()].filter((n) => !want.servers.has(n) && !pluginNames.has(n)); const redo = [ ...(config.mcp_servers || []).filter((s) => s && s.name && (had.servers.get(s.name) !== want.servers.get(s.name) || flipped(s.name))), ...fromPlugins.filter((s) => flipped(s.name)), ]; mcpManager.setDisabled(config.mcp_disabled || []); if (gone.length) mcpManager.stop(gone); if (redo.length) { mcpManager.stop(redo.map((s) => s.name)); // 连缓存那条一起清:配置改了就当场连一次,别拿旧工具表糊过去 await mcpManager.startAll(redo); } markMcpApplied(want); console.log(`[MCP] config.json 里的连接器变了,已同步:${[...gone.map((n) => `停 ${n}`), ...redo.map((s) => `重连 ${s.name}`)].join("、") || "开关表更新"}`); } /** * 磁盘上的 config.json 自从上回看过之后被改了,就只把 mcp_servers / mcp_disabled 认进来。 * * 为什么只认这两个:别的键(Key、模型)下次存盘时 saveConfig 会三方合并,丢不了; * 连接器不一样,改了没人点保存就一直不生效——模型让人「去 config.json 里加一条」,加完连接器页上看不见, * 下一次随便存点什么,数组整块按内存那份盖回去(数组不逐项合并),手加的那条就悄悄没了。 * * 用 JSON.parse 直接读、不走 store.readJson:别人写到一半时读到的是半截 JSON, * readJson 会把它当坏文件挪去 .corrupt、退回备份——半截不是坏,等它写完就好。 * 读不成:这一版改动时间只说一次,CONFIG_SEEN 不动,下一轮接着读。 * 不碰 CONFIG_MTIME:只认了两个键不算整份对齐,下次存盘照样合并别的键。 */ let configPollWarned = 0; function adoptDiskMcp() { const now = cfgMerge.mtimeOf(CONFIG_PATH); if (!now || now === CONFIG_SEEN) return false; let disk; try { disk = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch (e) { if (configPollWarned !== now) { configPollWarned = now; console.warn(`[配置] config.json 改过了,但现在读不成(${(e && e.message) || e}),连接器先按原样跑,改好了会自动认`); } return false; } CONFIG_SEEN = now; if (!disk || typeof disk !== "object" || Array.isArray(disk)) return false; let took = false; for (const k of ["mcp_servers", "mcp_disabled"]) { const v = disk[k] === undefined ? [] : disk[k]; // 整个键删掉 = 清空 if (!Array.isArray(v)) { console.warn(`[配置] config.json 里的 ${k} 不是数组,没认,连接器先按原样跑`); continue; } if (JSON.stringify(v) === JSON.stringify(config[k] || [])) continue; if (JSON.stringify(config[k] || []) !== JSON.stringify(CONFIG_BASE[k] || [])) { // 本进程改了还没落盘就撞上外面也改:两边都是整张表,合不了。说出来,别悄悄丢一边 console.warn(`[配置] config.json 里的 ${k} 在外面被改了,这边也有没存的改动,这次没认外面那份`); continue; } config[k] = v; CONFIG_BASE[k] = cfgMerge.snapshot(v); // 基线跟着走:下次存盘合并时这一键不算本进程改的,磁盘那份不会被盖 took = true; } if (took) console.log("[配置] config.json 里的连接器在外面被改过,已认进来"); return took; } let configPolling = false; async function pollConfig() { if (configPolling) return; // 上一轮还在重连(npx 首次能拖一分钟),别叠着起 configPolling = true; try { adoptDiskMcp(); // 不只看这一轮认没认:别的路存盘时 mergeDiskEdits 也可能把外面改的连接器合进来 if (mcpChanged()) await mcpApply(syncMcp); } catch (e) { console.warn(`[配置] 同步 config.json 里的连接器出错了:${(e && e.message) || e}`); } finally { configPolling = false; } } /** * 这个人用的是哪份项目清单: * "shared" 留在共享根上的平台管理员:config.projects,切的是共享根(老行为); * "own" 用自己工作目录的人(成员、审计员、分公司的人):他自己那份,全在自己的工作目录里(spaces.js); * "none" 剩下的(共享根上的老主人却不是管理员,老账本才有):没有项目这回事。 */ function projectScope(user) { if (user && !admin.keepsSharedSpace(user)) return "own"; return ownsGlobalWorkspace(user) ? "shared" : "none"; } /** 他自己的项目清单,每项补上绝对路径给界面显示(他自己那份,设置页本来就显示)。存盘时不带 dir */ function ownProjects(user) { const home = spaces.rootOf(user); const { projects, active } = spaces.projectsOf(user.username); const dirOf = (p) => (p.folder ? path.join(home, spaces.PROJECTS, p.folder) : home); return { home, active, projects: projects.map((p) => ({ ...p, dir: dirOf(p) })) }; } const ownStored = (list) => list.map(({ dir, ...p }) => p); /** 项目名:去空白、必填、30 字以内、清单里不重名 */ function projectName(raw, list, self) { const name = String(raw || "").trim(); if (!name) throw new Error("缺少项目名"); if (name.length > 30) throw new Error("项目名太长(最多 30 字)"); if (list.some((p) => p !== self && p.name === name)) throw new Error("同名项目已存在"); return name; } const OWN_DIR_FIXED = "你的项目都建在自己的工作目录里,不能另选文件夹"; /** 改名时把旧名字记进 old_names:老对话记的是当时的项目名,侧栏靠它把它们认回这个项目(不去改一条条对话) */ function renameProject(p, to) { const old = Array.isArray(p.old_names) ? p.old_names : []; p.old_names = [p.name, ...old].filter((n, i, a) => typeof n === "string" && n && n !== to && a.indexOf(n) === i).slice(0, 20); p.name = to; } /** 用自己工作目录的人这一趟带哪个项目的说明;留在共享根上的带当前项目(跑在别人根里的那趟不带) */ function projectFor(user) { if (projectScope(user) === "own") { try { const { projects, active } = ownProjects(user); return projects.find((p) => p.name === active) || null; } catch { return null; } } return sharedRootHere() ? activeProject() : null; } app.get("/api/projects", (req, res) => { // 租户看到的是自己那一个根,不是总部的项目清单——后者连目录名都是信息。 // 以前这里编了个叫「本组织工作目录」的假项目顶上,两头都出事:侧栏多一个点不动的 tab, // 而且这个名字跟老会话记的项目名对不上,前端按项目过滤后整排任务历史都没了。 // 没有项目这回事的如实说 locked,前端见到就整块不画、也不按项目过滤; // 用自己工作目录的人回他自己那份清单(own: true,前端不给选文件夹) const scope = projectScope(req.user); if (scope === "none") return res.json({ projects: [], active: "", locked: true, root: workspaceKeyOf(getWorkspaceDir()) }); if (scope === "own") { try { const { projects, active } = ownProjects(req.user); return res.json({ projects, active, locked: false, own: true, root: workspaceKeyOf(getWorkspaceDir()) }); } catch (e) { return res.status(503).json({ error: "你的工作目录建不出来,稍后再试或找管理员" }); } } ensureProjects(); res.json({ projects: config.projects, active: config.active_project, locked: false, root: workspaceKeyOf(getWorkspaceDir()) }); }); app.post("/api/projects", (req, res) => { try { if (projectScope(req.user) === "own") { // 只动他自己那份清单,config 里共享根的项目、工作目录一个字不碰 const b = req.body || {}; const { home, projects } = ownProjects(req.user); const name = projectName(b.name, projects); if (String(b.dir || "").trim()) throw new Error(OWN_DIR_FIXED); const folder = spaces.newFolder(projects, name); spaces.openProject(home, folder); // 先把文件夹建好:软链接指出去的、建不出来的,当场报错,不留一条打不开的项目 spaces.saveProjects(req.user.username, [...ownStored(projects), { name, folder, ...projectMeta(b), created_at: new Date().toISOString() }], name); const now = ownProjects(req.user); return res.json({ ok: true, projects: now.projects, active: now.active }); } ensureProjects(); const name = String((req.body || {}).name || "").trim(); if (!name) throw new Error("缺少项目名"); if (name.length > 30) throw new Error("项目名太长(最多 30 字)"); if (config.projects.some((p) => p.name === name)) throw new Error("同名项目已存在"); let dir = String((req.body || {}).dir || "").trim(); if (!dir) dir = dataPath("projects", name.replace(/[/\\:*?"<>|]/g, "_")); const real = setWorkspaceDir(dir); // 建目录并切换过去 noteLayout(real); // 填的是用户自己的文件夹:按此刻判的记下(应用替项目建的 projects/<名> 不记,永远按对话分) config.projects.push({ name, dir: real, ...projectMeta(req.body || {}), created_at: new Date().toISOString() }); rememberRoot(real); // 以后在别的项目里回看这条项目下的老对话,靠的就是这份名单 config.active_project = name; config.workspace_dir = real; applyProjectSpaces(config.projects[config.projects.length - 1]); saveConfig(); res.json({ ok: true, projects: config.projects, active: name }); } catch (e) { res.status(400).json({ error: e.message }); } }); app.post("/api/projects/switch", (req, res) => { try { if (projectScope(req.user) === "own") { // 只记「他现在在哪个项目」;下一条请求起 tenantScope 就把他绑到那个项目的文件夹上 const { home, projects } = ownProjects(req.user); const p = projects.find((x) => x.name === (req.body || {}).name); if (!p) return res.status(404).json({ error: "项目不存在" }); const dir = spaces.openProject(home, p.folder); spaces.saveProjects(req.user.username, ownStored(projects), p.name); return res.json({ ok: true, active: p.name, dir, library_dir: p.library_dir || "" }); } ensureProjects(); const p = config.projects.find((x) => x.name === (req.body || {}).name); if (!p) return res.status(404).json({ error: "项目不存在" }); config.workspace_dir = setWorkspaceDir(p.dir); config.active_project = p.name; applyProjectSpaces(p); saveConfig(); res.json({ ok: true, active: p.name, dir: p.dir, library_dir: p.library_dir || "" }); } catch (e) { res.status(400).json({ error: e.message }); } }); // 新任务回到当前项目自己的目录:输入框里临时切过的文件夹不带进下一个任务 app.post("/api/workspace/reset", (req, res) => { // 用自己工作目录的人没有项目可回,回一句现状就行;往下走会把老主人的根切回他的项目 if (!admin.keepsSharedSpace(req.user)) return res.json({ ok: true, workspace_dir: getWorkspaceDir() }); try { // 跑着的任务不怕:每趟在开头就把自己的根钉住了(/api/chat 的 enterWorkspace、accountedRuntime 的 withWorkspace), // 这里改的只是下一条新任务从哪开始。以前在这儿遇忙就跳过,结果忙的时候点「新任务」,新任务落在上一条临时切过的文件夹里 ensureProjects(); const ap = config.projects.find((p) => p.name === config.active_project) || config.projects[0]; if (ap && ap.dir && ap.dir !== getDefaultWorkspaceDir()) { config.workspace_dir = setWorkspaceDir(ap.dir); saveConfig(); } applyProjectSpaces(ap); res.json({ ok: true, workspace_dir: getWorkspaceDir() }); } catch (e) { res.status(400).json({ error: e.message }); } }); // 设置页刚选了个文件夹、还没保存:问一声它会按哪种用法放成果(每个对话一个文件夹 / 直接放进去),单选框先摆对。 // 只给能改工作目录的人:它会回「这个路径看着像不像代码仓库」,等于能拿来探服务器上任意目录 app.get("/api/workspace/layout", (req, res) => { if (!isPlatformOwner(req) || !admin.keepsSharedSpace(req.user)) return res.status(403).json({ error: "这块是服务器级设置,归平台管理员管", platform_only: true }); const dir = String(req.query.dir || "").trim(); if (!dir || !path.isAbsolute(dir)) return res.status(400).json({ error: "要一个绝对路径" }); const { layout, locked, chosen, repo } = taskDirs.folderLayout(dir, layoutAnchors()); res.json({ layout, locked, chosen, repo }); }); // 改项目:名字之外的东西(指令、挂载的专家/技能/连接器)都能改,改完立即对新任务生效 app.patch("/api/projects/:name", (req, res) => { if (projectScope(req.user) === "own") { try { const b = req.body || {}; const { projects, active } = ownProjects(req.user); const p = projects.find((x) => x.name === req.params.name); if (!p) return res.status(404).json({ error: "项目不存在" }); // 弹窗把原来的路径原样带回来不算改;真换成别处的不行 const nd = String(b.dir || "").trim(); if (nd && !taskDirs.samePlace(nd, p.dir)) return res.status(400).json({ error: OWN_DIR_FIXED }); Object.assign(p, projectMeta({ ...p, ...b })); let now = active; const rename = String(b.name || "").trim(); if (rename && rename !== p.name) { projectName(rename, projects, p); if (active === p.name) now = rename; renameProject(p, rename); // 文件夹名不跟着改:改了就是搬家,跑着的任务和老对话都指着原来那个 } spaces.saveProjects(req.user.username, ownStored(projects), now); const out = ownProjects(req.user); return res.json({ ok: true, project: out.projects.find((x) => x.name === p.name), projects: out.projects, active: out.active }); } catch (e) { return res.status(400).json({ error: e.message }); } } ensureProjects(); const p = config.projects.find((x) => x.name === req.params.name); if (!p) return res.status(404).json({ error: "项目不存在" }); Object.assign(p, projectMeta({ ...p, ...(req.body || {}) })); // 换工作空间目录。老版本这儿根本收不了 dir,新建项目时也没处填,用户只能拿到 // data/projects/<名字> 这个藏在应用数据里的目录——「新增的项目好像没地方设置目录」说的就是它。 const nd = String((req.body || {}).dir || "").trim(); if (nd) { try { const real = path.resolve(nd); if (!path.isAbsolute(real)) throw new Error("工作空间必须是绝对路径"); fs.mkdirSync(real, { recursive: true }); // 没权限/盘不在,在这儿就炸,别等任务跑到一半 p.dir = real; rememberRoot(real); noteLayout(real); // 改的是当前项目就得同步切根,否则界面显示新目录、任务还往老目录写, // 下一次回看就是满屏「文件不存在」 if (config.active_project === p.name) config.workspace_dir = setWorkspaceDir(real); } catch (e) { return res.status(400).json({ error: "工作空间目录设置失败:" + e.message }); } } const rename = String((req.body || {}).name || "").trim(); if (rename && rename !== p.name) { if (config.projects.some((x) => x.name === rename)) return res.status(400).json({ error: "同名项目已存在" }); if (config.active_project === p.name) config.active_project = rename; renameProject(p, rename); } if (config.active_project === p.name) applyProjectSpaces(p); saveConfig(); res.json({ ok: true, project: p, projects: config.projects, active: config.active_project }); }); // 只从列表移除,不删磁盘文件 app.delete("/api/projects/:name", (req, res) => { if (projectScope(req.user) === "own") { try { const { projects, active } = ownProjects(req.user); if (projects.length <= 1) return res.status(400).json({ error: "至少保留一个项目" }); const i = projects.findIndex((p) => p.name === req.params.name); if (i < 0) return res.status(404).json({ error: "项目不存在" }); // 默认项目就是工作目录本身:移掉它,摊在工作目录里的老成果就没有哪个项目能打开了 if (!projects[i].folder) return res.status(400).json({ error: "这个项目就是你的工作目录本身,不能移除" }); projects.splice(i, 1); const now = active === req.params.name ? projects[0].name : active; spaces.saveProjects(req.user.username, ownStored(projects), now); const out = ownProjects(req.user); return res.json({ ok: true, projects: out.projects, active: out.active }); } catch (e) { return res.status(400).json({ error: e.message }); } } ensureProjects(); if (config.projects.length <= 1) return res.status(400).json({ error: "至少保留一个项目" }); const i = config.projects.findIndex((p) => p.name === req.params.name); if (i < 0) return res.status(404).json({ error: "项目不存在" }); config.projects.splice(i, 1); if (config.active_project === req.params.name) { const p0 = config.projects[0]; config.active_project = p0.name; try { config.workspace_dir = setWorkspaceDir(p0.dir); } catch {} } saveConfig(); res.json({ ok: true, projects: config.projects, active: config.active_project }); }); // ---------- 资料库·灵感(跨项目共享:参考文件 + 灵感笔记,agent 可用 library_* 工具读取) ---------- const LIB_DIR = dataPath("data", "library"); /** * 资料库的根:一人一个。 * * 老库 data/library 原地不动,归留在共享根上的人:只有一个账号时就是他,不止一个时是默认组织的管理员 * (admin.keepsSharedSpace,跟工作目录、项目同一个判据)。别人一人一个 data/library-users/<账号>/, * 头一次用的时候才建。 * * 为什么非改不可:资料库以前是整台机器**共用的一份**,而且 admin.js 那张读表还特地把 * /api/library 放行了(理由是拦了也白拦,agent 的 library_list 照样念得出来)。两件事叠在一起, * 结果就是新注册的号打开资料库,看见的是别人传进去的合同和素材——跟侧栏里那条会话历史 * 是同一个事故的两个面。 * * 不搬文件:管理员那一份还躺在原地,路径一个字符没变;新号拿到的是一个空目录。 */ function libraryRootOf(user) { if (!user || admin.keepsSharedSpace(user)) return LIB_DIR; return dataPath("data", "library-users", prefs.keyOf(user)); } function readNotes() { const list = store.readJson(notesFileOf(libBase()), []); return Array.isArray(list) ? list : []; } function writeNotes(notes) { store.writeJsonAtomic(notesFileOf(libBase()), notes, { pretty: true }); } /** * 资料库里的相对路径 → 绝对路径。 * * 老版本这儿是一句 `path.basename(name)`:资料库被硬压成**一层平铺**。用户传二十份合同进来 * 就是二十条并排,分不出「客户 A / 客户 B」,项目也没法只挂载其中一块—— * 这正是「资料库看起来没有子目录或者指定目录的概念」那条反馈的根。 * * 允许子目录之后越界检查就得自己做:basename 那一刀顺手也把 `../` 剁掉了,现在没有了。 * 三道:段里不许有 `..`、不许以 `.` 开头(.git/.ssh 这类别被翻出来)、不许有 Windows 非法字符; * 拼完再让 safePathIn 按根复核一遍,两道都过才算数。 */ function libPath(rel, root = libBase()) { const parts = String(rel || "").replace(/\\/g, "/").split("/").filter((x) => x && x !== "."); for (const seg of parts) { if (seg === ".." || seg.startsWith(".") || /[<>:"|?*\u0000-\u001f]/.test(seg)) throw new Error(`路径不合法:${seg}`); } return safePathIn(root, parts.join("/")); } /** 规整成对外用的相对路径(统一正斜杠、去掉首尾斜杠);越界的在 libPath 里抛 */ function libRel(rel) { const parts = String(rel || "").replace(/\\/g, "/").split("/").filter((x) => x && x !== "."); libPath(parts.join("/")); // 只为触发校验 return parts.join("/"); } /** 资料库里所有子目录的相对路径(给项目弹窗那个「挂哪一块」的下拉用)。深度封 4 层,防止有人把整个硬盘软链进来 */ function libFolders(rel = "", depth = 0, out = []) { if (depth >= 4) return out; let ents = []; try { ents = fs.readdirSync(libPath(rel), { withFileTypes: true }); } catch { return out; } for (const e of ents) { if (!e.isDirectory() || e.name.startsWith(".")) continue; const child = rel ? `${rel}/${e.name}` : e.name; out.push(child); libFolders(child, depth + 1, out); } return out; } /** 一个资料库目录里直接躺着几个文件(列表上给个「3 份」的角标,免得点进去才发现是空的) */ function libCount(abs) { try { return fs.readdirSync(abs, { withFileTypes: true }).filter((e) => !e.name.startsWith(".")).length; } catch { return 0; } } app.get("/api/library", (req, res) => { const dir = (() => { try { return libRel((req.query || {}).dir); } catch { return ""; } })(); let files = []; let dirs = []; // 收藏跟着资料库的根走(src/server/routes/library.js),列的时候逐个打上 fav,前端不用再单独问一趟 const favs = libraryRoutes.libFavKeys(libraryRootOf(req.user)); try { for (const e of fs.readdirSync(libPath(dir), { withFileTypes: true })) { if (e.name.startsWith(".")) continue; const abs = path.join(libPath(dir), e.name); let st; try { st = fs.statSync(abs); } catch { continue; } const row = { name: e.name, path: dir ? `${dir}/${e.name}` : e.name, size: st.size, mtime: st.mtime.toISOString() }; if (e.isDirectory()) dirs.push({ ...row, count: libCount(abs) }); else if (e.isFile()) files.push({ ...row, fav: favs.has("lib:" + row.path) }); } } catch {} dirs.sort((a, b) => a.name.localeCompare(b.name, "zh")); files.sort((a, b) => b.mtime.localeCompare(a.mtime)); // crumbs:["", "客户A", "客户A/2026"] 这样一层层的可点路径,前端不用自己切字符串 const segs = dir ? dir.split("/") : []; const crumbs = segs.map((name, i) => ({ name, path: segs.slice(0, i + 1).join("/") })); res.json({ dir, crumbs, dirs, files, folders: libFolders(), notes: readNotes() }); }); app.post("/api/library/folder", (req, res) => { try { const parent = libRel((req.body || {}).dir); const name = String((req.body || {}).name || "").trim(); if (!name) return res.status(400).json({ error: "文件夹名不能为空" }); const rel = parent ? `${parent}/${name}` : name; const abs = libPath(rel); if (fs.existsSync(abs)) return res.status(400).json({ error: "同名文件夹已存在" }); fs.mkdirSync(abs, { recursive: true }); res.json({ ok: true, dir: rel }); } catch (e) { res.status(400).json({ error: e.message }); } }); // 删空文件夹。非空的不给删:这个接口的调用方是一个「删除」小链接, // 点错的代价不该是一整个文件夹的东西不可逆地没了 app.delete("/api/library/folder", async (req, res) => { try { const rel = libRel((req.query || {}).dir || (req.body || {}).dir); if (!rel) return res.status(400).json({ error: "没说删哪个" }); const abs = libPath(rel); if (!fs.existsSync(abs)) return res.json({ ok: true }); if (libCount(abs)) return res.status(400).json({ error: "文件夹里还有东西,先清空再删" }); assertCleanupIdle(libBase()); const moved = await fileTrash.move(libBase(), abs, { beforeMove: () => assertCleanupIdle(libBase()) }); res.json({ ok: true, ...moved }); } catch (e) { res.status(400).json({ error: e.message }); } }); // 跟 /api/upload 一样认两种请求体:原始字节(名字、目录在 X-Upload-Name / X-Upload-Dir 里)边收边写盘, // 大视频不过内存;老的 JSON + base64 照收,受 express.json 60MB 的限制 app.post("/api/library/upload", async (req, res) => { const raw = !!req.is("application/octet-stream"); let tmp = ""; try { const meta = raw ? uploadHeaders(req) : (req.body || {}); const { name, data_b64 } = meta; if (!name || (!raw && !data_b64)) return res.status(400).json({ error: raw ? "缺少文件名(X-Upload-Name)" : "缺少 name 或 data_b64" }); const dir = libRel(meta.dir); // 浏览器拖整个文件夹进来时 file.name 可能自带层级,一并接住;两边都有就拼起来 const rel = libRel(dir ? `${dir}/${name}` : name); const abs = libPath(rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); if (raw) { tmp = await uploadName.receiveUploadToTemp(req, path.dirname(abs)); uploadName.checkUploadContent(rel, fs.statSync(tmp).size); fs.renameSync(tmp, abs); tmp = ""; } else { const buf = Buffer.from(data_b64, "base64"); uploadName.checkUploadContent(rel, buf.length); fs.writeFileSync(abs, buf); } res.json({ ok: true, name: path.basename(rel), path: rel }); } catch (e) { if (tmp) { try { fs.unlinkSync(tmp); } catch {} } res.status(400).json({ error: e.message }); } }); // 通配而不是 :name —— 子目录以后路径里有斜杠,:name 只吃得下一段 app.get("/api/library/file/*", async (req, res) => { try { const p = libPath(relOf(req)); if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).send("文件不存在"); // ?thumb=160:资料库那一页的图框最大 64px,一屏摆 120 张。缩不动就照旧发原件, // 跟 /api/files/view/ 走的是同一段(thumb.js),缓存键带绝对路径所以两处不会串 res.set("Cache-Control", viewCacheHeader(req, p)); // 跟 /api/files/view/ 一个规矩:换过图不许再摆七天旧缩略图 const thumb = await thumbFileAsync(p, parseInt(req.query.thumb, 10), path.join(dataPath("data"), "thumbs")); if (thumb) { res.set("Content-Type", "image/png"); return res.sendFile(thumb); } // 默认内联发。以前这里一律 res.download,带上 Content-Disposition: attachment 之后 //