server.js
Rama main · réplica de lectura del repositorio original.
// 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/<id>.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/<id>.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/<id>-<毫秒>.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);
// <pid>.<序号>.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/<id>.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 生成的网页里随便一个 <a>)都会把整条地址、连着那串码一起送到对面站点去。
* - 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('<!doctype html><meta charset="utf-8"><title>OpenWorkBuddy</title><h1>授权回调已收到</h1><p>请返回 OpenWorkBuddy。The authorization response was received. Return to OpenWorkBuddy.</p>'); }
catch { res.status(400).type("html").send('<!doctype html><meta charset="utf-8"><title>OpenWorkBuddy</title><h1>授权请求已失效</h1><p>请在连接器页面重新开始。Restart authorization from the Connectors page.</p>'); }
});
// 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/<id>.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 之后
// <audio>/<video>/<iframe> 全都渲染不出来,点什么都预览不了。
// 真要存盘的走 ?dl=1,附件头只在那一条路上加。
if (String(req.query.dl || "") === "1") return res.download(p);
const mm = mediaMime(p);
if (mm) res.set("Content-Type", mm);
// 资料库里的网页更是外来的:被当顶层页面直接打开时同样要落进 sandbox(见「工作区预览的隔离」)。
// 应用内的资料库预览走的是 blob: + sandbox 属性,不经过这个响应头,不受影响
sandboxPreviewDoc(res, viewTypeOf(p));
res.sendFile(p);
} catch (e) {
res.status(400).send(e.message);
}
});
// 资料库里的 docx / xlsx / pptx / zip:浏览器自己打不开(zip 里一包 XML),得服务端先拆。
// 跟 /api/files/preview 是同一个 previewData,只是换一个根——资料库在 data/library 下,
// 工作目录在别处。以前这条不存在,于是同一份 .pptx 在对话里点得开、拖进资料库就点不开了。
app.get("/api/library/preview/*", async (req, res) => {
try {
const rel = relOf(req);
const p = libPath(rel);
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).json({ error: "文件不存在" });
const controller = new AbortController(); res.once("close", () => { if (!res.writableEnded) controller.abort(); });
res.json(await previewData(p, rel, { layout: true, signal: controller.signal }));
} catch (e) {
res.status(400).json({ error: e.message });
}
});
for (const [prefix, resolve] of [["/api/library", (req) => libPath(relOf(req))], ["/api/files", (req) => rootedPath(req, relOf(req))]]) {
for (const mode of ["office-info", "office-view", "markdown"]) app.get(prefix + "/" + mode + "/*", async (req, res) => {
try {
const p = resolve(req);
if (!fs.existsSync(p) || !fs.statSync(p).isFile()) return res.status(404).json({ error: "文件不存在" });
if (mode === "markdown") {
const controller = new AbortController();
res.once("close", () => { if (!res.writableEnded) controller.abort(); });
const result = await require("./src/domains/library/document-markdown").documentMarkdown(p, relOf(req), { signal: controller.signal });
res.set("Cache-Control", "private, no-store"); return res.json(result);
}
const result = await require("./src/domains/library/office-compat").officeFile(p, relOf(req));
res.set("Cache-Control", "private, no-store");
if (mode === "office-info") return res.json({ kind: result.kind, converted: result.converted });
res.type(result.ext).sendFile(result.file);
} catch (error) { res.status(400).json({ error: error.message }); }
});
}
app.delete("/api/library/file/*", async (req, res) => {
try {
const p = libPath(relOf(req));
if (fs.existsSync(p) && fs.statSync(p).isDirectory()) return res.status(400).json({ error: "这是文件夹,用删文件夹那个接口" });
if (!fs.existsSync(p)) return res.json({ ok: true });
assertCleanupIdle(libBase());
const moved = await fileTrash.move(libBase(), p, { beforeMove: () => assertCleanupIdle(libBase()) });
res.json({ ok: true, ...moved });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post("/api/library/note", (req, res) => {
const text = String((req.body || {}).text || "").trim();
if (!text) return res.status(400).json({ error: "内容为空" });
const notes = readNotes();
notes.unshift({ id: "n_" + Date.now() + "_" + Math.floor(Math.random() * 1e4), text: text.slice(0, 2000), at: new Date().toISOString() });
writeNotes(notes.slice(0, 200));
res.json({ ok: true });
});
app.delete("/api/library/note/:id", (req, res) => {
writeNotes(readNotes().filter((n) => n.id !== req.params.id));
res.json({ ok: true });
});
// ---------- 资料库:按任务看产出 / 全库搜索 ----------
/**
* 「这次任务到底产出了哪些文件」——这份数据一直都在,只是从来没人从这个方向读过。
*
* 每跑完一批工具,recordingEmit 会往 transcript 里压一条 { type:"files", changed:[…] },
* changed 是 makeOwnership 认过主的那几个文件(不是整棵树的快照)。把一条会话里所有这种
* 事件的 changed 并起来,就是「这条任务产出了什么」的权威答案。
*
* 以前资料库只能按目录翻,而人记事情是按
* 「我那天让它做的那份周报」记的,不是按 out/2026-09/report-final-v3.md 记的。
*
* 按文件 mtime 增量缓存,跟 listSessionsOnDisk 同一个路子:几百个会话不能每次全量解析 JSON。
*/
const outIndexCache = new Map(); // 会话文件名 -> { mtime, row }
function sessionOutputRow(id, s) {
if (!s || !Array.isArray(s.transcript) || !s.transcript.length) return null;
const names = [];
const turns = [];
const seen = new Set();
let firstAt = 0;
// 「第几回合」只能数 user 条目,不能拿数组下标充数:回放时一条 user 起一个回合
// (openSession 就是这么拼界面的),下标里还夹着 assistant,差一倍。
// 同一个文件被改过好几轮就只认头一轮——用户点它是想看「这东西怎么来的」,
// 那句话在第一次写出它的那一段里,后面几轮是修修补补。
let ti = -1;
const fev = []; // 只留 files 事件:不在自己根里的那几个,sessionGrant 照这条会话自己的格子去找
for (const turn of s.transcript) {
if (turn.type === "user") { ti++; continue; }
if (turn.type !== "assistant" || !Array.isArray(turn.events)) continue;
for (const ev of turn.events) {
if (ev.type !== "files") continue;
fev.push({ type: "files", root: ev.root, changed: ev.changed });
for (const n of ev.changed || []) {
if (typeof n !== "string" || seen.has(n)) continue;
seen.add(n);
names.push(n);
turns.push(ti);
if (!firstAt) firstAt = Date.parse(turn.at || "") || 0;
}
}
}
if (!names.length) return null; // 没产出的对话不进这一页:这一页问的是「东西呢」,不是「聊过啥」
return {
id,
title: s.title || "未命名任务",
user: s.user || "",
project: s.project || "",
lane: lanes.normalize(s.lane) || undefined,
dir: s.dir || "",
at: Date.parse(s.updated_at || "") || firstAt,
names,
turns,
spot: { root: s.root || "", dir: s.dir || "", dirs: s.dirs || {}, transcript: [{ events: fev }] },
};
}
/**
* 任务清单里这条任务的一个文件,在请求人自己的根里找不到:不止一个账号时,可能是分到个人目录之前
* 在原来那份共享根里写的。按这条任务自己的格子找回来(sessionGrant 同一套),前端打开时带上 sid。
*/
function taskSpotStat(req, row, n) {
if (!rootFence(req)) return null;
const g = sessionGrant(row.spot, n);
if (!g) return null;
try { const st = fs.statSync(g.path); return st.isFile() ? { size: st.size, mtime: st.mtime.toISOString() } : null; } catch { return null; }
}
function listTaskOutputs() {
let files = [];
try { files = fs.readdirSync(SESS_DIR).filter((n) => n.endsWith(".json")); } catch { return []; }
const rows = [];
const seen = new Set();
for (const n of files) {
const id = n.slice(0, -5);
seen.add(n);
const live = sessions.get(id);
if (live) { const r = sessionOutputRow(id, live); if (r) rows.push(r); continue; }
let mtime = 0;
try { mtime = fs.statSync(path.join(SESS_DIR, n)).mtimeMs; } catch { continue; }
const hit = outIndexCache.get(n);
if (hit && hit.mtime === mtime) { if (hit.row) rows.push(hit.row); continue; }
const row = sessionOutputRow(id, store.readJson(path.join(SESS_DIR, n), null));
outIndexCache.set(n, { mtime, row });
if (row) rows.push(row);
}
for (const k of outIndexCache.keys()) if (!seen.has(k)) outIndexCache.delete(k);
rows.sort((a, b) => b.at - a.at);
return rows;
}
/**
* 清单不全时别猜,去看一眼。
*
* outputFiles() 按 mtime 倒序只留最新 500 条(FILES_CAP)。工作目录攒到 500 个以上,
* 旧任务的产出就整批掉出这份快照——而"不在快照里"跟"文件没了"是两回事。以前这儿只能耸耸肩:
* size 记 0、mtime 记空、gone 一律 false,于是界面把一个根本不存在的文件画成一行正常记录,
* 体积那一栏还是 libSize(0) 撞下限撞出来的「1 KB」。点进去才发现是张 404 的碎图,
* 点「新窗口打开」也是空的。
*
* 快照查不到的名字,就单独 stat 一次。任务清单最多 200 条、每条产出屈指可数,这是笔小账;
* 同名只查一次,再给个总预算兜底,免得某天真有人攒出几万条来。查不动的(预算用完)记 null,
* 那才是唯一该说「我不知道」的情况——size 留 0、gone 留 false,由界面显示成「—」。
*/
function statLookup(roots) {
const cache = new Map();
let budget = 4000;
const probe = (abs) => {
try { const st = fs.statSync(abs); return st.isFile() ? { size: st.size, mtime: st.mtime.toISOString() } : null; } catch { return null; }
};
/**
* 升级时 migrate.js 会把工作区根目录下的散文件收进「以前的文件_日期」。搬完之后,
* 资料库拿当时记下的裸文件名再去查,一律查不到——一整批还好端端躺在盘上的文件
* 被判成「已经不在」,点开是一句「这个文件已经不在工作目录里了」,而它只是往下挪了一层。
*
* 文件是这个程序自己搬的,那就自己跟着搬过去找。两条限制是故意的:
* · 只认「以前的文件_」这一个前缀 —— 那是我们自己建的目录,跟进去不算猜;
* · 只认根目录下的裸名字 —— 当初也只搬了这一种(见 migrate.js 的 tidyLooseFiles)。
* 不做全盘按文件名搜:两个任务目录里各有一个 cover.png,搜回来的很可能是另一份,
* 而「打开的不是我要的那张图」比「说它不在了」更难发现。
*/
const archives = (() => {
let list = null;
return () => {
if (list) return list;
list = [];
try {
list = fs.readdirSync(getWorkspaceDir()).filter((n) => n.startsWith("以前的文件_")).sort().reverse().slice(0, 8);
} catch {}
return list;
};
})();
return (rel) => {
if (cache.has(rel)) return cache.get(rel);
let v = null; // null = 没查(预算用完了);false = 确实不在了;对象 = 还在
if (budget > 0) {
budget--;
v = false;
try { v = probe(safePath(rel)) || false; } catch {} // 越界的名字:safePath 直接抛,那就是不在
// 当前根下没有,不等于没了:这次任务可能是在别的项目目录里跑的(中途切过工作目录/换过项目)。
// 只在用户自己配过的那几个根里找,跟 rootedPath 同一条口径,不会读到工作区以外的地方。
if (v === false) {
for (const d of roots) {
let hit = null;
try { hit = probe(safePathIn(d, rel)); } catch { continue; }
if (hit) { v = hit; break; }
}
}
// 最后一手:升级那次被收进「以前的文件_日期」的那批。只在**当前**工作目录下找,
// 因为 at 是要拿去当地址用的(/api/files/view/ 按当前根解析),
// 在别的根里找到一个同名文件却给不出能打开的地址,比说它不在了还糟。
if (v === false && !String(rel).includes("/")) {
for (const a of archives()) {
const at = a + "/" + rel;
let hit = null;
try { hit = probe(safePath(at)); } catch { continue; }
if (hit) { v = { ...hit, at }; break; }
}
}
}
cache.set(rel, v);
return v;
};
}
app.get("/api/library/outputs", async (req, res, next) => { try {
// 认领和「未归属」都拿**整个**工作区比,不拿 outputFiles():那份只有最近的 500 个、最深 3 层,
// 拿它比的话,第 4 层往下的文件既不算任务产出、也不进「未归属」,整页哪儿都找不到它。
// full 说的是「files 事件那份快照截没截断」,老调用方靠它判「已删除」。那份快照是这份清单的子集,
// 全量不到 500 个它就一定没截;到了就报「截了」,老调用方走保守那条路。
// 以前这里为了这一个布尔值再同步走一遍树:两万文件时每次请求主线程白停 100ms
const walk = await wsIndex.walk(getWorkspaceDir(), { appDataDir: dataPath("data") });
const { byName: meta, deckDirs } = wsIndex.derive(walk);
const scope = filesScope(walk.files);
if (walk.capped) scope.full = false;
// 未归属回多少条:默认 200(文件夹视图、搜索只借这份数据反查「出自哪次任务」,用不着全量);
// 「按任务」视图要翻完全部,会自己带上一个大数
const lim = Math.max(0, Math.min(wsBrowse.WALK_CAP, parseInt(String((req.query || {}).orphan_limit || ""), 10) || 200));
// 翻页:前端一页要 2000 条(2026-09-29 起,原来一口气要两万条,一条 JSON 就是几 MB),往后翻带 orphan_offset
const off = Math.max(0, parseInt(String((req.query || {}).orphan_offset || ""), 10) || 0);
const favs = libraryRoutes.libFavKeys(libraryRootOf(req.user));
const isFav = (n) => favs.has("ws:" + n);
// 网页幻灯片:同目录有 deck.json 的 html 标 deck:true,前端据此归到「幻灯片」页签。
// 只看这次遍历已经拿到的文件名,不读 html 正文、不多一次 stat——285 个 html 的工作区读一遍正文就是几十 MB
const deckOf = (n) => (deckDirs.size && /\.html?$/i.test(n) && deckDirs.has(path.posix.dirname(n)) ? { deck: true } : {});
const statOf = statLookup(knownRoots(req).slice(0, 12)); // 根的条数不设限的话,一次请求能把 stat 乘成几万次
const claimed = new Set();
const tasks = [];
for (const row of listTaskOutputs()) {
if (!ownSession(req.user, row)) continue; // 跟侧栏一个口径:别人的任务不该出现在我的资料库里
const files = [];
for (let i = 0; i < row.names.length; i++) {
const n = row.names[i];
// 回合号是给「跳到写出它的那段对话」用的。老的内存缓存行没有这个字段,
// -1 一路传到前端会被当成第 0 回合,所以查不到就写 undefined,让界面自己决定退回顶部
const tn = row.turns && row.turns[i] >= 0 ? row.turns[i] : undefined;
const f = meta.get(n);
if (f) { claimed.add(n); files.push({ name: n, size: f.size, mtime: f.mtime, gone: false, turn: tn, fav: isFav(n), ...deckOf(n) }); continue; }
const st = statOf(n);
// 搬过家的按新地址报出去:名字给人看的那一截没变(前端只取最后一段),
// 但地址是能打开的那个。顺带认领一下,免得同一个文件在「未归属」里再出现一遍
if (st) { const at = st.at || n; claimed.add(at); files.push({ name: at, size: st.size, mtime: st.mtime, gone: false, turn: tn, fav: isFav(at), ...deckOf(at) }); continue; }
const old = st === false ? taskSpotStat(req, row, n) : null;
if (old) { files.push({ name: n, size: old.size, mtime: old.mtime, gone: false, turn: tn, fav: isFav(n) }); continue; }
files.push({ name: n, size: 0, mtime: "", gone: st === false, turn: tn, fav: isFav(n) });
}
files.sort((a, b) => String(b.mtime).localeCompare(String(a.mtime)));
const { names, turns, user, spot, ...rest } = row;
tasks.push({ ...rest, files, live: files.filter((f) => !f.gone).length });
if (tasks.length >= 200) break;
}
// 「未归属」:工作目录里确实有、但没有任何一条任务认领过的文件——手动拷进来的素材、
// 更早版本产出的东西、别的工具写的。不列出来的话这一页就成了半份清单,用户会以为文件丢了。
const orphans = walk.files.filter((f) => !claimed.has(f.name));
// ws_total / ws_capped:工作区里一共多少个文件、数没数全(撞了两万个或十二层的线就是没数全)。
// 文件夹视图的「全部 N 个」和这里的 orphan_total 都照这个说,数不全就明说数不全
// 只把要回的那一页拼成行:两万个文件逐个拼对象、再扔掉一万八,是白干
const page = orphans.slice(off, off + lim).map((f) => ({ name: f.name, size: f.size, mtime: f.mtime, gone: false, fav: isFav(f.name), ...deckOf(f.name) }));
res.json({ tasks, orphans: page, orphan_total: orphans.length, orphan_offset: off, ws_total: walk.files.length, ws_capped: walk.capped, ...scope });
} catch (e) { next(e); } });
// 能当正文搜的类型。二进制(图片/压缩包/PDF)只搜文件名——把 PDF 当 utf8 读进来
// 匹配到的只会是一堆乱码里的巧合,比搜不到更糟
const TEXTY = /\.(md|markdown|txt|csv|tsv|json|jsonl|ya?ml|html?|xml|log|ini|conf|toml|js|ts|py|sh|css|srt|vtt)$/i;
const SEARCH_MAX_BYTES = 512 * 1024; // 单个文件超过这个大小只搜名字:搜一个 30MB 的日志会把请求拖死
const SEARCH_SCAN_CAP = 600; // 最多读多少个文件的正文
// 光数文件个数不够:600 个 400KB 的导出日志就是 240MB 的同步读,那几百毫秒里事件循环整个停着。
// 两道闸哪道先到都停,然后如实把 capped 报给界面——搜不全可以,假装搜全了不行。
const SEARCH_BYTE_CAP = 12 * 1024 * 1024;
/**
* 正文缓存。搜索框是边打字边搜的(前端 260ms 防抖),「周报」四个字就是四趟请求,
* 不缓存的话同一批文件要被原样读四遍。键里带了大小和 mtime——文件一动键就失效,
* 所以永远读不到过期的内容(跟 tools.js 里 digestCache 同一个路子)。
* 满了整份丢掉重来:维护 LRU 不值这个复杂度,代价不过是下一次键入冷一趟。
*/
const searchTextCache = new Map();
let searchTextBytes = 0;
function searchRead(full, size, mtime) {
const key = `${full}|${size}|${mtime}`;
const hit = searchTextCache.get(key);
if (hit !== undefined) return hit;
let text = "";
try { text = fs.readFileSync(full, "utf8"); } catch { text = ""; }
if (searchTextBytes > SEARCH_BYTE_CAP) { searchTextCache.clear(); searchTextBytes = 0; }
searchTextCache.set(key, text);
searchTextBytes += text.length;
return text;
}
/** 命中行的上下文摘录:给出行号和那一行,用户一眼能判断「是不是我要找的那份」 */
function searchExcerpt(text, needle, max = 3) {
const out = [];
const lower = text.toLowerCase();
let from = 0;
while (out.length < max) {
const i = lower.indexOf(needle, from);
if (i < 0) break;
const s = text.lastIndexOf("\n", i) + 1;
let e = text.indexOf("\n", i);
if (e < 0) e = text.length;
const line = text.slice(s, e).trim();
out.push({ line: text.slice(0, s).split("\n").length, text: line.length > 200 ? line.slice(0, 200) + "…" : line });
from = e + 1;
}
return out;
}
/** 递归走资料库;depth 封 6 层、条数封 4000,软链成环也走不死 */
function libWalk(rel = "", depth = 0, out = []) {
if (depth >= 6 || out.length >= 4000) return out;
let ents = [];
try { ents = fs.readdirSync(libPath(rel), { withFileTypes: true }); } catch { return out; }
for (const e of ents) {
if (e.name.startsWith(".")) continue;
const child = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) { libWalk(child, depth + 1, out); continue; }
if (!e.isFile()) continue;
let st; try { st = fs.statSync(libPath(child)); } catch { continue; }
out.push({ name: e.name, path: child, size: st.size, mtime: st.mtime.toISOString() });
if (out.length >= 4000) break;
}
return out;
}
/**
* 全库搜索。
*
* 以前那个 #lb-q 只是把**当前这一层已经加载出来的**文件名过滤一遍——换句话说,
* 东西在隔壁文件夹里就搜不到,正文里写了什么更是无从谈起。那不叫搜索,叫筛选。
*
* 这里搜四种东西,每种都如实标明来源:
* lib 资料库文件(文件名 + 正文,正文命中给出行号和那一行)
* ws 当前工作区的产出文件(文件名 + 正文,跟资料库共用同一份扫描预算)
* note 灵感笔记
* task 任务标题,附带这条任务的产出文件——「那份周报是哪次做的」这种找法才是常态
*/
app.get("/api/library/search", async (req, res, next) => { try {
const q = String((req.query || {}).q || "").trim().slice(0, 120);
if (!q) return res.json({ q: "", lib: [], ws: [], notes: [], tasks: [], scanned: 0, capped: false });
const needle = q.toLowerCase();
const hitName = (n) => String(n).toLowerCase().includes(needle);
// 两棵树共用一份预算(资料库先扫、工作区后扫)。scanned/bytes 任一道到顶就只剩搜名字,
// capped 一路带到界面上明说「还有没搜到的」。
const budget = { scanned: 0, bytes: 0, capped: false };
/** 名字 + 正文各搜一遍,返回 null 表示这个文件根本没命中 */
const probe = (name, full, size, mtime) => {
const byName = hitName(name);
let lines = [];
if (TEXTY.test(name) && size <= SEARCH_MAX_BYTES) {
if (budget.scanned >= SEARCH_SCAN_CAP || budget.bytes >= SEARCH_BYTE_CAP) budget.capped = true;
else {
budget.scanned++;
budget.bytes += size;
const text = searchRead(full, size, mtime);
if (text) lines = searchExcerpt(text, needle);
}
}
if (!byName && !lines.length) return null;
return { by: byName && lines.length ? "both" : byName ? "name" : "text", lines };
};
// 名字命中排在正文命中前面:搜「周报」的人多半在找那个叫周报的文件,不是找提到周报的文件
const byNameFirst = (a, b) =>
(a.by === "text" ? 1 : 0) - (b.by === "text" ? 1 : 0) || String(b.mtime).localeCompare(String(a.mtime));
const lib = [];
for (const f of libWalk()) {
let hit;
try { hit = probe(f.path, libPath(f.path), f.size, f.mtime); } catch { continue; }
if (hit) lib.push({ ...f, ...hit });
if (lib.length >= 200) break;
}
lib.sort(byNameFirst);
// 工作区产出也搜正文。以前这儿只过滤文件名,理由写的是「正文可能是几百兆的中间产物」——
// 可那正是 TEXTY 和 SEARCH_MAX_BYTES 两道闸在管的事。真实后果是:这台机器上资料库一个文件
// 都没有,东西全在工作区,于是「全文搜索」实际上一次都没真正跑起来过,搜「上个月那份复盘里
// 提到的那家供应商」永远是空的。搜的是内容,不是文件名。
// 名字按整个工作区搜(不是 outputFiles() 那最近 500 个、最深 3 层);新的在前,
// 所以正文预算先花在最近的文件上,翻不完照旧报 capped
const wsRoot = getWorkspaceDir();
const wsWalk = await wsIndex.walk(wsRoot, { appDataDir: dataPath("data") });
const wsAll = wsWalk.files;
const ws = [];
for (const f of wsAll) {
let hit;
try { hit = probe(f.name, safePathIn(wsRoot, f.name), f.size, f.mtime); } catch { continue; }
if (hit) ws.push({ name: f.name, size: f.size, mtime: f.mtime, ...hit });
if (ws.length >= 200) break;
}
ws.sort(byNameFirst);
const notes = readNotes().filter((n) => String(n.text || "").toLowerCase().includes(needle))
.slice(0, 50).map((n) => ({ id: n.id, text: n.text, at: n.at }));
const wsMeta = new Map(wsAll.map((f) => [f.name, f])); // 上面已经遍历过一次,别再走一趟全树
// 全量那趟撞了线(两万个、十二层)或者文件被收进了「以前的文件_」,不在 wsMeta 里≠没了:跟 outputs 一样再 stat 一眼
const statOf = statLookup(knownRoots(req).slice(0, 12));
const taskFile = (row, n) => {
const f = wsMeta.get(n);
if (f) return { name: n, size: f.size, mtime: f.mtime, gone: false };
const st = statOf(n);
if (st) return { name: st.at || n, size: st.size, mtime: st.mtime, gone: false };
const old = st === false ? taskSpotStat(req, row, n) : null;
return old ? { name: n, size: old.size, mtime: old.mtime, gone: false } : { name: n, size: 0, mtime: "", gone: st === false };
};
const tasks = [];
for (const row of listTaskOutputs()) {
if (!ownSession(req.user, row)) continue;
const titleHit = hitName(row.title);
const files = row.names.filter((n) => titleHit || hitName(n));
if (!titleHit && !files.length) continue;
tasks.push({
id: row.id, title: row.title, at: row.at, project: row.project, lane: row.lane,
by: titleHit ? "title" : "file",
files: files.slice(0, 20).map((n) => taskFile(row, n)),
});
if (tasks.length >= 50) break;
}
// ws_capped:工作区文件多到全量那趟没走完,名字也没搜全。跟 capped(正文预算用完)是两回事,分开报
res.json({ q, lib, ws, notes, tasks, scanned: budget.scanned, capped: budget.capped, ws_capped: wsWalk.capped });
} catch (e) { next(e); } });
// ---------- 长期记忆 ----------
// 手写区(memory.md,全局共享)+ 条目区(agent 用 remember 自己记的,按账号隔离)
/**
* 记忆能管到哪一格,跟审批那套是同一个判法。
*
* 返回 undefined = 不限定(平台管理员,以及双击打开的桌面版——屏幕前就一个人)。
* 返回登录名 = 只限定他自己那个作用域:共享区(memory.SHARED)会原样进**所有人**的系统提示词,
* 谁都能往里塞、谁都能删,那就不是「我的记忆」而是一块公告板了。
*/
const memScope = (req) =>
admin.isSoloDesktop() || admin.platformAdmin(req.user) ? undefined : (req.user && req.user.username) || "";
app.get("/api/memory", (req, res) => {
const u = req.user ? req.user.username : undefined;
const scopeTo = memScope(req);
res.json({
content: memory.manual(), items: memory.list(u), shared_tag: memory.SHARED,
limits: { max_text: memory.MAX_TEXT, max_items: memory.MAX_PER_SCOPE }, vectors: memory.vectorStatus(),
// 界面照这两个决定要不要画那颗按钮:会 403 的按钮不该摆在那儿
can_share: scopeTo === undefined, // 能不能往共享区写、能不能删共享区的
can_edit_manual: scopeTo === undefined, // 背景说明是全局一份,仍旧归平台管理员
// 嵌入接口是整台服务器一份的,只有平台管理员看得见那张表单
embedding: isPlatformOwner(req) ? embeddingView(req) : null,
});
});
// 记忆面板「测一下」:拿表单里的选择真打一次。点名渠道的用那条渠道的地址和 Key;
// 单独填的那种 Key 传八颗星 = 用已存的那把(地址得没换)
app.post("/api/embedding/test", async (req, res) => {
if (!isPlatformOwner(req)) return res.status(403).json({ ok: false, error: "嵌入接口是整台服务器一份的,归平台管理员配", platform_only: true });
const b = req.body || {};
const provider = String(b.provider || "").trim();
let base = String(b.base_url || "").trim().replace(/\/+$/, "");
const model = String(b.model || "").trim();
if (!model) return res.status(400).json({ ok: false, error: "模型名要填,比如 text-embedding-3-small" });
let key = String(b.api_key == null ? "" : b.api_key).trim();
if (provider) {
const p = (config.providers || []).find((x) => x && String(x.id) === provider);
if (!p) return res.status(400).json({ ok: false, error: "没有这条渠道,先去 设置 → 模型 把它加上" });
if (!embedHostable(p.kind)) return res.status(400).json({ ok: false, error: `「${p.name || p.id}」算不了向量,换一条渠道或单独填接口` });
// 跟真用的时候同一个取法,测的就是记忆会走的那条路
const c = embedCandidates({ providers: config.providers, embedding: { provider, model } })[0];
if (!c) return res.status(400).json({ ok: false, error: "这条渠道没有接口地址,先去 设置 → 模型 补上" });
base = c.base_url; key = c.api_key;
} else {
if (!/^https?:\/\/[^\s/]+/i.test(base)) return res.status(400).json({ ok: false, error: "接口地址要以 http:// 或 https:// 开头" });
if (/^\*+$/.test(key)) {
const old = config.embedding || {};
if (old.provider || String(old.base_url || "").replace(/\/+$/, "") !== base) return res.status(400).json({ ok: false, error: "换了接口地址,Key 要重新填一遍" });
key = String(old.api_key || "");
}
}
const r = await probeEmbedding({ base_url: base, api_key: key, model, provider });
if (r.ok) {
// 通了就把死渠道记号擦了、重建一次:先前全挂停用的 embedder 不会自己活过来
memory.setEmbedder(createEmbedder(config));
setSessEmbedder(createEmbedder(config));
memory.ensureVectors().catch(() => {});
}
res.json(r);
});
app.post("/api/memory", (req, res) => {
memory.saveManual((req.body || {}).content || "");
res.json({ ok: true });
});
app.post("/api/memory/item", (req, res) => {
const b = req.body || {};
// 受限的人勾了「所有账号共用」:降成他自己的,并且照实说出来,不许悄悄换个作用域还报「已记住」
const wantShared = !!b.shared;
const downgraded = wantShared && memScope(req) !== undefined;
const r = memory.add({
text: b.text, user: req.user ? req.user.username : undefined,
shared: wantShared && !downgraded, source: "user",
});
if (r.ok && downgraded) r.note = (r.note || "记住了") + "。共享给这台机器上所有账号要平台管理员来做,这条先记成你自己的";
res.status(r.ok ? 200 : 400).json({ ...r, downgraded });
});
app.delete("/api/memory/item/:id", (req, res) => {
const r = memory.remove(req.params.id, memScope(req));
// 越权跟「本来就没有」不能返回同一种结果:前者要报出来,后者是正常竞态(别处已经删过)
if (r.forbidden) return res.status(403).json({ ok: false, removed: 0, error: "这条不是你记的,删不了" });
res.json({ ok: true, removed: r.removed });
});
// ---------- 自进化:反馈 → 信号 → 提案 → 人审 → 复盘 ----------
// 👍👎 以前点了只换个高亮色,一个字节都没往外送。反馈得在干活的地方零摩擦地收,
// 收不到就没有后面这一整条链——这是整套自进化的第一环。
app.post("/api/feedback", (req, res) => {
try {
const b = req.body || {};
res.json({ ok: true, item: evolve.recordFeedback({ ...b, user: req.user ? req.user.username : "" }) });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// 反馈汇总:👍👎 总数/好评率/按模型/按模式/最近的 👎 清单。管理员看全员,成员只看自己
app.get("/api/feedback/summary", (req, res) => {
const days = Math.max(1, Math.min(365, Number(req.query.days) || 30));
const user = req.user && !account.canAdmin(req.user) ? req.user.username : "";
res.json(evolve.feedbackSummary({ days, user }));
});
// 体检:这段时间它都败在哪儿、各多少次。不调模型,纯数数,随时能看
app.get("/api/evolve/signals", (req, res) => {
try {
res.json(evolve.mineSignals({ days: Math.min(365, +req.query.days || evolve.CAPS.window) }));
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get("/api/evolve/state", (_req, res) => {
res.json({
caps: evolve.CAPS,
rules: evolve.activeRules(),
proposals: evolve.listProposals().slice(-50).reverse(),
scored: evolve.scoreRules(),
runs: evolve.listRuns(5),
auto: config.evolve || {},
});
});
// 跑一轮复盘:数信号 → 让模型提最小改动 → 过闸门 → 落盘等人审。**永远不自动生效**
app.post("/api/evolve/review", async (req, res) => {
try {
const r = await evolve.runReview({ llm, days: +((req.body || {}).days) || undefined, promptExcerpt: (req.body || {}).prompt_excerpt || "" });
res.json({ ok: true, turns: r.mined.turns, signals: r.mined.signals.slice(0, 10), added: r.added, gated: r.gated, notes: r.notes, scored: r.scored });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.post("/api/evolve/proposal/:id", (req, res) => {
try {
const b = req.body || {};
const p = evolve.decideProposal(req.params.id, b.decision, { by: req.user ? req.user.username : "", reason: b.reason });
res.json({ ok: true, proposal: p, rules: evolve.activeRules() });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post("/api/evolve/rule/:id/retire", (req, res) => {
try {
res.json({ ok: true, ...evolve.retireRule(req.params.id, (req.body || {}).why || "人工下架"), rules: evolve.activeRules() });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// ---------- 工作空间:原生文件夹选择(桌面版)与打开文件夹 ----------
app.post("/api/pick-folder", async (_req, res) => {
const bridge = require("./src/platform/electron-bridge");
if (bridge.isRemote()) {
// 独立服务进程里(2026-09-29 起桌面版默认)没有 dialog:弹框归主进程,人选文件夹给足 15 分钟
try {
const result = await bridge.call("dialog.openDirectory", { title: "选择工作空间文件夹" }, { timeoutMs: 15 * 60 * 1000 });
if (!result || result.canceled || !result.filePaths || !result.filePaths.length) return res.json({ canceled: true });
return res.json({ path: result.filePaths[0] });
} catch (e) {
return res.status(502).json({ error: `没能打开文件夹选择框:${e.message}` });
}
}
try {
const { dialog } = require("electron");
const result = await dialog.showOpenDialog({ properties: ["openDirectory"], title: "选择工作空间文件夹" });
if (result.canceled || !result.filePaths.length) return res.json({ canceled: true });
res.json({ path: result.filePaths[0] });
} catch {
res.status(501).json({ error: "仅桌面版支持系统文件夹选择,Web 版请直接输入路径" });
}
});
/**
* 用系统程序打开文件/文件夹/URL(跨平台:macOS open / Windows explorer / Linux xdg-open)。
*
* 必须是 execFile,不能是 exec。exec 把整条命令交给 /bin/sh,而 target 里有用户能控制的文件名:
* 一个叫 `a";id>pwn.txt;"b.txt` 的文件(macOS/Linux 上完全合法,上传接口也没拦过这些字符)
* 就能把老写法 exec(`${opener} "${target}"`) 里的引号闭合掉,分号后面那段以服务端权限执行。
* execFile 不起 shell,参数原样递给程序,这类解析从根上就没有了。
* 回调吞掉错误是有意的:explorer 打开成功也常回非 0 退出码,看它的退出码只会误报。
*
* Windows 上本地文件不再首选 explorer:execFile 只给带空格的参数加引号,`报告,终稿.docx` 原样递过去,
* explorer 把逗号当开关分隔符,打开的是默认文件夹而不是这个文件,还一声不吭。桌面版交给主进程的
* shell.openPath(走系统的「打开」,没有命令行解析这一层),它回来的报错原样带给用户;
* 没有桌面主进程(命令行、Web 部署)才退回 explorer,并且自己加引号。网址照旧交给 explorer。
* macOS / Linux 跟以前一模一样。
*
* 不抛错:返回 { ok, error },调用方决定要不要把 error 说给用户。
* @param {string} target
* @param {{platform?:string, bridge?:any, electron?:any, execFile?:Function}} [deps] 只给测试用
* @returns {Promise<{ok:boolean, error:string}>}
*/
async function openWithSystem(target, deps = {}) {
const t = String(target);
try {
const platform = deps.platform || process.platform;
const execFile = deps.execFile || require("child_process").execFile;
if (platform !== "win32") {
execFile(platform === "darwin" ? "open" : "xdg-open", [t], () => {});
return { ok: true, error: "" };
}
const isUrl = /^[a-z][a-z0-9+.-]*:\/\//i.test(t);
if (!isUrl) {
// 约定:成功回空串,失败回系统给的那句话。只认非空字符串是失败,别把别的返回值当报错念给用户
const said = (err) => (typeof err === "string" && err ? { ok: false, error: `系统打开失败:${err}` } : { ok: true, error: "" });
const bridge = deps.bridge || require("./src/platform/electron-bridge");
if (bridge.isRemote()) {
try {
// 打开大文件时主进程要等 Word / WPS 起来才回话,给足 15 秒,别把正常的慢当成失败
return said(await bridge.call("shell.openPath", { path: t }, { timeoutMs: 15000 }));
} catch (e) {
// 主进程没这个操作(版本对不上)、没了、连不上:它那边什么都没开,退回 explorer。
// 超时就不退了——主进程可能只是慢,过一会儿自己打开,再起一个 explorer 就是同一个文件开两遍
const code = e && e.code;
if (code !== "NO_OP" && code !== "NO_SHELL" && code !== "SHELL_GONE") return { ok: false, error: String((e && e.message) || e) };
}
} else if (bridge.mode() === "inproc") {
try {
const { shell } = deps.electron || require("electron");
if (shell && shell.openPath) return said(await shell.openPath(t));
} catch {}
}
}
// 不加 windowsHide:要的就是打开一个给人看的窗口,加了资源管理器那扇窗会被藏掉。
// 本地路径整个包一层引号、原样递(Windows 路径里不可能有 "),逗号就不会被拆开
if (isUrl) execFile("explorer", [t], () => {});
else execFile("explorer", [`"${t}"`], { windowsVerbatimArguments: true }, () => {});
return { ok: true, error: "" };
} catch (e) {
// 起进程同步就抛的那种(参数不合法之类):以前是同步抛给路由,现在照样变成一句报错,别成了没人接的 rejection
return { ok: false, error: String((e && e.message) || e) };
}
}
app.post("/api/open-workspace", async (_req, res) => {
const r = await openWithSystem(getWorkspaceDir());
if (!r.ok) return res.status(500).json({ error: r.error });
res.json({ ok: true });
});
// ================= 飞书:扫码授权(借本机 lark-cli 的设备码流程,larksuite/cli,MIT) =================
// 说明一句免得误解:机器人「收消息」必须有应用的 app_id + app_secret,这是飞书的设计,扫码替代不了。
// 扫码解决的是另一半——把「你本人」的身份授权出来,之后读日历/文档/邮件是以你的身份调的。
const LARK_TMP = path.join(require("os").tmpdir(), "openworkbuddy-lark");
/**
* lark-cli 在 Windows 上是 npm 装的 .cmd 垫片:按名字直接 execFile 找不到它(只认 .exe),
* 找到了新版 Node 也不许不经 shell 起批处理。先认出全路径,再走引擎那套起法(src/platform/win.js,顺带不弹黑窗)
*/
function larkBin() {
if (process.platform !== "win32") return "lark-cli";
return require("./src/platform/which").findIn(shellPath().split(path.delimiter), "lark-cli") || "lark-cli";
}
/** 同 execFile;起不来时回调里给 err,返回 null */
function larkExec(args, opts, cb) {
return require("./src/platform/win").execFile(larkBin(), args, opts, cb);
}
function larkRun(args, { timeout = 60000, cwd } = {}) {
return new Promise((resolve) => {
const child = larkExec(
args,
{ timeout, cwd: cwd || LARK_TMP, env: { ...process.env, PATH: shellPath() }, maxBuffer: 4 << 20 },
(err, stdout, stderr) => resolve({ ok: !err, code: err ? err.code : 0, stdout: stdout || "", stderr: stderr || "" }),
);
if (child) child.on("error", () => {});
});
}
function larkJson(s) { try { return JSON.parse(String(s).trim()); } catch { return null; } }
const larkCli = require("./src/util/lark-cli");
/** 读 lark-cli 当前绑定的应用凭证;读不到返回 null */
async function larkConfig() {
return larkCli.parseConfigShow((await larkRun(["config", "show"], { timeout: 15000 })).stdout);
}
/**
* 把 lark-cli 里的凭证搬进飞书通道并重启长连接(新建和导入两条路都走这里)。
* 守卫:macOS 上 lark-cli 把 secret 锁在钥匙串里,`config show` 只回 `****`。
* 那串掩码要是写进去,用户原来能用的凭证就被顶掉了 —— 宁可不搬,也不搬一个假的。
* app_id 也一并不动:单搬 app_id 会让它跟旧 secret 配成一对错的,连上去只会报 10014。
*/
function adoptLarkCreds(cfg) {
const appId = (cfg && cfg.appId) || "";
if (!appId || !larkCli.usableSecret(cfg && cfg.appSecret)) {
return { ok: false, app_id: appId, error: larkCli.SECRET_LOCKED_HINT, console_url: larkCli.appConsoleUrl(appId, cfg && cfg.brand) };
}
config.im = config.im || {};
config.im.feishu = Object.assign(config.im.feishu || {}, { app_id: cfg.appId, app_secret: cfg.appSecret });
saveConfig();
if (imBridge) imBridge.startFeishuWs(true).catch((e) => console.warn("[飞书] 长连接重启失败:", e.message));
return { ok: true, app_id: cfg.appId };
}
app.get("/api/feishu/lark-cli", async (_req, res) => {
fs.mkdirSync(LARK_TMP, { recursive: true });
const v = await larkRun(["--version"], { timeout: 15000 });
if (!v.ok) return res.json({ installed: false, install_cmd: "npx @larksuite/cli@latest install" });
const version = (v.stdout.match(/[\d.]+/) || [""])[0];
const cfg = await larkConfig();
// 只回布尔和非敏感字段,app_secret 一个字节都不出后端
res.json({
installed: true, version,
configured: !!(cfg && cfg.appId),
app_id: (cfg && cfg.appId) || "",
brand: (cfg && cfg.brand) || "",
has_secret: larkCli.usableSecret(cfg && cfg.appSecret),
// 读不出来跟没有是两回事:钥匙串锁着的时候得让前端说清楚,而不是装作「没配」
secret_locked: !!(cfg && cfg.appId && !larkCli.usableSecret(cfg.appSecret)),
users: (cfg && cfg.users) || "",
});
});
// 把 lark-cli 里已经配好的应用凭证搬进 OpenWorkBuddy 的飞书通道,省掉手动复制两串东西
app.post("/api/feishu/lark-cli/import", async (_req, res) => {
fs.mkdirSync(LARK_TMP, { recursive: true });
const cfg = await larkConfig();
if (!cfg || !cfg.appId) {
return res.status(400).json({ error: "lark-cli 还没配置应用凭证,先「扫码新建应用」,或自己跑 lark-cli config init" });
}
const r = adoptLarkCreds(cfg);
if (!r.ok) return res.status(400).json({ error: r.error, app_id: r.app_id, secret_locked: true, console_url: r.console_url });
res.json({ ok: true, app_id: cfg.appId }); // secret 不回前端
});
// 反向:把 OpenWorkBuddy 里填好的凭证写进 lark-cli,这样才能开始扫码(设备码流程需要一个已绑定的应用)
app.post("/api/feishu/lark-cli/bind", (_req, res) => {
const f = (config.im || {}).feishu || {};
if (!f.app_id || !f.app_secret) return res.status(400).json({ error: "先在上面填好 App ID / App Secret 并保存" });
fs.mkdirSync(LARK_TMP, { recursive: true });
// secret 走 stdin,不进进程参数表(ps 能看到 argv)
const child = larkExec(
["config", "init", "--app-id", f.app_id, "--app-secret-stdin", "--brand", "feishu", "--lang", "zh"],
{ timeout: 60000, cwd: LARK_TMP, env: { ...process.env, PATH: shellPath() } },
(err, stdout, stderr) => {
if (res.headersSent) return;
if (err) return res.status(400).json({ error: ((stderr || stdout || err.message) + "").slice(0, 300) });
res.json({ ok: true });
},
);
if (!child) return; // 起不来:回调里已经回过话了
child.on("error", (e) => { if (!res.headersSent) res.status(400).json({ error: "lark-cli 没装或调不起来:" + e.message }); });
try { child.stdin.end(f.app_secret + "\n"); } catch {}
});
// ---- 一键新建飞书应用:全程不用手打 app_id / app_secret ----
// 用户问过两次「不能扫码连机器人吗」。直答是:机器人在飞书这边就是一个「应用」,平台只认
// app_id/app_secret,扫码换不来这两串。但 lark-cli 的 `config init --new` 能**替你新建一个应用**——
// 它阻塞着打印一条验证链接,用户在浏览器里点完,凭证就落进 ~/.lark-cli/config.json。
// 我们把链接变成二维码给用户扫,建完直接导进飞书通道 → 一个字都不用手打。
let larkNew = null; // { url, state, error, app_id, child, startedAt }
app.post("/api/feishu/app/create", async (_req, res) => {
fs.mkdirSync(LARK_TMP, { recursive: true });
if (larkNew && larkNew.child) { try { larkNew.child.kill(); } catch {} }
const before = await larkConfig();
// lark-cli 在 Agent 环境里会拒绝新建,把那两个标记摘掉;同时避免继承本进程的杂环境
const env = { ...process.env, PATH: shellPath() };
delete env.OPENCLAW_HOME; delete env.HERMES_HOME;
let out = "";
// Windows 上经 launchPlan 起(理由见 larkBin),windowsHide 由它给;别的系统跟以前一样直接起
const plan = require("./src/platform/win").launchPlan(larkBin(), ["config", "init", "--new", "--brand", "feishu", "--lang", "zh"]);
const child = require("child_process").spawn(
plan.bin, plan.args,
{ cwd: LARK_TMP, env: { ...env, ...plan.env }, stdio: ["ignore", "pipe", "pipe"], ...(process.platform === "win32" ? plan.opts : {}) },
);
child.stdout.on("data", (c) => (out += c));
child.stderr.on("data", (c) => (out += c));
child.on("error", (e) => { if (larkNew) { larkNew.state = "error"; larkNew.error = larkCli.explainLarkError(e.message); } });
child.on("close", async (code) => {
if (!larkNew || larkNew.child !== child) return; // 已被新的一轮顶掉
const cfg = await larkConfig();
const born = larkCli.appReady(before, cfg, code); // 选了原来那个已有应用也算,见 appReady
if (born) {
const r = adoptLarkCreds(cfg);
larkNew.app_id = cfg.appId;
// 应用是真建出来了,只是 secret 被钥匙串锁着 —— 这不是失败,是「还差最后一步」
larkNew.state = r.ok ? "ok" : "need_secret";
larkNew.error = r.ok ? "" : r.error;
larkNew.console_url = r.console_url || "";
} else if (larkNew.state === "pending") {
larkNew.state = "error"; larkNew.error = larkCli.explainLarkError(out) || "应用没建成,凭证没变化";
}
});
larkNew = { url: "", state: "pending", error: "", app_id: "", console_url: "", child, startedAt: Date.now() };
// 等它把验证链接打出来(一般 1~3 秒);拿到就出二维码,拿不到就如实说
const url = await new Promise((resolve) => {
const t0 = Date.now();
const tick = setInterval(() => {
const u = larkCli.verifyUrlOf(out);
if (u || child.exitCode !== null || Date.now() - t0 > 30000) { clearInterval(tick); resolve(u); }
}, 200);
});
if (!url) {
try { child.kill(); } catch {}
if (larkNew) larkNew.state = "error";
return res.status(400).json({ error: larkCli.explainLarkError(out) });
}
larkNew.url = url;
const png = "newapp-" + Date.now() + ".png";
await larkRun(["auth", "qrcode", url, "-o", png, "--size", "256"], { timeout: 20000 });
let qr = null;
try {
qr = "data:image/png;base64," + fs.readFileSync(path.join(LARK_TMP, png)).toString("base64");
fs.unlinkSync(path.join(LARK_TMP, png));
} catch {}
res.json({ ok: true, url, qr });
});
app.get("/api/feishu/app/create/status", (_req, res) => {
if (!larkNew) return res.json({ state: "idle" });
if (larkNew.state === "pending" && Date.now() - larkNew.startedAt > 15 * 60 * 1000) {
try { larkNew.child.kill(); } catch {}
larkNew.state = "error"; larkNew.error = "等了 15 分钟没完成,重新点一次";
}
// app_id 不敏感(前端本来就要显示),app_secret 一个字节都不出后端
res.json({ state: larkNew.state, error: larkNew.error || null, app_id: larkNew.app_id || "", url: larkNew.url || "", console_url: larkNew.console_url || "" });
});
// 设备码流程:start 拿二维码 → 用户在飞书里扫 → 后台那条 --device-code 自己会跑完 → status 变 ok
let larkQr = null; // { device_code, url, expires_at, state, error, child }
let larkQrEpoch = 0, larkLoggingOut = false, larkQrStop = null;
function stopLarkQr() {
if (!larkQrStop) larkQrStop = stopLarkQrNow().finally(() => { larkQrStop = null; });
return larkQrStop;
}
/** 先收掉整棵扫码进程树,避免 npm 包装器退出后,原生 CLI 仍在后台完成登录。 */
async function stopLarkQrNow() {
const qr = larkQr, child = qr && qr.child;
larkQr = null;
if (!child || !child.pid || child.exitCode !== null || child.signalCode !== null) return;
let timer, onClose;
const closed = new Promise((resolve) => { onClose = resolve; child.once("close", onClose); });
try {
const win = require("./src/platform/win");
await win.treeWatch(child.pid).kill("SIGKILL");
win.killTree(child, "SIGKILL");
await Promise.race([closed, new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("扫码进程尚未退出,请稍后重试")), 3000);
})]);
} catch (e) {
// 没停下来就保留句柄供重试,不能把仍在运行的授权进程遗忘。
if (!larkQr && child.exitCode === null && child.signalCode === null) larkQr = qr;
throw e;
} finally { clearTimeout(timer); child.removeListener("close", onClose); }
}
app.post("/api/feishu/lark-cli/logout", async (req, res) => {
if (!isPlatformOwner(req)) return res.status(403).json({ error: "飞书本人身份连接归本机主人管理", platform_only: true });
if (larkLoggingOut) return res.status(409).json({ error: "正在取消连接,请稍候" });
larkLoggingOut = true;
++larkQrEpoch;
try {
await stopLarkQr();
fs.mkdirSync(LARK_TMP, { recursive: true });
const r = await larkRun(["auth", "logout", "--json"], { timeout: 15000 });
const j = larkJson(r.stdout);
if (!r.ok || !j || j.loggedOut !== true) return res.status(400).json({ error: "未能确认本机登录态已清除,请重试。" });
res.json({ ok: true, logged_out: true });
} catch {
res.status(400).json({ error: "取消连接失败,请稍后重试。" });
} finally { larkLoggingOut = false; }
});
app.post("/api/feishu/qr/start", async (req, res) => {
if (larkLoggingOut) return res.status(409).json({ error: "正在取消连接,请稍候再扫码" });
const epoch = ++larkQrEpoch;
fs.mkdirSync(LARK_TMP, { recursive: true });
try { await stopLarkQr(); } catch { return res.status(400).json({ error: "上一轮扫码尚未停止,请重试" }); }
if (epoch !== larkQrEpoch) return res.status(409).json({ error: "这次扫码已取消" });
const domains = String((req.body && req.body.domains) || "im,docs,drive,calendar,task");
const r = await larkRun(["auth", "login", "--no-wait", "--json", "--domain", domains], { timeout: 60000 });
if (epoch !== larkQrEpoch) return res.status(409).json({ error: "这次扫码已取消" });
const j = larkJson(r.stdout);
if (!j || !j.verification_url || !j.device_code) {
const msg = (r.stderr || r.stdout || "").slice(0, 300);
return res.status(400).json({ error: /config init|app.?id/i.test(msg)
? "lark-cli 还没绑定应用:先在上面填好 App ID / App Secret 并「写入 lark-cli」,或自己跑 lark-cli config init"
: "拿不到授权链接:" + (msg || "lark-cli 没有返回内容") });
}
// 出二维码:写进临时目录再读成 data URI,前端直接 <img>,不落工作区
const png = "qr-" + Date.now() + ".png";
await larkRun(["auth", "qrcode", j.verification_url, "-o", png, "--size", "256"], { timeout: 20000 });
let dataUri = null;
try {
dataUri = "data:image/png;base64," + fs.readFileSync(path.join(LARK_TMP, png)).toString("base64");
fs.unlinkSync(path.join(LARK_TMP, png));
} catch {}
if (epoch !== larkQrEpoch) return res.status(409).json({ error: "这次扫码已取消" });
// 阻塞式轮询交给后台子进程,前端只问我们自己的 status
const child = larkExec(
["auth", "login", "--device-code", j.device_code, "--json"],
{ timeout: (j.expires_in || 600) * 1000 + 15000, cwd: LARK_TMP, env: { ...process.env, PATH: shellPath() } },
(err, stdout, stderr) => {
if (!larkQr || larkQr.device_code !== j.device_code) return; // 已被新的一轮顶掉
if (err) { larkQr.state = "error"; larkQr.error = ((stderr || stdout || err.message) + "").slice(0, 300); }
else { larkQr.state = "ok"; larkQr.result = larkJson(stdout) || {}; }
},
);
if (child) child.on("error", (e) => { if (larkQr && epoch === larkQrEpoch) { larkQr.state = "error"; larkQr.error = e.message; } });
larkQr = { device_code: j.device_code, url: j.verification_url, expires_at: Date.now() + (j.expires_in || 600) * 1000, state: "pending", child };
res.json({ ok: true, url: j.verification_url, qr: dataUri, expires_in: j.expires_in || 600 });
});
app.get("/api/feishu/qr/status", async (_req, res) => {
if (!larkQr) return res.json({ state: "idle" });
if (larkQr.state === "pending" && Date.now() > larkQr.expires_at) {
larkQr.state = "error"; larkQr.error = "二维码超时失效,重新点一次生成";
}
if (larkQr.state !== "ok") return res.json({ state: larkQr.state, error: larkQr.error || null });
const qr = larkQr;
const who = larkJson((await larkRun(["whoami"], { timeout: 15000 })).stdout) || {};
if (qr !== larkQr) return res.json({ state: "idle" });
res.json({ state: "ok", identity: who.identity || "", app_id: who.appId || "", user: qr.result && qr.result.user_name || "" });
});
app.post("/api/feishu/qr/cancel", async (_req, res) => {
++larkQrEpoch;
try { await stopLarkQr(); res.json({ ok: true }); }
catch { res.status(400).json({ error: "扫码进程尚未停止,请重试" }); }
});
// ---- 缓存清理:界面缓存(Electron chromium) + 各项目 .tmp 临时脚本;不动会话记录/工作区文件/登录态 ----
const CHROMIUM_CACHE_DIRS = ["Cache", "Code Cache", "GPUCache", "DawnGraphiteCache", "DawnWebGPUCache", "blob_storage", "Shared Dictionary"];
function appUserDataDir() {
// 独立服务进程里没有 electron.app:主进程把 userData 放在 OWB_USER_DATA 里递过来
if (require("./src/platform/electron-bridge").isRemote() && process.env.OWB_USER_DATA) return process.env.OWB_USER_DATA;
if (process.versions.electron) {
try { return require("electron").app.getPath("userData"); } catch {}
}
const home = require("os").homedir();
if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "openworkbuddy");
if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "openworkbuddy");
return path.join(home, ".config", "openworkbuddy");
}
function dirSize(p) {
let n = 0;
try {
for (const e of fs.readdirSync(p)) {
const fp = path.join(p, e);
try {
const st = fs.lstatSync(fp); // lstat:.tmp/node_modules 软链绝不能跟进去统计/删除
if (st.isSymbolicLink()) continue;
n += st.isDirectory() ? dirSize(fp) : st.size;
} catch {}
}
} catch {}
return n;
}
function cacheTmpDirs() {
const set = new Set([path.join(getWorkspaceDir(), ".tmp")]);
for (const p of config.projects || []) if (p && p.dir) set.add(path.join(p.dir, ".tmp"));
// 每个成果文件夹自己的 .tmp/:提示词让模型把调试脚本、自检截图放这儿,不摊在成品旁边
try {
for (const e of fs.readdirSync(getWorkspaceDir(), { withFileTypes: true })) {
if (!e.isDirectory() || !e.name.startsWith("任务_")) continue;
const d = path.join(getWorkspaceDir(), e.name, ".tmp");
if (fs.existsSync(d)) set.add(d);
}
} catch {}
return [...set];
}
/**
* 成果文件夹里的 .tmp/ 是给模型的草稿区。一轮一轮只进不出的话,截图攒多了一样占地方——
* 收尾时把三天没动过的清掉。三天:续跑、第二天接着改都还在,一周前那次调试就没人要了
*
* 子目录「动没动过」按它里面最新的那一项算,不看目录自己的 mtime:往已有的文件里接着写、
* 在更深一层加东西,目录自己的 mtime 都纹丝不动。2026-09-29 复审:三天前建的 .tmp/venv、
* .tmp/调试/ 今天还在往里写,按目录 mtime 算整棵被删,下一步 import 直接找不到。
* 翻目录全走异步、翻到一个新的就停——收尾这一刻在 Electron 主进程上,同步翻一棵 node_modules 整个应用陪着卡。
* 翻了 TASK_TMP_SCAN_MAX 项还没翻完就当它还在用:宁可留着一棵大的,不拿半截证据去删。
*/
const TASK_TMP_SCAN_MAX = 20000;
async function pruneTaskTmp(taskAbs) {
const dir = path.join(taskAbs, ".tmp");
const dead = Date.now() - 3 * 24 * 3600 * 1000;
let names = [];
try { names = await fs.promises.readdir(dir); } catch { return; }
for (const n of names) {
const fp = path.join(dir, n);
try {
const st = await fs.promises.lstat(fp); // 软链只解链,绝不跟进去
if (st.mtimeMs >= dead) continue;
if (st.isSymbolicLink()) { await fs.promises.unlink(fp); continue; }
if (st.isDirectory() && await touchedSince(fp, dead, { left: TASK_TMP_SCAN_MAX })) continue;
await fs.promises.rm(fp, { recursive: true, force: true });
} catch {}
}
}
/** 目录里有没有 dead 之后动过的(软链只看它自己,不跟进去)。读不了、翻不完一律按「动过」算 */
async function touchedSince(dir, dead, budget) {
let ents;
try { ents = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return true; }
for (const e of ents) {
if (--budget.left < 0) return true;
const fp = path.join(dir, e.name);
let st;
try { st = await fs.promises.lstat(fp); } catch { continue; } // 翻的这一下被删了:它不算数
if (st.mtimeMs >= dead) return true;
if (st.isDirectory() && await touchedSince(fp, dead, budget)) return true;
}
return false;
}
function cacheStats() {
const ud = appUserDataDir();
const ui = CHROMIUM_CACHE_DIRS.reduce((n, d) => n + dirSize(path.join(ud, d)), 0);
const tmp = cacheTmpDirs().reduce((n, d) => n + dirSize(d), 0);
return { ui, tmp, total: ui + tmp };
}
// ---------- 数据备份与恢复 ----------
// 打包 data/(会话/记忆/账号/用量/审计)+ config.json + schedules.json + experts.json。
// 工作空间成果文件不进备份(可能巨大,且用户自己看得见摸得着)。备份放项目根 backups/,
// 用系统 tar(mac/linux 自带,win10+ 也有),不为这事拖第三方压缩依赖。
const BACKUP_DIR = dataPath("backups");
// prefs/ 在 data/ 外面(prefs.js 直接落 DATA_DIR/prefs),得单列一项——
// 漏了它的话搬完家宠物、快捷指令、上次挑的模型全要重设一遍
const BACKUP_ENTRIES = ["data", "prefs", "config.json", "schedules.json", "experts.json"];
// 包里允许出现的顶层条目。比 BACKUP_ENTRIES 多一个 skills/——自己写的技能是跟着备份走的
// (见 userSkillEntries),所以列清单和解包的时候也得认它。
const BACKUP_TOP = [...BACKUP_ENTRIES, "skills"];
/**
* 能重新生成的东西不进包。2026-09-28 量过一台用了一个月的机器:data/ 里缩略图、每份会话旁边
* 那个孪生 .bak、压缩前的原文归档、codex 的下载缓存、运维指标加起来占了一大半,
* 每天一个自动备份把它们原样再背一遍。
* - 缩略图/codex 缓存:看一眼就重新生成、重新下载。
* - *.json.bak:正本坏了才用得上的那一份,打包那一刻正本是好的。
* - compact-archive / metrics:前者是压缩前的原文(会话里留着压缩后的版本),后者是运维曲线。
* 恢复不会删这几样:恢复是 tar -x 盖回去、不清目录,盘上现有的原样留着。
* 必须摆在文件参数前面:GNU tar 规定 --exclude 只管它后面的参数。
* 模式都从包里的开头算(只管顶层那个 data/):两家 tar 默认都不锚定,不锚的话用户技能
* skills/x/data/ 底下同名的东西会一起漏打。怎么锚按 tar 是哪一家定,见 backup-auto.js 的 tarExcludeArgs。
*/
const BACKUP_EXCLUDES = ["data/thumbs", "data/*.json.bak", "data/compact-archive", "data/runtime/codex/cache", "data/metrics"];
let tarVersionText = null; // 本机 tar 是哪一家:一个进程里问一次就够
function backupExcludeArgs() {
if (!tarVersionText) {
tarVersionText = new Promise((resolve) => {
require("child_process").execFile("tar", ["--version"], { timeout: 10000, windowsHide: true }, (e, out) => {
if (e) tarVersionText = null; // 没问成下回再问;这一回按认不出处理,一个都不排除
resolve(e ? "" : String(out || ""));
});
});
}
return tarVersionText.then((v) => require("./src/server/backup-auto").tarExcludeArgs(v, BACKUP_EXCLUDES));
}
/**
* 自己写的技能也得跟着搬家。
*
* 随包出厂的那一堆不进包:光 ppt-master 一个就 170M,装机时 seedDataDir 本来就会把它们
* 铺到数据目录,新机器上一样有;每次备份都背一遍只会把包撑到没人敢点「立即备份」。
* 所以这儿只挑「出厂列表里没有的」——用户自己建的、或者从别处装进来的那些。
* 开发态(DATA_DIR === APP_DIR)下 skills/ 就是仓库里那份,git 管着,整个跳过。
* 代价说在明处:出厂技能被改过的话,那份改动不进包,到新机器上会是出厂那一版。
*/
function userSkillEntries() {
if (DATA_DIR === APP_DIR) return [];
const dirs = (d) => {
try { return fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); }
catch { return []; }
};
const shipped = new Set(dirs(appPath("skills")));
return dirs(dataPath("skills")).filter((n) => !shipped.has(n)).map((n) => path.join("skills", n));
}
// 备份里有 config.json(含 API Key)和全部账号数据——只有管理员能碰。
// 本地单人用没登录态时视同管理员(和任务归属的口径一致)
function backupAllowed(req, res) {
if (!req.user || account.isAdmin(req.user)) return true;
res.status(403).json({ error: "只有管理员能操作备份" });
return false;
}
function listBackups() {
try {
return fs.readdirSync(BACKUP_DIR)
// wb- 那个前缀随改名退休了,但**认还得认**:老备份包就叫那个名字,
// 只认新名字的话,升上来这一刻磁盘上的备份会从列表里整个消失(文件还在,人以为丢了)
.filter((f) => /^(?:openworkbuddy|wb)-backup-[\w.-]+\.tar\.gz$/.test(f))
.map((f) => {
const st = fs.statSync(path.join(BACKUP_DIR, f));
return { name: f, size: st.size, at: st.mtime.toISOString() };
})
.sort((a, b) => b.at.localeCompare(a.at));
} catch { return []; }
}
function makeBackup(tag) {
return new Promise((resolve, reject) => {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
const name = `openworkbuddy-backup-${stamp}${tag ? "-" + tag : ""}.tar.gz`;
const entries = [...BACKUP_ENTRIES.filter((e) => fs.existsSync(dataPath(e))), ...userSkillEntries()];
if (!entries.length) return reject(new Error("没有可备份的数据"));
backupExcludeArgs().then((excludes) => require("child_process").execFile(
"tar", ["-czf", path.join(BACKUP_DIR, name), ...excludes, "-C", DATA_DIR, ...entries],
{ timeout: 300000, windowsHide: true },
(err) => {
if (err) return reject(new Error(err.code === "ENOENT" ? "系统里没有 tar 命令(macOS/Linux/Windows 10 1803+ 都自带;更老的 Windows 请先升级系统)" : "tar 打包失败:" + err.message));
// 包里整整齐齐装着 config.json(九把 Key)、账号表、积分账本、审计流水。
// 下载接口是管理员专属的,可这个文件本身就躺在 data/backups/ 下——tar 默认落 0644,
// 同机器上任何一个别的账号都能整包拿走,等于绕开了上面所有的权限判断
store.tighten(path.join(BACKUP_DIR, name), store.SECRET_MODE);
resolve(name);
}
)).catch(reject);
});
}
// 定时自动备份(默认关)。周期存在 config.backup.every_days:0 关 / 1 每天 / 7 每周。
// 细节和规矩见 backup-auto.js
let restoring = false;
const autoBackup = require("./src/server/backup-auto").createAutoBackup({
everyDays: () => (config.backup || {}).every_days,
list: listBackups,
make: makeBackup,
remove: (name) => { const p = backupFile(name); if (p) fs.unlinkSync(p); },
busy: () => restoring,
audit: (msg, verdict) => security.audit("数据备份", msg, verdict),
});
autoBackup.start();
/** 校验名字必须来自现有备份列表,杜绝路径注入 */
function backupFile(name) {
const hit = listBackups().find((b) => b.name === name);
return hit ? path.join(BACKUP_DIR, hit.name) : null;
}
// 备份包里允许出现的顶层条目,和 BACKUP_ENTRIES 一一对应(data/ 是目录,其余三个是文件)。
const BACKUP_SAFE_ENTRY = new RegExp(
"^(?:\\./)?(?:" + BACKUP_TOP.map((e) => e.replace(/\./g, "\\.") + (/\.json$/.test(e) ? "$" : "(?:/|$)")).join("|") + ")"
);
/**
* 把一个 .tar.gz 拆开看一遍,看不过就不收。
*
* 这是整套备份里唯一一个「外面来的文件」会经过的地方。restore 那一步是 `tar -xzf 包 -C DATA_DIR`,
* 在有这个接口之前,它解的每一个包都是本机自己 makeBackup() 打出来的,所以怎么解都安全;
* 现在包可能是从另一台机器、甚至从别人手里拿来的,那就得按不可信的东西对待:
* · 绝对路径(/etc/...)和带 .. 的路径能写到 DATA_DIR 外面去;
* · 软链接/硬链接更狠——先塞一个 data/x -> /Users/xxx/.ssh,后面往 data/x 里写就落到链接指的地方了;
* · 顶层只认 data/ 和那三个 json,别的形状根本不是这套系统的备份,收进来也只会解出一地垃圾。
* 这里只看不解,验完才落盘。
*/
function inspectBackup(p) {
const execFile = require("child_process").execFile;
// 32MB 的清单大概装得下四十万条;再多的包不是正常备份,让它在这儿失败比解到一半失败强
const opt = { timeout: 300000, maxBuffer: 32 * 1024 * 1024, windowsHide: true };
const run = (args) =>
new Promise((resolve, reject) =>
execFile("tar", args, opt, (err, out) => { // windowsHide 在 opt 里
if (!err) return resolve(String(out || ""));
if (err.code === "ENOENT") return reject(new Error("系统里没有 tar 命令(macOS/Linux/Windows 10 1803+ 都自带)"));
if (err.code === "ENOBUFS") return reject(new Error("这个包里的文件太多了,不像是 OpenWorkBuddy 的备份"));
reject(new Error("这个文件打不开,可能不是完整的 .tar.gz:" + (err.message || "").split("\n")[0]));
})
);
return (async () => {
const names = (await run(["-tzf", p])).split("\n").map((x) => x.trim()).filter(Boolean);
if (!names.length) throw new Error("这个包是空的");
for (const n of names) {
if (n.startsWith("/")) throw new Error(`包里有绝对路径:${n}`);
if (n.split("/").includes("..")) throw new Error(`包里有跳出目录的路径:${n}`);
if (!BACKUP_SAFE_ENTRY.test(n)) throw new Error(`包里有不属于备份范围的东西:${n}`);
}
// 详细清单第一个字符就是条目类型:- 文件、d 目录、l 软链接、h 硬链接。只收前两种。
for (const line of (await run(["-tzvf", p])).split("\n")) {
const t = line.trim()[0];
if (!t) continue;
if (t !== "-" && t !== "d") throw new Error("包里有链接文件(软链接/硬链接),正常备份不会有,不收");
}
return names.length;
})();
}
app.get("/api/backup", (req, res) => {
if (!backupAllowed(req, res)) return;
const days = require("./src/server/backup-auto").normalizeDays((config.backup || {}).every_days);
res.json({
list: listBackups(), covers: BACKUP_ENTRIES, skills: userSkillEntries().length,
auto: { every_days: days, keep: require("./src/server/backup-auto").KEEP, last_error: autoBackup.lastError() },
});
});
app.post("/api/backup/auto", (req, res) => {
if (!backupAllowed(req, res)) return;
const raw = (req.body || {}).every_days;
const days = require("./src/server/backup-auto").normalizeDays(raw);
if (String(raw) !== String(days)) return res.status(400).json({ error: "周期只能是 0(关)、1(每天)或 7(每周)" });
config.backup = { ...(config.backup || {}), every_days: days };
saveConfig();
security.audit("数据备份", days ? `自动备份已开:每 ${days} 天一份` : "自动备份已关", "放行");
// 刚打开、一份自动备份都还没有:现在就备一份,不用等下一个整点
if (days) autoBackup.tick().catch(() => {});
res.json({ ok: true, every_days: days });
});
app.post("/api/backup", async (req, res) => {
if (!backupAllowed(req, res)) return;
try {
const name = await makeBackup("");
security.audit("数据备份", `已创建备份 ${name}`, "放行");
res.json({ ok: true, name });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get("/api/backup/download/:name", (req, res) => {
if (!backupAllowed(req, res)) return;
const p = backupFile(req.params.name);
if (!p) return res.status(404).json({ error: "备份不存在" });
res.download(p);
});
app.delete("/api/backup/:name", (req, res) => {
if (!backupAllowed(req, res)) return;
const p = backupFile(req.params.name);
if (!p) return res.status(404).json({ error: "备份不存在" });
fs.unlinkSync(p);
res.json({ ok: true });
});
// 上传导入:换电脑、换服务器、从旧机器搬家时,把下载下来的那个包送回来。
// 没有这一步的话「下载备份带走」就是句空话——新机器上没有任何地方能把它放进去。
// 只有这里用 raw body:备份是二进制,走 JSON 得先 base64(凭空胖三分之一),
// 而全局那个 express.json 的上限是给接口留的,不该为了搬家把它撑到 GB 级。
app.post(
"/api/backup/upload",
express.raw({ type: () => true, limit: "2gb" }),
async (req, res) => {
if (!backupAllowed(req, res)) return;
const buf = req.body;
if (!Buffer.isBuffer(buf) || !buf.length) return res.status(400).json({ error: "没收到文件" });
// gzip 的前两个字节永远是 1f 8b。先拦一道,省得把一个 .zip / 半截文件写到磁盘上再去解
if (buf[0] !== 0x1f || buf[1] !== 0x8b) return res.status(400).json({ error: "这不是 .tar.gz 备份文件" });
fs.mkdirSync(BACKUP_DIR, { recursive: true });
// 点开头的临时名不会被 listBackups() 的 openworkbuddy-backup-*.tar.gz 捞到,验不过的包不会在列表里露脸
const tmp = path.join(BACKUP_DIR, `.incoming-${Date.now()}.tar.gz`);
try {
fs.writeFileSync(tmp, buf);
const entries = await inspectBackup(tmp);
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
let name = `openworkbuddy-backup-${stamp}-imported.tar.gz`;
for (let i = 2; fs.existsSync(path.join(BACKUP_DIR, name)); i++) name = `openworkbuddy-backup-${stamp}-imported-${i}.tar.gz`;
fs.renameSync(tmp, path.join(BACKUP_DIR, name));
security.audit("数据备份", `已导入外部备份 ${name}(${(buf.length / 1048576).toFixed(1)} MB,${entries} 个条目)`, "放行");
res.json({ ok: true, name, entries, size: buf.length });
} catch (e) {
try { fs.unlinkSync(tmp); } catch {}
security.audit("数据备份", `导入备份被拒:${e.message}`, "拦截");
res.status(400).json({ error: e.message });
}
}
);
app.post("/api/backup/restore", async (req, res) => {
if (!backupAllowed(req, res)) return;
const p = backupFile(String((req.body || {}).name || ""));
if (!p) return res.status(404).json({ error: "备份不存在" });
// 自动备份正打着包时去解包,两边读写同一批文件,打出来的和恢复出来的都可能是半新半旧
if (autoBackup.isRunning()) return res.status(409).json({ error: "自动备份正在打包,等一分钟再恢复" });
restoring = true;
try {
// 恢复前先把现状自动备一份——恢复错了还能回来,这一步绝不省
const safety = await makeBackup("before-restore");
await new Promise((resolve, reject) =>
// --no-same-owner:包可能是从另一台机器导进来的,里面记的 uid 跟这儿对不上,
// 不加这个的话以 root 跑的服务会把文件的属主改成那台机器上的号,恢复完自己反而读不了
require("child_process").execFile("tar", ["-xzf", p, "--no-same-owner", "-C", DATA_DIR], { timeout: 300000, windowsHide: true },
(err) => (err ? reject(new Error(err.code === "ENOENT" ? "系统里没有 tar 命令(macOS/Linux/Windows 10 1803+ 都自带)" : "tar 解包失败:" + err.message)) : resolve()))
);
security.audit("数据恢复", `已从 ${path.basename(p)} 恢复(恢复前现状已存为 ${safety})`, "放行");
res.json({ ok: true, safety, restart_required: true, note: "已恢复到磁盘。内存里还是旧数据,重启应用后完全生效。" });
} catch (e) { res.status(500).json({ error: e.message }); }
finally { restoring = false; }
});
app.post("/api/backup/restart", (req, res) => {
if (!backupAllowed(req, res)) return;
if (!process.versions.electron) return res.status(400).json({ error: "非桌面版:请手动重启服务进程" });
res.json({ ok: true });
// 先把响应发出去再重启,不然前端只看到断线
setTimeout(() => {
try {
// 独立服务进程里没有 app:让主进程先收掉服务进程,再整个重启
const bridge = require("./src/platform/electron-bridge");
if (bridge.isRemote()) { bridge.notify("app.relaunch"); return; }
const { app: eApp } = require("electron");
eApp.relaunch();
eApp.exit(0);
} catch (e) { console.warn("[备份] 重启失败:", e.message); }
}, 600);
});
// ---------- 记忆搬家:导出 / 从其它 agent 导入 ----------
// 导出成一份人能读的 Markdown(手写区 + 条目区),到哪都能用。
// 导入支持两路:① 扫描本机已知的其它 agent 记忆文件(Claude Code / Codex / Claude Cowork),
// 只读扫描白名单里的路径,绝不接受任意路径;② 粘贴任意文本(记忆不落在固定文件里的工具,
// 从它界面里复制出来贴进来就行)。解析是确定性的,不烧 token。
app.get("/api/memory/export", (req, res) => {
const u = req.user ? req.user.username : undefined;
const items = memory.list(u);
const lines = [
"# OpenWorkBuddy 记忆导出",
"",
`导出时间:${new Date().toLocaleString("zh-CN")}${req.user ? ` · 账号:${req.user.username}` : ""}`,
"",
"## 背景说明(手写区,全局共享)",
"",
memory.manual() || "(空)",
"",
"## 记忆条目",
"",
...(items.length
? items.map((it) => `- [${it.scope === memory.SHARED ? "共享" : it.scope}] ${it.text}`)
: ["(还没有条目)"]),
"",
];
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="openworkbuddy-memory-${new Date().toISOString().slice(0, 10)}.md"`);
res.send(lines.join("\n"));
});
/** 本机其它 agent 的记忆文件白名单扫描(找得到才列出来,路径不存在就静默跳过) */
function memoryImportSources() {
const home = require("os").homedir();
const out = [];
const push = (label, p, mode) => {
try {
const st = fs.statSync(p);
if (st.isFile() && st.size > 0 && st.size < 2 * 1024 * 1024) out.push({ label, path: p, size: st.size, mode });
} catch {}
};
push("Claude Code 全局记忆(~/.claude/CLAUDE.md)", path.join(home, ".claude", "CLAUDE.md"), "manual");
try {
for (const d of fs.readdirSync(path.join(home, ".claude", "projects"))) {
push(`Claude Code 项目记忆(${d.replace(/^-/, "").slice(0, 48)})`, path.join(home, ".claude", "projects", d, "memory", "MEMORY.md"), "items");
}
} catch {}
push("Codex 全局记忆(~/.codex/AGENTS.md)", path.join(home, ".codex", "AGENTS.md"), "manual");
push("Claude Cowork 记忆(~/.cowork/CLAUDE.md)", path.join(home, ".cowork", "CLAUDE.md"), "manual");
push("Claude Cowork 记忆(应用目录)", path.join(home, "Library", "Application Support", "Claude Cowork", "CLAUDE.md"), "manual");
if (process.env.APPDATA) push("Claude Cowork 记忆(应用目录)", path.join(process.env.APPDATA, "Claude Cowork", "CLAUDE.md"), "manual");
return out;
}
app.get("/api/memory/import/scan", (_req, res) => res.json({ sources: memoryImportSources() }));
app.post("/api/memory/import", (req, res) => {
const b = req.body || {};
let content = "", label = "粘贴的内容";
if (b.path) {
const hit = memoryImportSources().find((s) => s.path === String(b.path));
if (!hit) return res.status(400).json({ error: "只能导入扫描列表里的文件(防任意路径读取)" });
try { content = fs.readFileSync(hit.path, "utf8"); } catch (e) { return res.status(500).json({ error: "读取失败:" + e.message }); }
label = hit.label;
} else {
content = String(b.text || "");
}
content = content.trim();
if (!content) return res.status(400).json({ error: "没有可导入的内容" });
const mode = b.mode === "manual" ? "manual" : "items";
if (mode === "manual") {
// 成段的背景/规范:整段并入手写区,加来源标头,去重靠人眼(手写区本来就是人编辑的)
const cur = memory.manual();
if (cur.includes(content.slice(0, 200))) return res.json({ ok: true, added: 0, skipped: 1, note: "内容已在背景说明里,跳过" });
memory.saveManual((cur ? cur + "\n\n" : "") + `## 导入自 ${label}(${new Date().toISOString().slice(0, 10)})\n\n` + content);
return res.json({ ok: true, added: 1, mode });
}
// 条目模式:逐行解析 markdown 列表(- / * / 数字.),[标题](链接) 压成 标题,跳过标题行和短行
const user = req.user ? req.user.username : undefined;
let added = 0, skipped = 0;
const lines = content.split(/\r?\n/).slice(0, 500);
for (const raw of lines) {
let t = raw.trim();
if (!t || /^#{1,6}\s/.test(t) || /^[-*_]{3,}$/.test(t)) continue; // 标题、分隔线
t = t.replace(/^[-*+]\s+/, "").replace(/^\d+[.)]\s+/, "");
t = t.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); // markdown 链接压成文字
t = t.replace(/\*\*/g, "").trim();
if (t.length < 4) continue;
const r = memory.add({ text: t, user, shared: !!b.shared, source: "user" });
if (r.ok) added++; else skipped++;
}
security.audit("记忆导入", `${label}:导入 ${added} 条,跳过 ${skipped} 条`, "放行");
res.json({ ok: true, added, skipped, mode });
});
// 两份缓存,口径完全不同,所以一起报但分开说:上面那份是可再生的磁盘垃圾(清了只是下次慢一点),
// gen 那份是「这一格已经买过了」的账(清了下次要重新花钱)。清理按钮只动前者。
app.get("/api/cache", (_req, res) => res.json({ ...cacheStats(), gen: genCache.stats() }));
app.post("/api/cache/clear", async (_req, res) => {
const before = cacheStats();
if (process.versions.electron) {
// 桌面版走官方 API:HTTP 缓存/代码缓存/着色器缓存;Cookie 与 localStorage(登录态、主题)不动
try {
// 独立服务进程里没有 session:界面那份由主进程清(同样三样,同样不动 Cookie)
const bridge = require("./src/platform/electron-bridge");
const ses = bridge.isRemote() ? null : require("electron").session.defaultSession;
if (!ses) await bridge.call("session.clearCaches", null, { timeoutMs: 30000 });
else {
await ses.clearCache();
try { await ses.clearCodeCaches({}); } catch {}
try { await ses.clearStorageData({ storages: ["shadercache", "cachestorage"] }); } catch {}
}
} catch (e) { console.warn("[缓存] Electron 清理失败:", e.message); }
}
const ud = appUserDataDir();
for (const d of CHROMIUM_CACHE_DIRS) {
const dir = path.join(ud, d);
try {
for (const e of fs.readdirSync(dir)) {
try { fs.rmSync(path.join(dir, e), { recursive: true, force: true }); } catch {} // 被占用就跳过
}
} catch {}
}
for (const dir of cacheTmpDirs()) {
try {
for (const e of fs.readdirSync(dir)) {
const fp = path.join(dir, e);
try {
if (fs.lstatSync(fp).isSymbolicLink()) fs.unlinkSync(fp); // node_modules 软链只解链,下次 runNode 自动重建
else fs.rmSync(fp, { recursive: true, force: true });
} catch {}
}
} catch {}
}
const after = cacheStats();
res.json({ ok: true, freed: Math.max(0, before.total - after.total), before: before.total, after: after.total });
});
app.get("/api/skills", (req, res) =>
// plugin 字段标出技能来自哪个 Agent Plugins 插件(本地技能没有这个字段),界面据此禁用编辑/删除
res.json((runtime ? runtime.getSkills(req.user ? req.user.username : undefined) : []).map((s) => ({ name: s.name, description: s.description, plugin: s.plugin || "" })))
);
// 技能管理:getSkills 每次现读磁盘,增删改/安装即热生效,无需重启
const skillsMgr = require("./src/core/ext/skills");
const actorOf = (req) => ({ user: (req && req.user && (req.user.username || req.user.name)) || "" });
/**
* 被安装检查拦下的错误,要比普通 400 多带两样东西:
* needs 前端据此决定画「我知道了,还是装」还是「只有平台管理员能强装」
* scan 那份清单本身,好让界面把命中的文件和行号摊开,而不是只弹一句红字
* 普通错误(名字空、链接格式不对)照旧只有 message。
*/
const skillErr = (e) => (e && e.skillScan
? { error: e.message, needs: e.needs, scan: { level: e.skillScan.level, hosts: e.skillScan.hosts, findings: e.skillScan.findings } }
: { error: e.message });
app.get("/api/skills/:name", (req, res) => {
const s = skillsMgr.getSkillFull(req.params.name);
if (!s) return res.status(404).json({ error: "技能不存在" });
res.json(s);
});
// 增 / 改 / 删 / 装技能都归平台管理员,闸不在这儿——admin.js 的 PLATFORM_WRITE 表里
// 有 "/api/skills" 前缀,app.use(admin.platformGuard) 排在所有路由前面,四条路一次拦完。
// 这里不再写第二道:两张表迟早对不上,而对不上的那次一定是放行的那次。
app.post("/api/skills", (req, res) => {
try {
const { name, description, content, original_name, confirm, force } = req.body || {};
if (!String(name || "").trim()) throw new Error("技能名不能为空");
if (!String(content || "").trim()) throw new Error("技能内容不能为空");
res.json(skillsMgr.saveSkill({ name, description, content, original_name, confirm: !!confirm, force: !!force, actor: actorOf(req) }));
} catch (e) {
res.status(400).json(skillErr(e));
}
});
app.delete("/api/skills/:name", (req, res) => {
try {
res.json({ ok: skillsMgr.deleteSkill(req.params.name) });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post("/api/skills/install", async (req, res) => {
try {
const { url, confirm, force } = req.body || {};
const installed = await skillsMgr.installFromGitHub(url, { confirm: !!confirm, force: !!force, actor: actorOf(req) });
res.json({ ok: true, installed });
} catch (e) {
res.status(400).json(skillErr(e));
}
});
// ---- 默认技能:不随仓库打包,点一下从上游装 ----
app.get("/api/skills/defaults/list", (_req, res) => res.json(skillsMgr.listDefaultSkills()));
app.post("/api/skills/defaults/install", async (req, res) => {
try {
const { names, force } = req.body || {};
const results = await skillsMgr.ensureDefaultSkills({
only: Array.isArray(names) && names.length ? names : null,
force: !!force, // 「重新下载」要真的重下,不然点了没反应
});
res.json({ ok: true, results });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// ---- Agent Plugins 1.0.0 插件 ----
const pluginsMgr = require("./src/core/ext/plugins");
app.get("/api/plugins", (_req, res) => {
const list = pluginsMgr.loadPlugins().map((p) => ({
ok: p.ok,
name: p.name,
error: p.error || "",
warnings: p.warnings || [],
version: p.manifest?.version || "",
description: p.manifest?.description || "",
license: p.manifest?.license || "",
author: p.manifest?.author?.name || "",
homepage: p.manifest?.homepage || "",
repository: p.manifest?.repository || "",
skills: (p.skills || []).map((s) => ({ name: s.name, description: s.description })),
mcp_servers: (p.mcpServers || []).map((s) => ({ name: s.name, transport: s.transport })),
bytes: p.ok ? skillsMgr.dirSize(p.dir) : 0,
source: pluginsMgr.pluginSource(p.name), // 有来源才给「更新」按钮
}));
res.json({ spec: pluginsMgr.SPEC_VERSION, plugins: list, mcp: mcpManager.status() });
});
app.post("/api/plugins/install", async (req, res) => {
try {
const info = await pluginsMgr.installPluginFromGitHub((req.body || {}).url);
// 插件带的 MCP 服务器要现起才能用;技能是每次任务现读磁盘的,不用管
const started = await startPluginMcp(info.name);
res.json({ ok: true, installed: info, mcp_started: started });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.post("/api/plugins/:name/update", async (req, res) => {
try {
const name = req.params.name;
// 旧的先停干净:重装会把目录整个换掉,老进程留着还指着已经删掉的文件
mcpManager.stopPlugin(name);
const info = await pluginsMgr.updatePlugin(name);
const started = await startPluginMcp(info.name);
res.json({ ok: true, updated: info, mcp_started: started });
} catch (e) {
// 更新失败也得把停掉的服务器捞回来,不然用户点一下「更新」反而把能用的搞没了
try { await startPluginMcp(req.params.name); } catch { /* 插件目录可能已经没了 */ }
res.status(400).json({ error: e.message });
}
});
app.delete("/api/plugins/:name", (req, res) => {
try {
// 先停进程再删目录:目录一删就查不出它带过哪些服务器,子进程会一直挂到重启应用
const stopped = mcpManager.stopPlugin(req.params.name);
const ok = pluginsMgr.removePlugin(req.params.name);
if (!ok) return res.json({ ok: false, note: "没有这个插件" });
res.json({
ok: true,
mcp_stopped: stopped,
note: stopped.length ? `插件已卸载,同时停掉了它带的 ${stopped.length} 个 MCP 服务器` : "插件已卸载",
});
} catch (e) {
res.status(400).json({ error: e.message });
}
});
/** 只起某个插件里还没连上的 MCP 服务器(装完立刻可用,不用重启) */
async function startPluginMcp(pluginName) {
// 排进连接器那条队:跟页面保存、agent 加连接器同时来时别交错着起停
return mcpApply(async () => {
const want = pluginsMgr.pluginMcpServers().filter((s) => s.plugin === pluginName && !mcpManager.clients.has(s.name));
if (!want.length) return [];
await mcpManager.startAll(want);
return want.filter((s) => mcpManager.clients.has(s.name)).map((s) => s.name);
});
}
// 文件上传到工作空间(输入框 + 按钮)
// 用户传进来的文件要和这轮的产出待在一起。以前一律落工作空间根目录,于是
// 「我传的素材」和「它做出来的成果」分了家,根目录越堆越乱(真实数据里躺了 22 个)。
// 时序坑:用户是**先传文件再发消息**,而成果文件夹要等第一条消息才建得出名字。
// 所以没文件夹时先落根目录并记账,等文件夹一建好,settlePendingUploads 再把它们搬进去。
//
// 两种请求体:
// - 原始字节(Content-Type: application/octet-stream):输入框、画布现在都走这条。边收边写盘,多大的视频都不过内存;
// 名字、会话、replace 放在请求头里,URI 编码过(请求头只认 ASCII)
// - JSON + base64:老页面、脚本还在用,照收。这条受全局 express.json 60MB 的限制,大文件别走它
// Only the desktop shell can supply source paths. The HTTP caller supplies a
// one-use opaque grant or asks for a native picker, never an arbitrary path.
app.post("/api/desktop/open-files", taskFileWrite, taskFileHandler(async (req, res) => {
const bridge = require("./src/platform/electron-bridge");
if (bridge.mode() === "none" || !isPlatformOwner(req)) return res.status(403).json({ error: "请在本机桌面版打开文件" });
try {
const b = req.body || {};
const sess = b.session ? getSession(String(b.session)) : null;
if (!sess || !sessionAllowed(req.user, sess)) return res.status(403).json({ error: "这条对话不可用" });
const call = async (op, args) => {
if (bridge.isRemote()) return op === "files.takeOpened"
? bridge.call("files.takeOpened", args, { timeoutMs: 15 * 60 * 1000 })
: bridge.call("dialog.openFiles", args, { timeoutMs: 15 * 60 * 1000 });
if (op === "files.takeOpened") return require("./src/desktop/open-files").take(String(args.token || ""));
const { dialog } = require("electron");
return dialog.showOpenDialog({ properties: ["openFile", "multiSelections"], title: "打开文件并对话" });
};
const picked = b.token ? await call("files.takeOpened", { token: String(b.token) }) : b.pick === true ? await call("dialog.openFiles", {}) : [];
const sources = Array.isArray(picked) ? picked : picked.canceled ? [] : picked.filePaths || [];
const files = [], errors = [];
// A new file conversation starts in the active project's default folder,
// even if the previous conversation temporarily used a different workspace.
const importRoot = (admin.keepsSharedSpace(req.user) && activeProject()?.dir) || getWorkspaceDir();
await withWorkspace(importRoot, async () => {
for (const source of sources.slice(0, 20)) {
let tmp = "";
try {
const st = await fs.promises.stat(source);
if (!st.isFile() || st.size > 200 * 1024 * 1024) throw Error("请选择 200 MB 以内的文件");
const base = uploadName.uploadBaseName(source);
uploadName.checkUploadContent(base, st.size);
const own = perChatHere() ? sessDirOf(sess) : null;
const dir = path.dirname(safePath(own ? path.join(own, base) : base));
await fs.promises.mkdir(dir, { recursive: true });
tmp = path.join(dir, ".open-" + require("crypto").randomBytes(12).toString("hex"));
await fs.promises.copyFile(source, tmp, fs.constants.COPYFILE_EXCL);
const saved = uploadName.writeUploadFresh(dir, base, { from: tmp }); tmp = "";
const rel = own ? path.join(own, saved) : saved;
noteUserInput(rel);
if (!own) sess.pending_uploads = (sess.pending_uploads || []).concat({ name: saved, root: getWorkspaceDir(), by: req.user?.username || "" });