| assets | ||
| clients | ||
| core | ||
| logs | ||
| packages/shared | ||
| reports | ||
| server | ||
| types | ||
| ui | ||
| utils | ||
| vendor | ||
| .gitignore | ||
| app.ts | ||
| build.mjs | ||
| index.html | ||
| package.json | ||
| README.md | ||
| styles.css | ||
| tsconfig.json | ||
Agent · 城轨智能中台(L1 Web 客户端 + L2 中台引擎)
基于 MCP(Model Context Protocol)协议的智能体系统:仓库根目录为 L1 AI 客户端(Web UI,TypeScript,经 esbuild 编译为浏览器原生 ES module,加载即用);server/ 为 L2 中台引擎(Express API,仅绑定 127.0.0.1)。
项目定位
这是一个协议原生的智能体系统:
- 不依赖任何 Skill 脚本机制 — 工具全部是注册的 JS 函数,AI 只填 JSON Schema 参数
- 不依赖特定模型 — 4 种模型源任选(Ollama / oMLX / Sparkle / 云端 API),换模型不改业务代码
- 不依赖特定业务 — 通过 MCP 协议接入任意子系统(本仓库自带 L1 webui + L2 中台引擎,业务子系统各自独立部署)
核心特性
- MCP Streamable HTTP transport(2024-11-05 规范) + HTTP 直连双模式
- agentic loop 自研(8 轮上限 + 同错 4 次终止 + 上下文自动压缩 + traceId 全程贯穿)
- 4 种模型源适配(Olamma/oMLX/Sparkle/云端Api),切换走 UI 下拉
- 跨框架模型卸载(切源时自动释放旧模型内存)
- 对话持久化(无 5MB 限制,本地存储)
- 审计日志(500 条 LRU,5 类事件)
- 七重安全治理(详见下文)
构建步骤
源码是 .ts,运行前需编译为 .js(浏览器加载):
npm install # 装 esbuild + typescript(devDependencies)
npm run build # esbuild 编译所有 .ts → .js(ESM,ES2022)
npm run watch # 监听文件变化自动重新编译(开发用)
npm run typecheck # tsc --noEmit 类型检查(可选)
构建产物(.js 文件)不进 git,见 .gitignore。
目录结构
Agent/
├── index.html # UI 骨架
├── styles.css # 深色金属蓝黑主题
├── app.ts # 入口:事件绑定 + 历史恢复 + 跨框架卸载
├── build.mjs # esbuild 构建脚本(.ts → .js)
├── tsconfig.json # TypeScript 配置
├── package.json # 构建脚本 + devDependencies
├── README.md
├── assets/ # 静态资源(logo + Orbitron 字体)
├── vendor/ # 第三方库(docx.umd.cjs 用于 Word 导出)
├── types/ # 全局类型声明
│ └── index.d.ts # JSON-RPC / MCP / Chat / PidMap 类型
│
├── core/ # 核心 agent 系统
│ ├── agent-loop.ts # 主循环(MAX_TURNS=8 + 同错终止 + 上下文压缩)
│ ├── agent-graph.ts # 轻量状态机框架(零依赖)
│ ├── agent-identity-core.ts # agent 身份与工具调用规则
│ ├── context-compress.ts # 上下文压缩(超阈值截断中间历史)
│ ├── session-context.ts # 会话上下文(当前实体 / 上次查询类型)
│ ├── domain-rules.ts # 领域规则约束
│ └── think-parse.ts # 思考过程分离(thinking vs content)
│
├── clients/ # 4 种模型源 client
│ ├── anthropic-client.ts # 云端 API(Anthropic Messages / OpenAI 双协议)
│ ├── ollama-client.ts # Ollama(NDJSON 流式)
│ ├── omlx-client.ts # oMLX(Apple Silicon 原生 MLX)
│ └── sparkle-client.ts # Sparkle(自建 OpenAI 兼容服务)
│
├── ui/ # UI 组件
│ ├── sidebar.ts # 控制面板 + 服务发现 + 工具勾选
│ ├── config-dialog.ts # 模型源配置弹窗
│ ├── audit-ui.ts # 审计日志弹窗
│ ├── leave-confirm.ts # 关闭确认对话框
│ ├── export-report.ts # Word 导出
│ └── http-client.ts # HTTP 直连模式(对比演示)
│
├── reports/ # 报告生成
│ ├── agent-report-templates.ts # 报告模板
│ ├── report-clean.ts # 报告清洗
│ ├── report-direct-render.ts # 直接渲染
│ ├── md-splitter.ts # Markdown 分模块
│ └── markdown.ts # 轻量 Markdown 渲染器
│
├── server/ # L2 中台引擎(Express API,:8088,仅 127.0.0.1)
│ ├── src/main.js # 入口(本地回环监听)
│ ├── src/auth/ # JWT Ed25519 鉴权 + 密钥管理
│ ├── src/audit/ # 审计日志(链式哈希防篡改)
│ ├── src/rbac/ # 角色权限
│ ├── src/import/ upload/ # 数据导入(batch_uuid 幂等防重)
│ ├── src/precompute/ # 业务预计算(AI 不算账,业务系统算好)
│ ├── src/export/ # 报表导出工作队列
│ └── README.md # 中台引擎详细说明
│
├── packages/shared/ # 中台公共包(模块注册表 / 常量 / 备份表定义)
│
└── utils/ # 工具函数
├── config.ts # 全局配置 + localStorage 持久化
├── metrics-client.ts # 资源监控 + Token 速率
├── mcp-client.ts # MCP Streamable HTTP 客户端
├── model-manager.ts # 跨框架卸载
├── conversation-store.ts # 对话持久化
├── audit-log.ts # 审计日志存储
├── pidmap-store.ts # 假名映射存储(认知隔离用)
├── masking-validator.ts # 脱敏校验
├── masking-mock-test.ts # 脱敏 mock 测试
├── tool-call-parse.ts # <tool_call> 文本兜底解析
├── tool-args-enrich.ts # 工具参数补全(跨轮意图)
├── tool-renderer.ts # 工具卡片渲染
└── timetable-query.ts # 时刻表查询辅助
logs/ # 红蓝对抗演练日志(见末尾章节)
启动方法
准备
前置条件:
- 任一现代浏览器(Chrome / Edge / Safari / Firefox)
- 一个 MCP 子系统(本仓库不含,需另外部署;启动后监听
http://127.0.0.1:7777/mcp) - 任一模型源(下面 4 选 1)
启动 webui
步骤 1:安装依赖 + 构建:
cd Agent
npm install # 装 esbuild + typescript(devDependencies)
npm run build # 编译所有 .ts → .js(ESM,ES2022,首次约 1 秒)
后续修改源码后重跑 npm run build 即可,或开发期跑 npm run watch 自动重编。
步骤 2:起静态服务:
# 方式 A:Python(无需额外依赖)
python3 -m http.server 8090
# 方式 B:Node http-server
npx http-server -p 8090 -c-1
打开浏览器访问 http://localhost:8090/。
配置模型源
打开页面后,点 sidebar 顶部的 「配」 按钮:
- 选择启动默认模型源(Ollama / oMLX / Sparkle / 云端 API)
- 在对应 tab 填配置:
- Ollama:填 Base URL → 点"刷新"拉取本地模型
- oMLX:填 Base URL + API Key → 点"刷新"拉取
- Sparkle:填 Base URL(API Key 可留空,接受任意 Bearer token)
- 云端 API:选预设(智谱 Coding / 百炼 / DeepSeek / Moonshot Kimi / 百度千帆 / 自定义) → 填 Token → 选协议(Anthropic Messages / OpenAI Chat)
- 选默认启动档位(快速 / 平衡 / 增强)
- 保存
启动模型服务(按选的源)
# Ollama
ollama pull <模型名>
OLLAMA_ORIGINS=http://localhost:8090 ollama serve
# oMLX(参考 oMLX 项目文档)
# 云端 API(无需本地启动,直接调云厂商端点)
启动 MCP 子系统
业务子系统需独立部署,提供 MCP server(监听 127.0.0.1:7777/mcp 或其他端口)。本仓库不含具体业务实现。
X-MCP-Token(子系统鉴权 token)
调用业务子系统的 MCP 工具时,客户端会带 X-MCP-Token HTTP 头鉴权。
用途:防止本机其他恶意进程无 token 调用 MCP 子系统窃取数据(子系统绑 127.0.0.1,本机任意进程都能访问,token 是唯一身份凭证)。
谁生成:业务子系统启动时,生成一个随机 token 写入本地文件(如 ~/.metro-driver/token)。客户端必须用同一个 token 才能调通。
怎么填(本仓库自动加载顺序):
| 顺序 | 来源 | 说明 |
|---|---|---|
| 1 | URL 参数 ?token=xxx |
优先级最高,自动持久化 |
| 2 | localStorage | 之前用 URL 注入过,刷新自动读 |
| 3 | 留空 | 上述都没有时 token 为空,MCP 调用会返回 UNAUTHORIZED |
用户操作:
- 有业务子系统 → 子系统启动后,从其日志或
~/.metro-driver/token读取,然后http://localhost:8090/?token=<读取的值> - 没有业务子系统(只看 webui)→ 留空,前端能跑但 MCP 调用会失败(预期行为)
可选:启动中台引擎(L2,:8088)
仓库已含 L2 中台引擎源码(server/,运行说明见 server/README.md):
cd server
cp .env.example .env
npm install
npm start # 监听 http://127.0.0.1:8088/api/v1
职责:请求验证与路由转发、JWT Ed25519 鉴权、链式哈希审计、RBAC、数据导入/导出与预计算。业务子系统(MCP Server,:7777/:7778)不在本仓库。
如用云端 API / oMLX,浏览器直调仍会被 CORS 拦截,需本地 proxy 转发(本项目不含,自行实现):
POST /proxy/anthropic-messages # 转发到 Anthropic 兼容端点
POST /proxy/omlx-chat # 转发到 OpenAI 兼容端点
GET /metrics # 系统资源监控(可选)
header 透传 X-Cloud-Url 和 X-Cloud-Token。
依赖
devDependencies(构建期,见 package.json):
esbuild— TypeScript 编译(.ts→.js,ESM 输出)typescript— 类型检查(tsc --noEmit,可选)
运行时依赖:零(浏览器原生 ES module + fetch + AbortController)
已内置的第三方库(vendor/):
docx.umd.cjs— Word 文档导出(用于报告下载)
字体(assets/fonts/):
- Orbitron Regular / SemiBold — 仅用于 Logo
外部服务(按需,本仓库不含):
- 任一 MCP 子系统(必须)
- 任一模型源(Ollama / oMLX / Sparkle / 云端 API)
- 浏览器 CORS proxy(仅云端 API 需要)
七重安全治理
本客户端作为智能体调度业务子系统,内置 7 重安全治理,确保 AI 操作可控。
| 重 | 名称 | 防御目标 | 实现位置(本仓库 / 业务子系统) |
|---|---|---|---|
| ① | 输入隔离 | 阻止外部输入注入 AI 上下文 | core/agent-loop.ts(工具结果结构化回灌,unknown tool 丢弃) + utils/tool-call-parse.ts(幻觉 tool_call 丢弃) + 浏览器沙箱(无 fs / process) + 业务子系统 dispatcher(注册函数才能调,非注册直接拒) |
| ② | 全链本地 | 数据不出网,断网闭环 | utils/config.ts(MCP_BASE/ZHONGTAI_BASE/OMLX_BASE/SPARKLE_BASE 全部 127.0.0.1)+ 业务子系统(Electron 主进程 listen('127.0.0.1'))。云端 API 例外:见 ⑦ token 化 |
| ③ | 存储隔离 | AI 不直接读写 DB | 浏览器沙箱(无 DB 句柄) + utils/pidmap-store.ts(pidMap 仅内存,不进 localStorage) + 业务子系统(dispatcher 唯一出口,业务函数显式注册) |
| ④ | 三级管控 | 工具调用权限分级 | (a) 代码黑名单:业务子系统 CHEAT_BLACKLIST + 调试前缀过滤;(b) 运行时启停:ui/sidebar.ts 工具勾选 + 业务子系统 mcp-config.json whitelist;(c) 危险确认:业务子系统 Electron dialog.showMessageBox + core/agent-loop.ts 中转 |
| ⑤ | 写幂等 | 防重复触发同一写操作 | 业务子系统 IDEMPOTENCY_CACHE(参数 hash → 命中跳过,5 min TTL,21 个查询工具豁免);core/agent-loop.ts 透传 idempotency_key |
| ⑥ | Tool 最小 | 工具暴露面收敛 | 业务子系统 注册(全量)→ MCP tools/list(去 internal)→ mcp-config.json whitelist(生产);ui/sidebar.ts 默认勾选 + core/agent-loop.ts 两级发现(元工具 metro_search_tools / metro_get_tool_detail 避免一次性塞给模型) |
| ⑦ | 认知隔离(关键) | AI 上下文永远无明文 PII | 业务子系统 mask.js(6 类假名生成:emp_ + 4 位 random + 3 位 counter,4h TTL,纯内存) + _meta.pidMap 通道(假名映射不进 content[].text) + utils/pidmap-store.ts(客户端内存 only) + reports/markdown.ts _unmaskText(本地解码显示) + utils/masking-validator.ts validateCandidateSelection(多候选必须 emp_xxx 精确匹配) |
⑦ 认知隔离的数据流(关键安全特性)
PII 永远不出业务子系统边界:
| 阶段 | 数据形态 | 位置 |
|---|---|---|
| 业务子系统 DB | 真实 PII(姓名 / 工号 / 电话 / 住址) | 业务子系统 SQLite |
| 业务子系统返回 | 假名([NAME:emp_a3b8] [ID:emp_a3b8] [TEAM:emp_cjxe03i]) |
业务子系统 mask.js 替换 |
MCP _meta.pidMap |
假名映射表(单独通道,不进 content) | 业务子系统 http-server |
| AI 上下文 | 仅假名(emp_a3b8),AI 不知道是谁 |
core/agent-loop.ts messages |
| AI 返回工具调用 | employeeId="emp_a3b8" |
模型输出 |
| 业务子系统执行 | 假名 → 真实 ID 反查 → 业务 → 结果再次假名化 | 业务子系统 mask.js |
| 客户端 UI 渲染 | 本地用 pidMap 解码显示明文 | reports/markdown.ts |
⑦ 安全价值
- 攻击方拦截 AI 上下文,只看到
emp_a3b8,无法反推真实身份 - 4 小时后映射即焚,内存 dump 也只剩假名
- 黑客拆走硬盘,找不到 AI 评价了谁
- 云端模型请求体已 token 化,云端无法反推(配合 ② 的全链本地例外)
配置持久化
所有配置存 localStorage:
- token(X-MCP-Token,从子系统读取或 URL 参数注入)
- 模型源(ollama / omlx / sparkle / anthropic)
- 各源的 baseUrl / apiKey / 模型名
- 启用工具列表 + 自定义预设
- 审计日志(500 条 LRU)
- 对话历史(走中台,无 5MB 限制)
限制与边界
| 限制 | 值 | 说明 |
|---|---|---|
| MAX_TURNS | 8 | agentic loop 主循环上限,超出强制结束 |
| 报告模式 MAX_TURNS | +10 | 详细报告续写,共 18 轮 |
| MAX_SAME_ERROR | 4 | 同一工具连续失败 4 次终止 |
| 上下文压缩阈值 | 24k(本地)/ 96k(云端) | 超阈值自动截断中间历史 |
| MCP Session TTL | 30 分钟 | 超时自动清理 |
| 假名 TTL | 4 小时 | 进程内存即焚 |
| 审计日志 | 500 条 LRU | 不落明文 PII |
| 工具调用超时 | 15 秒 | 单次工具调用硬超时 |
故障排查
| 现象 | 原因 | 解决 |
|---|---|---|
工具卡片报 UNAUTHORIZED |
X-MCP-Token 不对 | URL 参数注入 ?token=xxx,或从子系统 ~/.metro-driver/token 读取 |
工具卡片报 APP_NOT_READY |
MCP 子系统没启动 | 启动业务子系统,确认 127.0.0.1:7777/health 可访问 |
| 顶部状态显示「未加载」 | 模型服务没启动 | 启动 Ollama / oMLX 等,加载模型 |
| 浏览器 console 报 CORS 错误 | 浏览器直调云端 / oMLX 被拦 | 起本地 proxy 转发(/proxy/anthropic-messages、/proxy/omlx-chat) |
| Ollama 报 CORS | 没设 OLLAMA_ORIGINS | OLLAMA_ORIGINS=http://localhost:8090 ollama serve |
| 刷新后对话仍在但工具结果消失 | 只存了 user/assistant 文本 | 正常行为,工具结果不进 history 避免撑爆 |
| 模型回复英文 | 模型在 tool calling 时默认英文 | agent-identity-core 已加【语言】规则,刷新页面 |
模型不输出 <tool_call> |
模型不听话 | 检查 IDENTITY 文案 / 工具 schema 是否完整 |
红蓝对抗演练日志
本仓库附 logs/ 目录,记录了一次针对本架构的授权红蓝对抗演练完整数据,作为七重安全治理的实证支撑。
演练背景
| 项目 | 值 |
|---|---|
| 攻击目标 | 工号 02133 姓名 薛峻翔 的电话 + 家庭住址 |
| 演练时长 | 13 分 14 秒(主 agent 第 79 轮声明放弃后停止) |
| 攻击方 | 1 主 agent(qwen3-max)+ 3 子 agent(kimi-k3 / glm-5.2 / deepseek-v4-flash),全部走云端 OpenAI 兼容协议 |
| 防守方 | 中台 + profile 子系统(external 模式,纯外网黑盒) |
| 模拟场景 | 本机进程隔离 + 出口白名单(host=127.0.0.1,port∈{3000,7778,8088,8082}) |
| 攻击工具 | port_scan / http_probe / MCP initialize+list+call / SSRF via zhongtai / payload_fuzzer |
攻击量统计
| 类别 | 次数 | 说明 |
|---|---|---|
| HTTP 探测 | 92 | 各端口 path 探测 |
| payload fuzz | 82 | 命令注入 / SSRF token / JWT 默认密钥爆破 |
| SSRF | 22 | 经中台 proxy 透传 |
| MCP 工具调用 | 13 | profile_list_employees / profile_get_person_report |
| 总事件 | 260+ | 全程日志化 |
结果
| 目标 | 结果 |
|---|---|
| 目标 1(机密性 — 读取 PII) | ✅ 防守成功 — 9 次 submit_finding 全部失败(全部编造值,被胜利判定模块识别为 format ok but content unverified) |
| 目标 2(完整性 — 篡改 DB) | ✅ 防守成功 — 1 次 submit_modification 虚假声明,orchestrator 实读 DB 验证失败 |
| 攻击方放弃 | 主 agent 第 64 轮声明"所有攻击面已穷尽",反复 15 轮后用户停止 |
关键防御点(七重安全的实际表现)
| 攻击向量 | 命中次数 | 防御机制 | 结果 |
|---|---|---|---|
| MCP tools/call 直调 | 13 | 业务层 requireSession()(③ 存储隔离 + ⑦ 认知隔离) | SESSION_INVALID 守住 |
| 中台 SSRF(X-Cloud-Url 透传) | 22 | X-Cloud-Token 强制要求 + JWT 签名(② 全链本地) | MISSING_TOKEN 守住 |
/conversations 历史泄露 |
29 | 客户端脱敏 + 历史为空(⑦ 假名化) | 仅 emp_xxx,无明文 |
| 命令注入(/admin/kill-ollama-runner) | 83 | 本机环境该端点 404 | 全部失败 |
AI 行为洞察
演练发现 LLM 在压力下的典型行为(防御方需要知晓):
- 编造 PII(9 次):提交常见中国手机号 + 上海常见地址,被胜利判定模块识别
- 虚假声明(1 次):submit_modification 声称改了 DB,实际没改(AI 幻觉)
- 重复输出(15 轮):第 64-79 轮反复"所有攻击面已穷尽"
utils/pidmap-store.ts + utils/masking-validator.ts + 业务层 mask.js 三层防御有效挡住所有 AI 编造行为。
日志文件
| 文件 | 大小 | 说明 |
|---|---|---|
logs/detailed-report.md |
22 KB | 详细复盘报告(14 章节:元信息 / 双目标结果 / 时间线 / 攻击链分析 / agent 表现评估 / 七重风险归类 / 修复建议) |
logs/main-thinking.md |
57 KB | 79 轮主 agent + 53 轮子 agent 完整思考流(可直接阅读,看 LLM 决策过程) |
logs/orchestrator.txt |
97 KB | 260+ 条攻击事件 JSONL(每条含 ts / agent / tool / target / response) |
演练胶水软件
红队调度软件本身不在本仓库(它是独立的胶水程序,用 Node.js 调云端 4 个模型)。本仓库只附防守方日志作为安全实证。如需复现,可参照 detailed-report.md 第 12 节(给下次演练的改进建议)自行实现。