release: v1.7.0

This commit is contained in:
mateaix 2026-07-04 20:28:15 +08:00
parent db6caea824
commit c8bf4e0f89
474 changed files with 37224 additions and 1283 deletions

View File

@ -34,6 +34,11 @@ MATECLAW_CORS_ALLOWED_ORIGINS=
# 留空时回退到当前请求的 host再退回相对路径。反代后部署建议显式设置。
MATECLAW_PUBLIC_BASE_URL=
# 是否公开 Swagger UI / OpenAPI 文档(/swagger-ui.html、/v3/api-docs
# 生产数据库 profilemysql/kingbase/postgres默认 false —— 匿名无法浏览全部
# 端点结构需全局管理员ROLE_ADMIN。仅在内网/预发临时调试时设为 true。
MATECLAW_OPENAPI_EXPOSE_UI=
# SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32
SEARXNG_SECRET=
@ -55,6 +60,20 @@ MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL=
# ==================== 局域网 部署放开(可选,默认 false 严格模式) ====================
# 浏览器 SSRF 防护:放行本地回环和私有 IP127.0.0.1 / 10.x / 192.168.x /
# 172.16-31.x / IPv6 fc00::/7 等),公网部署务必保持 false否则 SSRF 防护失效
PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=false
# 浏览器忽略 HTTPS 证书错误(自签证书 / IP 直连 HTTPS 场景)
# 公网部署务必保持 false否则中间人攻击可绕过证书校验
PLAYWRIGHT_IGNORE_HTTPS_ERRORS=false
# Playwright 单次操作超时(秒),慢链路 / 大页面可调高
PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS=30
# Playwright 导航超时(秒),慢网络可调高
PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS=30
# snapshot 文本截断长度,超出会返回 truncated:true 提示 LLM 用 selector 缩小范围
PLAYWRIGHT_SNAPSHOT_MAX_LENGTH=20000
# ==================== OpenAI OAuthDocker可选 ====================
#
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code

3
.gitignore vendored
View File

@ -105,6 +105,9 @@ CLAUDE.md
# Codex CLI local artifacts
.codex/
# Codebase memory (local agent index / graph artifact; do not commit)
.codebase-memory/
# Sync tooling local state (generated each run; report is intentionally tracked)
scripts/.*-sync-state.json

View File

@ -10,14 +10,14 @@
<p align="center"><sub><b>Agent Harness · Spring Boot inside · One JAR to ship</b></sub></p>
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![GitHub Repo](https://img.shields.io/badge/GitHub-Repo-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![Documentation](https://img.shields.io/badge/Docs-Website-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![Live Demo](https://img.shields.io/badge/Demo-Online-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![Website](https://img.shields.io/badge/Website-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java Version](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![Last Commit](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw)
[![Last Commit](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![License](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[[Website](https://claw.mate.vip)] [[Live Demo](https://claw-demo.mate.vip)] [[Documentation](https://claw.mate.vip/docs)] [[中文](README_zh.md)]
@ -161,7 +161,7 @@ docker compose up -d # http://localhost:18080
### Desktop
Download from [GitHub Releases](https://github.com/matevip/mateclaw/releases). Bundles JRE 21. No Java install needed.
Download from [GitHub Releases](https://github.com/mateaix/mateclaw/releases). Bundles JRE 21. No Java install needed.
---
@ -193,7 +193,7 @@ mateclaw/
└── .env.example
```
Desktop binaries ship via [GitHub Releases](https://github.com/matevip/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/releases) with a bundled JRE 21 — no Java install needed.
## Tech stack
@ -217,25 +217,29 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap
**v1.7.0 (shipped 2026-07-04)** — a *productionization pass*: once it's in real collaboration, close every loop you can't see, gather, reach, fit, or connect:
- **All three approval paths close the loop** — workflow `await_approval` actually pushes to channels and resolves → resumes, the WebChat (API-key) channel can approve/deny and replay, and Feishu/WeCom card clicks resolve workflow approvals directly
- **Long tasks are visible** — an always-on Run Overview rail + a per-turn token breakdown (cache hit/miss/write + reasoning split) + sub-agent cost rolled up + one-click generated-file download
- **Fits the real model window** — local-model context-window probing, a unified token budget for prefix injection, small-context degradation, and tool-schema budget gating — no more "guess 32K" pre-flight rejections or silent truncation
- **Opens up** — a knowledge-base + Deep Research open API (API-key + rate limit + SSE), a pluggable search Provider SPI, and MCP identity forwarding (carry the authenticated user's identity into a STDIO MCP)
- **Reaches further** — desktop local-embedded / remote-centralized dual mode (with `mateclaw-desktop` source opened) + a LAN deployment mode for controlled intranet access
- **One-click operational data export** — Dashboard 9-sheet Excel + a CLI for offline export
Full story in the [v1.7.0 release notes](https://claw.mate.vip/docs/en/releases/1.7.0).
**v1.6.0 (shipped 2026-06-22)** — make the autonomous employee *fast, sharp-eyed, and embeddable*: two-stage skill loading + prefix compression (faster first token) · `execute_code` native sandboxed code execution · vision that persists across turns + `image_analyze` · embeddable/headless webchat with per-`endUserId` memory · a Wiki you actually read (reading split from management · unified Sources tab · clickable `[[wikilinks]]`) · steadier under load (self-healing MCP · tool-call recovery · evidence-gated plans). Full story in the [v1.6.0 release notes](https://claw.mate.vip/docs/en/releases/1.6.0).
**v1.5.0 (shipped 2026-06-04)** — Goal checklists (fuzzy score → ticked boxes) · self-maintaining Wiki (`[[wikilinks]]` · fact/experience layers · pageType profiles & permissions · KB pipelines · local-directory ingest) · per-owner memory isolation (`owner_key` + visibility scope + `endUserId` passthrough) · per-agent primary knowledge base · provider-preference model routing. Full story in the [v1.5.0 release notes](https://claw.mate.vip/docs/en/releases/1.5.0).
**v1.4.0 (shipped 2026-05-23)** — Persistent Goals (lock a goal, self-evaluate every turn) · subagent delegation tree (3 levels deep · sync / parallel / async · one-sentence team builder) · progressive tool/skill disclosure · Workspace RBAC (Owner / Admin / Member / Viewer) · Feishu first-class (interactive / approval / streaming cards · channel-native tools). See the [v1.4.0 release notes](https://claw.mate.vip/docs/en/releases/1.4.0).
**v1.3.0 (shipped 2026-05-13)** — Workflow engine · 6-pattern trigger system · Wiki transformations · per-agent MCP binding · multimodal sidecar routing · four JVM-native document-generation tools · image edit. See the [v1.3.0 release notes](https://claw.mate.vip/docs/en/releases/1.3.0).
**v1.6.0 (in progress)** — make the autonomous employee *fast, sharp-eyed, and embeddable*:
- **Faster first token** — two-stage skill loading (base skills resident, scenario skills retrieved on demand by a relevance scorer) plus prefix compression, cutting the cold-start payload that used to blow past a million characters
- **Native code execution**`execute_code` lets an employee write and run sandboxed code to compute, transform data, and assemble multi-format reports, all JVM-side
- **Vision that persists** — images stay in context across turns; `image_analyze` re-reads an attachment on demand, so "zoom into that chart" follow-ups work without re-uploading
- **Embeddable & headless** — the webchat widget becomes a Web/API surface with multi-session support and per-end-user identity (`endUserId`), isolating memory per end user
- **A Wiki you actually read** — reading split from management, a unified Sources tab with per-KB auto-sync, and clickable cross-KB `[[wikilinks]]`
- **Steadier under load** — self-healing MCP connections · tool-call recovery on interleaved-thinking models · evidence-gated plan execution
## Contributing
```bash
git clone https://github.com/matevip/mateclaw.git
git clone https://github.com/mateaix/mateclaw.git
cd mateclaw
cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && pnpm install && pnpm dev

View File

@ -10,14 +10,14 @@
<p align="center"><sub><b>Agent Harness · Spring Boot 内核 · 一个 JAR 交付</b></sub></p>
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/matevip/mateclaw)
[![GitHub 仓库](https://img.shields.io/badge/GitHub-仓库-black.svg?logo=github)](https://github.com/mateaix/mateclaw)
[![文档](https://img.shields.io/badge/文档-在线-green.svg?logo=readthedocs&label=Docs)](https://claw.mate.vip/docs)
[![在线演示](https://img.shields.io/badge/演示-在线-orange.svg?logo=vercel&label=Demo)](https://claw-demo.mate.vip)
[![官网](https://img.shields.io/badge/官网-claw.mate.vip-blue.svg?logo=googlechrome&label=Site)](https://claw.mate.vip)
[![Java 版本](https://img.shields.io/badge/Java-21+-blue.svg?logo=openjdk&label=Java)](https://adoptium.net/)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.5-brightgreen.svg?logo=springboot)](https://spring.io/projects/spring-boot)
[![Vue](https://img.shields.io/badge/Vue-3-4FC08D.svg?logo=vuedotjs)](https://vuejs.org/)
[![最后提交](https://img.shields.io/github/last-commit/matevip/mateclaw)](https://github.com/matevip/mateclaw)
[![最后提交](https://img.shields.io/github/last-commit/mateaix/mateclaw)](https://github.com/mateaix/mateclaw)
[![许可证](https://img.shields.io/badge/license-Apache--2.0-red.svg?logo=opensourceinitiative&label=License)](LICENSE)
[[官网](https://claw.mate.vip)] [[在线演示](https://claw-demo.mate.vip)] [[文档](https://claw.mate.vip/docs)] [[English](README.md)]
@ -161,7 +161,7 @@ docker compose up -d # http://localhost:18080
### 桌面端
从 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外装 Java。
从 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 下载安装包。内嵌 JRE 21无需额外装 Java。
---
@ -193,7 +193,7 @@ mateclaw/
└── .env.example
```
桌面端安装包通过 [GitHub Releases](https://github.com/matevip/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
桌面端安装包通过 [GitHub Releases](https://github.com/mateaix/mateclaw/releases) 分发,内嵌 JRE 21——无需安装 Java。
## 技术栈
@ -217,25 +217,29 @@ mateclaw/
## 路线图
**v1.7.02026-07-04 发布)** — 一次*生产化加固*:把它放进真正的协作里之后,那些看不见、收不拢、够不着、装不下、连不通的地方全补上:
- **审批三条链路彻底闭环** — 工作流 `await_approval` 真的推到渠道并 resolve→恢复执行、WebChatAPI-Key渠道能批准/拒绝并重放、飞书/企微点卡片直接 resolve 工作流审批
- **长任务看得见** — 常驻「运行总览」侧栏 + 本轮 Token 明细(缓存命中/未命中/写入 + 推理拆分)+ 子 Agent 成本向上滚加 + 生成文件一键下载
- **装得下真实模型窗口** — 本地模型上下文窗口探测、prefix 注入统一 Token 预算、小上下文降级、工具 schema 预算门——不再被"猜个 32K"坑到预检拒绝或悄悄截断
- **开放出去** — 知识库 / Deep Research 开放 APIAPI-Key + 限流 + SSE、插件化搜索 Provider SPI、MCP 身份透传(把认证用户身份带给 STDIO MCP
- **够得着更远** — 桌面端本地内嵌 / 远程集中部署双模式(`mateclaw-desktop` 源码开放)+ 局域网部署模式放开受控内网访问
- **运营数据一键导出** — Dashboard 9 表 Excel + CLI 命令行离线导出
完整故事见 [v1.7.0 release notes](https://claw.mate.vip/docs/zh/releases/1.7.0)。
**v1.6.02026-06-22 发布)** — 让自驱的数字员工*更快、更会看、更易嵌入*:技能两段式载入 + prefix 压缩(首字节更快)· `execute_code` 原生沙箱代码执行 · 图片跨轮次留存 + `image_analyze` · 可嵌入/无头 webchat 按 `endUserId` 隔离记忆 · 真正可读的 Wiki阅读与管理分离 · 统一 Sources 标签 · 可点击 `[[wikilinks]]`)· 高负载更稳MCP 自愈 · 工具调用恢复 · 计划证据闸门)。完整故事见 [v1.6.0 release notes](https://claw.mate.vip/docs/zh/releases/1.6.0)。
**v1.5.02026-06-04 发布)** — Goal 可勾选清单(模糊评分 → 逐项打勾)· Wiki 自维护(`[[wikilinks]]` · 事实层/经验层 · pageType 模板与权限 · 知识库流水线 · 本地目录接入)· 按拥有者隔离记忆(`owner_key` + 可见域 + `endUserId` 透传)· 每员工绑定主知识库 · 偏好 provider 驱动选型。完整故事见 [v1.5.0 release notes](https://claw.mate.vip/docs/zh/releases/1.5.0)。
**v1.4.02026-05-23 发布)** — 持续目标(锁定目标,每轮自评)· 子员工委派树(最深 3 层 · 同步 / 并行 / 异步 · 一句话组队)· 工具/技能渐进式披露 · 工作空间 RBACOwner / Admin / Member / Viewer· 飞书一等公民(交互卡 / 审批卡 / 流式卡 · 渠道原生工具)。详见 [v1.4.0 release notes](https://claw.mate.vip/docs/zh/releases/1.4.0)。
**v1.3.02026-05-13 发布)** — 工作流引擎 · 6 种 pattern 触发器 · Wiki 加工器 · 每员工独立 MCP 绑定 · 多模态旁路路由 · 4 个 JVM 原生文档生成工具 · 图像编辑。详见 [v1.3.0 release notes](https://claw.mate.vip/docs/zh/releases/1.3.0)。
**v1.6.0(开发中)** — 让自驱的数字员工*更快、更会看、更易嵌入*
- **首字节更快** — 技能两段式载入(基础技能常驻,场景技能由相关性评分器按需检索)+ prefix 压缩,砍掉过去单请求动辄上百万字符的冷启动负载
- **原生代码执行**`execute_code` 让员工自己写、自己跑沙箱代码,完成计算、数据加工与多格式报告生成,全程在 JVM 内
- **能记住图的视觉** — 图片跨轮次保留在上下文里;`image_analyze` 按需重新解析某张附件,"放大看那张图表"这类追问无需重新上传
- **可嵌入、可无头** — webchat 组件升级为 Web/API 接入面,支持多会话与按终端用户身份(`endUserId`)隔离记忆
- **真正可读的 Wiki** — 阅读与管理分离、统一的 Sources 标签页(按知识库自动同步)、可点击的跨库 `[[wikilinks]]`
- **高负载更稳** — MCP 连接自愈 · interleaved-thinking 模型的工具调用恢复 · 计划执行的证据闸门
## 参与贡献
```bash
git clone https://github.com/matevip/mateclaw.git
git clone https://github.com/mateaix/mateclaw.git
cd mateclaw
cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && pnpm install && pnpm dev

View File

@ -92,6 +92,30 @@ services:
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-}
# SSRF / TLS relaxations for isolated LAN / on-prem deployments.
# Both default to false (strict mode, public-internet safe).
# The .env file uses the PLAYWRIGHT_* prefix (component-oriented naming,
# not product-oriented) — here we translate to the MATECLAW_BROWSER_*
# container env that Spring Boot relaxed-binding maps to BrowserProperties.
# - PLAYWRIGHT_ALLOW_PRIVATE_NETWORK=true: allow loopback / private / link-local
# addresses through the browser SSRF guard. Cloud-metadata endpoints stay
# blocked. Turn on when the agent must drive http://192.168.x.x:port style
# internal services and has no path to the public internet.
# - PLAYWRIGHT_IGNORE_HTTPS_ERRORS=true: ignore HTTPS certificate errors.
# Auto-enables --ignore-certificate-errors at the Chromium command line
# when ALLOW_PRIVATE_NETWORK is also true (so CDP-attached external
# browsers benefit too). Leave false on internet-facing deployments.
MATECLAW_BROWSER_ALLOW_PRIVATE_NETWORK: ${PLAYWRIGHT_ALLOW_PRIVATE_NETWORK:-false}
MATECLAW_BROWSER_IGNORE_HTTPS_ERRORS: ${PLAYWRIGHT_IGNORE_HTTPS_ERRORS:-false}
# Playwright action / navigation timeouts (seconds). Increase for slow
# LAN or large-page scenarios. Defaults match Playwright's own (30s).
MATECLAW_BROWSER_DEFAULT_TIMEOUT_SECONDS: ${PLAYWRIGHT_DEFAULT_TIMEOUT_SECONDS:-30}
MATECLAW_BROWSER_DEFAULT_NAVIGATION_TIMEOUT_SECONDS: ${PLAYWRIGHT_NAVIGATION_TIMEOUT_SECONDS:-30}
# Hard cap on the textual snapshot returned by action=snapshot. Content
# beyond this length is dropped with a truncated:true flag and a hint to
# retry with selector. Results > framework spill threshold (~8000 chars)
# are further spilt to disk by ToolResultStorage.
MATECLAW_BROWSER_SNAPSHOT_MAX_LENGTH: ${PLAYWRIGHT_SNAPSHOT_MAX_LENGTH:-20000}
# OAuth 模式默认保持 autolocalhost 访问走 LOCALIP/域名访问走 DEVICE_CODE。
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-}

View File

@ -0,0 +1,15 @@
# macOS 代码签名与公证
# 本地构建推荐不设 CSC_LINK让 electron-builder 自动从钥匙串发现证书
# CSC_LINK=/path/to/developer_id_application.p12 # CI/CD 专用
# CSC_KEY_PASSWORD= # CI/CD 专用
APPLE_ID=your@apple.id
APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
APPLE_TEAM_ID=XXXXXXXXXX
# GitHub Releases 发布
GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Windows 代码签名(可选)
# WIN_CSC_LINK=/path/to/windows-cert.pfx
# WIN_CSC_KEY_PASSWORD=

33
mateclaw-desktop/.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
dist-electron/
release/
# Resources (downloaded/generated, not committed)
resources/jre/
resources/app.jar
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Runtime data (H2 database created during dev testing)
data/
# Environment
.env
.env.local

View File

@ -0,0 +1,275 @@
# macOS 代码签名证书操作指南
本文档详细说明如何创建、导出和配置 macOS **Developer ID Application** 证书,用于 MateClaw Desktop 的签名与公证。
---
## 前置条件
- [Apple Developer Program](https://developer.apple.com/programs/) 会员($99/年)
- macOS 系统(需要钥匙串访问生成密钥对)
## Step 1: 撤销旧证书(如有)
如果本地证书已过期或私钥丢失,需先撤销线上旧证书:
1. 登录 https://developer.apple.com/account/resources/certificates/list
2. 找到旧的 `Developer ID Application` 证书 → 点击进入详情
3. 点击 **Revoke** → 确认撤销
4. 回到本地 **钥匙串访问** → 删除过期证书(右键 → 删除)
## Step 2: 生成 CSR证书签名请求
CSR 会在本地生成密钥对(私钥留在钥匙串,公钥随 CSR 提交给 Apple
1. 打开 **钥匙串访问**
2. 菜单栏 → 钥匙串访问 → **证书助理** → **从证书颁发机构请求证书…**
3. 填写:
- **用户电子邮件地址**:你的 Apple ID 邮箱
- **常用名称**:与开发者账号一致(如 `ZHANFU XU`
- **CA 电子邮件地址**:留空
- **请求是**:选择 **存储到磁盘**
4. 保存 `CertificateSigningRequest.certSigningRequest` 到桌面
## Step 3: 创建 Developer ID Application 证书
1. 访问 https://developer.apple.com/account/resources/certificates/add
2. 在 **Software** 分类下,选择 **Developer ID Application**
3. 点击 **Continue**
4. 上传 Step 2 保存的 CSR 文件
5. 点击 **Continue****Download** 下载 `developerID_application.cer`
6. **双击**下载的 `.cer` 文件 → 自动安装到钥匙串
## Step 4: 验证安装
```bash
security find-identity -v -p codesigning | grep "Developer ID Application"
```
应输出类似:
```
"Developer ID Application: ZHANFU XU (MR97WAD978)"
```
在钥匙串访问 → 登录 → **我的证书**中,展开该证书应能看到关联的**私钥**(左侧三角展开)。
## Step 5: 导出 .p12 文件
`.p12` 文件包含证书 + 私钥,是 `electron-builder` 签名所需的文件。
1. 钥匙串访问 → 登录 → **我的证书**
2. 找到 `Developer ID Application: Your Name (TEAMID)`
3. 点左侧三角**展开**,确认包含私钥
4. **右键证书**(不是私钥)→ **导出…**
5. 格式选择:**个人信息交换 (.p12)**
6. 保存为 `developer_id_application.p12`
7. 设置一个强密码(后续用作 `CSC_KEY_PASSWORD` 环境变量)
> **安全提醒**`.p12` 文件包含私钥,绝不要提交到 Git 仓库。
## Step 6: 创建 App 专用密码(公证用)
Apple 公证notarization需要通过 Apple ID 验证身份,使用 App 专用密码代替账号密码。
1. 访问 https://appleid.apple.com/account/manage
2. 登录 → **登录与安全****App 专用密码** → **生成**
3. 标签填:`mateclaw-notarize`
4. 记录生成的密码(格式如 `xxxx-xxxx-xxxx-xxxx`
## Step 7: 查找 Team ID
```bash
security find-identity -v -p codesigning | grep "Developer ID Application"
```
输出中括号内的 10 位字母数字即为 Team ID`MR97WAD978`)。
## Step 8: 配置环境变量并构建
### 方式 A本地钥匙串自动发现推荐
证书已安装到本地钥匙串时,**不需要设置 `CSC_LINK``CSC_KEY_PASSWORD`**electron-builder 会自动从钥匙串中发现 Developer ID Application 证书。
```bash
cd mateclaw-desktop
# 只需设置公证相关变量
export APPLE_ID="your@apple.id"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="XXXXXXXXXX"
# 执行签名+公证构建
bash scripts/build-all-platforms.sh --mac-only
```
> **为什么推荐这种方式?** 设置 `CSC_LINK`electron-builder 会创建一个临时钥匙串来导入 `.p12` 文件,这可能导致签名过程静默卡死(无报错)。直接使用本地钥匙串可以避免此问题。
### 方式 B指定 .p12 文件CI/CD 专用)
在 CI/CD 环境或证书不在本地钥匙串时,需要通过环境变量指定 `.p12` 文件:
```bash
cd mateclaw-desktop
export CSC_LINK="$HOME/developer_id_application.p12"
export CSC_KEY_PASSWORD="你的p12密码"
export APPLE_ID="your@apple.id"
export APPLE_APP_SPECIFIC_PASSWORD="xxxx-xxxx-xxxx-xxxx"
export APPLE_TEAM_ID="XXXXXXXXXX"
bash scripts/build-all-platforms.sh --mac-only
```
> **注意**`CSC_KEY_PASSWORD` 中如有特殊字符(`$`、`!`、`"`、`` ` ``),必须用**单引号**包裹,如 `export CSC_KEY_PASSWORD='pa$$w0rd!'`。
### GitHub Actions Secrets
`.p12` 文件 Base64 编码后存为 GitHub Secret
```bash
base64 -i developer_id_application.p12 | pbcopy
# 粘贴到 GitHub Secret: MAC_CSC_LINK
```
| GitHub Secret | 值 |
|---|---|
| `MAC_CSC_LINK` | `.p12` 的 Base64 内容 |
| `MAC_CSC_KEY_PASSWORD` | `.p12` 密码 |
| `APPLE_ID` | Apple ID 邮箱 |
| `APPLE_APP_SPECIFIC_PASSWORD` | App 专用密码 |
| `APPLE_TEAM_ID` | 10 位 Team ID |
## Step 9: 验证签名和公证
构建完成后验证:
```bash
# 验证代码签名
codesign --verify --deep --strict release/mac-arm64/MateClaw.app
# 验证 Gatekeeper 公证状态
spctl --assess --type execute --verbose release/mac-arm64/MateClaw.app
# 期望输出: accepted, source=Developer ID
# 验证 DMG
spctl --assess --type open --context context:primary-signature release/MateClaw-*.dmg
```
---
## 故障排查
### 签名卡死(无报错)
**现象**:构建停在 `signing` 行不动,`ps aux | grep codesign` 无进程或进程短暂出现后消失。
**原因**:设置了 `CSC_LINK`electron-builder 会创建临时钥匙串导入 `.p12`,临时钥匙串的访问权限可能导致 `codesign` 静默卡死。
**解决**
```bash
# 方案一(推荐):取消 CSC_LINK使用本地钥匙串自动发现
unset CSC_LINK
unset CSC_KEY_PASSWORD
# 方案二:授权 codesign 访问钥匙串
security unlock-keychain -p "你的Mac登录密码" ~/Library/Keychains/login.keychain-db
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "你的Mac登录密码" ~/Library/Keychains/login.keychain-db
```
### `Permission denied` (classes.jsa)
**现象**`codesign` 报错 `Permission denied`,通常指向 JRE 中的 `classes.jsa` 文件。
**原因**:下载的 Adoptium JRE 中部分文件是只读的,`codesign --force` 需要写权限。
**解决**`download-jre.sh` 已在解压后自动执行 `chmod -R u+w`。如果使用旧版 JRE手动修复
```bash
# 删除旧 JRE 重新下载(推荐)
rm -rf resources/jre/mac-arm64 resources/jre/mac-x64
npm run setup:jre
# 或手动修复权限
chmod -R u+w resources/jre/
```
### `MAC verification failed` (wrong password)
**现象**`SecKeychainItemImport: MAC verification failed during PKCS12 import (wrong password?)`
**原因**`CSC_KEY_PASSWORD` 与导出 `.p12` 时设置的密码不匹配。
**解决**
```bash
# 验证密码是否正确
openssl pkcs12 -in ~/developer_id_application.p12 -nokeys -passin pass:"你的密码"
# 如果报错 mac verify failure重新导出 .p12
# 钥匙串访问 → 我的证书 → 右键 Developer ID Application → 导出 → 重新设置密码
# 注意特殊字符需用单引号包裹
export CSC_KEY_PASSWORD='pa$$w0rd!'
```
### 公证上传超时 (deadlineExceeded)
**现象**`HTTPClientError.deadlineExceeded`,公证上传到 Apple S3 超时。
**原因**:网络到 Apple 服务器不稳定700MB+ 的应用上传容易超时。
**解决**:先跳过公证构建,再用 `xcrun notarytool` 手动公证(支持断点续传,超时容忍度更高):
```bash
# 1. 去掉公证变量,仅签名
unset APPLE_ID
unset APPLE_APP_SPECIFIC_PASSWORD
unset APPLE_TEAM_ID
bash scripts/build-all-platforms.sh --mac-only
# 2. 手动公证
xcrun notarytool submit release/MateClaw_1.0.0_arm64.zip \
--apple-id "your@apple.id" \
--password "app专用密码" \
--team-id "XXXXXXXXXX" \
--wait
xcrun notarytool submit release/MateClaw_1.0.0_x64.zip \
--apple-id "your@apple.id" \
--password "app专用密码" \
--team-id "XXXXXXXXXX" \
--wait
# 3. 装订公证票据到 DMG
xcrun stapler staple release/MateClaw_1.0.0_arm64.dmg
xcrun stapler staple release/MateClaw_1.0.0_x64.dmg
```
---
## 常见问题
### 证书过期了怎么办?
Developer ID Application 证书有效期 **5 年**。过期后需重复 Step 1 ~ Step 5 重新创建。Apple 会在自动轮换日期前通过邮件提醒。
### 导出 .p12 时没有"导出"选项?
说明本地钥匙串中没有该证书对应的私钥。私钥只存在于当初生成 CSR 的那台 Mac 上。解决方案:
- **方案 A**:在原 Mac 上导出 `.p12`,再导入到当前 Mac
- **方案 B**:撤销旧证书,在当前 Mac 重新创建Step 1 ~ Step 5
### 签名很慢正常吗?
正常。700MB+ 的应用(含 JRE + Electron Framework签名需要 **15~30 分钟**,公证上传+审核需要额外 **5~15 分钟**。可以用以下命令监控签名进度:
```bash
watch -n 2 'ps aux | grep codesign | grep -v grep'
# macOS 需先安装brew install watch
```
### 跳过签名(开发测试用)
```bash
export CSC_IDENTITY_AUTO_DISCOVERY=false
bash scripts/build-all-platforms.sh --mac-only
```
未签名的应用无法使用自动升级功能macOS 用户需手动下载 DMG 安装。

432
mateclaw-desktop/README.md Normal file
View File

@ -0,0 +1,432 @@
# MateClaw Desktop
MateClaw 的桌面客户端,基于 Electron 构建,自动集成 JRE 21 和后端服务,实现双击即用。
## 架构
```
Electron Shell
├── Splash Screen (Vue 3) ← 启动加载界面
├── Bundled JRE 21 ← 自带 Java 运行时
├── mateclaw-server.jar ← Spring Boot 后端 + Vue 前端
└── BrowserWindow → localhost:18088
```
**启动流程**: Electron 启动 → 显示 Splash → 用内置 JRE 启动 JAR → 等待后端就绪 → 加载主界面
## 快速开始
### 前置要求
- Node.js 18+
- pnpm (前端构建)
- Maven 3.9+ (后端构建)
- Java 21+ (仅构建时需要,运行时使用内置 JRE)
### 开发模式
```bash
# 1. 安装依赖
npm install
# 2. 构建后端 JAR包含前端资源
npm run setup:jar
# 3. 下载 JRE当前平台
npm run setup:jre
# 4. 启动开发模式
npm run dev
```
### 打包发布
```bash
# macOS (.dmg)
npm run package:mac
# Windows (.exe)
npm run package:win
# 全平台
npm run package:all
```
输出在 `release/` 目录。
## 目录结构
```
mateclaw-desktop/
├── electron/main/ # Electron 主进程Java 生命周期管理)
├── electron/preload/ # 预加载脚本(安全 IPC 桥接)
├── src/ # Splash ScreenVue 3 加载页面)
├── build/ # 应用图标和 macOS entitlements
├── scripts/ # 构建脚本
│ ├── download-jre.sh # 下载 Adoptium JRE 21
│ └── build.sh # 构建前端 + 后端 JAR
└── resources/ # 运行时资源JRE + JAR不提交到 Git
```
## 环境变量
桌面应用**不需要任何环境变量**就能启动——LLM 供应商 Key 在 UI 里加。
以下是可选的环境变量(桌面应用会继承系统环境):
| 变量 | 必须 | 说明 |
|------|------|------|
| `SERPER_API_KEY` | ❌ | Google Serper 搜索 API搜索工具暂未迁到 UI |
| `TAVILY_API_KEY` | ❌ | Tavily 搜索 API |
> 💡 DashScope / OpenAI / Anthropic / DeepSeek / Kimi / Ollama 等 LLM 供应商 Key 启动后在「设置 → 模型 → 添加供应商」里粘进去,加密存到本地 H2 数据库。
## 自动升级
应用内置 `electron-updater` 自动升级,更新产物托管在 [GitHub Releases](https://github.com/matevip/mateclaw/releases)。
**升级流程**:启动时检查 → Splash Screen 底部通知 → 用户点击下载 → 下载完成点击重启 → 自动停止 Java 后端 → 安装新版本
| 平台 | 更新包格式 | 元数据文件 | 签名要求 |
|------|-----------|-----------|---------|
| Windows | NSIS `.exe` | `latest.yml` | 可选(不签名会触发 SmartScreen |
| macOS | `.zip` | `latest-mac.yml` | **必须签名+公证**(否则只能手动 DMG 安装) |
## 发布操作手册
### 第一步:配置 GitHub Token
`electron-builder` 使用 `github` provider需要 GitHub Personal Access Token 来创建 Release 并上传产物。
1. 前往 https://github.com/settings/tokens → **Generate new token (classic)**
2. 勾选 `repo` 权限(需要完整 repo 访问才能创建 Release
3. 生成后保存 token
```bash
# 设置环境变量(建议写入 ~/.zshrc 或 CI Secret
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
### 第二步:版本号管理
每次发布前必须更新 `package.json` 中的 `version` 字段。`electron-updater` 客户端通过对比本地版本号和 `latest.yml` 中的版本号来判断是否有更新。
```bash
# 编辑版本号
cd mateclaw-desktop
vim package.json # 修改 "version": "1.0.0" → "1.1.0"
```
版本号遵循 [SemVer](https://semver.org/)
- 修复 bug → `1.0.0``1.0.1`
- 新功能 → `1.0.0``1.1.0`
- 破坏性变更 → `1.0.0``2.0.0`
### 第三步:构建并发布
```bash
cd mateclaw-desktop
# 一键构建全平台 + 自动上传到 GitHub Releases
export GH_TOKEN=ghp_xxxxxxxxxxxx
bash scripts/build-all-platforms.sh --all --publish=always
```
这会自动:
1. 构建后端 JAR
2. 下载各平台 JRE
3. 编译前端
4. 打包 macOSDMG + ZIP和 WindowsNSIS
5. 生成 `latest.yml``latest-mac.yml`
6. 创建 GitHub Draft Release 并上传所有产物
完成后前往 https://github.com/matevip/mateclaw/releases ,找到 Draft Release
- 填写 Release Notes更新说明
- 点击 **Publish release** 正式发布
也可以仅构建特定平台:
```bash
bash scripts/build-all-platforms.sh --mac-only --publish=always # 仅 macOS
bash scripts/build-all-platforms.sh --win-only --publish=always # 仅 Windows
```
### 第四步(可选):手动发布
如果不想用 `--publish=always` 自动上传:
```bash
# 1. 仅构建,不上传
bash scripts/build-all-platforms.sh --all
# 2. 查看生成的产物
ls -la release/
# 产物包括:
# MateClaw_1.1.0_arm64.dmg macOS ARM64 安装包
# MateClaw_1.1.0_x64.dmg macOS x64 安装包
# MateClaw_1.1.0_arm64.zip macOS ARM64 更新包(升级用)
# MateClaw_1.1.0_x64.zip macOS x64 更新包(升级用)
# MateClaw_1.1.0_x64_Setup.exe Windows x64 安装包
# MateClaw_1.1.0_arm64_Setup.exe Windows ARM64 安装包
# MateClaw_1.1.0_*.blockmap 差分下载支持文件
# latest.yml Windows 更新元数据
# latest-mac.yml macOS 更新元数据
# 3. 在 GitHub 手动创建 Release
# Tag: v1.1.0
# 上传 release/ 目录中的所有 .exe .zip .dmg .blockmap .yml 文件
```
> **注意**`latest.yml` 和 `latest-mac.yml` 必须上传,客户端靠它们检测新版本。
---
## macOS 代码签名与公证
macOS 自动升级**必须**签名+公证,否则 Gatekeeper 会阻止更新后的应用启动。未签名时 macOS 用户只能手动下载 DMG 安装。
> **证书创建完整指南**:首次配置或证书过期时,参见 [CODESIGNING.md](./CODESIGNING.md)(含 CSR 生成、证书创建、.p12 导出、公证配置等完整步骤)。
### 本地签名构建(推荐)
证书安装到本地钥匙串后,**不需要设置 `CSC_LINK`**electron-builder 会自动发现证书:
```bash
# 只需设置公证相关变量
export APPLE_ID=your@apple.id
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx # 在 appleid.apple.com 生成
export APPLE_TEAM_ID=XXXXXXXXXX # 10 位团队 ID
bash scripts/build-all-platforms.sh --mac-only --publish=always
```
`electron-builder` 会自动完成签名 → 公证 → 装订staple→ 上传。
> **注意**:不要设置 `CSC_LINK` 环境变量,否则 electron-builder 会创建临时钥匙串,可能导致签名卡死。详见 [CODESIGNING.md](./CODESIGNING.md) 故障排查章节。
### CI/CD 签名构建
CI 环境无本地钥匙串,需通过 `CSC_LINK` 指定 `.p12` 文件Base64 编码存入 GitHub Secret
```bash
export CSC_LINK=base64_encoded_p12_content
export CSC_KEY_PASSWORD=your_certificate_password
export APPLE_ID=your@apple.id
export APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx
export APPLE_TEAM_ID=XXXXXXXXXX
bash scripts/build-all-platforms.sh --mac-only --publish=always
```
### 公证超时处理
如果公证上传超时(`deadlineExceeded`),可先跳过公证构建,再用 `xcrun notarytool` 手动公证:
```bash
# 1. 去掉公证变量,仅签名出包
unset APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID
bash scripts/build-all-platforms.sh --mac-only
# 2. 手动公证(支持断点续传)
xcrun notarytool submit release/MateClaw_*.zip \
--apple-id your@apple.id \
--password "app专用密码" \
--team-id XXXXXXXXXX \
--wait
# 3. 装订公证票据
xcrun stapler staple release/MateClaw_*.dmg
```
### 跳过签名(开发/测试用)
```bash
export CSC_IDENTITY_AUTO_DISCOVERY=false
bash scripts/build-all-platforms.sh --mac-only
```
---
## Windows 代码签名(可选)
未签名的 Windows 安装包会触发 SmartScreen 警告("Windows 已保护你的电脑"),用户可以点击"仍要运行"。签名可消除此警告。
### EV 代码签名证书
推荐使用 EVExtended Validation证书可立即获得 SmartScreen 信誉,无需积累安装量。
证书提供商(参考):
- [DigiCert](https://www.digicert.com/signing/code-signing-certificates) — 需硬件 token
- [SSL.com](https://www.ssl.com/certificates/ev-code-signing/) — 支持云签名
- [Certum](https://shop.certum.eu/code-signing-certificates/) — 较便宜的选项
### 配置
```bash
# PFX 文件签名
export WIN_CSC_LINK=/path/to/windows-cert.pfx
export WIN_CSC_KEY_PASSWORD=password
# 或使用 signtool需要硬件 token 的 EV 证书)
# 在 electron-builder.json 的 win 节中配置:
# "signingHashAlgorithms": ["sha256"],
# "sign": "./scripts/sign.js"
```
---
## CI/CD 自动发布GitHub Actions
以下为 GitHub Actions 完整示例,实现 Git tag 推送时自动构建全平台并发布:
```yaml
# .github/workflows/release.yml
name: Release Desktop
on:
push:
tags:
- 'v*' # 推送 v1.0.0 等 tag 时触发
jobs:
release-mac:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Build and publish macOS
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
cd mateclaw-desktop
npm install
bash scripts/build-all-platforms.sh --mac-only --publish=always
release-win:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Build and publish Windows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd mateclaw-desktop
npm install
bash scripts/build-all-platforms.sh --win-only --publish=always
```
### 配置 CI Secrets
在 GitHub 仓库 → Settings → Secrets and variables → Actions → New repository secret
| Secret 名称 | 说明 |
|-------------|------|
| `MAC_CSC_LINK` | macOS 签名证书 .p12 的 Base64 编码:`base64 -i cert.p12 \| tr -d '\n'` |
| `MAC_CSC_KEY_PASSWORD` | .p12 证书密码 |
| `APPLE_ID` | Apple ID 邮箱 |
| `APPLE_APP_SPECIFIC_PASSWORD` | App 专用密码 |
| `APPLE_TEAM_ID` | 10 位开发者团队 ID |
| `GITHUB_TOKEN` | 自动提供,无需手动配置 |
### 发布流程CI 方式)
```bash
# 1. 更新版本号
cd mateclaw-desktop
vim package.json # "version": "1.1.0"
# 2. 提交并打 tag
git add -A && git commit -m "release: v1.1.0"
git tag v1.1.0
git push origin main --tags
# 3. GitHub Actions 自动构建并创建 Draft Release
# 4. 前往 GitHub Releases 确认并发布
```
---
## 本地测试自动升级
### 方式一:开发模式 + dev-app-update.yml
在开发模式下测试 updater 流程(不需要打包):
```bash
# 1. 在 mateclaw-desktop/ 根目录创建 dev-app-update.yml
cat > dev-app-update.yml << 'EOF'
provider: generic
url: http://localhost:8080/
EOF
# 2. 构建一个"新版本"的产物
# 先把 package.json 的 version 改为更高版本(如 9.9.9
# 然后构建:
npm run build
npx electron-builder --mac --publish=never # 或 --win
# 构建完成后把 version 改回原值
# 3. 启动本地文件服务器
cd release && python3 -m http.server 8080
# 4. 另一个终端启动开发模式
cd mateclaw-desktop && npm run dev
# updater 会从 localhost:8080 检查更新并发现"新版本"
```
> 开发模式下 `quitAndInstall()` 不会真正安装,但可验证检查→发现→下载的完整流程。
### 方式二:打包后端到端测试(推荐)
```bash
# 1. 打包 v1.0.0 并安装到系统
# 2. 修改 package.json version 为 v1.1.0
# 3. 重新构建,产物上传到 GitHub Release或本地服务器
# 4. 启动已安装的 v1.0.0,观察完整升级流程:
# 检查更新 → 发现 v1.1.0 → 下载 → 重启安装
```
---
## 发布检查单
- [ ] `package.json` 版本号已更新
- [ ] 后端 JAR 已构建(`npm run setup:jar`
- [ ] 各平台 JRE 已下载
- [ ] `npm run build` 编译通过
- [ ] `GH_TOKEN` 环境变量已设置
- [ ] macOS 签名证书环境变量已设置(若需要签名)
- [ ] `bash scripts/build-all-platforms.sh --all --publish=always` 执行成功
- [ ] GitHub Draft Release 已确认发布
- [ ] 在旧版本应用上验证升级通知正常
## 技术栈
- **Electron** - 桌面应用框架
- **Vite + Vue 3** - Splash Screen 构建
- **electron-builder** + **electron-updater** - 跨平台打包与自动升级
- **Adoptium JRE 21** - 内置 Java 运行时

View File

@ -0,0 +1,67 @@
# MateClaw v1.0.101
## What's New
### Mobile Responsive UI
- Sidebar transforms to slide-in drawer with hamburger menu on mobile (<=768px)
- Conversation panel becomes a toggleable overlay on mobile
- Welcome screen centers properly with auto text wrapping, single-column suggestion cards
- Chat header auto-simplifies: icon-only agent badge, adaptive model selector
- Reduced padding/gaps across all chat components for mobile screens
### Drag & Drop File Upload
- Drag-and-drop files and folders directly into the chat area
- Electron: directory references via local path; Web: recursive file collection and upload
### Multi-Agent Collaboration
- `DelegateAgentTool` for agent-to-agent task delegation
### LLM Context Awareness
- Current datetime automatically injected into LLM context for time-aware responses
### MCP Server
- Pre-configured GitHub MCP Server in seed data (ready to use out of the box)
### Ollama Auto-Discovery
- Auto-detect local Ollama instance on startup
- Pre-configured 6 popular local models (Qwen3, Llama, DeepSeek, Gemma, Phi, Mistral)
- Local providers sorted first in model management UI
### Model Management Enhancements
- Provider list grouped by Local / Cloud with section headers
- Zhipu AI models updated to GLM-5 series (GLM-5-Turbo / GLM-5V-Turbo / GLM-5 / GLM-5.1)
- 20+ model providers supported
### API Docs
- Replaced Knife4j with SpringDoc OpenAPI 2.8.16 (`/swagger-ui.html`)
## Bug Fixes
- **Security**: Fixed SPA frontend route refresh returning 401
- **i18n**: Window title dynamically set from language pack instead of hardcoded
- **i18n**: Fixed 5 hardcoded Chinese strings in approval bar
- **i18n**: Fixed hardcoded time formatting (locale-aware now)
- **Guard**: Aligned tool guard rule names with runtime `@Tool` method names
- **LLM**: Fixed Zhipu connection test 404
- **UI**: Fixed suggestion cards grid misalignment with longer text
- **Upload**: File upload size limit raised to 100MB
## Download
| Platform | File | Note |
|----------|------|------|
| macOS Apple Silicon | `MateClaw_1.0.101_arm64.dmg` | M1 / M2 / M3 / M4 / M5 |
| macOS Intel | `MateClaw_1.0.101_x64.dmg` | Intel Mac |
| Windows | `MateClaw_1.0.101_Setup.exe` | Windows 10/11 (x64+arm64) |
| Windows x64 | `MateClaw_1.0.101_x64_Setup.exe` | Windows 10/11 x64 |
| Windows ARM64 | `MateClaw_1.0.101_arm64_Setup.exe` | Windows ARM64 |
> zip / blockmap / yml files are for auto-update support.
## Links
- GitHub: https://github.com/matevip/mateclaw
- Gitee: https://gitee.com/matevip_admin/mateclaw
- Documentation: https://mateclaw.com
**Full Changelog**: https://github.com/matevip/mateclaw/compare/v1.0.0...v1.0.101

View File

@ -0,0 +1,9 @@
{
"name": "MateClaw",
"tagline": "AI Personal Assistant",
"team": "MateClaw Team",
"copyright": "Copyright © 2026 MateClaw Team",
"appId": "vip.mate.mateclaw",
"githubUrl": "https://github.com/matevip/mateclaw",
"logoFile": "mateclaw_logo_s.png"
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -0,0 +1,139 @@
/**
* electron-builder.cjs Dynamic build configuration.
*
* Two packaging modes are controlled by the BUILD_MODE environment variable:
*
* BUILD_MODE=local (default) Full build: bundles the embedded JRE and
* Spring Boot JAR so the desktop app can run a
* local backend. Original behavior.
*
* BUILD_MODE=remote Lightweight build: omits the JRE/JAR
* resources (~530 MB smaller on macOS). The app
* can only connect to a remote server the
* "local" connection option is hidden in the
* splash UI.
*
* Branding is controlled by branding.config.json or BRAND_* env vars.
* See scripts/branding.cjs for details.
*
* Usage:
* BUILD_MODE=remote npx electron-builder --mac
* npm run package:mac:remote
* BRAND_NAME=MyAI npm run package:mac:remote
*/
'use strict'
const { loadBrandConfig } = require('./scripts/branding.cjs')
const mode = process.env.BUILD_MODE === 'remote' ? 'remote' : 'local'
const brand = loadBrandConfig(__dirname)
// Derive a short slug from the brand name for artifact file names.
// "MyAI" → "MyAI", "Cool App" → "Cool_App"
const brandSlug = brand.name.replace(/\s+/g, '_')
// Parse GitHub URL for publish config (owner/repo)
let githubOwner = 'matevip'
let githubRepo = 'mateclaw'
const ghMatch = brand.githubUrl.match(/github\.com\/([^/]+)\/([^/]+)/)
if (ghMatch) {
githubOwner = ghMatch[1]
githubRepo = ghMatch[2]
}
/** @type {import('electron-builder').Configuration} */
const config = {
appId: brand.appId,
productName: brand.name,
copyright: brand.copyright,
directories: { output: 'release' },
publish: [
{
provider: 'github',
owner: githubOwner,
repo: githubRepo,
},
],
files: ['dist-electron', 'dist'],
afterPack: 'scripts/trim-playwright-driver.cjs',
// extraResources: only bundle JRE + JAR in local mode.
// In remote mode this array is empty — the packaged app contains only the
// Electron + Vue shell, cutting ~530 MB from the installer.
extraResources:
mode === 'local'
? [
{
from: 'resources/jre/${os}-${arch}/',
to: 'jre/',
filter: ['**/*'],
},
{
from: 'resources/app.jar',
to: 'app.jar',
},
]
: [],
mac: {
category: 'public.app-category.productivity',
target: [
{ target: 'dmg', arch: ['arm64', 'x64'] },
{ target: 'zip', arch: ['arm64', 'x64'] },
],
icon: 'build/icon.icns',
hardenedRuntime: true,
gatekeeperAssess: false,
entitlements: 'build/entitlements.mac.plist',
entitlementsInherit: 'build/entitlements.mac.inherit.plist',
// Differentiate installers so users can tell local vs remote builds apart.
artifactName:
mode === 'remote'
? `${brandSlug}_Remote_${'$'}{version}_${'$'}{arch}.${'$'}{ext}`
: `${brandSlug}_${'$'}{version}_${'$'}{arch}.${'$'}{ext}`,
},
dmg: {
contents: [
{ x: 130, y: 220 },
{ x: 410, y: 220, type: 'link', path: '/Applications' },
],
title: `${brand.name} ${'$'}{version}`,
},
win: {
target: [
{ target: 'nsis', arch: 'x64' },
{ target: 'nsis', arch: 'arm64' },
],
icon: 'build/icon.ico',
artifactName:
mode === 'remote'
? `${brandSlug}_Remote_${'$'}{version}_${'$'}{arch}_Setup.${'$'}{ext}`
: `${brandSlug}_${'$'}{version}_${'$'}{arch}_Setup.${'$'}{ext}`,
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
deleteAppDataOnUninstall: false,
installerIcon: 'build/icon.ico',
uninstallerIcon: 'build/icon.ico',
installerHeaderIcon: 'build/icon.ico',
createDesktopShortcut: true,
createStartMenuShortcut: true,
},
linux: {
target: ['AppImage'],
icon: 'build/icon.png',
category: 'Utility',
artifactName:
mode === 'remote'
? `${brandSlug}_Remote_${'$'}{version}.${'$'}{ext}`
: `${brandSlug}_${'$'}{version}.${'$'}{ext}`,
},
}
module.exports = config

View File

@ -0,0 +1,87 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync } from 'fs'
// ─── Connection configuration ────────────────────────────────────────────────
// Persists how the desktop shell reaches its backend: either an embedded local
// JVM ("local") or a centrally deployed remote server ("remote"). Stored as a
// small JSON file in userData so no extra dependency is required.
export type ConnectionMode = 'local' | 'remote'
export interface RemoteServer {
url: string
name?: string
lastUsed?: number
}
export interface ConnectionConfig {
// null = no choice made yet (first run → show the connection chooser)
mode: ConnectionMode | null
remoteUrl: string
servers: RemoteServer[]
}
const DEFAULT_CONFIG: ConnectionConfig = {
mode: null,
remoteUrl: '',
servers: [],
}
function getConfigPath(): string {
return join(app.getPath('userData'), 'connection.json')
}
export function loadConfig(): ConnectionConfig {
try {
const path = getConfigPath()
if (!existsSync(path)) return { ...DEFAULT_CONFIG }
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<ConnectionConfig>
return {
...DEFAULT_CONFIG,
...raw,
servers: Array.isArray(raw.servers) ? raw.servers : [],
}
} catch (err) {
console.error('[MateClaw] Failed to read connection config:', err)
return { ...DEFAULT_CONFIG }
}
}
export function saveConfig(patch: Partial<ConnectionConfig>): ConnectionConfig {
const merged: ConnectionConfig = { ...loadConfig(), ...patch }
try {
writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8')
} catch (err) {
console.error('[MateClaw] Failed to write connection config:', err)
}
return merged
}
// Normalize a user-entered server URL: trim, default to https when no scheme is
// given, and strip a trailing slash. Returns null when the input cannot form a
// valid http(s) URL.
export function normalizeServerUrl(input: string): string | null {
const trimmed = (input || '').trim()
if (!trimmed) return null
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Drop a trailing slash on the path-less root so URLs compare cleanly.
return withScheme.replace(/\/+$/, '')
} catch {
return null
}
}
// Record a successful remote connection in the most-recently-used server list,
// de-duplicating by URL and capping the history length.
export function recordServer(url: string, name?: string): ConnectionConfig {
const cfg = loadConfig()
const now = Date.now()
const without = cfg.servers.filter((s) => s.url !== url)
const servers: RemoteServer[] = [{ url, name, lastUsed: now }, ...without].slice(0, 8)
return saveConfig({ servers })
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,205 @@
import WebSocket from 'ws'
import {
readFile,
writeFile,
editFile,
listDir,
statPath,
executeShell,
LocalToolError,
} from './localToolsExecutor'
import { requestApproval, clearApprovalCache } from './localToolsApproval'
// ─── Desktop → server local-tool tunnel (client side) ────────────────────────
// Opens a WebSocket to the backend's /api/v1/desktop/ws endpoint, advertises the
// local tool capabilities, and services "call" frames the server forwards when a
// cloud agent invokes a local_* tool. File/shell work runs through the executor
// (whitelist-enforced) and approval (native dialog) modules. Reconnects with
// backoff while the desktop is meant to be online.
const PROTOCOL_VERSION = 1
const CAPABILITIES = ['read', 'list', 'stat', 'write', 'edit', 'shell']
const RECONNECT_MIN_MS = 2000
const RECONNECT_MAX_MS = 30_000
type TokenProvider = () => Promise<string | null>
type UrlProvider = () => string
export class LocalBridge {
private ws: WebSocket | null = null
private shouldRun = false
private reconnectDelay = RECONNECT_MIN_MS
private reconnectTimer: NodeJS.Timeout | null = null
constructor(
private readonly getBackendUrl: UrlProvider,
private readonly getToken: TokenProvider
) {}
// Begin maintaining a connection. Safe to call repeatedly.
start(): void {
if (this.shouldRun) return
this.shouldRun = true
void this.connect()
}
// Tear down the tunnel and stop reconnecting (e.g. on logout or app quit).
stop(): void {
this.shouldRun = false
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
clearApprovalCache()
if (this.ws) {
try {
this.ws.close()
} catch {
/* ignore */
}
this.ws = null
}
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN
}
private buildWsUrl(token: string): string | null {
const base = this.getBackendUrl()
if (!base) return null
const wsBase = base.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
return `${wsBase}/api/v1/desktop/ws?token=${encodeURIComponent(token)}`
}
private async connect(): Promise<void> {
if (!this.shouldRun) return
const token = await this.getToken()
if (!token) {
// Not logged in yet — retry shortly without escalating backoff.
this.scheduleReconnect(RECONNECT_MIN_MS)
return
}
const url = this.buildWsUrl(token)
if (!url) {
this.scheduleReconnect(RECONNECT_MIN_MS)
return
}
console.log('[LocalBridge] Connecting tunnel…')
// rejectUnauthorized:false mirrors the app's handling of enterprise
// self-signed certificates for remote servers the user chose to trust.
const ws = new WebSocket(url, { rejectUnauthorized: false })
this.ws = ws
ws.on('open', () => {
console.log('[LocalBridge] Tunnel connected')
this.reconnectDelay = RECONNECT_MIN_MS
this.send({
type: 'hello',
protocolVersion: PROTOCOL_VERSION,
capabilities: CAPABILITIES,
platform: process.platform,
})
})
ws.on('message', (raw: WebSocket.RawData) => {
void this.onMessage(raw.toString())
})
ws.on('close', () => {
console.log('[LocalBridge] Tunnel closed')
this.ws = null
if (this.shouldRun) this.scheduleReconnect(this.reconnectDelay)
})
ws.on('error', (err: Error) => {
console.warn('[LocalBridge] Tunnel error:', err.message)
// 'close' fires after 'error'; reconnect is scheduled there.
})
}
private scheduleReconnect(delay: number): void {
if (!this.shouldRun || this.reconnectTimer) return
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS)
void this.connect()
}, delay)
}
private send(obj: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(obj))
}
}
private async onMessage(text: string): Promise<void> {
let frame: any
try {
frame = JSON.parse(text)
} catch {
return
}
if (frame.type === 'hello-ack') {
if (frame.ok === false) console.warn('[LocalBridge] Handshake rejected:', frame.error)
return
}
if (frame.type === 'pong') return
if (frame.type !== 'call') return
const { id, method, params } = frame
try {
const data = await this.dispatch(method, params || {})
this.send({ type: 'result', id, ok: true, data })
} catch (e) {
const code = e instanceof LocalToolError ? e.code : 'ERROR'
const error = e instanceof Error ? e.message : String(e)
this.send({ type: 'result', id, ok: false, code, error })
}
}
private async dispatch(method: string, params: any): Promise<unknown> {
switch (method) {
case 'read_file':
return readFile(params.filePath, params.startLine, params.endLine)
case 'list_dir':
return listDir(params.dirPath)
case 'stat':
return statPath(params.path)
case 'write_file': {
await this.approveOrThrow('write_file', params.filePath,
`文件: ${params.filePath}\n\n内容预览:\n${preview(params.content)}`)
return writeFile(params.filePath, params.content)
}
case 'edit_file': {
await this.approveOrThrow('edit_file', params.filePath,
`文件: ${params.filePath}\n\n替换:\n- ${preview(params.oldText, 200)}\n+ ${preview(params.newText, 200)}`)
return editFile(params.filePath, params.oldText, params.newText, !!params.replaceAll)
}
case 'execute_shell': {
await this.approveOrThrow('execute_shell', params.command,
`命令:\n${params.command}`)
return executeShell(params.command, params.timeoutSeconds || 60)
}
default:
throw new LocalToolError('UNKNOWN_METHOD', `Unknown method: ${method}`)
}
}
private async approveOrThrow(
kind: 'write_file' | 'edit_file' | 'execute_shell',
subject: string,
detail: string
): Promise<void> {
const { approved } = await requestApproval({ kind, subject, detail })
if (!approved) throw new LocalToolError('DENIED', 'User denied')
}
}
function preview(text: string | undefined, max = 500): string {
const s = text ?? ''
return s.length > max ? `${s.slice(0, max)}\n…(${s.length - max} more chars)` : s
}

View File

@ -0,0 +1,82 @@
import { dialog, BrowserWindow } from 'electron'
// ─── Local tool approval ─────────────────────────────────────────────────────
// High-risk local operations (file write/edit, shell execution) prompt the user
// with a native dialog showing the full operation context before they run. The
// user may tick "don't ask again this session" to temporarily allow matching
// operations — same path for file ops, same command prefix for shell — until the
// app restarts (the cache is in-memory only).
// Cache of approvals the user chose to remember this session.
const sessionAllow = new Set<string>()
export type ApprovalKind = 'write_file' | 'edit_file' | 'execute_shell'
// The cache key scopes "remember": file ops by exact path, shell by command
// prefix (first word + first 40 chars) so re-running the same kind of command
// doesn't re-prompt, but a different command still does.
function cacheKey(kind: ApprovalKind, subject: string): string {
if (kind === 'execute_shell') {
const head = subject.trim().split(/\s+/)[0] || ''
return `shell:${head}:${subject.trim().slice(0, 40)}`
}
return `${kind}:${subject}`
}
interface ApprovalRequest {
kind: ApprovalKind
// The path (file ops) or command (shell) this approval is scoped to.
subject: string
// Human-readable detail shown in the dialog body.
detail: string
}
export interface ApprovalResult {
approved: boolean
}
function titleFor(kind: ApprovalKind): string {
switch (kind) {
case 'write_file':
return '允许写入本地文件?'
case 'edit_file':
return '允许修改本地文件?'
case 'execute_shell':
return '允许执行本地命令?'
}
}
export async function requestApproval(req: ApprovalRequest): Promise<ApprovalResult> {
const key = cacheKey(req.kind, req.subject)
if (sessionAllow.has(key)) return { approved: true }
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]
const options = {
type: 'warning' as const,
title: titleFor(req.kind),
message: titleFor(req.kind),
detail: `${req.detail}\n\n该操作由远程 Agent 发起,将在你的本机执行。`,
buttons: ['拒绝', '允许'],
defaultId: 0,
cancelId: 0,
checkboxLabel: '本次会话不再询问相同操作',
checkboxChecked: false,
noLink: true,
}
const result = parent
? await dialog.showMessageBox(parent, options)
: await dialog.showMessageBox(options)
const approved = result.response === 1
if (approved && result.checkboxChecked) {
sessionAllow.add(key)
}
return { approved }
}
// Drop all remembered approvals — called when the desktop disconnects/logs out so
// a new session starts from a clean slate.
export function clearApprovalCache(): void {
sessionAllow.clear()
}

View File

@ -0,0 +1,122 @@
import { app } from 'electron'
import { join, resolve, relative, isAbsolute } from 'path'
import { homedir } from 'os'
import { existsSync, readFileSync, writeFileSync } from 'fs'
// ─── Local tools configuration ───────────────────────────────────────────────
// Governs the desktop's local file/shell tool proxy: whether it is enabled, the
// directory whitelist every local file operation is constrained to, and the
// default policy when no whitelist is configured. Stored as its own JSON file in
// userData so it is independent of the connection config.
export interface LocalToolsConfig {
// Master switch. When false the desktop advertises no local-tool capabilities
// and rejects any forwarded call.
enabled: boolean
// Absolute (or ~-prefixed) directories the agent may touch. Every local file
// operation must resolve to a path inside one of these.
allowedDirs: string[]
// Policy when allowedDirs is empty:
// true (fail-closed, default) → deny all local file access
// false (fail-open) → allow the entire local filesystem
failClosed: boolean
}
const DEFAULT_CONFIG: LocalToolsConfig = {
enabled: true,
allowedDirs: [],
failClosed: true,
}
function getConfigPath(): string {
return join(app.getPath('userData'), 'local-tools.json')
}
export function loadLocalToolsConfig(): LocalToolsConfig {
try {
const path = getConfigPath()
if (!existsSync(path)) return { ...DEFAULT_CONFIG }
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<LocalToolsConfig>
return {
...DEFAULT_CONFIG,
...raw,
allowedDirs: Array.isArray(raw.allowedDirs) ? raw.allowedDirs : [],
}
} catch (err) {
console.error('[MateClaw] Failed to read local-tools config:', err)
return { ...DEFAULT_CONFIG }
}
}
export function saveLocalToolsConfig(patch: Partial<LocalToolsConfig>): LocalToolsConfig {
const merged: LocalToolsConfig = { ...loadLocalToolsConfig(), ...patch }
try {
writeFileSync(getConfigPath(), JSON.stringify(merged, null, 2), 'utf-8')
} catch (err) {
console.error('[MateClaw] Failed to write local-tools config:', err)
}
return merged
}
// Expand a leading ~ to the user's home directory and resolve to an absolute,
// normalized path. Returns null for empty input.
export function expandPath(input: string): string | null {
const trimmed = (input || '').trim()
if (!trimmed) return null
const expanded = trimmed === '~' || trimmed.startsWith('~/')
? join(homedir(), trimmed.slice(1))
: trimmed
return resolve(expanded)
}
// Whether `target` is contained by `dir` (or equal to it). Both are resolved
// absolute paths. Uses path.relative so it is symlink-name-agnostic but does not
// follow symlinks — the whitelist is enforced on the lexical path the agent asked
// for, which is the path the user approved.
function isInside(dir: string, target: string): boolean {
const rel = relative(dir, target)
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
}
export interface PathCheck {
allowed: boolean
// Resolved absolute path (when input was parseable), for use by the caller.
resolved: string | null
// Machine-readable reason when not allowed.
reason?: 'disabled' | 'unparseable' | 'whitelist'
}
// Decide whether a local file operation on `inputPath` is permitted by the
// current configuration. This is the single chokepoint every file tool calls.
export function checkPath(inputPath: string): PathCheck {
const cfg = loadLocalToolsConfig()
if (!cfg.enabled) return { allowed: false, resolved: null, reason: 'disabled' }
const target = expandPath(inputPath)
if (!target) return { allowed: false, resolved: null, reason: 'unparseable' }
if (cfg.allowedDirs.length === 0) {
return { allowed: !cfg.failClosed, resolved: target, reason: cfg.failClosed ? 'whitelist' : undefined }
}
for (const dir of cfg.allowedDirs) {
const base = expandPath(dir)
if (base && isInside(base, target)) {
return { allowed: true, resolved: target }
}
}
return { allowed: false, resolved: target, reason: 'whitelist' }
}
// The working directory to run a shell command in: the first configured
// whitelist directory, falling back to the user's home. Shell commands are not
// path-checked (they are arbitrary), so they are gated by approval + timeout and
// pinned to a sensible cwd rather than wherever the app launched.
export function shellWorkingDir(): string {
const cfg = loadLocalToolsConfig()
for (const dir of cfg.allowedDirs) {
const base = expandPath(dir)
if (base && existsSync(base)) return base
}
return homedir()
}

View File

@ -0,0 +1,194 @@
import { spawn } from 'child_process'
import {
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
existsSync,
} from 'fs'
import { dirname } from 'path'
import { checkPath, shellWorkingDir } from './localToolsConfig'
// ─── Local tool executor ─────────────────────────────────────────────────────
// Runs the actual file/shell operations on the user's machine. Every file
// operation is constrained to the directory whitelist via checkPath(); shell
// commands are gated by approval (handled by the caller) and a hard timeout.
// Output limits mirror the server-side tools: ~30KB for file reads, ~10KB each
// for shell stdout/stderr.
const MAX_FILE_BYTES = 30 * 1024
const MAX_SHELL_BYTES = 10_000
const IS_WINDOWS = process.platform === 'win32'
export class LocalToolError extends Error {
constructor(public code: string, message: string) {
super(message)
}
}
function requireAllowed(inputPath: string): string {
const check = checkPath(inputPath)
if (!check.allowed) {
if (check.reason === 'disabled') {
throw new LocalToolError('DISABLED', 'Local tools are disabled in the desktop app')
}
if (check.reason === 'unparseable') {
throw new LocalToolError('BAD_PATH', `Invalid path: ${inputPath}`)
}
throw new LocalToolError(
'WHITELIST',
`Path is outside the allowed local directories: ${inputPath}`
)
}
return check.resolved as string
}
export function readFile(filePath: string, startLine?: number, endLine?: number): unknown {
const path = requireAllowed(filePath)
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`)
if (statSync(path).isDirectory()) {
throw new LocalToolError('IS_DIR', `Path is a directory: ${filePath}`)
}
const raw = readFileSync(path, 'utf-8')
const allLines = raw.split('\n')
const totalLines = allLines.length
const start = startLine && startLine > 0 ? startLine : 1
const end = endLine && endLine > 0 ? Math.min(endLine, totalLines) : totalLines
if (start > totalLines) {
throw new LocalToolError('RANGE', `startLine ${start} exceeds total lines ${totalLines}`)
}
let content = ''
let readLines = 0
let truncated = false
for (let i = start - 1; i < end; i++) {
const line = `${String(i + 1).padStart(6)}\t${allLines[i]}\n`
if (Buffer.byteLength(content + line, 'utf-8') > MAX_FILE_BYTES) {
truncated = true
break
}
content += line
readLines++
}
return { filePath: path, totalLines, startLine: start, readLines, content, truncated }
}
export function writeFile(filePath: string, content: string): unknown {
const path = requireAllowed(filePath)
const existed = existsSync(path)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content ?? '', 'utf-8')
return {
filePath: path,
bytesWritten: Buffer.byteLength(content ?? '', 'utf-8'),
created: !existed,
overwritten: existed,
}
}
export function editFile(
filePath: string,
oldText: string,
newText: string,
replaceAll: boolean
): unknown {
const path = requireAllowed(filePath)
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `File not found: ${filePath}`)
const original = readFileSync(path, 'utf-8')
if (!original.includes(oldText)) {
throw new LocalToolError('NO_MATCH', 'oldText not found in file')
}
let replacements = 0
let updated: string
if (replaceAll) {
updated = original.split(oldText).join(newText)
replacements = original.split(oldText).length - 1
} else {
updated = original.replace(oldText, newText)
replacements = 1
}
writeFileSync(path, updated, 'utf-8')
return { filePath: path, replacements, replaceAll: !!replaceAll }
}
export function listDir(dirPath: string): unknown {
const path = requireAllowed(dirPath)
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Directory not found: ${dirPath}`)
if (!statSync(path).isDirectory()) {
throw new LocalToolError('NOT_DIR', `Path is not a directory: ${dirPath}`)
}
const entries = readdirSync(path, { withFileTypes: true }).map((e) => ({
name: e.name,
type: e.isDirectory() ? 'dir' : 'file',
}))
return { dirPath: path, entries }
}
export function statPath(targetPath: string): unknown {
const path = requireAllowed(targetPath)
if (!existsSync(path)) throw new LocalToolError('NOT_FOUND', `Path not found: ${targetPath}`)
const st = statSync(path)
return {
path,
size: st.size,
isDirectory: st.isDirectory(),
modifiedTime: st.mtime.toISOString(),
}
}
function truncateUtf8(buf: Buffer, maxBytes: number): { text: string; truncated: boolean } {
if (buf.length <= maxBytes) return { text: buf.toString('utf-8'), truncated: false }
return {
text: buf.subarray(0, maxBytes).toString('utf-8') +
`\n... [output truncated, exceeds ${maxBytes} byte limit]`,
truncated: true,
}
}
export function executeShell(command: string, timeoutSeconds: number): Promise<unknown> {
const cwd = shellWorkingDir()
const timeoutMs = Math.min(Math.max(timeoutSeconds, 1), 300) * 1000
// cmd.exe on Windows, /bin/sh on macOS/Linux — mirrors the server tool.
const child = IS_WINDOWS
? spawn('cmd.exe', ['/D', '/S', '/C', command], { cwd })
: spawn('/bin/sh', ['-c', command], { cwd })
const stdoutChunks: Buffer[] = []
const stderrChunks: Buffer[] = []
child.stdout.on('data', (d: Buffer) => stdoutChunks.push(d))
child.stderr.on('data', (d: Buffer) => stderrChunks.push(d))
return new Promise((resolvePromise) => {
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGKILL')
}, timeoutMs)
const finish = (exitCode: number) => {
clearTimeout(timer)
const out = truncateUtf8(Buffer.concat(stdoutChunks), MAX_SHELL_BYTES)
const err = truncateUtf8(Buffer.concat(stderrChunks), MAX_SHELL_BYTES)
resolvePromise({
command,
exitCode,
stdout: out.text,
stderr: err.text,
timedOut,
})
}
child.on('error', (e) => {
clearTimeout(timer)
resolvePromise({ command, exitCode: -1, stdout: '', stderr: String(e), timedOut: false })
})
child.on('close', (code) => finish(code == null ? -1 : code))
})
}

View File

@ -0,0 +1,55 @@
import { contextBridge, ipcRenderer } from 'electron'
// Expose safe APIs to the renderer process (splash screen)
contextBridge.exposeInMainWorld('mateClawAPI', {
// Platform info
getPlatform: () => ipcRenderer.invoke('app:get-platform'),
getVersion: () => ipcRenderer.invoke('app:get-version'),
getBuildMode: () => ipcRenderer.invoke('app:get-build-mode'),
getBackendUrl: () => ipcRenderer.invoke('app:get-backend-url'),
isBackendReady: () => ipcRenderer.invoke('app:is-backend-ready'),
getUserDataPath: () => ipcRenderer.invoke('app:get-user-data-path'),
// Actions
openExternal: (url: string) => ipcRenderer.invoke('app:open-external', url),
restartBackend: () => ipcRenderer.invoke('app:restart-backend'),
navigateToApp: () => ipcRenderer.invoke('app:navigate-to-app'),
// Connection management
getConnectionConfig: () => ipcRenderer.invoke('connection:get-config'),
testConnection: (url: string) => ipcRenderer.invoke('connection:test', url),
useLocalConnection: () => ipcRenderer.invoke('connection:use-local'),
useRemoteConnection: (url: string) => ipcRenderer.invoke('connection:use-remote', url),
switchServer: () => ipcRenderer.invoke('connection:switch-server'),
// Backend status events
onBackendStatus: (callback: (status: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, status: string) => callback(status)
ipcRenderer.on('backend:status', handler)
return () => ipcRenderer.removeListener('backend:status', handler)
},
onBackendCrashed: (callback: (message: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, message: string) => callback(message)
ipcRenderer.on('backend:crashed', handler)
return () => ipcRenderer.removeListener('backend:crashed', handler)
},
// Local tools (file/shell proxy) management
getLocalToolsConfig: () => ipcRenderer.invoke('localtools:get-config'),
setLocalToolsConfig: (patch: unknown) => ipcRenderer.invoke('localtools:set-config', patch),
addLocalToolsDir: () => ipcRenderer.invoke('localtools:add-dir'),
removeLocalToolsDir: (dir: string) => ipcRenderer.invoke('localtools:remove-dir', dir),
// Auto-updater
getUpdaterState: () => ipcRenderer.invoke('updater:get-state'),
checkForUpdates: () => ipcRenderer.invoke('updater:check'),
downloadUpdate: () => ipcRenderer.invoke('updater:download'),
installUpdate: () => ipcRenderer.invoke('updater:install'),
onUpdaterState: (callback: (state: any) => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: any) => callback(state)
ipcRenderer.on('updater:state', handler)
return () => ipcRenderer.removeListener('updater:state', handler)
},
})

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MateClaw</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
-webkit-app-region: drag;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -0,0 +1,51 @@
{
"name": "mateclaw-desktop",
"version": "1.7.0",
"description": "MateClaw Desktop - AI Assistant powered by Spring AI Alibaba",
"author": "MateClaw Team",
"license": "Apache-2.0",
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"setup:jre": "bash scripts/download-jre.sh",
"setup:jar": "bash scripts/build.sh",
"setup": "npm run setup:jar && npm run setup:jre",
"setup:all-platforms": "bash scripts/build-all-platforms.sh --all",
"package:mac": "npm run build && electron-builder --mac",
"package:mac:local": "npm run build && cross-env BUILD_MODE=local electron-builder --mac",
"package:mac:remote": "npm run build && cross-env BUILD_MODE=remote electron-builder --mac",
"package:win": "npm run build && electron-builder --win",
"package:win:local": "npm run build && cross-env BUILD_MODE=local electron-builder --win",
"package:win:remote": "npm run build && cross-env BUILD_MODE=remote electron-builder --win",
"package:all": "bash scripts/build-all-platforms.sh --all",
"package:all:local": "bash scripts/build-all-platforms.sh --local",
"package:all:remote": "bash scripts/build-all-platforms.sh --remote",
"publish:github": "bash scripts/publish-github.sh",
"publish:github:draft": "bash scripts/publish-github.sh --draft"
},
"dependencies": {
"electron-updater": "^6.3.9",
"vue": "^3.5.13",
"ws": "^8"
},
"devDependencies": {
"@types/ws": "^8.18.1",
"@vitejs/plugin-vue": "^5.2.1",
"cross-env": "^10.1.0",
"electron": "^33.3.1",
"electron-builder": "^25.1.8",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6",
"vue-tsc": "^2.2.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"electron",
"esbuild"
]
}
}

3942
mateclaw-desktop/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

1355
mateclaw-desktop/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

67
mateclaw-desktop/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,67 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface UpdaterState {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error'
version?: string
releaseNotes?: string
progress?: { percent: number; bytesPerSecond: number; transferred: number; total: number }
error?: string
}
interface RemoteServer {
url: string
name?: string
lastUsed?: number
}
interface ConnectionConfigState {
mode: 'local' | 'remote' | null
remoteUrl: string
servers: RemoteServer[]
forceChoose: boolean
buildMode: 'local' | 'remote'
}
interface ConnectionTestResult {
ok: boolean
status?: number
error?: string
}
interface MateClawAPI {
getPlatform: () => Promise<string>
getVersion: () => Promise<string>
getBuildMode: () => Promise<'local' | 'remote'>
getBackendUrl: () => Promise<string>
isBackendReady: () => Promise<boolean>
getUserDataPath: () => Promise<string>
openExternal: (url: string) => Promise<void>
restartBackend: () => Promise<void>
onBackendStatus: (callback: (status: string) => void) => () => void
onBackendCrashed: (callback: (message: string) => void) => () => void
navigateToApp: () => void
// Connection management
getConnectionConfig: () => Promise<ConnectionConfigState>
testConnection: (url: string) => Promise<ConnectionTestResult>
useLocalConnection: () => Promise<void>
useRemoteConnection: (url: string) => Promise<ConnectionTestResult>
switchServer: () => Promise<void>
// Auto-updater
getUpdaterState: () => Promise<UpdaterState>
checkForUpdates: () => Promise<UpdaterState>
downloadUpdate: () => Promise<void>
installUpdate: () => Promise<void>
onUpdaterState: (callback: (state: UpdaterState) => void) => () => void
}
interface Window {
mateClawAPI: MateClawAPI
}

View File

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"resolveJsonModule": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "electron/**/*.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"composite": true,
"skipLibCheck": true,
"noEmit": false
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,72 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import electron from 'vite-plugin-electron'
import renderer from 'vite-plugin-electron-renderer'
import { resolve } from 'path'
import { brandingPlugin } from './scripts/branding.cjs'
export default defineConfig(({ command }) => {
const isServe = command === 'serve'
const isBuild = command === 'build'
// Shared branding plugin instance — applied to the renderer build as well
// as the electron main/preload builds so brand strings are replaced
// everywhere without touching source code.
const brand = brandingPlugin()
return {
plugins: [
vue(),
// White-label branding: replaces "MateClaw" with the configured brand
// name at build time. Source code stays untouched. Configure via
// branding.config.json or BRAND_* env vars.
brand,
electron([
{
entry: 'electron/main/index.ts',
onstart(args) {
args.startup()
},
vite: {
plugins: [brand],
build: {
sourcemap: isServe,
minify: isBuild,
outDir: 'dist-electron/main',
rollupOptions: {
external: ['electron', 'electron-updater'],
},
},
},
},
{
entry: 'electron/preload/index.ts',
onstart(args) {
args.reload()
},
vite: {
plugins: [brand],
build: {
sourcemap: isServe ? 'inline' : undefined,
minify: isBuild,
outDir: 'dist-electron/preload',
rollupOptions: {
external: ['electron'],
},
},
},
},
]),
renderer(),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
}
})

View File

@ -5,6 +5,7 @@ import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.plugin.api.channel.PluginChannelAdapter;
import vip.mate.plugin.api.memory.PluginMemoryProvider;
import vip.mate.plugin.api.search.PluginSearchProvider;
import java.util.function.Supplier;
@ -60,6 +61,19 @@ public interface PluginContext {
*/
void registerMemoryProvider(PluginMemoryProvider provider);
/**
* Register a web-search provider that joins the platform's search provider
* chain used by the {@code web_search} tool.
* <p>
* The provider id must be globally unique registration fails with a
* {@link PluginException} if it clashes with a built-in provider
* (serper / tavily / searxng / duckduckgo) or another plugin's provider.
*
* @param provider the search provider
* @throws PluginException if the id is blank or already taken
*/
void registerSearchProvider(PluginSearchProvider provider);
/**
* Read a configuration value from the plugin's config.
*

View File

@ -17,5 +17,8 @@ public enum PluginType {
CHANNEL,
/** Register new memory providers */
MEMORY
MEMORY,
/** Register new web-search providers for the web_search tool */
SEARCH
}

View File

@ -0,0 +1,53 @@
package vip.mate.plugin.api.search;
import java.util.List;
/**
* SPI for plugin-provided web-search providers.
* <p>
* Implementations are registered via {@code PluginContext#registerSearchProvider}
* and appear in the platform's search provider chain alongside the built-in
* providers (serper / tavily / searxng / duckduckgo).
* <p>
* Configuration (API keys, base URLs, ...) is NOT passed in plugins read their
* own config declared in {@code mateclaw-plugin.json} via
* {@code PluginContext#getConfig(String, Class)}.
*
* @author MateClaw Team
*/
public interface PluginSearchProvider {
/** Globally unique provider id, e.g. "my-search". Must not clash with built-in ids. */
String id();
/** Human-readable display name. */
String label();
/** Whether this provider needs a credential (affects auto-detect priority). */
default boolean requiresCredential() {
return true;
}
/**
* Auto-detect ordering (ascending). Built-in providers occupy 50-400;
* plugin providers default to 500 (after built-ins) but may override.
*/
default int autoDetectOrder() {
return 500;
}
/**
* Whether the provider is currently usable typically: required config present.
* Called on every provider resolution; keep it cheap (no network I/O).
*/
boolean isAvailable();
/**
* Execute the search.
*
* @param query the query (never null)
* @return results; empty list if nothing found. Must not return null.
* Throw on failure the platform falls back to the next provider.
*/
List<PluginSearchResult> search(PluginSearchQuery query);
}

View File

@ -0,0 +1,22 @@
package vip.mate.plugin.api.search;
/**
* Search query passed from the platform to a plugin search provider.
* <p>
* Self-contained SDK type must not depend on any mateclaw-server class,
* because plugin JARs are compiled only against mateclaw-plugin-api.
*
* @param query search keywords (never null/blank)
* @param freshness time-range filter: day / week / month / year (nullable)
* @param language language preference, e.g. zh-CN / en (nullable)
* @param count max results 1-10, already clamped by the platform (never null)
*
* @author MateClaw Team
*/
public record PluginSearchQuery(
String query,
String freshness,
String language,
Integer count
) {
}

View File

@ -0,0 +1,24 @@
package vip.mate.plugin.api.search;
/**
* A single search result returned by a plugin search provider.
* <p>
* Self-contained SDK type mirrors the platform's internal SearchResult
* (title/url/snippet/source/date) without depending on server classes.
*
* @param title result title
* @param url result link
* @param snippet short excerpt
* @param source source domain, e.g. "reuters.com" (nullable)
* @param date published date as raw string (nullable)
*
* @author MateClaw Team
*/
public record PluginSearchResult(
String title,
String url,
String snippet,
String source,
String date
) {
}

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>vip.mate</groupId>
<artifactId>mateclaw</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>mateclaw-plugin-search-sample</artifactId>
<packaging>jar</packaging>
<name>MateClaw Search Provider Sample Plugin</name>
<description>Sample plugin registering a custom web-search provider via the MateClaw Plugin SDK</description>
<dependencies>
<!-- MateClaw Plugin API -->
<dependency>
<groupId>vip.mate</groupId>
<artifactId>mateclaw-plugin-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- Spring AI (provided by the platform) — PluginContext method signatures
reference ToolCallback/ChatModel, so it must be resolvable at compile time -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-model</artifactId>
<scope>provided</scope>
</dependency>
<!-- Jackson (provided by the platform parent classloader) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope>
</dependency>
<!-- SLF4J (provided by the platform) -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,124 @@
package vip.mate.plugin.sample.search;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import vip.mate.plugin.api.MateClawPlugin;
import vip.mate.plugin.api.PluginContext;
import vip.mate.plugin.api.search.PluginSearchProvider;
import vip.mate.plugin.api.search.PluginSearchQuery;
import vip.mate.plugin.api.search.PluginSearchResult;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Sample plugin demonstrating {@code PluginType.SEARCH}: registers a search
* provider that queries a configurable JSON endpoint. Expected response shape:
* {@code {"results":[{"title":"...","url":"...","snippet":"..."}]}}
*
* @author MateClaw Team
*/
public class SimpleSearchPlugin implements MateClawPlugin {
private Logger log;
@Override
public void onLoad(PluginContext context) {
this.log = context.getLogger();
context.registerSearchProvider(new DemoSearchProvider(context));
log.info("SimpleSearchPlugin loaded, search provider registered");
}
@Override
public void onEnable() {
if (log != null) log.info("SimpleSearchPlugin enabled");
}
@Override
public void onDisable() {
if (log != null) log.info("SimpleSearchPlugin disabled");
}
static class DemoSearchProvider implements PluginSearchProvider {
private static final Duration TIMEOUT = Duration.ofSeconds(15);
private final PluginContext context;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
private final ObjectMapper objectMapper = new ObjectMapper();
DemoSearchProvider(PluginContext context) {
this.context = context;
}
@Override
public String id() {
return "demo-search";
}
@Override
public String label() {
return "Demo Search";
}
@Override
public boolean isAvailable() {
String baseUrl = context.getConfig("baseUrl", String.class);
return baseUrl != null && !baseUrl.isBlank();
}
@Override
public List<PluginSearchResult> search(PluginSearchQuery query) {
String baseUrl = context.getConfig("baseUrl", String.class);
String apiKey = context.getConfig("apiKey", String.class);
// Minimal demo: only q/count are wired. query.freshness() and query.language()
// are also available see the built-in SearXNGSearchProvider for how to map them.
String url = baseUrl + (baseUrl.contains("?") ? "&" : "?")
+ "q=" + URLEncoder.encode(query.query(), StandardCharsets.UTF_8)
+ "&count=" + query.count();
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create(url))
.timeout(TIMEOUT)
.GET();
if (apiKey != null && !apiKey.isBlank()) {
req.header("Authorization", "Bearer " + apiKey);
}
try {
HttpResponse<String> resp = http.send(req.build(), HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
throw new IllegalStateException("Search endpoint returned HTTP " + resp.statusCode());
}
return parse(resp.body());
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException("Search request failed: " + e.getMessage(), e);
}
}
private List<PluginSearchResult> parse(String body) throws JsonProcessingException {
List<PluginSearchResult> results = new ArrayList<>();
JsonNode items = objectMapper.readTree(body).path("results");
for (JsonNode item : items) {
results.add(new PluginSearchResult(
item.path("title").asText(null),
item.path("url").asText(null),
item.path("snippet").asText(null),
null,
null));
}
return results;
}
}
}

View File

@ -0,0 +1,24 @@
{
"name": "mateclaw-plugin-search-demo",
"version": "1.0.0",
"type": "search",
"displayName": "Demo Search Provider",
"description": "Registers a custom web-search provider backed by a configurable JSON search endpoint.",
"entrypoint": "vip.mate.plugin.sample.search.SimpleSearchPlugin",
"minPlatformVersion": "1.1.0",
"author": "MateClaw Team",
"config": {
"baseUrl": {
"type": "string",
"required": true,
"secret": false,
"description": "Search endpoint returning {\"results\":[{\"title\",\"url\",\"snippet\"}]}"
},
"apiKey": {
"type": "string",
"required": false,
"secret": true,
"description": "Optional bearer token sent as Authorization header"
}
}
}

View File

@ -38,7 +38,12 @@ import vip.mate.llm.chatmodel.ReasoningEffortResolver;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.agent.context.PrefixBudgetPlan;
import vip.mate.agent.context.PrefixBudgetPlanner;
import vip.mate.agent.context.TokenEstimator;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.llm.routing.ProviderRouter;
import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService;
@ -47,6 +52,7 @@ import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.skill.service.SkillService;
import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.ToolRegistry;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.tool.guard.service.ToolGuardService;
@ -96,6 +102,9 @@ public class AgentGraphBuilder {
private final ConversationService conversationService;
private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService;
private final ModelContextWindowResolver contextWindowResolver;
private final PrefixBudgetPlanner prefixBudgetPlanner;
private final ToolUsageRecencyTracker toolUsageRecencyTracker;
private final vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter;
private final PlanningService planningService;
@ -353,6 +362,12 @@ public class AgentGraphBuilder {
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
// Effective context window: explicit config > local-server probe > null
// (downstream keeps its global-default fallback). Without probing, a
// local 8k/16k model with maxInputTokens unset budgets against the
// 128k global default and the first oversized request fails outright.
Integer effectiveMaxInputTokens = contextWindowResolver.resolveMaxInputTokens(provider, runtimeModel);
// 内置搜索检测DashScope / Kimi但不再移除 WebSearchTool 两者协同而非互斥
boolean builtinSearchEnabled = false;
Map<String, Object> providerKwargs = modelProviderService.readProviderGenerateKwargs(provider);
@ -387,23 +402,45 @@ public class AgentGraphBuilder {
}
}
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled);
// Prefix injection budget: optional blocks (memory / wiki / skill
// catalog / extension catalog / ledger) share a token budget scaled
// to the model's effective window. The agent's own prompt and the
// tool schemas are never truncated they are subtracted from the
// budget so the optional blocks absorb the squeeze.
int basePromptTokens = TokenEstimator.estimateTokens(entity.getSystemPrompt());
int toolSchemaTokens = TokenEstimator.estimateToolsTokens(toolSet.callbacks());
PrefixBudgetPlan prefixBudgetPlan = prefixBudgetPlanner.plan(
effectiveMaxInputTokens, basePromptTokens, toolSchemaTokens);
if (basePromptTokens > prefixBudgetPlan.effectiveMaxTokens() / 2) {
log.warn("Agent {} 的身份 prompt 约 {} tokens,已超过模型有效窗口 {} 的一半——"
+ "系统不会截断用户自写的身份 prompt,请自行精简,否则小上下文模型可能无法响应",
entity.getId(), basePromptTokens, prefixBudgetPlan.effectiveMaxTokens());
}
String enhancedPrompt = buildEnhancedPrompt(entity, builtinSearchEnabled, prefixBudgetPlan.memoryTokens());
// Runtime skill-catalog renderer captures this agent's bound skills,
// effective tool allowlist, model window and workspace; invoked each
// turn by the reasoning / step-execution nodes with the skills loaded
// so far this run so load_skill pins float to the top of the catalog.
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
entity, boundTools, runtimeModel.getMaxInputTokens());
entity, boundTools, effectiveMaxInputTokens);
// Extension-tool catalog only for ReAct. The dynamic tool split runs
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
// action node to record enable_tool), so baking the catalog there would
// describe an enable_tool flow that can never take effect.
// Auto-demotion is likewise ReAct-only: hiding a tool from Plan-Execute
// would remove it with no enable_tool path to recover it.
boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
Set<String> autoDemotedTools = Set.of();
if (!isPlanExecute) {
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, runtimeModel.getMaxInputTokens());
toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog;
}
@ -423,7 +460,8 @@ public class AgentGraphBuilder {
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId());
} else {
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer);
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer,
prefixBudgetPlan, autoDemotedTools);
// StateGraph 路径下工具调用由 ActionNode 控制始终启用
toolCallingEnabled = true;
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
@ -451,7 +489,7 @@ public class AgentGraphBuilder {
agent.userLocale = resolveLocale();
agent.temperature = runtimeModel.getTemperature();
agent.maxTokens = runtimeModel.getMaxTokens();
agent.maxInputTokens = runtimeModel.getMaxInputTokens();
agent.maxInputTokens = effectiveMaxInputTokens;
agent.topP = runtimeModel.getTopP();
agent.toolCallingEnabled = toolCallingEnabled;
@ -510,11 +548,17 @@ public class AgentGraphBuilder {
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildReActAgent(toolSet, runtimeModel, maxIter, agentId, skillCatalogRenderer, null, Set.of());
}
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer,
PrefixBudgetPlan prefixBudgetPlan, Set<String> autoDemotedTools) {
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer);
runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager, toolSet);
}
@ -565,6 +609,14 @@ public class AgentGraphBuilder {
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
if (primaryModelConfig != null) {
// Feed "prompt too long" rejections back into the window resolver
// so the next turn budgets against the server-reported limit.
streamingHelper.setContextLimitObserver(errorMessage ->
contextWindowResolver.noteContextLimitError(
primaryModelConfig.getProvider(),
primaryModelConfig.getModelName(), errorMessage));
}
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@ -573,6 +625,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -647,6 +700,9 @@ public class AgentGraphBuilder {
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
@ -829,12 +885,28 @@ public class AgentGraphBuilder {
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) {
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort,
primaryModelConfig, agentId, skillCatalogRenderer, null, Set.of());
}
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer,
PrefixBudgetPlan prefixBudgetPlan, Set<String> autoDemotedTools) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
if (primaryModelConfig != null) {
// Feed "prompt too long" rejections back into the window resolver
// so the next turn budgets against the server-reported limit.
streamingHelper.setContextLimitObserver(errorMessage ->
contextWindowResolver.noteContextLimitError(
primaryModelConfig.getProvider(),
primaryModelConfig.getModelName(), errorMessage));
}
ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@ -843,6 +915,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) {
@ -853,10 +926,21 @@ public class AgentGraphBuilder {
// capability from reasoningEffort == null.
boolean supportsReasoningEffort = primaryModelConfig != null
&& ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort();
// Honor the model's configured output cap. Passing 0 here made the
// node fall back to its 16384 default, so the user-configured
// maxTokens never took effect and strict local servers (vLLM's
// max_model_len pre-check) rejected the request outright.
int configuredMaxOutputTokens = (primaryModelConfig != null
&& primaryModelConfig.getMaxTokens() != null
&& primaryModelConfig.getMaxTokens() > 0)
? primaryModelConfig.getMaxTokens() : 0;
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
supportsReasoningEffort,
streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService,
streamingHelper, conversationWindowManager, streamTracker,
configuredMaxOutputTokens, wikiContextService,
skillCatalogRenderer, toolDisclosureService, progressLedgerService);
reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan);
reasoningNode.setAutoDemotedTools(autoDemotedTools);
ActionNode actionNode = new ActionNode(executor, streamTracker);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
@ -935,6 +1019,9 @@ public class AgentGraphBuilder {
// Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_READ_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.CACHE_WRITE_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.REASONING_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
@ -1184,68 +1271,101 @@ public class AgentGraphBuilder {
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
// RFC-009 PR-3: bias by agent preferences (if any). Listed providers win
// their declared order; everything else keeps the global priority order.
List<String> preferred = agentId == null
? java.util.Collections.emptyList()
: agentBindingService.getPreferredProviderIds(agentId);
if (!preferred.isEmpty()) {
providers = reorderByPreferences(providers, preferred);
log.debug("[LlmFailover] agent={} preferences={} -> chain head reordered", agentId, preferred);
}
// RFC-090 §9.2 调整 C second-pass reorder: lift providers
// that satisfy the bound-skill capability set (vision / video /
// audio) ahead of those that don't. Stable otherwise so the
// user-preferred order still wins among capable providers.
// RFC-090 §9.2 调整 C lift providers that satisfy the bound-skill
// capability set (vision / video / audio) ahead of those that don't.
// Run before planning so the non-preferred tail inherits this order;
// the explicit preferred-model head keeps the user's declared order.
try {
providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers));
} catch (Exception e) {
log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage());
}
// Preferred-model chain: explicit (provider, model) entries lead in the
// user's order the same provider may repeat with different models
// then every non-preferred provider follows with its default model.
List<ProviderModelRef> preferred = agentId == null
? java.util.Collections.emptyList()
: agentBindingService.getPreferredProviderModels(agentId);
List<String> globalProviderIds = providers.stream()
.map(ModelProviderEntity::getProviderId)
.toList();
List<ProviderModelRef> plan = planFallbackOrder(preferred, globalProviderIds);
if (!preferred.isEmpty()) {
log.debug("[LlmFailover] agent={} preferred-model chain={} -> plan={}", agentId, preferred, plan);
}
// Dedup by exact (provider, model) seeded with the primary so we never
// rebuild the primary call, but OTHER models of the primary provider are
// still legitimate fallback entries.
List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
for (ModelProviderEntity p : providers) {
// Don't put the primary provider's row into the fallback chain same-instance
// skipping is also done in the runtime walker, but excluding here saves building
// a duplicate ChatModel at agent-build time.
if (primaryProviderId != null && primaryProviderId.equals(p.getProviderId())) {
log.debug("[LlmFailover] skipping primary provider {} in fallback chain", primaryProviderId);
continue;
}
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime walker in
// NodeStreamingChatHelper re-checks pool membership per request, so a provider
// that re-enters the pool later still gets used (the graph is rebuilt on
Set<String> seen = new java.util.HashSet<>();
if (primaryProviderId != null && primaryModelName != null) {
seen.add(primaryProviderId + "::" + primaryModelName);
}
for (ProviderModelRef ref : plan) {
String pid = ref.providerId();
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime
// walker re-checks pool membership per request, so a provider that
// re-enters the pool later still gets used (graph rebuilt on
// ModelConfigChangedEvent).
if (providerPool != null && !providerPool.contains(p.getProviderId())) {
log.debug("[LlmFailover] skipping provider {} — not in available pool",
p.getProviderId());
if (providerPool != null && !providerPool.contains(pid)) {
log.debug("[LlmFailover] skipping provider {} — not in available pool", pid);
continue;
}
ModelConfigEntity fallbackConfig = pickFallbackModel(p.getProviderId());
ModelConfigEntity fallbackConfig = resolveChainModel(ref);
if (fallbackConfig == null) {
log.debug("[LlmFailover] skipping provider {} — no enabled chat model",
p.getProviderId());
log.debug("[LlmFailover] skipping {} — no usable chat model", pid);
continue;
}
if (primaryModelName != null && primaryModelName.equals(fallbackConfig.getModelName())) {
// Same model name picked for a different provider exact same call, skip.
String key = pid + "::" + fallbackConfig.getModelName();
if (!seen.add(key)) {
// Exact (provider, model) already queued or equal to the primary.
continue;
}
try {
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
chain.add(new vip.mate.llm.failover.FallbackEntry(p.getProviderId(), m));
log.info("[LlmFailover] chain[{}] = {}/{} (priority={})",
chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
p.getFallbackPriority());
chain.add(new vip.mate.llm.failover.FallbackEntry(pid, m));
log.info("[LlmFailover] chain[{}] = {}/{}", chain.size(), pid, fallbackConfig.getModelName());
} catch (Exception e) {
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}",
p.getProviderId(), e.getMessage());
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", pid, e.getMessage());
}
}
return chain;
}
/**
* Resolve a planned chain entry to a concrete chat model. A pinned model
* ({@code modelId != null}) is used when it still exists and is enabled;
* otherwise we fall back to the provider's default chat model so a deleted
* or disabled pin keeps the provider in the chain.
*/
private ModelConfigEntity resolveChainModel(ProviderModelRef ref) {
if (ref.modelId() != null) {
try {
ModelConfigEntity m = modelConfigService.getModel(ref.modelId());
// Honour the pin only when it is a usable chat model that actually
// belongs to this entry's provider. The FallbackEntry is keyed by
// ref.providerId() for cooldown/pool, so a model from a different
// provider would mis-key the chain; an embedding model would never
// serve as a chat fallback. Either case falls back to the
// provider's default chat model.
if (m != null && Boolean.TRUE.equals(m.getEnabled())
&& ref.providerId().equals(m.getProvider())
&& (m.getModelType() == null || "chat".equals(m.getModelType()))) {
return m;
}
log.info("[LlmFailover] pinned model {} for provider {} not usable "
+ "(disabled / wrong provider / non-chat), using provider default",
ref.modelId(), ref.providerId());
} catch (Exception e) {
log.info("[LlmFailover] pinned model {} for provider {} unresolved ({}), using provider default",
ref.modelId(), ref.providerId(), e.getMessage());
}
}
return pickFallbackModel(ref.providerId());
}
/**
* Pick a chat model to use as a fallback for the given provider:
* <ol>
@ -1275,33 +1395,40 @@ public class AgentGraphBuilder {
}
/**
* Reorder a provider list by an agent's preference list. Listed provider
* ids come first in their preference order; any provider not in the
* preference list keeps its original position relative to other unlisted
* providers (stable partition). Preference entries that don't match any
* actual provider are silently dropped.
* Plan the fallback order as a list of (provider, model) refs.
*
* <p>Head: the agent's explicit preference entries in declared order,
* model-granular the same provider may appear more than once with
* different models. Exact (provider, model) duplicates are dropped.
*
* <p>Tail: every provider not named in the preferences, in the supplied
* global order, each using its default model ({@code modelId == null}).
*
* <p>Preference entries with a blank provider id are ignored. Package-private
* for unit testing see {@code AgentGraphBuilderPreferenceTest}.
*/
/** Package-private for unit testing — see {@code AgentGraphBuilderPreferenceTest}. */
static List<ModelProviderEntity> reorderByPreferences(List<ModelProviderEntity> providers,
List<String> preferredOrder) {
Map<String, ModelProviderEntity> byId = new java.util.LinkedHashMap<>();
for (ModelProviderEntity p : providers) {
byId.put(p.getProviderId(), p);
}
List<ModelProviderEntity> reordered = new ArrayList<>(providers.size());
Set<String> placed = new java.util.HashSet<>();
for (String prefId : preferredOrder) {
ModelProviderEntity p = byId.get(prefId);
if (p != null && placed.add(prefId)) {
reordered.add(p);
static List<ProviderModelRef> planFallbackOrder(List<ProviderModelRef> preferred,
List<String> globalProviderIds) {
List<ProviderModelRef> plan = new ArrayList<>();
Set<String> headEntryKeys = new java.util.HashSet<>();
Set<String> headProviderIds = new java.util.HashSet<>();
if (preferred != null) {
for (ProviderModelRef ref : preferred) {
if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
String key = ref.providerId() + "::" + (ref.modelId() == null ? "" : ref.modelId());
if (!headEntryKeys.add(key)) continue; // exact (provider, model) dup
plan.add(ref);
headProviderIds.add(ref.providerId());
}
}
for (ModelProviderEntity p : providers) {
if (placed.add(p.getProviderId())) {
reordered.add(p);
if (globalProviderIds != null) {
for (String pid : globalProviderIds) {
if (pid == null || pid.isBlank()) continue;
if (headProviderIds.contains(pid)) continue; // already led by an explicit entry
plan.add(new ProviderModelRef(pid, null));
}
}
return reordered;
return plan;
}
/**
@ -1327,7 +1454,7 @@ public class AgentGraphBuilder {
* @throws IllegalArgumentException when an absolute override escapes the
* workspace root
*/
static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
public static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
if (!hasOverride) {
@ -1347,6 +1474,15 @@ public class AgentGraphBuilder {
return agentOverride;
}
if (hasWorkspace) {
// Relative override resolves under the workspace root; reject any value
// that escapes it via "../" so attachment/media/tool I/O stays contained.
Path wsRoot = Paths.get(workspaceBase).toAbsolutePath().normalize();
Path resolved = wsRoot.resolve(agentOverride).normalize();
if (!resolved.startsWith(wsRoot)) {
throw new IllegalArgumentException(
"Agent workspaceBasePath override must stay inside the workspace root: "
+ resolved + " escapes " + wsRoot);
}
return Paths.get(workspaceBase).resolve(agentOverride).toString();
}
return agentOverride;
@ -1393,6 +1529,10 @@ public class AgentGraphBuilder {
""";
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) {
return buildEnhancedPrompt(entity, builtinSearchEnabled, Integer.MAX_VALUE);
}
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled, int memoryBudgetTokens) {
// The agent's own systemPrompt encodes its identity (role / goal /
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
// PROFILE.md, MEMORY.md, ...) augments that identity with durable
@ -1401,7 +1541,7 @@ public class AgentGraphBuilder {
// dropped the identity prompt, so editor-side identity changes never
// reached runtime if the agent had any workspace files.
String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : "";
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId());
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId(), memoryBudgetTokens);
StringBuilder basePromptBuilder = new StringBuilder();
if (!identityPrompt.isEmpty()) {
basePromptBuilder.append(identityPrompt);
@ -1480,10 +1620,11 @@ public class AgentGraphBuilder {
adopting a KB article as the user's project.
## Session Search
- `session_search(agentId, currentConversationId, mode, query, limit)` search conversation history
- `session_search(agentId, mode, query, limit)` search conversation history
- mode="recent": list recent conversations (titles, times, message counts)
- mode="search": keyword full-text search across past messages
- Use this to recall previous discussions, look up past decisions, or find context from earlier conversations
- Only completed sessions (not currently running) are included in results
## Tool Usage Guidelines
When you have available tools, use them to access local system information, files, or execute commands.

View File

@ -1218,10 +1218,14 @@ public abstract class BaseAgent {
}
/**
* 解析图片文件的绝对路径
* Resolve the absolute path of an image file.
* <p>
* 上传文件存储在 data/chat-uploads/ 是相对于 Spring Boot 工作目录的路径
* MCP 工具的工作目录可能不同所以这里直接解析为绝对路径
* The storage location of uploaded files is resolved by
* {@code ChatUploadLocationResolver} in priority order: the Agent's
* workspaceBasePath the Workspace's basePath a configurable default
* directory ({@code mateclaw.chat.upload.base-dir}, default
* {@code data/chat-uploads}). An MCP tool's working directory may differ,
* so this resolves directly to an absolute path.
*/
/**
* 构建当前用户消息的 UserMessage multimodal 图片注入

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import vip.mate.agent.AgentService;
import vip.mate.agent.binding.model.AgentProviderPreference;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.agent.binding.model.AgentSkillBinding;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.model.AgentWikiKbBinding;
@ -122,18 +123,18 @@ public class AgentBindingController {
return R.ok(bindingService.listProviderPreferences(agentId));
}
@Operation(summary = "批量设置 Agent 的偏好 Provider 顺序(替换模式)")
@Operation(summary = "批量设置 Agent 的偏好模型链(供应商 + 模型,替换模式)")
@PutMapping("/provider-preferences")
@RequireWorkspaceRole("member")
public R<Void> setProviderPreferences(
@PathVariable Long agentId,
@RequestBody List<String> providerIds,
@RequestBody List<ProviderModelRef> preferences,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setProviderPreferences(agentId, providerIds);
bindingService.setProviderModelPreferences(agentId, preferences);
agentService.invalidateAgentCache(agentId);
auditEventService.record("UPDATE", "AGENT_PROVIDER_PREF", String.valueOf(agentId),
"providers=" + providerIds.size(), null);
"entries=" + (preferences == null ? 0 : preferences.size()), null);
return R.ok();
}

View File

@ -29,6 +29,16 @@ public class AgentProviderPreference {
/** Provider id (matches {@code mate_model_provider.provider_id}). */
private String providerId;
/**
* Specific chat model to pin for this entry (matches
* {@code mate_model_config.id}). {@code null} means "use the provider's
* default chat model" — backward compatible with provider-only
* preferences. With this column the same {@code providerId} may appear
* in multiple rows, each pinning a different model, forming a per-agent
* preferred-model chain.
*/
private Long modelId;
/** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
private Integer sortOrder;

View File

@ -0,0 +1,71 @@
package vip.mate.agent.binding.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import vip.mate.agent.binding.model.AgentToolBinding;
import vip.mate.agent.binding.repository.AgentToolBindingMapper;
import vip.mate.tool.mcp.event.McpServerRemovedEvent;
import vip.mate.tool.mcp.runtime.McpToolNameResolver;
import java.util.List;
/**
* Drops {@code mate_agent_tool} rows that pointed at a now-removed MCP server's
* tools (issue #127, MCP half).
*
* <p>MCP tool bindings are stored under the resolved name
* {@code mcp_<serverId>_<slug>_<hash6>}. Deleting the server used to leave these
* rows behind: the agent edit page kept showing the bindings and the user could
* not clear them (the tools no longer exist in the live set, so the picker can't
* render a row to uncheck). This mirrors the agent-skill cleanup for removed
* skills.
*
* <p>Matching is done with an exact Java prefix rather than a SQL {@code LIKE}:
* the literal underscores in {@code mcp_<serverId>_} are wildcards in {@code LIKE},
* so {@code mcp_123_%} would also match server {@code 1234}'s tools. The coarse
* query narrows to MCP bindings; the precise {@code startsWith} avoids deleting a
* sibling server's rows.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AgentBindingMcpRemovalListener {
private final AgentToolBindingMapper toolBindingMapper;
@EventListener
public void onMcpServerRemoved(McpServerRemovedEvent event) {
if (event == null || event.serverId() == null) {
return;
}
String serverPrefix = McpToolNameResolver.PREFIX + event.serverId() + "_";
// Coarse-filter to MCP bindings in SQL, then match the exact server
// prefix in Java to avoid the LIKE-underscore-wildcard false match.
List<AgentToolBinding> candidates = toolBindingMapper.selectList(
new LambdaQueryWrapper<AgentToolBinding>()
.likeRight(AgentToolBinding::getToolName, McpToolNameResolver.PREFIX));
List<Long> orphanIds = candidates.stream()
.filter(b -> belongsToServer(b.getToolName(), serverPrefix))
.map(AgentToolBinding::getId)
.toList();
if (orphanIds.isEmpty()) {
return;
}
int dropped = toolBindingMapper.delete(
new LambdaQueryWrapper<AgentToolBinding>()
.in(AgentToolBinding::getId, orphanIds));
if (dropped > 0) {
log.info("Cleaned {} agent-tool binding row(s) for removed MCP server {} (id={})",
dropped, event.serverName(), event.serverId());
}
}
/** Exact prefix test: {@code mcp_123_x} belongs to server 123, {@code mcp_1234_x} does not. */
static boolean belongsToServer(String toolName, String serverPrefix) {
return toolName != null && toolName.startsWith(serverPrefix);
}
}

View File

@ -18,6 +18,7 @@ import vip.mate.agent.model.AgentEntity;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException;
import vip.mate.llm.routing.AgentBindingResolver;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.lifecycle.BlockedByBindingRow;
@ -935,30 +936,33 @@ public class AgentBindingService implements AgentBindingResolver {
* fallback chain order per agent.</p>
*/
@Override
public List<String> getPreferredProviderIds(Long agentId) {
public List<ProviderModelRef> getPreferredProviderModels(Long agentId) {
if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream()
.filter(p -> Boolean.TRUE.equals(p.getEnabled()))
.map(AgentProviderPreference::getProviderId)
.map(p -> new ProviderModelRef(p.getProviderId(), p.getModelId()))
.collect(Collectors.toList());
}
/**
* Replace the full preference list for an agent. {@code providerIds}
* is the new ordered preference (index 0 = highest preference).
* Empty / null list clears all preferences for the agent.
* Replace the full preference list for an agent with (provider, model)
* entries. {@code refs} is the new ordered preference (index 0 = highest);
* a {@code modelId} of {@code null} pins the provider's default model. The
* same provider may appear multiple times with different models, forming a
* preferred-model chain. Empty / null list clears all preferences.
*/
public void setProviderPreferences(Long agentId, List<String> providerIds) {
public void setProviderModelPreferences(Long agentId, List<ProviderModelRef> refs) {
providerPreferenceMapper.delete(
new LambdaQueryWrapper<AgentProviderPreference>()
.eq(AgentProviderPreference::getAgentId, agentId));
if (providerIds == null) return;
if (refs == null) return;
int order = 0;
for (String providerId : providerIds) {
if (providerId == null || providerId.isBlank()) continue;
for (ProviderModelRef ref : refs) {
if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
AgentProviderPreference row = new AgentProviderPreference();
row.setAgentId(agentId);
row.setProviderId(providerId.trim());
row.setProviderId(ref.providerId().trim());
row.setModelId(ref.modelId());
row.setSortOrder(order++);
row.setEnabled(true);
providerPreferenceMapper.insert(row);

View File

@ -66,7 +66,16 @@ public record ChatOrigin(
* can still mint absolute download links. Null for IM/cron origins, which
* have no request host; those rely on {@code mateclaw.server.public-base-url}.
*/
@Nullable String baseUrl
@Nullable String baseUrl,
/**
* Immutable numeric id of the MateClaw user behind this request, when the
* requester is an <em>authenticated</em> account (JWT/PAT login via the
* web console). Null for non-account origins webchat visitors, IM
* senders, cron which carry no MateClaw user row. On-behalf-of identity
* forwarding uses this to tell "MateClaw authenticated this user" apart
* from "this is an external/anonymous identifier" (RFC: identity typing).
*/
@Nullable Long requesterUserId
) {
/** Key used when this origin is wrapped into a Spring AI {@link ToolContext}. */
@ -74,7 +83,7 @@ public record ChatOrigin(
/** Sentinel used by AgentService default overloads where no origin is supplied. */
public static final ChatOrigin EMPTY =
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null);
new ChatOrigin(null, null, "", null, null, null, null, false, null, null, null, null, null);
// ---------------- Factories per entry point ----------------
@ -90,9 +99,25 @@ public record ChatOrigin(
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable String baseUrl) {
return web(conversationId, requesterId, workspaceId, workspaceBasePath, baseUrl, null);
}
/**
* Web-console origin that also carries the authenticated user's immutable
* numeric id. Use this overload from the authenticated web entry point so
* on-behalf-of identity forwarding can assert "MateClaw authenticated this
* user" rather than an external/anonymous identifier.
*/
public static ChatOrigin web(@Nullable String conversationId,
@Nullable String requesterId,
@Nullable Long workspaceId,
@Nullable String workspaceBasePath,
@Nullable String baseUrl,
@Nullable Long requesterUserId) {
return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "",
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl);
workspaceId, workspaceBasePath, null, null, false, null, "web", null, baseUrl,
requesterUserId);
}
public static ChatOrigin cron(@Nullable String conversationId,
@ -101,7 +126,7 @@ public record ChatOrigin(
@Nullable Long channelId,
@Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system",
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null);
workspaceId, workspaceBasePath, channelId, target, true, null, null, null, null, null);
}
// ---------------- Wither-style updates ----------------
@ -109,27 +134,27 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl);
senderName, channelType, chatId, baseUrl, requesterUserId);
}
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl);
senderName, channelType, chatId, baseUrl, requesterUserId);
}
public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl);
senderName, channelType, chatId, baseUrl, requesterUserId);
}
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl);
senderName, channelType, chatId, newBaseUrl, requesterUserId);
}
/**
@ -143,7 +168,7 @@ public record ChatOrigin(
@Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl);
newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
}
// ---------------- Spring AI ToolContext interop ----------------

View File

@ -3,6 +3,7 @@ package vip.mate.agent.context;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
@ -118,6 +119,18 @@ public class ConversationWindowManager {
private final MemoryManager memoryManager;
private final ConversationService conversationService;
/**
* Optional adaptive compaction trigger for small context windows.
* Setter-injected so the many direct test constructions keep the plain
* configured ratio (null previous behavior).
*/
private PrefixBudgetPlanner prefixBudgetPlanner;
@Autowired(required = false)
public void setPrefixBudgetPlanner(PrefixBudgetPlanner prefixBudgetPlanner) {
this.prefixBudgetPlanner = prefixBudgetPlanner;
}
/**
* Optional spill store, injected via setter so unit tests and the two
* existing 3-arg constructor callers in tests stay source-compatible.
@ -251,7 +264,12 @@ public class ConversationWindowManager {
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
int triggerThreshold = (int) (effectiveMax * properties.getCompactTriggerRatio());
// Small windows compact later (higher trigger ratio): summarizing at
// 75% of an 8k window throws away room it cannot afford to lose.
double triggerRatio = prefixBudgetPlanner != null
? prefixBudgetPlanner.compactTriggerRatioFor(effectiveMax, properties.getCompactTriggerRatio())
: properties.getCompactTriggerRatio();
int triggerThreshold = (int) (effectiveMax * triggerRatio);
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
@ -1009,6 +1027,33 @@ public class ConversationWindowManager {
+ "' can be called again if its result is needed.]";
}
/**
* One-line informative summary for a cleared tool result: tool name,
* original size, and the first line as a gist. Far more useful to the
* model than a bare "removed" marker it can decide whether re-running
* the tool is worth it without guessing what the output was.
*/
static String buildInformativeCleared(String toolName, String body) {
String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName;
int length = body == null ? 0 : body.length();
String gist = "";
if (body != null) {
for (String line : body.split("\n", 8)) {
String candidate = line.strip();
if (!candidate.isEmpty()) {
gist = candidate.length() > 80 ? candidate.substring(0, 80) + "" : candidate;
break;
}
}
}
StringBuilder sb = new StringBuilder("[").append(safeName)
.append("").append(length).append(" chars, cleared to save context");
if (!gist.isEmpty()) {
sb.append("; began: \"").append(gist).append('"');
}
return sb.append("; call the tool again if the result is still needed]").toString();
}
/**
* Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
* <p>Spill-marker responses are left untouched so their on-disk pointer
@ -1063,7 +1108,8 @@ public class ConversationWindowManager {
replaced.add(r);
continue;
}
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"));
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
buildInformativeCleared(r.name(), r.responseData())));
changed = true;
}
if (changed) {

View File

@ -0,0 +1,41 @@
package vip.mate.agent.context;
/**
* Per-agent-build token budget for the prompt prefix's optional injection
* blocks. Produced once by {@link PrefixBudgetPlanner} when the agent graph
* is assembled (the inputs effective window, base prompt, tool schemas
* are all stable per build) and handed to each injection site.
*
* <p>{@code enabled == false} means budgeting is switched off: every budget
* field holds {@link Integer#MAX_VALUE} and consumers keep their existing
* absolute caps untouched.
*/
public record PrefixBudgetPlan(
boolean enabled,
int effectiveMaxTokens,
Profile profile,
int injectionBudgetTokens,
int memoryTokens,
int wikiTokens,
int skillCatalogTokens,
int extensionCatalogTokens,
int ledgerTokens,
int toolSchemaBudgetTokens) {
/** Window-size tier. Small windows tighten the injection ratio. */
public enum Profile {
/** Regular window — budget shares rarely bind (absolute caps are smaller). */
NORMAL,
/** Window below the compact threshold — tightened injection ratio. */
COMPACT,
/** Window below the minimal threshold — injection cut to the bone. */
MINIMAL
}
/** Budgeting disabled — unlimited budgets, previous behavior. */
public static PrefixBudgetPlan unlimited(int effectiveMaxTokens) {
return new PrefixBudgetPlan(false, effectiveMaxTokens, Profile.NORMAL,
Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE,
Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE);
}
}

View File

@ -0,0 +1,120 @@
package vip.mate.agent.context;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.config.PrefixBudgetProperties;
/**
* Computes the {@link PrefixBudgetPlan} for one agent build: how many tokens
* each optional prefix injection block (memory / wiki / skill catalog /
* extension catalog / progress ledger) may spend, scaled to the model's
* effective context window.
*
* <pre>
* injectionBudget = max(0, effectiveMax × ratio(profile)
* basePromptTokens toolSchemaTokens)
* block budget = injectionBudget × normalizedShare(block)
* </pre>
*
* The agent's own prompt and the tool schemas are never truncated here
* they are subtracted from the injection budget so the optional blocks
* absorb the squeeze. On large windows the shares far exceed each block's
* absolute cap, so behavior is byte-identical to the pre-budget code.
*/
@Slf4j
@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(PrefixBudgetProperties.class)
public class PrefixBudgetPlanner {
/** COMPACT-profile ceiling for the wiki relevance injection (~one page). */
static final int COMPACT_WIKI_TOKEN_CAP = 2000;
private final PrefixBudgetProperties properties;
private final ConversationWindowProperties windowProperties;
/**
* @param effectiveMaxInputTokens the model's effective window (explicit
* config or probed); null/0 falls back to
* the global default
* @param basePromptTokens estimated tokens of the agent's own
* identity prompt (before memory/guidance)
* @param toolSchemaTokens estimated tokens of the advertised tool
* schemas
*/
public PrefixBudgetPlan plan(Integer effectiveMaxInputTokens, int basePromptTokens, int toolSchemaTokens) {
int effectiveMax = (effectiveMaxInputTokens != null && effectiveMaxInputTokens > 0)
? effectiveMaxInputTokens : windowProperties.getDefaultMaxInputTokens();
if (!properties.isEnabled()) {
return PrefixBudgetPlan.unlimited(effectiveMax);
}
PrefixBudgetPlan.Profile profile = profileFor(effectiveMax);
double ratio = switch (profile) {
case NORMAL -> properties.getInjectionRatio();
case COMPACT -> properties.getCompactInjectionRatio();
case MINIMAL -> properties.getMinimalInjectionRatio();
};
int injectionBudget = Math.max(0,
(int) (effectiveMax * ratio) - Math.max(0, basePromptTokens) - Math.max(0, toolSchemaTokens));
PrefixBudgetProperties.Shares shares = properties.getShares();
double sum = shares.getMemory() + shares.getWiki() + shares.getSkill()
+ shares.getExtensionCatalog() + shares.getLedger();
if (sum <= 0) {
sum = 1.0;
}
// Profile-specific wiki clamps: knowledge-base reference pages are the
// most dispensable block on a small window the wiki tools stay
// callable, only the automatic pre-injection shrinks. COMPACT caps it
// at roughly one page; MINIMAL disables it outright.
int wikiTokens = (int) (injectionBudget * shares.getWiki() / sum);
wikiTokens = switch (profile) {
case NORMAL -> wikiTokens;
case COMPACT -> Math.min(wikiTokens, COMPACT_WIKI_TOKEN_CAP);
case MINIMAL -> 0;
};
PrefixBudgetPlan plan = new PrefixBudgetPlan(
true, effectiveMax, profile, injectionBudget,
(int) (injectionBudget * shares.getMemory() / sum),
wikiTokens,
(int) (injectionBudget * shares.getSkill() / sum),
(int) (injectionBudget * shares.getExtensionCatalog() / sum),
(int) (injectionBudget * shares.getLedger() / sum),
(int) (effectiveMax * properties.getToolSchemaRatio()));
if (profile != PrefixBudgetPlan.Profile.NORMAL) {
log.info("[PrefixBudget] 窗口 {} tokens 进入 {} 档:注入预算 {} tokens"
+ "(memory={}, wiki={}, skill={}, extCatalog={}, ledger={})",
effectiveMax, profile, injectionBudget,
plan.memoryTokens(), plan.wikiTokens(), plan.skillCatalogTokens(),
plan.extensionCatalogTokens(), plan.ledgerTokens());
}
return plan;
}
/** Compaction trigger ratio for this window size (small windows fill up before compacting). */
public double compactTriggerRatioFor(int effectiveMaxTokens, double defaultRatio) {
if (!properties.isEnabled()) {
return defaultRatio;
}
return profileFor(effectiveMaxTokens) == PrefixBudgetPlan.Profile.NORMAL
? defaultRatio : properties.getCompactTriggerRatioOverride();
}
private PrefixBudgetPlan.Profile profileFor(int effectiveMax) {
if (effectiveMax < properties.getMinimalThresholdTokens()) {
return PrefixBudgetPlan.Profile.MINIMAL;
}
if (effectiveMax < properties.getCompactThresholdTokens()) {
return PrefixBudgetPlan.Profile.COMPACT;
}
return PrefixBudgetPlan.Profile.NORMAL;
}
}

View File

@ -0,0 +1,95 @@
package vip.mate.agent.delegation;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Per-conversation accumulator for delegated sub-agent token usage.
*
* <p>When a parent turn delegates work to sub-agents, each child runs as its own
* agent invocation in a separate conversation, so its token usage never lands in
* the parent graph's own usage counters. This accumulator lets the delegation
* layer record each completed child's usage keyed by the <em>root</em>
* (user-facing) conversation, so the parent turn's {@code _usage_final} emission
* can roll the whole sub-tree up into the turn total surfaced live on the SSE
* stream and persisted on the assistant message.
*
* <p><b>No double counting across nesting:</b> every descendant (child,
* grandchild, ) records against the same root conversation, because the
* delegation context carries the original root forward. The root agent drains
* the full tree exactly once at its {@code _usage_final}; intermediate agents
* drain their own conversation key, which holds nothing. A child agent's own
* usage (returned to its parent and recorded once by the parent's delegation
* call) is therefore counted a single time.
*
* <p>Exposed via a static accessor because the StateGraph agents that emit
* {@code _usage_final} are built per-config and are not Spring-managed beans, so
* they cannot receive this singleton by constructor injection.
*/
@Component
public class DelegatedUsageAccumulator {
private static volatile DelegatedUsageAccumulator instance;
@PostConstruct
void register() {
instance = this;
}
/** Returns the singleton, or {@code null} before the context is ready. */
public static DelegatedUsageAccumulator getInstance() {
return instance;
}
private record Usage(AtomicLong prompt, AtomicLong completion) {
Usage() {
this(new AtomicLong(), new AtomicLong());
}
}
private final Map<String, Usage> byConversation = new ConcurrentHashMap<>();
/** Record one completed child's usage against its root conversation. */
public void add(String rootConversationId, int promptTokens, int completionTokens) {
if (rootConversationId == null || rootConversationId.isBlank()) {
return;
}
if (promptTokens <= 0 && completionTokens <= 0) {
return;
}
Usage u = byConversation.computeIfAbsent(rootConversationId, k -> new Usage());
if (promptTokens > 0) {
u.prompt().addAndGet(promptTokens);
}
if (completionTokens > 0) {
u.completion().addAndGet(completionTokens);
}
}
/** Token pair carrier for a drained accumulation. */
public record Drained(long promptTokens, long completionTokens) {
public boolean isEmpty() {
return promptTokens <= 0 && completionTokens <= 0;
}
}
/** Atomically read and remove the accumulated delegated usage for a conversation. */
public Drained drain(String conversationId) {
if (conversationId == null) {
return new Drained(0, 0);
}
Usage u = byConversation.remove(conversationId);
return u == null ? new Drained(0, 0) : new Drained(u.prompt().get(), u.completion().get());
}
/** Discard any accumulation for a conversation — leak guard on error/cancel. */
public void clear(String conversationId) {
if (conversationId != null) {
byConversation.remove(conversationId);
}
}
}

View File

@ -0,0 +1,75 @@
package vip.mate.agent.delegation;
import java.util.Set;
/**
* Immutable snapshot of one delegation layer's runtime identity.
*
* <p>This is the canonical value object that carries "who am I in the delegation
* tree" down a single child agent run: tree depth, the immediate parent
* conversation, the human-facing root conversation, the subagent id of the layer
* currently executing, and the tool deny set in force for this layer.
*
* <p>It exists as a first-class, named record (rather than an anonymous frame
* buried in a ThreadLocal stack) so the same identity can later be passed
* explicitly through the call chain instead of being reconstructed from
* thread-local state explicit passing survives virtual-thread and reactive
* hops, where a thread-confined stack does not. {@link vip.mate.tool.builtin.DelegationContext}
* currently holds a stack of these per thread; callers that already have a
* context in hand should prefer threading it explicitly.
*
* @param depth 1-based tree depth; {@code 0} means the top-level
* (non-delegated) call.
* @param parentConversationId the immediate parent conversation that spawned
* this layer, or {@code null} at the top level.
* @param rootConversationId the human-facing conversation at the top of the
* whole delegation tree; every layer carries it
* unchanged so a deep child's progress events can
* broadcast to the stream the user is watching.
* @param currentSubagentId the subagent id of the layer executing now; a
* deeper child reads it as its own parent id to
* reconstruct the spawn tree.
* @param deniedTools tool names this layer's agent may not call;
* normalised to a non-null immutable set.
*
* @author MateClaw Team
*/
public record SubagentRunContext(
int depth,
String parentConversationId,
String rootConversationId,
String currentSubagentId,
Set<String> deniedTools
) {
/** The top-level context: not inside any delegation. */
public static final SubagentRunContext ROOT = new SubagentRunContext(0, null, null, null, Set.of());
public SubagentRunContext {
// Normalise the deny set so every read site gets a non-null immutable
// view without re-checking mirrors the old accessor's null guard.
deniedTools = (deniedTools == null) ? Set.of() : Set.copyOf(deniedTools);
}
/** True when this context represents a delegated (sub-agent) layer. */
public boolean isDelegated() {
return depth > 0;
}
/**
* Build the context for the next layer spawned beneath this one. The root
* conversation is inherited unchanged (falling back to the child's parent
* conversation when this is the first delegation), and depth advances by one.
*
* @param childParentConversationId the spawning conversation for the child
* @param childSubagentId the subagent id assigned to the child
* @param childDeniedTools tool deny set for the child
*/
public SubagentRunContext childFrame(String childParentConversationId,
String childSubagentId,
Set<String> childDeniedTools) {
String inheritedRoot = (rootConversationId != null) ? rootConversationId : childParentConversationId;
return new SubagentRunContext(depth + 1, childParentConversationId,
inheritedRoot, childSubagentId, childDeniedTools);
}
}

View File

@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
* 节点级流式 LLM 调用辅助
@ -162,6 +163,19 @@ public class NodeStreamingChatHelper {
this.providerPool = providerPool;
}
/**
* Optional hook fired with the raw error chain whenever the PRIMARY model
* rejects a call for exceeding its context window. Lets the caller feed
* the server-reported limit back into the context-window resolver so the
* next turn budgets against the model's true window. Fallback-model
* rejections are not reported they belong to a different model.
*/
private Consumer<String> contextLimitObserver;
public void setContextLimitObserver(Consumer<String> observer) {
this.contextLimitObserver = observer;
}
private static List<vip.mate.llm.failover.FallbackEntry> wrap(ChatModel m) {
// Legacy single-fallback path: providerId is unknown so health tracking
// is silently disabled for that one entry (it gets a synthetic id).
@ -585,6 +599,15 @@ public class NodeStreamingChatHelper {
private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast) {
// Normalize every assistant tool call in the outgoing history to valid
// JSON arguments. The streaming aggregator already does this for the
// current turn's calls, but tool calls replayed from persisted history
// (e.g. an earlier MCP tool call with empty arguments, or messages
// stored by an older build) bypass that path. Strict providers reject
// the whole request with HTTP 400 when any function.arguments is not
// parseable JSON, so harmonize them here at the single send chokepoint.
prompt = normalizeToolCallArguments(prompt);
// 在开始 LLM 调用前检查停止标志
if (streamTracker.isStopRequested(conversationId)) {
log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId);
@ -632,7 +655,7 @@ public class NodeStreamingChatHelper {
}
llmCallCount++;
if (attempt > 0) retryCount++;
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true);
if (lastResult != null) {
// PTL: 不重试直接返回给上层 Node 处理
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
@ -774,7 +797,7 @@ public class NodeStreamingChatHelper {
failoverCount++;
llmCallCount++;
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
phase + "_fallback_" + (i + 1), broadcast, 0);
phase + "_fallback_" + (i + 1), broadcast, 0, false);
// Accept only fully successful fallbacks. Non-successful results (auth
// error, client error, still-rate-limited) propagate to the next
// fallback instead of being surfaced as the final result.
@ -821,7 +844,7 @@ public class NodeStreamingChatHelper {
*/
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt) {
boolean broadcast, int attempt, boolean primaryCall) {
// Collapse every SystemMessage in the prompt into a single SystemMessage
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in
// server, certain strict vLLM / SGLang deployments) reject 400
@ -874,7 +897,7 @@ public class NodeStreamingChatHelper {
}
try {
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt);
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall);
} finally {
// Idempotent: if consumer already took the entry, discard is a no-op.
if (relayToken != null) {
@ -906,7 +929,7 @@ public class NodeStreamingChatHelper {
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt) {
boolean broadcast, int attempt, boolean primaryCall) {
if (attempt > 0) {
long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
// 加入 jitter 防止雷群效应
@ -945,9 +968,10 @@ public class NodeStreamingChatHelper {
AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicInteger promptTokens = new AtomicInteger(0);
AtomicInteger completionTokens = new AtomicInteger(0);
// RFC-014: Anthropic prompt cache 计数其它 provider 永远为 0
// Prompt cache / reasoning counters; providers that don't report them stay 0.
AtomicInteger cacheReadTokens = new AtomicInteger(0);
AtomicInteger cacheWriteTokens = new AtomicInteger(0);
AtomicInteger reasoningTokens = new AtomicInteger(0);
// thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅
// 注意内容流的字符级 / 句子级重复检测已整体移除设计取舍
@ -1133,10 +1157,12 @@ public class NodeStreamingChatHelper {
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
completionTokens.set(usage.getCompletionTokens().intValue());
}
// RFC-014: 反射抽取 Anthropic prompt cache 字段DashScope/OpenAI 自然返回 0
// Reflective extraction of provider-native cache / reasoning
// counters (Anthropic / OpenAI-compatible / DashScope).
var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage);
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens());
}
})
.subscribe(
@ -1188,7 +1214,8 @@ public class NodeStreamingChatHelper {
toolCallAccumulators.size(), conversationId);
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase);
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase);
}
log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
phase, conversationId);
@ -1222,6 +1249,7 @@ public class NodeStreamingChatHelper {
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(),
phase, true, error.getMessage());
}
@ -1232,6 +1260,17 @@ public class NodeStreamingChatHelper {
if (errorType == ErrorType.PROMPT_TOO_LONG) {
log.warn("[{}] Prompt too long error, returning to node for compaction: {}",
phase, error.getMessage());
// Teach the context-window resolver the server-reported limit so
// the next turn budgets against the model's true window. Raw
// chain (incl. response body) the friendly text may drop the
// numbers. Primary model only; fallbacks are different models.
if (primaryCall && contextLimitObserver != null) {
try {
contextLimitObserver.accept(extractFullErrorChain(error));
} catch (Exception observerError) {
log.debug("context-limit observer failed: {}", observerError.getMessage());
}
}
return buildErrorResultWithType("Prompt 过长: " + extractUserFriendlyError(error),
conversationId, phase, errorType);
}
@ -1310,7 +1349,8 @@ public class NodeStreamingChatHelper {
: null;
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase,
truncated,
truncationReason);
}
@ -1319,7 +1359,8 @@ public class NodeStreamingChatHelper {
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok, String phase) {
int cacheReadTok, int cacheWriteTok,
int reasoningTok, String phase) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString();
@ -1342,14 +1383,14 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok);
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok);
}
/** 组装最终 StreamResult成功或 partial */
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok,
int cacheReadTok, int cacheWriteTok, int reasoningTok,
String phase, boolean partial, String errorMsg) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString();
@ -1375,7 +1416,7 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok, reasoningTok);
}
/**
@ -1539,6 +1580,66 @@ public class NodeStreamingChatHelper {
return new Prompt(new ArrayList<>(messages.subList(0, end)), prompt.getOptions());
}
/**
* Rebuild any {@link AssistantMessage} whose tool calls carry blank or
* non-JSON {@code function.arguments} so the entire outgoing prompt stays
* acceptable to strict OpenAI-compatible providers (e.g. aliyun-codingplan,
* which 400s the whole request otherwise). Messages with no tool calls, or
* whose tool-call arguments are already valid JSON, pass through untouched
* preserving content, metadata, and media. Returns the input unchanged when
* nothing needs fixing.
*/
static Prompt normalizeToolCallArguments(Prompt prompt) {
if (prompt == null) {
return null;
}
List<Message> messages = prompt.getInstructions();
if (messages == null || messages.isEmpty()) {
return prompt;
}
List<Message> rebuilt = null;
for (int i = 0; i < messages.size(); i++) {
Message m = messages.get(i);
if (!(m instanceof AssistantMessage am)
|| am.getToolCalls() == null || am.getToolCalls().isEmpty()) {
if (rebuilt != null) rebuilt.add(m);
continue;
}
List<AssistantMessage.ToolCall> fixedCalls = null;
List<AssistantMessage.ToolCall> calls = am.getToolCalls();
for (int j = 0; j < calls.size(); j++) {
AssistantMessage.ToolCall tc = calls.get(j);
String safe = sanitizeToolCallArguments(tc.name(), tc.arguments());
if (!safe.equals(tc.arguments())) {
if (fixedCalls == null) fixedCalls = new ArrayList<>(calls);
fixedCalls.set(j, new AssistantMessage.ToolCall(tc.id(), tc.type(), tc.name(), safe));
}
}
if (fixedCalls == null) {
if (rebuilt != null) rebuilt.add(m);
continue;
}
if (rebuilt == null) {
rebuilt = new ArrayList<>(messages.subList(0, i));
}
AssistantMessage.Builder builder = AssistantMessage.builder()
.content(am.getText() == null ? "" : am.getText())
.toolCalls(fixedCalls);
if (am.getMetadata() != null && !am.getMetadata().isEmpty()) {
builder.properties(am.getMetadata());
}
if (am.getMedia() != null && !am.getMedia().isEmpty()) {
builder.media(am.getMedia());
}
rebuilt.add(builder.build());
}
if (rebuilt == null) {
return prompt;
}
log.debug("[normalizeToolCallArguments] normalized non-JSON tool-call arguments in outgoing prompt");
return new Prompt(rebuilt, prompt.getOptions());
}
private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
if (streamTracker != null && conversationId != null) {
@ -1753,17 +1854,19 @@ public class NodeStreamingChatHelper {
ErrorType errorType,
/** 用户主动停止stopRequested导致的提前返回 */
boolean stopped,
/** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0 */
/** Prompt cache 命中 tokensprovider 未上报时为 0 */
int cacheReadTokens,
/** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0 */
int cacheWriteTokens
/** Prompt cache 写入 tokensprovider 未上报时为 0 */
int cacheWriteTokens,
/** 思考reasoning阶段消耗的 completion tokensprovider 未上报时为 0 */
int reasoningTokens
) {
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0);
promptTokens, completionTokens, false, null, ErrorType.NONE, false, 0, 0, 0);
}
/** 兼容 10-arg 调用点 */
@ -1772,17 +1875,17 @@ public class NodeStreamingChatHelper {
int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0);
promptTokens, completionTokens, partial, errorMessage, errorType, false, 0, 0, 0);
}
/** 兼容 12-arg 调用点pre-RFC-014 */
/** 兼容 11-arg 调用点(无 cache/reasoning 计数 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType,
boolean stopped) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls,
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0);
promptTokens, completionTokens, partial, errorMessage, errorType, stopped, 0, 0, 0);
}
/** 是否有不可忽略的错误(无内容 + 有错误) */

View File

@ -13,6 +13,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.context.ConversationWindowManager;
@ -197,6 +198,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护 chatStructuredStream
@ -268,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -282,10 +289,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(conversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"promptTokens", promptTokens,
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -304,6 +322,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
})
// Leak guard: discard delegated usage if the turn ends without
// emitting _usage_final (error / cancel).
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(conversationId);
});
} catch (Exception e) {
setState(AgentState.ERROR);
@ -333,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// Token usage 追踪每次 NodeOutput 更新最新累计值最后一次即最终值
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护StateGraph 对每个节点都 emit NodeOutputFINAL_ANSWER 一旦写入后续节点都携带
@ -418,6 +445,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_TOKENS, 0));
finalCacheReadTokens.set(output.state().value(CACHE_READ_TOKENS, 0));
finalCacheWriteTokens.set(output.state().value(CACHE_WRITE_TOKENS, 0));
finalReasoningTokens.set(output.state().value(REASONING_TOKENS, 0));
finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -434,10 +464,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
})
// 流正常完成后追加内部 usage 事件
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(conversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"promptTokens", promptTokens,
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -457,6 +498,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
})
// Leak guard: discard delegated usage if the turn ends without
// emitting _usage_final (error / cancel).
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(conversationId);
});
} catch (Exception e) {
setState(AgentState.ERROR);
@ -522,6 +569,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(FORCED_TOOL_CALL, "");
inputs.put(PROMPT_TOKENS, 0);
inputs.put(COMPLETION_TOKENS, 0);
inputs.put(CACHE_READ_TOKENS, 0);
inputs.put(CACHE_WRITE_TOKENS, 0);
inputs.put(REASONING_TOKENS, 0);
inputs.put(RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8));

View File

@ -7,6 +7,7 @@ import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin;
@ -251,6 +252,13 @@ public class ToolExecutionExecutor {
*/
private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService;
/** Optional recency feed for budget-driven tool-disclosure demotion. */
private ToolUsageRecencyTracker usageRecencyTracker;
public void setUsageRecencyTracker(ToolUsageRecencyTracker tracker) {
this.usageRecencyTracker = tracker;
}
public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
this.skillRuntimeService = s;
}
@ -885,6 +893,12 @@ public class ToolExecutionExecutor {
ToolExecutionContext.clear();
}
// Recency feed for budget-driven disclosure demotion: recently used
// tools keep their advertised schema, never-used ones demote first.
if (usageRecencyTracker != null) {
usageRecencyTracker.recordUse(toolName);
}
int rawLen = result != null ? result.length() : 0;
// RFC-052: returnDirect tools bypass spill / truncation / LLM context.
// Their full text goes to the user verbatim and is never persisted to

View File

@ -1,8 +1,10 @@
package vip.mate.agent.graph.executor;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.context.StructuredTruncator;
import vip.mate.tool.guard.WorkspacePathGuard;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
@ -73,6 +75,30 @@ public class ToolResultStorage {
this.excludedToolsSnapshot = props.excludedToolsSet();
}
/**
* Trust the deterministic spill roots with the workspace path guard at
* startup, before any spill happens in this JVM. Without this, a
* conversation that spilled in a previous run and is then resumed after a
* restart would have its {@code read_file} of the still-on-disk spill path
* rejected as a boundary escape until the next spill re-registers the root.
* The per-workspace branch ({@code <workspace>/.mateclaw/tool-results}) is
* intentionally not registered here it already sits inside its own
* workspace boundary.
*/
@PostConstruct
void registerSpillRootsAsTrusted() {
if (!props.isEnabled()) {
return;
}
if (!props.getStorageBaseDir().isEmpty()) {
WorkspacePathGuard.addTrustedRoot(props.getStorageBaseDir());
}
String tmp = System.getProperty("java.io.tmpdir");
if (tmp != null && !tmp.isEmpty()) {
WorkspacePathGuard.addTrustedRoot(Paths.get(tmp, "mateclaw", "tool-results").toString());
}
}
/** D-6: current cumulative spill count (monotonically increasing). */
public long getSpillCount() {
return spillCount.get();
@ -283,18 +309,30 @@ public class ToolResultStorage {
private Path resolveBaseDir(String workspaceBasePath) {
Path base;
boolean outsideWorkspace;
if (!props.getStorageBaseDir().isEmpty()) {
base = Paths.get(props.getStorageBaseDir());
outsideWorkspace = true;
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) {
// Inside the workspace boundary already read_file of these spill
// files is permitted without an extra trusted-root registration.
base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
outsideWorkspace = false;
} else {
String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null;
base = Paths.get(tmp, "mateclaw", "tool-results");
outsideWorkspace = true;
}
// Register so the retention sweep and conversation-delete hook can
// reach this root even when the workspace path is no longer in scope.
observedRoots.add(base);
// A spill directory that lives outside the workspace must be trusted by
// the path guard; otherwise the read_file the spill preview tells the
// agent to perform is rejected as a workspace-boundary escape.
if (outsideWorkspace) {
WorkspacePathGuard.addTrustedRoot(base.toString());
}
return base;
}

View File

@ -265,12 +265,15 @@ public class FinalAnswerNode implements NodeAction {
/**
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
* with a user-visible warning. No-op when no cache is wired (legacy
* tests) or when the answer is empty.
* with a user-visible warning, and wrap live bare URLs into
* {@code [filename](url)} markdown links so the chat shows the file name
* instead of the raw id. No-op when no cache is wired (legacy tests) or
* when the answer is empty.
*/
private String scrubFakeUrls(String text) {
if (generatedFileCache == null || text == null || text.isEmpty()) return text;
return generatedFileCache.scrubMissingReferences(text);
return generatedFileCache.linkifyBareReferences(
generatedFileCache.scrubMissingReferences(text));
}
private FinishReason parseFinishReason(String reason) {

View File

@ -22,6 +22,7 @@ import vip.mate.llm.chatmodel.ThinkingLevelHolder;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.LoopBudgetConfig;
import vip.mate.agent.context.PrefixBudgetPlan;
import vip.mate.agent.context.LoopMessageBudgeter;
import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.context.TokenEstimator;
@ -350,6 +351,52 @@ public class ReasoningNode implements NodeAction {
*/
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService;
/**
* Token budget for the optional prefix injection blocks, computed at
* agent-build time against the model's effective context window. Null
* when the graph was assembled without budgeting (tests, legacy paths)
* all injection sites then keep their previous absolute-cap behavior.
*/
private PrefixBudgetPlan prefixBudgetPlan;
public void setPrefixBudgetPlan(PrefixBudgetPlan prefixBudgetPlan) {
this.prefixBudgetPlan = prefixBudgetPlan;
}
/**
* Core-tier tools auto-demoted to the extension catalog because the
* advertised schemas exceeded the window's tool-schema budget. Decided
* once at agent-build time (kept stable for prompt caching); the baked
* extension catalog lists them so {@code enable_tool} can surface any of
* them back.
*/
private Set<String> autoDemotedTools = Set.of();
public void setAutoDemotedTools(Set<String> autoDemotedTools) {
this.autoDemotedTools = autoDemotedTools == null ? Set.of() : autoDemotedTools;
}
/** Floor for the window-aware output clamp — an answer needs at least this much room. */
private static final int MIN_CLAMPED_OUTPUT_TOKENS = 512;
/**
* Output cap actually sent to the provider. Strict local servers (vLLM)
* statically reject {@code max_tokens >= max_model_len}, so when the
* effective context window is known and smaller than the configured /
* default output cap, clamp to half the window (leaving the other half
* for the prompt). No-op when the window is unknown or already larger.
*/
int effectiveMaxOutputTokens() {
int window = (prefixBudgetPlan != null) ? prefixBudgetPlan.effectiveMaxTokens() : 0;
if (window > 0 && maxOutputTokens >= window) {
int clamped = Math.max(MIN_CLAMPED_OUTPUT_TOKENS, window / 2);
log.info("[ReasoningNode] max_tokens {} ≥ 模型窗口 {},钳制为 {}(窗口一半)以避免服务端拒绝",
maxOutputTokens, window, clamped);
return clamped;
}
return maxOutputTokens;
}
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
@ -470,6 +517,12 @@ public class ReasoningNode implements NodeAction {
* otherwise the documented fallback.
*/
private int loopContextWindowTokens() {
// Per-model effective window (explicit config or probed) beats the
// global default the loop budgeter is otherwise blind to small
// local models and never trims for them.
if (prefixBudgetPlan != null && prefixBudgetPlan.effectiveMaxTokens() > 0) {
return prefixBudgetPlan.effectiveMaxTokens();
}
if (conversationWindowManager != null) {
int v = conversationWindowManager.getDefaultMaxInputTokens();
if (v > 0) return v;
@ -709,13 +762,35 @@ public class ReasoningNode implements NodeAction {
// so an enable_tool call earlier in this loop takes effect immediately.
// Falls back to the full tool set when no disclosure service is wired.
List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null)
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks()
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
.activeCallbacks()
: toolCallbacks;
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
Prompt prompt = new Prompt(promptMessages, options);
// Prefix accounting: how much of the window the never-trimmed prefix
// (system prompt + runtime context + wiki + skill catalog + ledger)
// and the advertised tool schemas consume. Logged on the turn's first
// call so a small-window overflow is diagnosable per block instead of
// surfacing as an opaque provider 400.
int prefixEstimateTokens = TokenEstimator.estimateTokens(nonHistoryPrefix);
int toolSchemaEstimateTokens = TokenEstimator.estimateToolsTokens(activeCallbacks);
if (accessor.llmCallCount() == 0) {
log.info("[ReasoningNode] Prefix accounting conv={}: window={} tokens, prefix={} "
+ "(system+context+wiki+skills+ledger), toolSchemas={}, history={}",
conversationId, loopContextWindowTokens(), prefixEstimateTokens,
toolSchemaEstimateTokens, TokenEstimator.estimateTokens(messages));
}
// The prefix cannot be compacted (history compaction is the only lever),
// so a prefix that alone exceeds the window makes the request doomed
// fail fast with the same PROMPT_TOO_LONG shape a provider rejection
// would produce instead of sending it. Gated on budgeting being active
// (an estimation false-positive must not block requests otherwise).
boolean prefixOverflow = prefixBudgetPlan != null && prefixBudgetPlan.enabled()
&& prefixEstimateTokens + toolSchemaEstimateTokens > prefixBudgetPlan.effectiveMaxTokens();
// ======= LLM 调用区域 =======
// nextLlmCallCount 在首次 streamCall 之前计算
// 所有退出路径正常stoppedfatal errorCancellationException都必须写回此值
@ -745,7 +820,19 @@ public class ReasoningNode implements NodeAction {
NodeStreamingChatHelper.StreamResult result;
try {
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
if (prefixOverflow) {
String overflowMessage = "Prompt 前缀估算 " + (prefixEstimateTokens + toolSchemaEstimateTokens)
+ " tokens(注入块 " + prefixEstimateTokens + " + 工具 schema " + toolSchemaEstimateTokens
+ ")已超过模型上下文窗口 " + prefixBudgetPlan.effectiveMaxTokens()
+ " tokens,历史压缩无法解决——请精简 Agent 身份 prompt、减少绑定工具/技能,"
+ "或换用更大窗口的模型";
log.error("[ReasoningNode] {}", overflowMessage);
result = new NodeStreamingChatHelper.StreamResult(null, null, null, List.of(), false,
0, 0, false, overflowMessage,
NodeStreamingChatHelper.ErrorType.PROMPT_TOO_LONG, false, 0, 0, 0);
} else {
result = streamingHelper.streamCall(chatModel, prompt, conversationId, "reasoning");
}
// PTL 处理结构化压缩后重试复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context早期的 tail-only 路径会把
@ -1119,7 +1206,9 @@ public class ReasoningNode implements NodeAction {
if (!projectRecalled && wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try {
Long parsedAgentId = Long.parseLong(agentIdStr);
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg);
Integer wikiBudgetTokens = (prefixBudgetPlan != null && prefixBudgetPlan.enabled())
? prefixBudgetPlan.wikiTokens() : null;
String wikiRelevant = wikiContextService.buildRelevantContext(parsedAgentId, userMsg, wikiBudgetTokens);
if (wikiRelevant != null && !wikiRelevant.isBlank()) {
prefix.add(new UserMessage(wikiRelevant));
}
@ -1166,11 +1255,11 @@ public class ReasoningNode implements NodeAction {
default -> 16384;
};
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
builder.maxTokens(budgetTokens + maxOutputTokens);
builder.maxTokens(budgetTokens + effectiveMaxOutputTokens());
builder.temperature(1.0);
log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens);
} else {
builder.maxTokens(maxOutputTokens);
builder.maxTokens(effectiveMaxOutputTokens());
if (thinkingOn && !isClaudeModel) {
log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel);
}
@ -1185,7 +1274,7 @@ public class ReasoningNode implements NodeAction {
// DashScope rejects max_tokens above its 8192 ceiling with a 400 that
// the failover layer misreads as "model not found"; clamp so a
// DashScope-backed model never overflows the provider limit.
int effectiveMaxTokens = maxOutputTokens;
int effectiveMaxTokens = effectiveMaxOutputTokens();
if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel
&& effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) {
log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model",

View File

@ -11,6 +11,7 @@ import reactor.core.publisher.Mono;
import vip.mate.agent.AgentService;
import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.graph.plan.state.PlanStateKeys;
@ -97,8 +98,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
// DB 恢复 awaiting_approval 状态的计划上下文
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext();
// DB 恢复 awaiting_approval 状态的计划上下文 conversationId 过滤避免并发会话误取
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(conversationId);
if (ctx != null) {
inputs.put(PlanStateKeys.PLAN_ID, ctx.planId());
inputs.put(PlanStateKeys.PLAN_STEPS, ctx.steps());
@ -142,8 +143,15 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = new AtomicInteger(0);
AtomicInteger finalCacheReadTokens = new AtomicInteger(0);
AtomicInteger finalCacheWriteTokens = new AtomicInteger(0);
AtomicInteger finalReasoningTokens = new AtomicInteger(0);
AtomicReference<String> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>("");
// Root conversation for this turn used to roll delegated sub-agent
// token usage into the turn's _usage_final and to clear the accumulator
// on terminal so an errored turn never leaks an entry.
final String usageConversationId = (String) inputs.get(MateClawStateKeys.CONVERSATION_ID);
// 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
@ -203,16 +211,33 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_TOKENS, 0));
finalCacheReadTokens.set(output.state().value(MateClawStateKeys.CACHE_READ_TOKENS, 0));
finalCacheWriteTokens.set(output.state().value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0));
finalReasoningTokens.set(output.state().value(MateClawStateKeys.REASONING_TOKENS, 0));
finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
return deltas;
})
.concatWith(Mono.fromSupplier(() -> {
if (finalPromptTokens.get() > 0 || finalCompletionTokens.get() > 0) {
// Roll delegated sub-agent usage (whole sub-tree, keyed by this
// root conversation) into the turn total so the assistant
// message reflects what the orchestrator + all children cost.
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
DelegatedUsageAccumulator.Drained delegated = acc != null
? acc.drain(usageConversationId)
: new DelegatedUsageAccumulator.Drained(0, 0);
long promptTokens = finalPromptTokens.get() + delegated.promptTokens();
long completionTokens = finalCompletionTokens.get() + delegated.completionTokens();
if (promptTokens > 0 || completionTokens > 0) {
return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(),
"completionTokens", finalCompletionTokens.get(),
"promptTokens", promptTokens,
"completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get()
));
@ -223,6 +248,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
.doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR);
})
// Leak guard: if the turn ends without emitting _usage_final
// (error / cancel), discard any delegated usage left for this
// conversation so it can't bleed into a later turn.
.doFinally(sig -> {
DelegatedUsageAccumulator acc = DelegatedUsageAccumulator.getInstance();
if (acc != null) acc.clear(usageConversationId);
});
}
@ -297,6 +329,9 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
inputs.put(MateClawStateKeys.REQUESTER_ID, "");
inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0);
inputs.put(MateClawStateKeys.COMPLETION_TOKENS, 0);
inputs.put(MateClawStateKeys.CACHE_READ_TOKENS, 0);
inputs.put(MateClawStateKeys.CACHE_WRITE_TOKENS, 0);
inputs.put(MateClawStateKeys.REASONING_TOKENS, 0);
inputs.put(MateClawStateKeys.RUNTIME_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8));

View File

@ -33,6 +33,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@ -190,6 +191,48 @@ public class PlanGenerationNode implements NodeAction {
return goal;
}
/** Whole injected long-term-memory recall block (any casing). */
private static final Pattern MEMORY_CONTEXT_BLOCK =
Pattern.compile("(?is)<\\s*memory-context\\s*>.*?</\\s*memory-context\\s*>");
/** Stray open/close memory-context fence tags left after block removal. */
private static final Pattern MEMORY_CONTEXT_TAG =
Pattern.compile("(?i)</?\\s*memory-context\\s*>");
/** Marker that introduces the real instruction inside a scheduled-run wrapper. */
private static final String CRON_TASK_MARKER = "[任务指令]";
/** Suffix appended by a goal-driven re-plan pass; not part of the user's ask. */
private static final String FOLLOWUP_MARKER = "[Follow-up guidance]";
/**
* Recovers the user's actual request from the fully-assembled agent prompt so
* the persisted/displayed plan goal reads as the task itself, not the
* framework scaffolding wrapped around it. The graph receives the goal already
* enriched a {@code <memory-context></memory-context>} recall block is
* prepended for every turn, scheduled runs add a wrapper whose real payload
* sits after {@code [任务指令]}, and a re-plan pass appends a
* {@code [Follow-up guidance]} block. Persisting that verbatim left the Plan
* board showing "&lt;memory-context&gt; The following is what you…" instead of
* the user's goal. Strips, in order: the recall block, the scheduled-run
* preamble (keeping only the instruction body), and the follow-up suffix.
* Falls back to the raw goal if scrubbing would leave nothing.
*/
static String displayGoal(String goal) {
if (goal == null || goal.isBlank()) {
return goal == null ? "" : goal;
}
String s = MEMORY_CONTEXT_BLOCK.matcher(goal).replaceAll("");
s = MEMORY_CONTEXT_TAG.matcher(s).replaceAll("");
int task = s.lastIndexOf(CRON_TASK_MARKER);
if (task >= 0) {
s = s.substring(task + CRON_TASK_MARKER.length());
}
int followup = s.indexOf(FOLLOWUP_MARKER);
if (followup >= 0) {
s = s.substring(0, followup);
}
s = s.strip();
return s.isEmpty() ? goal.strip() : s;
}
public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
@ -260,7 +303,7 @@ public class PlanGenerationNode implements NodeAction {
if (goalService.findActiveByConversation(convId) != null) {
return null; // respect an existing goal (incl. re-plan passes)
}
String request = stripInjectedContext(accessor.goal()).strip();
String request = displayGoal(accessor.goal());
GoalCreateRequest req = new GoalCreateRequest();
req.setConversationId(convId);
req.setAgentId(origin.agentId());
@ -368,10 +411,16 @@ public class PlanGenerationNode implements NodeAction {
String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String conversationId = accessor.conversationId();
log.info("[PlanGeneration] Evaluating goal: {}", goal.length() > 100 ? goal.substring(0, 100) + "..." : goal);
// The graph's goal carries framework scaffolding (memory recall block,
// scheduled-run wrapper, follow-up suffix). Persist and display the
// scrubbed user request so the Plan board shows the actual task; the raw
// goal still feeds the triage LLM below.
String persistGoal = displayGoal(goal);
log.info("[PlanGeneration] Evaluating goal: {}", persistGoal.length() > 100 ? persistGoal.substring(0, 100) + "..." : persistGoal);
List<GraphEventPublisher.GraphEvent> events = new ArrayList<>();
events.add(GraphEventPublisher.phase("planning", Map.of("goal", goal)));
events.add(GraphEventPublisher.phase("planning", Map.of("goal", persistGoal)));
// Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null);
@ -508,8 +557,8 @@ public class PlanGenerationNode implements NodeAction {
log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; "
+ "downgrading to single-step plan so tools can execute (goal: {})",
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
List<String> gatedSteps = List.of(goal);
var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps);
List<String> gatedSteps = List.of(persistGoal);
var gatedPlan = planningService.createPlan(agentId, conversationId, persistGoal, gatedSteps);
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
return PlanStateAccessor.output()
.needsPlanning(true)
@ -547,7 +596,7 @@ public class PlanGenerationNode implements NodeAction {
// can still reach the tools. (Previous behavior dropped back to
// direct_answer, which silently stripped tool capability.)
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan");
steps = List.of(goal);
steps = List.of(persistGoal);
}
// Resolve any per-step agent delegation the planner asked for. Null
@ -555,7 +604,7 @@ public class PlanGenerationNode implements NodeAction {
List<Long> stepAgentIds = resolveStepAgents(steps,
triage != null ? triage.stepAgents() : null,
chatOrigin.workspaceId(), agentId);
var plan = planningService.createPlan(agentId, conversationId, goal, steps, stepAgentIds);
var plan = planningService.createPlan(agentId, conversationId, persistGoal, steps, stepAgentIds);
log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : "");
@ -600,12 +649,12 @@ public class PlanGenerationNode implements NodeAction {
// answer. This preserves tool access on the failure path; the previous
// "direct answer" fallback silently degraded tool-requiring tasks.
try {
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal)));
var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal)));
return PlanStateAccessor.output()
.needsPlanning(true)
.planId(plan.getId())
.planSteps(List.of(goal))
.planSteps(List.of(persistGoal))
.planValid(true)
.currentStepIndex(0)
.currentPhase("plan_generated")

View File

@ -30,6 +30,7 @@ import vip.mate.planning.service.PlanningService;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.skill.runtime.SkillCatalogRenderer;
import vip.mate.tool.builtin.DelegateAgentTool;
import vip.mate.tool.builtin.DelegateAgentTool.ChildResult;
import vip.mate.tool.builtin.DelegationContext;
import vip.mate.tool.builtin.ToolExecutionContext;
@ -247,6 +248,9 @@ public class StepExecutionNode implements NodeAction {
String approvalToolName = null;
int stepPromptTokens = 0;
int stepCompletionTokens = 0;
int stepCacheReadTokens = 0;
int stepCacheWriteTokens = 0;
int stepReasoningTokens = 0;
// RFC-052: any returnDirect tool that fires inside this step must
// short-circuit the entire plan (not just this step). We accumulate
@ -321,6 +325,9 @@ public class StepExecutionNode implements NodeAction {
stepPromptTokens += result.promptTokens();
stepCompletionTokens += result.completionTokens();
stepCacheReadTokens += result.cacheReadTokens();
stepCacheWriteTokens += result.cacheWriteTokens();
stepReasoningTokens += result.reasoningTokens();
if (!result.thinking().isEmpty()) {
stepThinking = result.thinking();
@ -443,8 +450,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("awaiting_approval")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -479,10 +486,8 @@ public class StepExecutionNode implements NodeAction {
.contentStreamed(false) // StateGraphPlanExecuteAgent finalSummary 推送
.put(MateClawStateKeys.RETURN_DIRECT_TRIGGERED, true)
.put(MateClawStateKeys.DIRECT_TOOL_OUTPUTS, List.copyOf(stepDirectOutputs))
.put(MateClawStateKeys.PROMPT_TOKENS,
state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS,
state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -531,8 +536,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -589,8 +594,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepTitle("")
.currentStepResult("")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -601,8 +606,8 @@ public class StepExecutionNode implements NodeAction {
.currentStepResult(shortError)
.currentPhase("plan_aborted")
.contentStreamed(false)
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -645,8 +650,8 @@ public class StepExecutionNode implements NodeAction {
.currentPhase("step_completed")
.contentStreamed(true)
.thinkingStreamed(!stepThinking.isEmpty())
.put(MateClawStateKeys.PROMPT_TOKENS, state.value(MateClawStateKeys.PROMPT_TOKENS, 0) + stepPromptTokens)
.put(MateClawStateKeys.COMPLETION_TOKENS, state.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + stepCompletionTokens)
.addStepUsage(state, stepPromptTokens, stepCompletionTokens,
stepCacheReadTokens, stepCacheWriteTokens, stepReasoningTokens)
.events(events)
.build();
}
@ -678,7 +683,7 @@ public class StepExecutionNode implements NodeAction {
// Seed the delegation context with the plan's REAL conversation id (from
// graph state) so the delegated child conversation is parented to it and
// stays hidden from the user's conversation list. The ChatOrigin in the
// plan-execute path carries no conversationId, so delegateByAgentId can't
// plan-execute path carries no conversationId, so the delegation can't
// derive the parent on its own we provide it here.
boolean seeded = false;
if (conversationId != null && !conversationId.isBlank()
@ -687,20 +692,30 @@ public class StepExecutionNode implements NodeAction {
DelegationContext.enter(conversationId, Set.of(), conversationId, null, 0);
seeded = true;
}
String result;
ChildResult childResult = null;
String delegateError = null;
try {
result = delegateAgentTool.delegateByAgentId(assignedAgentId, step, chatOrigin);
childResult = delegateAgentTool.delegateByAgentIdStructured(assignedAgentId, step, chatOrigin);
} catch (Exception e) {
log.error("[StepExecution] Delegated step {} threw: {}", stepIndex, e.getMessage(), e);
result = "[错误] 委派执行异常:" + e.getMessage();
delegateError = e.getMessage();
} finally {
if (seeded) {
DelegationContext.exit();
}
}
String finalResult = result != null ? result : "";
boolean failed = finalResult.isEmpty() || finalResult.startsWith("[错误]");
// Branch on the structured outcome instead of pattern-matching an error
// prefix out of the reply text: a successful child with non-empty content
// is the only "ok" case; blank / error / missing all count as failure.
boolean ok = childResult != null && childResult.success() && !childResult.isBlank();
String finalResult = ok
? (childResult.result() != null ? childResult.result() : "")
: "[错误] 委派执行失败:" + (delegateError != null ? delegateError
: childResult != null && childResult.error() != null ? childResult.error()
: childResult != null && childResult.isBlank() ? "子 Agent 返回内容为空"
: "未知错误");
boolean failed = !ok;
if (failed) {
planningService.updateSubPlanFailure(planId, stepIndex, finalResult);
} else {

View File

@ -258,10 +258,37 @@ public final class PlanStateAccessor {
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
map.put(MateClawStateKeys.REASONING_TOKENS,
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + result.reasoningTokens());
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
return this;
}
/**
* 将一个 step 的累计 usage cache / reasoning 分项加到 state 已有值上
* StepExecutionNode 在多个出口路径上写回同一组键统一走这里避免漏项
*/
public OutputBuilder addStepUsage(OverAllState currentState,
int promptTokens, int completionTokens,
int cacheReadTokens, int cacheWriteTokens,
int reasoningTokens) {
map.put(MateClawStateKeys.PROMPT_TOKENS,
currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0) + promptTokens);
map.put(MateClawStateKeys.COMPLETION_TOKENS,
currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0) + completionTokens);
map.put(MateClawStateKeys.CACHE_READ_TOKENS,
currentState.value(MateClawStateKeys.CACHE_READ_TOKENS, 0) + cacheReadTokens);
map.put(MateClawStateKeys.CACHE_WRITE_TOKENS,
currentState.value(MateClawStateKeys.CACHE_WRITE_TOKENS, 0) + cacheWriteTokens);
map.put(MateClawStateKeys.REASONING_TOKENS,
currentState.value(MateClawStateKeys.REASONING_TOKENS, 0) + reasoningTokens);
return this;
}
public Map<String, Object> build() {
return map;
}

View File

@ -537,6 +537,12 @@ public final class MateClawStateAccessor {
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);
map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens());
map.put(CACHE_READ_TOKENS,
currentState.value(CACHE_READ_TOKENS, 0) + result.cacheReadTokens());
map.put(CACHE_WRITE_TOKENS,
currentState.value(CACHE_WRITE_TOKENS, 0) + result.cacheWriteTokens());
map.put(REASONING_TOKENS,
currentState.value(REASONING_TOKENS, 0) + result.reasoningTokens());
return this;
}

View File

@ -155,6 +155,12 @@ public final class MateClawStateKeys {
// ===== Token Usage 累计REPLACE 策略节点内累加后写回=====
public static final String PROMPT_TOKENS = "prompt_tokens";
public static final String COMPLETION_TOKENS = "completion_tokens";
/** Prompt cache 命中 tokens 累计provider 未上报时保持 0 */
public static final String CACHE_READ_TOKENS = "cache_read_tokens";
/** Prompt cache 写入 tokens 累计provider 未上报时保持 0 */
public static final String CACHE_WRITE_TOKENS = "cache_write_tokens";
/** 思考reasoningtokens 累计provider 未上报时保持 0 */
public static final String REASONING_TOKENS = "reasoning_tokens";
// ===== 运行时模型快照REPLACE 策略buildInitialState 注入=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name";

View File

@ -335,6 +335,29 @@ public class ApprovalWorkflowService implements ApplicationRunner {
approvalMapper.insert(entity);
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
entity.getId(), runId, workspaceId, kind);
// ISSUE #413: register the workflow approval into the in-memory map
// so the resolve resume bridge actually fires. Without this, the
// row only lives in DB and ApprovalService.getPending("wf-...") returns
// null, so performResolve() short-circuits at the "not pending" guard
// and never reaches the WorkflowApprovalResolvedEvent publish in
// Phase 4 leaving ApprovalResumeBridge as dead code. Mirrors the
// recoverFromDb() snapshot shape exactly.
Instant createdAt = entity.getCreatedAt() != null
? entity.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()
: Instant.now();
PendingApproval snapshot = new PendingApproval(
entity.getPendingId(),
entity.getConversationId(),
/*userId*/ null,
entity.getToolName(),
entity.getToolArguments(),
/*reason*/ entity.getSummary(),
createdAt,
"pending");
snapshot.setSummary(entity.getSummary());
approvalService.registerRecovered(snapshot);
return entity.getId();
} catch (Exception e) {
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage());
@ -775,6 +798,17 @@ public class ApprovalWorkflowService implements ApplicationRunner {
/**
* 代理查询方法
*/
/**
* Look up a pending approval by its exact id. Delegates to the underlying
* {@link ApprovalService#getPending} so callers that only hold the workflow
* facade (e.g. WebChatController) can fetch the precise record for an IDOR
* cross-check without falling back to {@code findPendingByConversation}
* (which returns the earliest pending, wrong when several coexist).
*/
public java.util.Optional<PendingApproval> getPending(String pendingId) {
return approvalService.getPending(pendingId);
}
public PendingApproval findPendingByConversation(String conversationId) {
return approvalService.findPendingByConversation(conversationId);
}

View File

@ -50,7 +50,8 @@ public class AuthService {
.eq(UserEntity::getUsername, request.getUsername())
.eq(UserEntity::getEnabled, true));
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
if (user == null || user.getPassword() == null
|| !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
}
@ -212,7 +213,10 @@ public class AuthService {
return userMapper.selectById(userId);
}
private String generateToken(UserEntity user) {
/**
* 生成 JWT tokenSSO 登录路径复用此方法签发格式一致的 token
*/
public String generateToken(UserEntity user) {
return Jwts.builder()
.subject(user.getUsername())
.claim("userId", user.getId())

View File

@ -0,0 +1,33 @@
package vip.mate.auth.sso;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import vip.mate.auth.sso.provider.FeishuSsoProvider;
/**
* SSO 配置启用 {@link SsoProperties} 绑定 + 按需注册飞书 Provider
* <p>
* 仅当 {@code mateclaw.sso.enabled=true} 时此配置生效飞书 Provider 进一步要求
* {@code mateclaw.sso.feishu.enabled=true}
*
* @author MateClaw Team
*/
@Configuration
@EnableScheduling
@EnableConfigurationProperties(SsoProperties.class)
@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
public class SsoAutoConfiguration {
/**
* 飞书 SSO Provider仅当飞书 SSO 启用时注册
*/
@Bean
@ConditionalOnProperty(name = "mateclaw.sso.feishu.enabled", havingValue = "true")
public FeishuSsoProvider feishuSsoProvider(SsoProperties ssoProperties, ObjectMapper objectMapper) {
return new FeishuSsoProvider(ssoProperties.getFeishu(), objectMapper);
}
}

View File

@ -0,0 +1,43 @@
package vip.mate.auth.sso;
import lombok.AllArgsConstructor;
import lombok.Data;
import vip.mate.auth.model.LoginResponse;
/**
* SSO 回调响应两种互斥形态由 {@code bindRequired} 区分:
* <ul>
* <li>{@code bindRequired=false}: 登录成功, {@code loginResponse} 携带 JWT</li>
* <li>{@code bindRequired=true}: link-only 模式未绑定, {@code bindToken} 供前端引导绑定</li>
* </ul>
*
* <p>替代了原先用 {@code R.fail(200, Map.toString())} 传递绑定信号的 hack
*
* @author MateClaw Team
*/
@Data
@AllArgsConstructor
public class SsoCallbackResponse {
/** link-only 模式下未绑定时为 true */
private boolean bindRequired;
/** 登录成功时非空 */
private LoginResponse loginResponse;
/** bindRequired=true 时非空, 供前端调 /sso/bind */
private String bindToken;
private String provider;
private String displayName;
/** 登录成功响应工厂 */
public static SsoCallbackResponse of(LoginResponse loginResponse) {
return new SsoCallbackResponse(false, loginResponse, null, null, null);
}
/** 需绑定响应工厂 (link-only 模式) */
public static SsoCallbackResponse bindRequired(String bindToken, String provider, String displayName) {
return new SsoCallbackResponse(true, null, bindToken, provider, displayName);
}
}

View File

@ -0,0 +1,72 @@
package vip.mate.auth.sso;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.*;
import vip.mate.auth.model.LoginResponse;
import vip.mate.auth.sso.provider.SsoProviderRegistry;
import vip.mate.common.result.R;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* SSO 单点登录 HTTP 端点全部 permitAll ( /auth/login 同级)
*
* @author MateClaw Team
*/
@Tag(name = "SSO 单点登录")
@Slf4j
@RestController
@RequestMapping("/api/v1/auth/sso")
@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
@RequiredArgsConstructor
public class SsoController {
private final SsoProviderRegistry registry;
private final SsoService ssoService;
@Operation(summary = "列出已启用的 SSO Provider")
@GetMapping("/providers")
public R<List<Map<String, String>>> providers() {
List<Map<String, String>> list = registry.listEnabled().stream()
.map(p -> Map.of("id", p.id(), "displayName", p.displayName()))
.collect(Collectors.toList());
return R.ok(list);
}
@Operation(summary = "获取 SSO 授权 URL")
@GetMapping("/{provider}/authorize")
public R<Map<String, String>> authorize(@PathVariable String provider) {
return R.ok(ssoService.handleAuthorize(provider));
}
@Operation(summary = "SSO 回调: 授权码换 JWT")
@PostMapping("/{provider}/callback")
public R<SsoCallbackResponse> callback(@PathVariable String provider,
@RequestBody CallbackRequest body) {
if (body == null || body.code() == null || body.state() == null) {
return R.fail(400, "code 和 state 是必填项");
}
// handleCallback 返回结构化响应: bindRequired=false loginResponse 非空,
// bindRequired=true bindToken 非空 (link-only 模式)两种形态由前端判断
return R.ok(ssoService.handleCallback(provider, body.code(), body.state()));
}
@Operation(summary = "绑定 SSO 身份到已有账号 (link-only 模式)")
@PostMapping("/bind")
public R<LoginResponse> bind(@RequestBody BindRequest body) {
if (body == null || body.bindToken() == null || body.username() == null || body.password() == null) {
return R.fail(400, "bindToken, username, password 是必填项");
}
LoginResponse resp = ssoService.handleBind(body.bindToken(), body.username(), body.password());
return R.ok(resp);
}
public record CallbackRequest(String code, String state) {}
public record BindRequest(String bindToken, String username, String password) {}
}

View File

@ -0,0 +1,51 @@
package vip.mate.auth.sso;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* SSO 单点登录配置
* <p>
* 全局开关默认关闭, 不影响未启用 SSO 的现有部署
*
* @author MateClaw Team
*/
@Data
@ConfigurationProperties(prefix = "mateclaw.sso")
public class SsoProperties {
/** 是否启用 SSO (全局开关) */
private boolean enabled = false;
/**
* 仅允许绑定已有账号模式
* {@code false} = 未绑定时自动创建 mate_user; {@code true} = 要求绑定已存在的账号
*/
private boolean linkOnly = false;
/** 新建 SSO 用户的默认角色 */
private String defaultRole = "user";
/** 飞书 Provider 配置 */
private Feishu feishu = new Feishu();
@Data
public static class Feishu {
/** 是否启用飞书 SSO */
private boolean enabled = false;
/** 飞书应用 App ID */
private String appId;
/** 飞书应用 App Secret */
private String appSecret;
/**
* 国际版切换: {@code feishu} (国内) / {@code lark} (国际版 Lark)
* 决定 apiBase: {@code https://open.feishu.cn} / {@code https://open.larksuite.com}
*/
private String domain = "feishu";
/**
* SSO 回调地址, 通常 {@code https://your-domain/login?sso=callback}
* 飞书授权后带 code 回跳到此地址
*/
private String redirectUri;
}
}

View File

@ -0,0 +1,267 @@
package vip.mate.auth.sso;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import vip.mate.auth.model.LoginResponse;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.repository.UserMapper;
import vip.mate.auth.service.AuthService;
import vip.mate.auth.sso.model.ExternalIdentityEntity;
import vip.mate.auth.sso.provider.SsoProvider;
import vip.mate.auth.sso.provider.SsoProviderRegistry;
import vip.mate.auth.sso.provider.SsoUserInfo;
import vip.mate.auth.sso.repository.ExternalIdentityMapper;
import vip.mate.audit.service.AuditEventService;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.Map;
/**
* SSO 核心业务逻辑: 授权 URL 构造回调用户映射账号绑定
* <p>
* 用户映射策略 (两者结合):
* <ul>
* <li>已绑定 更新 last_login + external 信息 签发 JWT</li>
* <li>未绑定 + link-only 签发 bind_token, 前端引导绑定</li>
* <li>未绑定 + 默认 自动创建 mate_user + external_identity</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
@RequiredArgsConstructor
public class SsoService {
private final SsoProviderRegistry registry;
private final SsoStateService stateService;
private final ExternalIdentityMapper identityMapper;
private final UserMapper userMapper;
private final AuthService authService;
private final SsoProperties ssoProperties;
private final BCryptPasswordEncoder passwordEncoder;
private final ObjectMapper objectMapper;
/** Optional — audit may be null in narrow test contexts. */
@Autowired(required = false)
private AuditEventService auditService;
// ==================== authorize ====================
/**
* 构造授权 URL + 签发 state
*
* @return { authorizeUrl, state }
*/
public Map<String, String> handleAuthorize(String providerId) {
SsoProvider provider = registry.get(providerId)
.orElseThrow(() -> new MateClawException("err.sso.unknown_provider",
400, "未知的 SSO provider: " + providerId));
String state = stateService.issueState(providerId);
String url = provider.authorizeUrl(state);
return Map.of("authorizeUrl", url, "state", state);
}
// ==================== callback ====================
/**
* OAuth2 回调: code JWT
* <p>
* 已绑定用户直接签发 JWT; 未绑定用户根据 link-only 策略决定行为:
* <ul>
* <li>link-only 返回 {@link SsoCallbackResponse#bindRequired} 携带 bind_token</li>
* <li>默认 自动创建 mate_user (含并发幂等保护)</li>
* </ul>
*/
public SsoCallbackResponse handleCallback(String providerId, String code, String state) {
// 1. 校验 state (签名 + 过期 + 一次性消费)
stateService.verifyState(state);
// 2. code IdP 用户信息
SsoProvider provider = registry.get(providerId)
.orElseThrow(() -> new MateClawException("err.sso.unknown_provider",
400, "未知的 SSO provider: " + providerId));
SsoUserInfo info = provider.resolve(code, state);
// 3. 查已有绑定
ExternalIdentityEntity identity = findIdentity(providerId, info);
if (identity != null) {
UserEntity user = userMapper.selectById(identity.getUserId());
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) {
throw new MateClawException("err.sso.account_disabled",
403, "账号已停用或不存在");
}
updateIdentityOnLogin(identity, info);
audit("sso.login", providerId, info.externalId(), user.getId());
return SsoCallbackResponse.of(loginSuccess(user));
}
// 4. 未绑定
if (ssoProperties.isLinkOnly()) {
String bindToken = stateService.issueBindToken(providerId, info);
audit("sso.bind_required", providerId, info.externalId(), null);
return SsoCallbackResponse.bindRequired(bindToken, providerId, info.displayName());
}
// 5. 默认: 自动创建 (含并发幂等, 最多重试一次)
UserEntity newUser = createSsoUser(providerId, info, false);
return SsoCallbackResponse.of(loginSuccess(newUser));
}
// ==================== bind (link-only 模式) ====================
/**
* 绑定 SSO 身份到已有 mate_user 账号
* 校验 bind_token + 用户名密码 创建 external_identity 签发 JWT
*/
public LoginResponse handleBind(String bindToken, String username, String password) {
SsoStateService.BindTokenClaims claims = stateService.verifyBindToken(bindToken);
// 校验用户名密码 ( AuthService.login 一致的 BCrypt 校验)
UserEntity user = authService.findByUsername(username);
if (user == null || user.getPassword() == null
|| !passwordEncoder.matches(password, user.getPassword())) {
throw new MateClawException("err.auth.invalid_credentials",
401, "用户名或密码错误");
}
if (!Boolean.TRUE.equals(user.getEnabled())) {
throw new MateClawException("err.sso.account_disabled",
403, "账号已停用");
}
// 创建绑定 (并发幂等: UNIQUE(provider, external_id) 兜底)
try {
ExternalIdentityEntity identity = new ExternalIdentityEntity();
identity.setUserId(user.getId());
identity.setProvider(claims.provider());
identity.setExternalId(claims.externalId());
identity.setUnionId(claims.unionId());
identity.setExternalName(claims.externalName());
identity.setLastLoginAt(LocalDateTime.now());
identityMapper.insert(identity);
} catch (DuplicateKeyException e) {
throw new MateClawException("err.sso.already_bound",
409, "该飞书账号已绑定到其他用户");
}
audit("sso.bind", claims.provider(), claims.externalId(), user.getId());
return loginSuccess(user);
}
/**
* 查找已绑定的外部身份union_id 优先, 回退 external_id
*/
private ExternalIdentityEntity findIdentity(String providerId, SsoUserInfo info) {
// 优先 union_id
if (info.unionId() != null && !info.unionId().isBlank()) {
ExternalIdentityEntity byUnion = identityMapper.selectOne(
new LambdaQueryWrapper<ExternalIdentityEntity>()
.eq(ExternalIdentityEntity::getProvider, providerId)
.eq(ExternalIdentityEntity::getUnionId, info.unionId()));
if (byUnion != null) return byUnion;
}
// 回退 external_id
return identityMapper.selectOne(
new LambdaQueryWrapper<ExternalIdentityEntity>()
.eq(ExternalIdentityEntity::getProvider, providerId)
.eq(ExternalIdentityEntity::getExternalId, info.externalId()));
}
/**
* 更新绑定记录的 last_login + external 信息
*/
private void updateIdentityOnLogin(ExternalIdentityEntity identity, SsoUserInfo info) {
identityMapper.update(null, new LambdaUpdateWrapper<ExternalIdentityEntity>()
.eq(ExternalIdentityEntity::getId, identity.getId())
.set(ExternalIdentityEntity::getLastLoginAt, LocalDateTime.now())
.set(ExternalIdentityEntity::getExternalName, info.displayName())
.set(ExternalIdentityEntity::getExternalAvatar, info.avatarUrl())
.set(ExternalIdentityEntity::getExternalEmail, info.email()));
}
/**
* 自动创建 SSO 用户 (含并发幂等: catch DuplicateKeyException 回滚孤儿 user 重查)
*
* @param retry 是否已重试过一次第二次仍撞 PK 时直接抛异常 (不再递归, 避免栈溢出)
*/
private UserEntity createSsoUser(String providerId, SsoUserInfo info, boolean retry) {
UserEntity newUser = new UserEntity();
newUser.setUsername(providerId + "_" + info.externalId()); // feishu_<full open_id>
newUser.setPassword(null); // SSO 登录
newUser.setNickname(info.displayName());
newUser.setAvatar(info.avatarUrl());
newUser.setEmail(info.email());
newUser.setRole(ssoProperties.getDefaultRole());
newUser.setEnabled(true);
try {
userMapper.insert(newUser);
ExternalIdentityEntity identity = new ExternalIdentityEntity();
identity.setUserId(newUser.getId());
identity.setProvider(providerId);
identity.setExternalId(info.externalId());
identity.setUnionId(info.unionId());
identity.setExternalName(info.displayName());
identity.setExternalAvatar(info.avatarUrl());
identity.setExternalEmail(info.email());
identity.setLastLoginAt(LocalDateTime.now());
identityMapper.insert(identity);
audit("sso.auto_create", providerId, info.externalId(), newUser.getId());
return newUser;
} catch (DuplicateKeyException e) {
// 并发: 另一个请求已创建了该用户回滚刚建的孤儿 user, 重查已存在的 identity
log.info("[SSO] Concurrent auto-create for provider={}, externalId={}: "
+ "rolling back orphan user {}, falling back to existing", providerId, info.externalId(), newUser.getId());
userMapper.deleteById(newUser.getId()); // mate_user @TableLogic, 物理删
ExternalIdentityEntity existing = findIdentity(providerId, info);
if (existing != null) {
return userMapper.selectById(existing.getUserId());
}
// 极端竞态: identity 也被并发删了重试一次, 不再递归
if (retry) {
throw new MateClawException("err.sso.concurrent_create_failed",
503, "SSO 登录遇到并发冲突, 请重试");
}
return createSsoUser(providerId, info, true);
} catch (RuntimeException e) {
// Identity insert failed for a non-duplicate reason (e.g. transient DB error).
// The two inserts are not in a shared transaction this method is self-invoked
// and the enclosing callback performs a network call, so a method-level
// @Transactional would not apply. Roll back the freshly inserted user here so we
// never leave a passwordless orphan account behind.
if (newUser.getId() != null) {
userMapper.deleteById(newUser.getId());
}
throw e;
}
}
private LoginResponse loginSuccess(UserEntity user) {
String token = authService.generateToken(user);
return new LoginResponse(user.getId(), token, user.getUsername(),
user.getNickname(), user.getRole());
}
private void audit(String action, String provider, String externalId, Long userId) {
if (auditService != null) {
try {
String detail = objectMapper.writeValueAsString(Map.of(
"provider", provider != null ? provider : "",
"userId", userId != null ? userId : "null"));
auditService.record(action, "sso",
provider + ":" + externalId, externalId, detail);
} catch (Exception e) {
log.debug("[SSO] audit write failed for {}: {}", action, e.getMessage());
}
}
}
}

View File

@ -0,0 +1,233 @@
package vip.mate.auth.sso;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import vip.mate.auth.sso.model.SsoStateEntity;
import vip.mate.auth.sso.provider.SsoUserInfo;
import vip.mate.auth.sso.repository.SsoStateMapper;
import vip.mate.exception.MateClawException;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
/**
* OAuth2 state / bind_token 签发与校验服务
* <p>
* DB (sso_state ) 而非内存: 多节点部署下 /authorize /callback 可能落到不同节点
* state bind_token jti 共用同一张表, {@code kind} 列区分
*
* <p><b>State</b> (OAuth2 CSRF):
* <ul>
* <li>签发: Base64(nonce + "." + HMAC-SHA256(nonce, jwtSecret)), DB (kind=state)</li>
* <li>校验: HMAC 签名 + 5min TTL + 一次性消费 (UPDATE consumed=1 WHERE consumed=0)</li>
* </ul>
*
* <p><b>bind_token</b> (link-only 模式, 自包含 JWT):
* <ul>
* <li>签发: JWT(jti, provider, externalId, ..., exp=10min), jwtSecret 签名</li>
* <li>校验: 验签 + 过期 + 单次消费 (jti 写入 sso_state PK, 只有首个请求成功)</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
@RequiredArgsConstructor
public class SsoStateService {
private static final int STATE_TTL_SECONDS = 5 * 60; // 5 min
private static final int BIND_TOKEN_TTL_SECONDS = 10 * 60; // 10 min
private static final String KIND_STATE = "state";
private static final String KIND_BIND = "bind";
private final SsoStateMapper stateMapper;
@Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}")
private String jwtSecret;
// ==================== State (OAuth2 CSRF) ====================
/**
* 签发 OAuth2 state token 并持久化返回 Base64(nonce.signature) 格式
*/
public String issueState(String provider) {
String nonce = UUID.randomUUID().toString().replace("-", "");
String signature = hmacSha256Hex(nonce);
String state = nonce + "." + signature;
SsoStateEntity entity = new SsoStateEntity();
entity.setToken(state);
entity.setKind(KIND_STATE);
entity.setProvider(provider);
entity.setConsumed(0);
entity.setCreatedAt(LocalDateTime.now());
stateMapper.insert(entity);
return state;
}
/**
* 校验 state 签名 + 过期 + 一次性消费校验失败抛 400
*/
public void verifyState(String state) {
if (state == null || state.isBlank()) {
throw new MateClawException("err.sso.state_missing", 400, "缺少 state 参数");
}
int dot = state.indexOf('.');
if (dot <= 0 || dot >= state.length() - 1) {
throw new MateClawException("err.sso.state_invalid", 400, "state 格式无效");
}
String nonce = state.substring(0, dot);
String signature = state.substring(dot + 1);
// 1. HMAC 签名
String expected = hmacSha256Hex(nonce);
if (!expected.equals(signature)) {
throw new MateClawException("err.sso.state_invalid", 400, "state 签名校验失败");
}
// 2. 一次性消费 + TTL: UPDATE consumed=1 WHERE token=? AND consumed=0 AND created_at > cutoff.
// created_at 条件让 5min TTL 在消费阶段强制生效 否则未消费的 state
// 只在 1h purge 后才物理删除, /authorize 30min /callback 仍能通过
LocalDateTime cutoff = LocalDateTime.now().minusSeconds(STATE_TTL_SECONDS);
int rows = stateMapper.update(null, new LambdaUpdateWrapper<SsoStateEntity>()
.eq(SsoStateEntity::getToken, state)
.eq(SsoStateEntity::getConsumed, 0)
.gt(SsoStateEntity::getCreatedAt, cutoff)
.set(SsoStateEntity::getConsumed, 1));
if (rows == 0) {
throw new MateClawException("err.sso.state_expired_or_used",
400, "state 已过期或已被使用, 请重新登录");
}
}
// ==================== bind_token (link-only 模式) ====================
/**
* 签发 bind_token (自包含 JWT), 携带 IdP 用户信息TTL 10min
*/
public String issueBindToken(String provider, SsoUserInfo info) {
long now = System.currentTimeMillis();
return Jwts.builder()
.id(UUID.randomUUID().toString()) // jti
.claim("provider", provider)
.claim("externalId", info.externalId())
.claim("unionId", info.unionId())
.claim("externalName", info.displayName())
.issuedAt(new Date(now))
.expiration(new Date(now + BIND_TOKEN_TTL_SECONDS * 1000L))
.signWith(getSignKey())
.compact();
}
/**
* 校验 bind_token 验签 + 过期 + 单次消费 (jti PK)返回 claims 供绑定使用
*/
public BindTokenClaims verifyBindToken(String bindToken) {
if (bindToken == null || bindToken.isBlank()) {
throw new MateClawException("err.sso.bind_token_missing", 400, "缺少 bind_token");
}
// 1. 验签 + 过期
Claims claims;
try {
claims = Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(bindToken)
.getPayload();
} catch (Exception e) {
throw new MateClawException("err.sso.bind_token_invalid",
400, "bind_token 无效或已过期");
}
String jti = claims.getId();
if (jti == null) {
throw new MateClawException("err.sso.bind_token_invalid", 400, "bind_token 缺少 jti");
}
// 2. 单次消费: INSERT (token=jti, kind=bind) PK, 只有首个请求成功
SsoStateEntity consumed = new SsoStateEntity();
consumed.setToken(jti);
consumed.setKind(KIND_BIND);
consumed.setProvider(claims.get("provider", String.class));
consumed.setConsumed(1);
consumed.setCreatedAt(LocalDateTime.now());
try {
stateMapper.insert(consumed);
} catch (DuplicateKeyException e) {
throw new MateClawException("err.sso.bind_token_used",
400, "bind_token 已被使用, 请重新登录");
}
return new BindTokenClaims(
claims.get("provider", String.class),
claims.get("externalId", String.class),
claims.get("unionId", String.class),
claims.get("externalName", String.class));
}
/** bind_token 校验通过后返回的 claims。 */
public record BindTokenClaims(String provider, String externalId,
String unionId, String externalName) {}
// ==================== 过期清理 (ShedLock 定时任务) ====================
/**
* 每小时清理过期 state/bind_token
* LambdaQuery + Java 时间过滤, 通吃三方言 (不用 NOW() - INTERVAL SQL 方言)
*/
@Scheduled(fixedDelay = 3600_000) // 1h
public void purgeExpired() {
LocalDateTime cutoff = LocalDateTime.now().minus(1, ChronoUnit.HOURS);
int deleted = stateMapper.delete(new LambdaQueryWrapper<SsoStateEntity>()
.lt(SsoStateEntity::getCreatedAt, cutoff));
if (deleted > 0) {
log.info("[SsoState] Purged {} expired state/bind rows (cutoff={})", deleted, cutoff);
}
}
// ==================== helpers ====================
private SecretKey getSignKey() {
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
// HMAC-SHA 需要 >= 256 bit (32 byte); secret 0x00 填充到 32 byte ( AuthService 一致)
if (keyBytes.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
keyBytes = padded;
}
return Keys.hmacShaKeyFor(keyBytes);
}
private String hmacSha256Hex(String input) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(getSignKey());
byte[] hash = mac.doFinal(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("HMAC-SHA256 failed", e);
}
}
}

View File

@ -0,0 +1,60 @@
package vip.mate.auth.sso.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户外部身份关联实体SSO
* <p>
* 一个 {@code mate_user} 可绑定多个 IdP 身份一个 {@code (provider, external_id)}
* 至多归属一个用户匹配优先级union_id跨应用唯一优先回退到 external_id
*
* @author MateClaw Team
*/
@Data
@TableName("mate_user_external_identity")
public class ExternalIdentityEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
/** 身份提供方标识: feishu / dingtalk / wecom / ... */
private String provider;
/** IdP 内用户标识, 通常是 open_id */
private String externalId;
/** 跨应用唯一标识 (飞书特有), nullable */
private String unionId;
private String externalName;
private String externalAvatar;
private String externalEmail;
private LocalDateTime lastLoginAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除 ( wiki 系列表 @TableLogic 约定一致)
* <p>
* 注意: {@code mate_user.deleted} 当前无 @TableLogic, 全局无 logic-delete-field,
* deleteById 是物理删 本表的逻辑删除独立于 mate_user解绑时 service
* 改写 external_id / union_id {@code <原值>_del_<timestamp>} 释放唯一约束
*/
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,37 @@
package vip.mate.auth.sso.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* OAuth2 state / bind-token 防重放存储
* <p>
* DB 而非内存: 多节点部署下 /authorize /callback 可能落到不同节点,
* 内存存储会导致 state 找不到登录硬失败{@code kind} 区分 {@code state}
* (OAuth2 CSRF state) {@code bind} (bind_token jti)
*
* <p>一次性消费: state {@code UPDATE ... SET consumed=1 WHERE token=? AND consumed=0},
* affected rows 必须 = 1; bind_token jti {@code INSERT} PK 实现首个消费成功
*
* @author MateClaw Team
*/
@Data
@TableName("sso_state")
public class SsoStateEntity {
@TableId(type = IdType.INPUT)
private String token;
/** state | bind */
private String kind;
private String provider;
private Integer consumed;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,230 @@
package vip.mate.auth.sso.provider;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import vip.mate.auth.sso.SsoProperties;
import vip.mate.exception.MateClawException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/**
* 飞书 OAuth2 SSO Provider
* <p>
* 授权码流程:
* <ol>
* <li>app_id + app_secret app_access_token (有效期 2h, Caffeine 缓存 ~110min)</li>
* <li>app_access_token + code user_access_token (飞书 OIDC 端点)</li>
* <li>user_access_token 用户信息 (open_id / union_id / name / email / avatar)</li>
* </ol>
*
* <p>HTTP 调用模式复刻 {@code FeishuChannelAdapter.getUserName}:
* JDK {@code HttpClient} + Jackson {@code ObjectMapper} + 飞书 {@code code==0} 约定
* 注意 SSO app_access_token IM 渠道的 tenant_access_token 是不同 token不同应用, 无法复用
*
* <p>apiBase {@code domain} 切换: {@code feishu} {@code https://open.feishu.cn};
* {@code lark} {@code https://open.larksuite.com}
*
* @author MateClaw Team
*/
public class FeishuSsoProvider implements SsoProvider {
private static final String PROVIDER_ID = "feishu";
private static final String DISPLAY_NAME = "飞书";
private final SsoProperties.Feishu cfg;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final String apiBase;
/** app_access_token 缓存: 飞书有效期 2h, TTL 110min 留余量 */
private final Cache<String, String> appTokenCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(110))
.maximumSize(1)
.build();
public FeishuSsoProvider(SsoProperties.Feishu cfg, ObjectMapper objectMapper) {
this.cfg = cfg;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.apiBase = "lark".equalsIgnoreCase(cfg.getDomain())
? "https://open.larksuite.com"
: "https://open.feishu.cn";
}
@Override
public String id() { return PROVIDER_ID; }
@Override
public String displayName() { return DISPLAY_NAME; }
@Override
public String authorizeUrl(String state) {
return apiBase + "/open-apis/authen/v1/authorize"
+ "?app_id=" + cfg.getAppId()
+ "&redirect_uri=" + encode(cfg.getRedirectUri())
+ "&response_type=code"
+ "&state=" + encode(state);
}
@Override
public SsoUserInfo resolve(String code, String state) {
String appAccessToken = getAppAccessToken();
String userAccessToken = exchangeUserAccessToken(code, appAccessToken);
return fetchUserInfo(userAccessToken);
}
// ------------------------------------------------------------------
// 飞书 API 调用
// ------------------------------------------------------------------
/**
* 获取 app_access_token (带缓存)POST /auth/v3/app_access_token/internal
*/
private String getAppAccessToken() {
String cached = appTokenCache.getIfPresent("token");
if (cached != null) return cached;
try {
String body = objectMapper.writeValueAsString(Map.of(
"app_id", cfg.getAppId(),
"app_secret", cfg.getAppSecret()));
Map<String, Object> resp = postJson(
apiBase + "/open-apis/auth/v3/app_access_token/internal", body, null);
checkCode(resp, "app_access_token");
String token = (String) resp.get("app_access_token");
if (token == null || token.isBlank()) {
throw new MateClawException("err.sso.feishu_token_empty",
502, "飞书未返回 app_access_token");
}
appTokenCache.put("token", token);
return token;
} catch (MateClawException e) {
throw e;
} catch (Exception e) {
throw new MateClawException("err.sso.feishu_app_token_failed",
502, "获取飞书 app_access_token 失败: " + e.getMessage());
}
}
/**
* code user_access_tokenPOST /authen/v1/oidc/access_token
*/
private String exchangeUserAccessToken(String code, String appAccessToken) {
try {
String body = objectMapper.writeValueAsString(Map.of(
"grant_type", "authorization_code",
"code", code));
Map<String, Object> resp = postJson(
apiBase + "/open-apis/authen/v1/oidc/access_token", body, appAccessToken);
checkCode(resp, "user_access_token");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) resp.get("data");
if (data == null) {
throw new MateClawException("err.sso.feishu_no_data", 502, "飞书未返回 token 数据");
}
String token = (String) data.get("access_token");
if (token == null || token.isBlank()) {
throw new MateClawException("err.sso.feishu_user_token_empty",
502, "飞书未返回 user_access_token");
}
return token;
} catch (MateClawException e) {
throw e;
} catch (Exception e) {
throw new MateClawException("err.sso.feishu_code_exchange_failed",
502, "飞书授权码换取 token 失败: " + e.getMessage());
}
}
/**
* user_access_token 用户信息GET /authen/v1/user_info
*/
@SuppressWarnings("unchecked")
private SsoUserInfo fetchUserInfo(String userAccessToken) {
try {
Map<String, Object> resp = getJson(
apiBase + "/open-apis/authen/v1/user_info", userAccessToken);
checkCode(resp, "user_info");
Map<String, Object> data = (Map<String, Object>) resp.get("data");
if (data == null) {
throw new MateClawException("err.sso.feishu_no_user_data",
502, "飞书未返回用户信息");
}
String openId = str(data.get("open_id"));
if (openId == null || openId.isBlank()) {
throw new MateClawException("err.sso.feishu_no_open_id",
502, "飞书用户信息缺少 open_id");
}
return new SsoUserInfo(
openId,
str(data.get("union_id")),
str(data.get("name")),
str(data.get("avatar")),
str(data.get("email")),
str(data.get("mobile")));
} catch (MateClawException e) {
throw e;
} catch (Exception e) {
throw new MateClawException("err.sso.feishu_user_info_failed",
502, "获取飞书用户信息失败: " + e.getMessage());
}
}
// ------------------------------------------------------------------
// HTTP helpers (复刻 FeishuChannelAdapter.getUserName 模式)
// ------------------------------------------------------------------
private Map<String, Object> postJson(String url, String jsonBody, String bearerToken) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json; charset=utf-8")
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
if (bearerToken != null) {
builder.header("Authorization", "Bearer " + bearerToken);
}
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return objectMapper.readValue(response.body(), Map.class);
}
private Map<String, Object> getJson(String url, String bearerToken) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + bearerToken)
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return objectMapper.readValue(response.body(), Map.class);
}
private void checkCode(Map<String, Object> resp, String api) {
Integer code = resp.get("code") instanceof Number n ? n.intValue() : null;
if (code == null || code != 0) {
String msg = str(resp.get("msg"));
throw new MateClawException("err.sso.feishu_api_error",
502, "飞书 " + api + " 接口返回错误: code=" + code + ", msg=" + msg);
}
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static String encode(String s) {
try {
return URLEncoder.encode(s, "UTF-8");
} catch (Exception e) {
return s;
}
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.auth.sso.provider;
/**
* SSO 身份提供方抽象每个 IdP飞书/钉钉/企微/...实现此接口
* <p>
* 注册到 {@link SsoProviderRegistry} 后由 {@code SsoController} id 路由
*
* @author MateClaw Team
*/
public interface SsoProvider {
/** Provider 标识, 如 "feishu" */
String id();
/** 展示名, 如 "飞书" (前端渲染按钮用) */
String displayName();
/**
* 构造授权 URL前端 window.location 跳转到此 URL 让用户授权
*
* @param state CSRF 防护 token, 原样附加到授权 URL state 参数
* @return 完整的 IdP 授权 URL
*/
String authorizeUrl(String state);
/**
* 用授权码换取用户信息
*
* @param code IdP 回调带回的授权码
* @param state 回调带回的 state已由 Controller 校验过签名 + 一次性消费
* @return IdP 侧的标准化用户身份信息
*/
SsoUserInfo resolve(String code, String state);
}

View File

@ -0,0 +1,50 @@
package vip.mate.auth.sso.provider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* SSO Provider 注册表 id 查找 Provider, 列出已启用的 Provider 供前端渲染按钮
* <p>
* Provider 通过构造函数注入 (Spring {@code @ConditionalOnProperty} 按需实例化)
*
* @author MateClaw Team
*/
@Component
@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
public class SsoProviderRegistry {
private final Map<String, SsoProvider> providers = new LinkedHashMap<>();
/**
* Spring 注入所有已启用的 {@link SsoProvider} bean SSO 未启用时该列表为空
*/
public SsoProviderRegistry(List<SsoProvider> providerBeans) {
if (providerBeans != null) {
for (SsoProvider p : providerBeans) {
providers.put(p.id(), p);
}
}
}
/** 按 id 查 */
public Optional<SsoProvider> get(String providerId) {
if (providerId == null) return Optional.empty();
return Optional.ofNullable(providers.get(providerId));
}
/** 列出所有已启用的 Provider (供前端渲染 SSO 按钮) */
public List<SsoProvider> listEnabled() {
return List.copyOf(providers.values());
}
/** 是否有任何 Provider 已启用 */
public boolean hasEnabled() {
return !providers.isEmpty();
}
}

View File

@ -0,0 +1,22 @@
package vip.mate.auth.sso.provider;
/**
* IdP 返回的标准化用户信息 Provider 把平台特异字段映射到此结构
*
* @param externalId open_idprovider 内唯一
* @param unionId union_id跨应用唯一, nullable 飞书需开启 union_id 数据权限
* @param displayName 昵称
* @param avatarUrl 头像 URL
* @param email 邮箱nullable
* @param mobile 手机nullable
*
* @author MateClaw Team
*/
public record SsoUserInfo(
String externalId,
String unionId,
String displayName,
String avatarUrl,
String email,
String mobile
) {}

View File

@ -0,0 +1,14 @@
package vip.mate.auth.sso.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.auth.sso.model.ExternalIdentityEntity;
/**
* 用户外部身份关联 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface ExternalIdentityMapper extends BaseMapper<ExternalIdentityEntity> {
}

View File

@ -0,0 +1,14 @@
package vip.mate.auth.sso.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.auth.sso.model.SsoStateEntity;
/**
* OAuth2 state / bind-token 存储 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface SsoStateMapper extends BaseMapper<SsoStateEntity> {
}

View File

@ -44,7 +44,8 @@ public class ChannelChatOriginFactory {
? message.getChannelType()
: channel.getChannelType(),
/* chatId */ message.getChatId(),
/* baseUrl */ null); // IM origins have no request host; rely on public-base-url config
/* baseUrl */ null, // IM origins have no request host; rely on public-base-url config
/* requesterUserId */ null); // IM senders are external platform ids, not MateClaw accounts
}
/**

View File

@ -134,6 +134,13 @@ public class ChannelManager {
*/
private final ChannelLeaderElection leaderElection;
/**
* Workspace/agent-aware chat-upload resolver, passed into adapters so their
* inbound media downloads land under the channel's workspace base path
* (falling back to the configured default dir).
*/
private final vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
/** 运行中的渠道适配器channelId -> adapter */
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
@ -1197,14 +1204,16 @@ public class ChannelManager {
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService);
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService,
chatUploadLocationResolver);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler,
generatedFileCache);
generatedFileCache, chatUploadLocationResolver);
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper);
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper);
case "weixin" -> new WeixinChannelAdapter(channel, messageRouter, objectMapper,
chatUploadLocationResolver);
case "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
default -> throw new IllegalArgumentException("Unsupported channel type: " + type);

View File

@ -70,6 +70,13 @@ public class ChannelMessageRouter {
@Autowired(required = false)
private ApplicationEventPublisher events;
/** Field-injected for the same reason as {@link #events}: the chat-upload
* resolver resolves the workspace-aware TTS output directory on the
* voice-reply path. Optional so tests that build the router directly
* still work; falls back to the legacy default dir when unset. */
@Autowired(required = false)
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@ -783,7 +790,7 @@ public class ChannelMessageRouter {
StringBuilder replyAccumulator = new StringBuilder();
final String channelType = adapter.getChannelType();
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin)
@ -793,6 +800,9 @@ public class ChannelMessageRouter {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
@ -833,7 +843,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", reply, null, status,
usage[0], usage[1], modelInfo[0], modelInfo[1]);
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
savedAssistantId = saved != null ? saved.getId() : null;
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
@ -948,13 +958,16 @@ public class ChannelMessageRouter {
// plan_step_* events, leaving the Web Console mirror with no
// PlanStepsPanel for IM-routed conversations.
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
@ -982,7 +995,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", finalContent, null, status,
usage[0], usage[1], modelInfo[0], modelInfo[1]);
usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
}
@ -1491,10 +1504,12 @@ public class ChannelMessageRouter {
// 构建音频 MessageContentPart
String audioUrl = (String) result.get("audioUrl");
String fileName = Paths.get(audioUrl).getFileName().toString();
Path audioPath = Paths.get("data", "chat-uploads", conversationId, fileName);
// TTS output may live under a workspace-scoped dir or the legacy
// default dir probe each candidate root to find the file.
Path audioPath = resolveVoiceReplyAudio(conversationId, fileName);
if (!Files.exists(audioPath)) {
log.warn("[voice-reply] TTS output file not found: {}", audioPath);
if (audioPath == null) {
log.warn("[voice-reply] TTS output file not found for conversation {} ({})", conversationId, fileName);
return;
}
@ -1516,6 +1531,27 @@ public class ChannelMessageRouter {
});
}
/**
* Resolve the TTS audio file across every candidate upload root. Returns the
* first existing match, or {@code null} when the file is absent under every
* root. Used by the voice-reply path so workspace-scoped and legacy default
* outputs are both found.
*/
private Path resolveVoiceReplyAudio(String conversationId, String fileName) {
if (chatUploadLocationResolver != null) {
for (Path root : chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
Path candidate = root.resolve(conversationId).resolve(fileName);
if (Files.exists(candidate)) {
return candidate;
}
}
}
// Fallback to the legacy default dir when the resolver is absent
// (e.g. direct-construction unit tests).
Path legacy = Paths.get("data", "chat-uploads", conversationId, fileName);
return Files.exists(legacy) ? legacy : null;
}
/**
* 判断是否需要为此消息生成语音回复
*/

View File

@ -229,8 +229,48 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
.build();
// Package-private for testing: redirect to a temp directory without touching real disk.
// Used as the fallback upload root when no ChatUploadLocationResolver is wired
// (e.g. direct-construction unit tests set this to a tmp dir).
Path chatUploadsRoot = Path.of("data", "chat-uploads");
/**
* Workspace/agent-aware upload-root resolver. Set by the production factory
* (ChannelManager); null in unit tests, which override {@link #chatUploadsRoot}
* instead. When non-null, attachment reads/writes resolve through it so files
* land under the workspace base path; otherwise the legacy
* {@link #chatUploadsRoot} field applies.
*/
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
/**
* Resolve the upload root for a conversation, preferring the wired resolver
* (workspace/agent-aware) and falling back to the legacy field. Read paths
* should use {@link #candidateChatUploadRoots(String)} to probe both the
* workspace-scoped root and the legacy root.
*/
private java.nio.file.Path chatUploadRootFor(String conversationId) {
if (chatUploadLocationResolver != null) {
return chatUploadLocationResolver.resolveUploadRoot(conversationId);
}
return chatUploadsRoot;
}
/**
* Ordered candidate upload roots for a conversation: workspace-scoped first
* (when the resolver is wired), then the legacy field. Used by read/scan
* paths so attachments written before the workspace-aware relocation are
* still found.
*/
private java.util.List<java.nio.file.Path> candidateChatUploadRoots(String conversationId) {
java.util.List<java.nio.file.Path> roots = new java.util.ArrayList<>();
if (chatUploadLocationResolver != null) {
roots.addAll(chatUploadLocationResolver.resolveCandidateUploadRoots(conversationId));
} else {
roots.add(chatUploadsRoot);
}
return roots;
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
@ -291,6 +331,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
FeishuClientFactory clientFactory,
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
vip.mate.stt.SttService sttService) {
this(channelEntity, messageRouter, objectMapper, mediaUploader,
generatedFileScrubber, streamingCardManager, cardDispatcher,
clientFactory, generatedFileCache, sttService, null);
}
/**
* Full constructor used by the production factory (ChannelManager). The
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
* attachment storage; {@code null} (or a shorter overload) keeps the legacy
* {@code data/chat-uploads} behaviour.
*/
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber,
FeishuStreamingCardManager streamingCardManager,
vip.mate.channel.feishu.cards.FeishuCardDispatcher cardDispatcher,
FeishuClientFactory clientFactory,
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
vip.mate.stt.SttService sttService,
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
super(channelEntity, messageRouter, objectMapper);
this.mediaUploader = mediaUploader;
this.generatedFileScrubber = generatedFileScrubber;
@ -299,6 +361,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
this.clientFactory = clientFactory;
this.generatedFileCache = generatedFileCache;
this.sttService = sttService;
this.chatUploadLocationResolver = chatUploadLocationResolver;
// Feishu WebSocket reconnect: 2s4s8s16s30s, infinite retry
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
}
@ -1712,8 +1775,8 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
: maybeDownloadResource(messageId, fileKey, type, fileName);
if (dl == null) return null;
// Save to data/chat-uploads/{conversationId}/
Path uploadDir = chatUploadsRoot.resolve(conversationId);
// Save under the workspace/agent-aware upload root ({convId}/ subdir)
Path uploadDir = chatUploadRootFor(conversationId).resolve(conversationId);
Files.createDirectories(uploadDir);
String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
? dl.fileName() : fileKey;
@ -1796,15 +1859,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
}
/**
* Scan {@code data/chat-uploads/{conversationId}/} on disk and return
* the most recent files as {@link RecentFileEntry}s. Used as a
* fallback when the in-memory Caffeine cache has been evicted
* (process restart, TTL expiry, GC pressure) but the staged copies
* are still on disk.
* Scan the conversation's upload dir(s) on disk and return the most recent
* files as {@link RecentFileEntry}s. Used as a fallback when the in-memory
* Caffeine cache has been evicted (process restart, TTL expiry, GC pressure)
* but the staged copies are still on disk. Probes every candidate root
* (workspace-scoped + legacy default) so files written before the
* workspace-aware relocation are still found.
*/
private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L;
return loadRecentFilesFromDisk(chatUploadsRoot.resolve(conversationId), cutoff);
List<RecentFileEntry> merged = new java.util.ArrayList<>();
for (Path root : candidateChatUploadRoots(conversationId)) {
merged.addAll(loadRecentFilesFromDisk(root.resolve(conversationId), cutoff));
}
return merged;
}
/**

View File

@ -9,7 +9,9 @@ import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerData;
import com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
import lombok.extern.slf4j.Slf4j;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.PendingApproval;
import vip.mate.approval.ResolveOutcome;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.feishu.cards.FeishuCardHandler;
@ -59,11 +61,23 @@ import java.util.Optional;
public class ToolGuardCardHandler implements FeishuCardHandler {
private final ApprovalService approvalService;
/**
* ISSUE #413 P2-B3: needed to resolve workflow-scoped approvals
* ({@code wf-} pendingIds) directly from the card click. Workflow
* approvals cannot go through the synthetic /approve injection
* (their conversationId is {@code workflow:run:{runId}}, which no
* IM conversation matches), so the handler resolves them inline
* mirroring the Web / WebChat path. May be null in narrow test
* contexts (wf- approvals then fall back to the admin console).
*/
private final ApprovalWorkflowService approvalWorkflowService;
private final ToolGuardButtonValue buttonValue;
public ToolGuardCardHandler(ApprovalService approvalService,
ApprovalWorkflowService approvalWorkflowService,
ToolGuardButtonValue buttonValue) {
this.approvalService = approvalService;
this.approvalWorkflowService = approvalWorkflowService;
this.buttonValue = buttonValue;
}
@ -101,6 +115,19 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
PendingApproval pending = opt.get();
// ---- 3. Identity check (fail-closed)
// Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no
// human requester the userId is null because the run is system-
// initiated. Their approval cards are only ever pushed to the
// channels declared in await_approval's approverChannels, so any
// member of that audience is a legitimate approver; we skip the
// requester==clicker guard and resolve inline (the synthetic /approve
// injection is a dead end for wf- ids: their conversationId is
// workflow:run:{runId}, which no IM conversation matches, so the
// router's findPendingByConversation would miss it).
if (pendingId.startsWith("wf-")) {
return handleWorkflowApproval(pendingId, decoded.toolName(), act, clickerOpenId);
}
// Agent/cron ("system") or unattributed (null) approvals have no human
// requester to match the clicker against. A guarded-tool card landing in
// a group chat would otherwise let ANY member click Approve and run the
@ -142,6 +169,54 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
return buildResolvedResponse(decoded.toolName(), act, clickerOpenId);
}
// ------------------------------------------------------------------
// Workflow-scoped approval (ISSUE #413 P2-B3)
// ------------------------------------------------------------------
/**
* Resolve a {@code wf-} workflow approval directly from the card click,
* bypassing the synthetic /approve injection. Workflow approvals live
* under a synthetic {@code workflow:run:{runId}} conversationId that no
* IM conversation matches, so the router path is a dead end. Instead we
* resolve inline (mirroring the Web / WebChat path); the
* {@link vip.mate.workflow.runtime.ApprovalResumeBridge} then picks up
* the {@code WorkflowApprovalResolvedEvent} published inside
* {@code ApprovalWorkflowService.resolve} and resumes the paused run.
*
* <p>No tool-call replay is needed a workflow {@code await_approval}
* step is a declarative gate, not a tool invocation; resume simply
* advances to the next step.
*
* <p>Identity: any audience member may resolve. The card only reaches
* the channels declared in {@code await_approval.approverChannels}
* (pushed by {@code AwaitApprovalStepAdapter}'s notify step), so whoever
* can see it is a designated approver.
*/
private P2CardActionTriggerResponse handleWorkflowApproval(String pendingId, String toolName,
ToolGuardButtonValue.Action act,
String clickerOpenId) {
if (approvalWorkflowService == null) {
log.warn("[feishu-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} "
+ "(use the admin console)", pendingId);
return buildErrorResponse("⚠️ 工作流审批需在管理端处理");
}
String decision = act == ToolGuardButtonValue.Action.APPROVE ? "approved" : "denied";
try {
ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerOpenId, decision);
if (!outcome.dbSynced()) {
// already resolved / superseded not an error, but tell the clicker.
log.info("[feishu-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision());
return buildExpiredResponse(toolName);
}
log.info("[feishu-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)",
pendingId, decision, abbrev(clickerOpenId));
return buildResolvedResponse(toolName, act, clickerOpenId);
} catch (Exception e) {
log.error("[feishu-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e);
return buildErrorResponse("⚠️ 工作流审批未生效,请重试或在管理端处理");
}
}
// ------------------------------------------------------------------
// Response builders assemble P2CardActionTriggerResponse{toast,card}
// ------------------------------------------------------------------

View File

@ -3,6 +3,7 @@ package vip.mate.channel.feishu.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.feishu.cards.FeishuCardKind;
/**
@ -27,21 +28,27 @@ public class ToolGuardCardKindFactory {
public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX;
private final ApprovalService approvalService;
/** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */
private final ApprovalWorkflowService approvalWorkflowService;
private final ObjectMapper objectMapper;
public ToolGuardCardKindFactory(ApprovalService approvalService,
ApprovalWorkflowService approvalWorkflowService,
ObjectMapper objectMapper) {
this.approvalService = approvalService;
this.approvalWorkflowService = approvalWorkflowService;
this.objectMapper = objectMapper;
}
public FeishuCardKind create() {
ToolGuardButtonValue buttonValue = new ToolGuardButtonValue(objectMapper);
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonValue);
// Handler no longer needs ApprovalWorkflowService the canonical
// resolve + replay path runs via a synthetic /approve|/deny
// message injected back into the router.
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonValue);
// ISSUE #413 P2-B3: handler needs ApprovalWorkflowService to resolve
// wf- workflow approvals inline (the synthetic /approve injection is
// a dead end for wf- ids). Regular tool approvals still go through
// the synthetic /approve | /deny router path as before.
ToolGuardCardHandler handler = new ToolGuardCardHandler(
approvalService, approvalWorkflowService, buttonValue);
return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler);
}
}

View File

@ -30,7 +30,6 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.io.IOException;
import reactor.core.Disposable;
@ -38,6 +37,8 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
@ -62,7 +63,7 @@ public class ChatController {
private final ObjectMapper objectMapper;
private final ConversationCompletionPublisher completionPublisher;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver;
private final Path uploadRoot = Paths.get("data", "chat-uploads");
private final vip.mate.workspace.core.service.ChatUploadLocationResolver uploadLocationResolver;
// 使用虚拟线程池处理 SSEJava 17+ 兼容Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@ -356,6 +357,9 @@ public class ChatController {
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); // includes toolCalls metadata
@ -435,6 +439,9 @@ public class ChatController {
errStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -555,7 +562,7 @@ public class ChatController {
// tools that need a workspace path read it from the agent (origin
// is enriched with workspaceBasePath in StateGraph buildInitialState).
vip.mate.agent.context.ChatOrigin webOrigin =
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId())
memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
.withBaseUrl(requestBaseUrl);
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
.doOnNext(delta -> {
@ -628,6 +635,9 @@ public class ChatController {
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -746,6 +756,9 @@ public class ChatController {
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -848,6 +861,9 @@ public class ChatController {
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1056,7 +1072,7 @@ public class ChatController {
// Carry the web origin so per-owner memory recall (read) and the
// post-conversation memory write below agree on the same owner key.
vip.mate.agent.context.ChatOrigin webOrigin =
memoryOrigin(request.getConversationId(), username, workspaceId, request.getEndUserId());
memoryOrigin(request.getConversationId(), username, requesterUserIdOf(auth), workspaceId, request.getEndUserId());
AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
String response = result.content();
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
@ -1075,7 +1091,9 @@ public class ChatController {
Authentication auth) throws IOException {
String username = auth != null ? auth.getName() : "anonymous";
// 校验会话归属会话可能尚未创建此时允许上传后续 stream/chat 会创建并绑定用户
// 校验会话归属会话可能尚未创建此时允许上传后续 stream/chat 会创建并绑定用户
// 注意会话尚不存在时附件暂存到默认目录resolveUploadRoot 查不到会话即回退
// 会话创建后读取走双重查找仍能命中
if (conversationService.conversationExists(conversationId)
&& !conversationService.isConversationOwner(conversationId, username)) {
return R.fail(403, "无权操作该会话");
@ -1087,6 +1105,7 @@ public class ChatController {
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
String storedName = System.currentTimeMillis() + "_" + safeFilename;
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
Path conversationDir = uploadRoot.resolve(conversationId);
Files.createDirectories(conversationDir);
Path target = conversationDir.resolve(storedName);
@ -1099,8 +1118,8 @@ public class ChatController {
response.setFileName(originalFilename);
response.setStoredName(storedName);
response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName);
// 使用相对路径避免暴露服务端绝对路径
response.setPath(uploadRoot.resolve(conversationId).resolve(storedName).toString());
// root 相对路径避免暴露服务端绝对路径uploadRoot 现在恒为绝对路径
response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName));
response.setSize(file.getSize());
response.setContentType(file.getContentType());
return R.ok(response);
@ -1119,8 +1138,20 @@ public class ChatController {
return ResponseEntity.status(403).build();
}
Path filePath = uploadRoot.resolve(conversationId).resolve(storedName).normalize();
if (!Files.exists(filePath) || !filePath.startsWith(uploadRoot.resolve(conversationId).normalize())) {
// Check every candidate root (workspace-scoped dir + legacy default dir)
// so attachments written before the workspace-aware relocation, and the
// current workspace-scoped ones, are both servable. Each candidate keeps
// its own startsWith traversal guard.
Path filePath = null;
for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
Path conversationDir = root.resolve(conversationId).normalize();
Path candidate = conversationDir.resolve(storedName).normalize();
if (Files.exists(candidate) && candidate.startsWith(conversationDir)) {
filePath = candidate;
break;
}
}
if (filePath == null) {
return ResponseEntity.notFound().build();
}
@ -1154,17 +1185,33 @@ public class ChatController {
* MateClaw user ({@code user:<username>}).
*/
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String username,
Long workspaceId, String endUserId) {
Long requesterUserId, Long workspaceId,
String endUserId) {
// Resolve the public base URL here, on the request thread, so it can ride
// the origin into async tool execution where no request is bound. Tools
// then mint absolute download links without operator config.
String baseUrl = resolveRequestBaseUrl();
if (endUserId != null && !endUserId.isBlank()) {
// Third-party single-account integration: the requester is an external
// end-user id, not a MateClaw account no requesterUserId to assert.
return vip.mate.agent.context.ChatOrigin
.web(conversationId, endUserId.trim(), workspaceId, null, baseUrl)
.withSender(null, "api", null);
}
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl);
// Authenticated web user: carry the immutable id so on-behalf-of identity
// forwarding can assert "MateClaw authenticated this user" (not an anon id).
return vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null, baseUrl, requesterUserId);
}
/**
* Extract the authenticated user's immutable numeric id from the
* {@link Authentication} details (stamped by {@code JwtAuthFilter} for both
* the JWT and PAT paths). Null when not authenticated or details absent.
*/
private Long requesterUserIdOf(org.springframework.security.core.Authentication auth) {
if (auth == null) return null;
Object details = auth.getDetails();
return details instanceof Long id ? id : null;
}
/**
@ -1343,6 +1390,9 @@ public class ChatController {
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1395,6 +1445,9 @@ public class ChatController {
"failed",
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1484,6 +1537,31 @@ public class ChatController {
return savedAssistant != null;
}
/**
* Build the value stored in {@code ChatUploadResponse.path} (and, downstream,
* the message content part): a root-relative path like
* {@code chat-uploads/{convId}/{storedName}}, never the absolute on-disk
* location.
* <p>
* {@code uploadRoot} is always absolute (the resolver normalizes it via
* {@code toAbsolutePath().normalize()}), and this field is purely
* informational it is rendered into the LLM prompt ("附件: foo (path)") and
* returned to the client, while retrieval goes through the basename-based
* {@code ChatUploadResolver} plus the {@code /api/v1/chat/files/...} URL. So
* the absolute form must be avoided: it leaks the server's filesystem layout
* into the prompt/response and breaks if the deploy directory ever moves.
* <p>
* The path is made relative to {@code uploadRoot}'s parent so the trailing
* upload sub-directory name is preserved (e.g. {@code chat-uploads/...}), and
* separators are normalized to {@code /} so the value is stable across OSes.
*/
static String toRelativeUploadPath(Path uploadRoot, String conversationId, String storedName) {
Path target = uploadRoot.resolve(conversationId).resolve(storedName);
Path base = uploadRoot.getParent();
Path relative = base != null ? base.relativize(target) : target;
return relative.toString().replace('\\', '/');
}
private MessageEntity saveEmptyAssistantPlaceholder(String conversationId, String status,
StreamAccumulator accumulator, String source) {
log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}",
@ -1493,6 +1571,9 @@ public class ChatController {
emptyAssistantPlaceholder(status), null, status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1540,6 +1621,19 @@ public class ChatController {
}
if (promptTokens > 0) payload.put("promptTokens", promptTokens);
if (completionTokens > 0) payload.put("completionTokens", completionTokens);
// Cache / reasoning detail rides on the persisted row so the live bubble
// can render the usage breakdown without waiting for a history reload.
if (savedAssistant != null) {
if (savedAssistant.getCacheReadTokens() != null && savedAssistant.getCacheReadTokens() > 0) {
payload.put("cacheReadTokens", savedAssistant.getCacheReadTokens());
}
if (savedAssistant.getCacheWriteTokens() != null && savedAssistant.getCacheWriteTokens() > 0) {
payload.put("cacheWriteTokens", savedAssistant.getCacheWriteTokens());
}
if (savedAssistant.getReasoningTokens() != null && savedAssistant.getReasoningTokens() > 0) {
payload.put("reasoningTokens", savedAssistant.getReasoningTokens());
}
}
payload.put("persisted", persisted);
if (messageCount != null) payload.put("messageCount", messageCount);
return payload;
@ -1594,6 +1688,9 @@ public class ChatController {
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
@ -1717,6 +1814,11 @@ public class ChatController {
|| lower.contains("client abort") || lower.contains("closed");
}
/** Markdown link pointing at a generated-file download URL. Used by the
* StreamAccumulator to surface generated artifacts in the run-overview rail. */
private static final Pattern GENERATED_FILE_LINK_PATTERN =
Pattern.compile("\\[([^\\]]+)\\]\\(((?:https?://[^/\\s)\\]]+)?/api/v1/files/generated/[A-Za-z0-9-]+)\\)");
/**
* 流式累积器 收集 StreamDelta 事件持久化到 DB
* <p>
@ -1739,9 +1841,14 @@ public class ChatController {
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
/** RFC-052: tool names whose returnDirect output was folded into the assistant message */
private final List<String> directToolNames = new ArrayList<>();
/** Generated file artifacts extracted from tool results — surfaced in the run-overview rail. */
private final List<Map<String, Object>> generatedFiles = new ArrayList<>();
private int segCounter = 0;
private int promptTokens = 0;
private int completionTokens = 0;
private int cacheReadTokens = 0;
private int cacheWriteTokens = 0;
private int reasoningTokens = 0;
private String runtimeModelName = "";
private String runtimeProviderId = "";
private boolean awaitingApproval = false;
@ -1788,6 +1895,9 @@ public class ChatController {
Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
completionTokens = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
cacheReadTokens = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
cacheWriteTokens = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
reasoningTokens = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return;
@ -2010,6 +2120,30 @@ public class ChatController {
break;
}
}
// Extract generated-file links from the tool result so the
// run-overview rail can surface artifacts without re-scanning
// segments on the frontend.
extractGeneratedFiles(String.valueOf(data.getOrDefault("result", "")), toolName);
}
}
/** Scan a tool result for markdown links pointing at generated-file
* download URLs and collect them into {@link #generatedFiles}.
* De-duplicates by URL so a link echoed in later tool results doesn't
* produce duplicate entries in the run-overview rail. */
private void extractGeneratedFiles(String result, String toolName) {
if (result == null || result.isBlank()) return;
Matcher m = GENERATED_FILE_LINK_PATTERN.matcher(result);
while (m.find()) {
String url = m.group(2);
boolean dup = generatedFiles.stream()
.anyMatch(f -> url.equals(String.valueOf(f.get("url"))));
if (dup) continue;
Map<String, Object> file = new LinkedHashMap<>();
file.put("filename", m.group(1));
file.put("url", url);
file.put("toolName", toolName);
generatedFiles.add(file);
}
}
@ -2052,6 +2186,9 @@ public class ChatController {
String getThinking() { return thinking.toString().trim(); }
int getPromptTokens() { return promptTokens; }
int getCompletionTokens() { return completionTokens; }
int getCacheReadTokens() { return cacheReadTokens; }
int getCacheWriteTokens() { return cacheWriteTokens; }
int getReasoningTokens() { return reasoningTokens; }
String getRuntimeModelName() { return runtimeModelName; }
String getRuntimeProviderId() { return runtimeProviderId; }
String getCurrentPhase() { return currentPhase; }
@ -2133,6 +2270,9 @@ public class ChatController {
// historical messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames);
}
if (!generatedFiles.isEmpty()) {
metadata.put("generatedFiles", generatedFiles);
}
if (!finishReason.isEmpty()) {
// Surface graph FinishReason so MemorySummarizationGate and
// any other downstream consumer can branch on a structured

View File

@ -47,6 +47,10 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import reactor.core.Disposable;
import vip.mate.approval.PendingApproval;
import vip.mate.approval.ResolveOutcome;
import vip.mate.agent.context.ChatOrigin;
/**
* WebChat 嵌入式对话接口
@ -79,6 +83,14 @@ public class WebChatController {
private final vip.mate.skill.repository.SkillMapper skillMapper;
private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper;
private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper;
/**
* ISSUE #413 P1-A2/A3/A4: drives the approval lifecycle for WebChat
* (API-Key) channels. Before this, a tool guarded by ToolGuard would
* create a pending approval and park the turn, but the visitor had no
* way to resolve it -- the approval hung until the 30-min GC timeout
* and the turn was wasted.
*/
private final vip.mate.approval.ApprovalWorkflowService approvalService;
/** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */
static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L;
@ -200,7 +212,7 @@ public class WebChatController {
// delta is not a persistence-only echo of content already streamed by inner nodes.
StringBuilder assistantReply = new StringBuilder();
// Token usage + model attribution: capture _usage_final event emitted at stream end
final int[] usage = {0, 0}; // [promptTokens, completionTokens]
final int[] usage = {0, 0, 0, 0, 0}; // [prompt, completion, cacheRead, cacheWrite, reasoning]
final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
// Attribute memory to this external visitor so each end-user
@ -218,6 +230,9 @@ public class WebChatController {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
@ -253,7 +268,7 @@ public class WebChatController {
if (!reply.isBlank()) {
conversationService.saveMessage(
conversationId, "assistant", reply, List.of(),
"completed", usage[0], usage[1], modelInfo[0], modelInfo[1]);
"completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
}
completionPublisher.publish(
resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey);
@ -1013,9 +1028,12 @@ public class WebChatController {
* Disposable 实际中断 Flux;返回 {@code stopped=false} 表示当前没有活跃流
* (幂等,不报错)
* <p>
* 不做 approval sweep:webchat 渠道目前不暴露 approval UI,且无 MateClaw
* username 可传给 {@code denyAllByConversation}若未来 webchat 接入审批流,
* 再单独评估是否补这层
* Approval sweep (ISSUE #413 P1-A4): deny any pending approvals on this
* conversation so they do not hang for 30 minutes until the GC timeout.
* The visitor username derived from visitorId is the actor -- it resolves
* the "no MateClaw username" blocker noted in the old javadoc.
* Each denied approval broadcasts a { tool_approval_resolved} SSE
* event so the SDK clears its banner immediately.
*/
@Operation(summary = "停止访客会话线程的进行中流")
@PostMapping("/sessions/stop")
@ -1049,9 +1067,319 @@ public class WebChatController {
conversationId, visitorId, stopped);
audit(channel, visitorId, "webchat.stop-session", conversationId,
"{\"sessionId\":\"" + sid + "\",\"stopped\":" + stopped + "}");
// ISSUE #413 P1-A4: deny pending approvals so they do not linger for
// 30 min waiting on a GC timeout. The visitor username is the actor,
// mirroring how the web ChatController uses the logged-in username.
int deniedCount = 0;
try {
java.util.List<ResolveOutcome> denied =
approvalService.denyAllByConversation(conversationId, webchatUsername(visitorId));
deniedCount = denied.size();
for (ResolveOutcome o : denied) {
try {
streamTracker.broadcast(conversationId, "tool_approval_resolved",
objectMapper.writeValueAsString(java.util.Map.of(
"pendingId", o.pendingId(),
"decision", "denied",
"toolName", o.toolName() != null ? o.toolName() : "")));
} catch (Exception broadcastErr) {
log.debug("[WebChat] approval_resolved broadcast failed for {}: {}",
o.pendingId(), broadcastErr.getMessage());
}
}
} catch (Exception sweepErr) {
log.warn("[WebChat] approval sweep failed for {}: {}", conversationId, sweepErr.getMessage());
}
if (deniedCount > 0) {
log.info("[WebChat] Denied {} pending approval(s) on stop for {}", deniedCount, conversationId);
}
return R.ok(Map.of("stopped", stopped));
}
/**
* 拒绝一个待审批的工具调用 (ISSUE #413 P1-A2)
* <p>
* 鉴权同其它会话管理端点 (API Key + visitorToken + 会话归属)仅允许
* 发起对话的访客拒绝自己会话的审批 身份校验通过
* {@code webchatUsername(visitorId)} pending.userId 的等价比较
* (对位 IM 渠道的 senderId == requester 校验)
* <p>
* resolve 后立即广播 {@code tool_approval_resolved} SSE 事件 SDK
* 实时清理审批 banner返回同步 JSON ( SSE)因为 deny 不需要重放工具
*
* @param pendingId the approval pendingId returned in the
* {@code tool_approval_requested} event
*/
@Operation(summary = "拒绝访客会话中的待审批工具调用")
@PostMapping("/sessions/deny")
public R<Map<String, Object>> denySession(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam String visitorId,
@RequestParam(required = false) String sessionId,
@RequestParam String pendingId) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
}
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
String sid;
try {
sid = normalizeSessionId(sessionId);
} catch (IllegalArgumentException ex) {
return R.fail(400, ex.getMessage());
}
String conversationId = deriveConversationId(apiKey, visitorId, sid);
if (!ownsConversation(conversationId, visitorId)) {
return R.fail(404, "Session not found");
}
// IDOR guard (review #415): the caller owns the conversation, but the
// pendingId is client-supplied cross-check that the pending actually
// belongs to this conversation before resolving, otherwise a visitor
// could resolve / replay another visitor's guarded tool call.
// getPending(pendingId) gives the exact record (vs findPendingByConversation
// which returns the earliest, wrong when several pendings coexist).
var ownedOpt = approvalService.getPending(pendingId);
if (ownedOpt.isEmpty()
|| !conversationId.equals(ownedOpt.get().getConversationId())) {
return R.fail(404, "Pending approval not found for this session");
}
// Resolve and broadcast outside the persistence transaction: SSE is
// not rollback-capable, so the broadcast must follow a committed DB write.
String actor = webchatUsername(visitorId);
ResolveOutcome outcome = approvalService.resolve(pendingId, actor, "denied");
broadcastApprovalResolved(conversationId, outcome);
audit(channel, visitorId, "webchat.deny-approval", conversationId,
"{\"pendingId\":\"" + escapeJson(pendingId) + "\",\"resolved\":"
+ outcome.dbSynced() + "}");
return R.ok(Map.of("resolved", outcome.dbSynced(), "decision", outcome.decision()));
}
/**
* 批准一个待审批的工具调用并重放 (ISSUE #413 P1-A2 + P1-A3)
* <p>
* deny 不同approve 返回 SSE 原子消费审批记录后用捕获的
* toolCallPayload 重放工具调用把工具结果回灌 agent 继续本轮对话
* 重放模式对位 web 渠道的 ChatController 复用
* {@code chatWithReplayStream} + {@code restoreChatOrigin} 恢复原始
* ChatOrigin (webchat origin createPending 时已通过 ChatOriginHolder
* 持久化到 approval )
* <p>
* 重放期间可能再次触发审批 (一个工具批准后 agent 可能调用下一个受保护
* 工具) 该场景由 {@code tool_approval_requested} 直推事件自然覆盖
* 无需特殊处理事件投递走与 {@link #chatStream} 相同的 broadcast 路径
*
* @param pendingId the approval pendingId returned in the
* {@code tool_approval_requested} event
*/
@Operation(summary = "批准访客会话中的待审批工具调用并重放")
@PostMapping(value = "/sessions/approve", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter approveSession(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam String visitorId,
@RequestParam(required = false) String sessionId,
@RequestParam String pendingId) {
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
sendErrorAndComplete(emitter, "Invalid API Key");
return emitter;
}
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
sendErrorAndComplete(emitter, "Invalid or missing visitor token");
return emitter;
}
String sid;
try {
sid = normalizeSessionId(sessionId);
} catch (IllegalArgumentException ex) {
sendErrorAndComplete(emitter, ex.getMessage());
return emitter;
}
String conversationId = deriveConversationId(apiKey, visitorId, sid);
if (!ownsConversation(conversationId, visitorId)) {
sendErrorAndComplete(emitter, "Session not found");
return emitter;
}
// IDOR guard (review #415): cross-check the client-supplied pendingId
// actually belongs to this conversation before resolving, otherwise a
// visitor could approve + replay another visitor's guarded tool call.
var ownedApprovalOpt = approvalService.getPending(pendingId);
if (ownedApprovalOpt.isEmpty()
|| !conversationId.equals(ownedApprovalOpt.get().getConversationId())) {
sendErrorAndComplete(emitter, "Pending approval not found for this session");
return emitter;
}
emitter.onCompletion(() -> log.debug("[WebChat] approve SSE completed: {}", conversationId));
emitter.onTimeout(() -> {
log.debug("[WebChat] approve SSE timeout: {}", conversationId);
streamTracker.complete(conversationId);
});
emitter.onError(e -> {
log.debug("[WebChat] approve SSE error: {} - {}", conversationId, e.getMessage());
streamTracker.complete(conversationId);
});
String actor = webchatUsername(visitorId);
sseExecutor.execute(() -> {
// Register + attach the emitter FIRST so every downstream branch
// (already-resolved, no-agent, error, replay) can broadcast a
// terminal event the SDK actually receives. Doing this after
// resolveAndConsume left the already-resolved / error paths
// broadcasting into a subscriber-less tracker, so the SSE hung
// to the 10-min timeout (review #415).
streamTracker.register(conversationId);
streamTracker.attach(conversationId, emitter);
try {
// Atomically consume the approval (DB + metadata + memory, single tx).
ResolveOutcome consumed = approvalService.resolveAndConsume(pendingId, actor);
if (consumed.consumedSnapshot() == null) {
// already resolved / not found emit a terminal done so the
// SDK's stream listener closes cleanly instead of hanging.
broadcastApprovalResolved(conversationId, consumed);
streamTracker.broadcast(conversationId, "done",
"{\"status\":\"already_resolved\"}");
return;
}
// Notify the SDK the approval flipped (clears the banner) before
// replay output starts streaming.
broadcastApprovalResolved(conversationId, consumed);
PendingApproval snapshot = consumed.consumedSnapshot();
Long replayAgentId = snapshot.getAgentId() != null
? parseLongOrNull(snapshot.getAgentId()) : null;
if (replayAgentId == null) {
log.warn("[WebChat] approve: no agentId on consumed approval {}, cannot replay",
pendingId);
streamTracker.broadcast(conversationId, "done",
"{\"status\":\"error\",\"message\":\"No agent bound to approval\"}");
return;
}
// Restore the original ChatOrigin captured at createPending time.
// Falls back to a fresh webchat origin when none was persisted
// (defensive mirrors ChatController:304-306).
ChatOrigin replayOrigin =
approvalService.restoreChatOrigin(snapshot.getChatOrigin());
if (replayOrigin == ChatOrigin.EMPTY) {
var agent = agentService.getAgent(replayAgentId);
Long wsId = agent != null ? agent.getWorkspaceId() : 1L;
replayOrigin = ChatOrigin.web(
conversationId, actor, wsId, null).withSender(null, "api", null);
}
// Neutral replay prompt (aligned with IM + web channels naming a
// tool here can mislead the LLM on fallthrough).
String replayPrompt = "继续执行已批准的工具调用。";
StringBuilder assistantReply = new StringBuilder();
final int[] usage = {0, 0};
final String[] modelInfo = {null, null};
streamTracker.broadcast(conversationId, "message_start",
"{\"role\":\"assistant\"}");
Disposable disposable = agentService.chatWithReplayStream(
replayAgentId, replayPrompt, conversationId,
snapshot.getToolCallPayload(), actor, replayOrigin)
.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 0)).intValue();
usage[2] = ((Number) data.getOrDefault("cacheReadTokens", 0)).intValue();
usage[3] = ((Number) data.getOrDefault("cacheWriteTokens", 0)).intValue();
usage[4] = ((Number) data.getOrDefault("reasoningTokens", 0)).intValue();
Object model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString();
if (provider != null) modelInfo[1] = provider.toString();
}
if (delta.isEvent()) {
forwardVisitorEvent(conversationId, delta.eventType(), delta.eventData());
}
if (delta.content() != null && !delta.content().isEmpty()) {
assistantReply.append(delta.content());
if (!delta.persistenceOnly()) {
streamTracker.broadcast(conversationId, "content_delta",
"{\"text\":" + escapeJson(delta.content()) + "}");
}
}
if (delta.thinking() != null && !delta.thinking().isEmpty()
&& !delta.persistenceOnly()) {
streamTracker.broadcast(conversationId, "thinking_delta",
"{\"text\":" + escapeJson(delta.thinking()) + "}");
}
})
.doOnComplete(() -> {
String reply = assistantReply.toString();
try {
if (!reply.isBlank()) {
conversationService.saveMessage(
conversationId, "assistant", reply, List.of(),
"completed", usage[0], usage[1], usage[2], usage[3], usage[4], modelInfo[0], modelInfo[1], null);
}
} catch (Exception persistErr) {
log.warn("[WebChat] approve replay persist failed: {}", persistErr.getMessage());
}
streamTracker.broadcast(conversationId, "done",
"{\"status\":\"completed\"}");
streamTracker.complete(conversationId);
})
.doOnError(e -> {
log.error("[WebChat] approve replay stream error: {}", e.getMessage());
streamTracker.broadcast(conversationId, "error",
"{\"message\":" + escapeJson(e.getMessage()) + "}");
streamTracker.complete(conversationId);
})
.subscribe();
streamTracker.setDisposable(conversationId, disposable);
} catch (Exception e) {
log.error("[WebChat] approve failed for {}: {}", conversationId, e.getMessage());
try {
streamTracker.broadcast(conversationId, "error",
"{\"message\":" + escapeJson(e.getMessage()) + "}");
} catch (Exception ignored) {}
streamTracker.complete(conversationId);
}
});
audit(channel, visitorId, "webchat.approve-approval", conversationId,
"{\"pendingId\":\"" + escapeJson(pendingId) + "\",\"replay\":true}");
return emitter;
}
/** Parse a Long leniently; null/blank/non-numeric return null. */
private static Long parseLongOrNull(String s) {
if (s == null || s.isBlank()) return null;
try {
return Long.parseLong(s.trim());
} catch (NumberFormatException e) {
return null;
}
}
/**
* Broadcast a {@code tool_approval_resolved} event so the SDK clears its
* approval banner in real time. Shared by approve / deny / stop-sweep.
* (ISSUE #413 P1)
*/
private void broadcastApprovalResolved(String conversationId, ResolveOutcome outcome) {
try {
streamTracker.broadcast(conversationId, "tool_approval_resolved",
objectMapper.writeValueAsString(Map.of(
"pendingId", outcome.pendingId(),
"decision", outcome.decision() != null ? outcome.decision() : "",
"toolName", outcome.toolName() != null ? outcome.toolName() : "")));
} catch (Exception e) {
log.debug("[WebChat] approval_resolved broadcast failed for {}: {}",
outcome.pendingId(), e.getMessage());
}
}
/**
* 重新生成最后一条助手回复
* <p>

View File

@ -5,11 +5,11 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
@ -39,9 +39,6 @@ import java.util.stream.Stream;
@Service
public class WebChatFileService {
/** Shared with the JWT chat upload dir so deleteConversation cleanup applies. */
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
/** How long an uploaded-but-unreferenced file lingers before the sweep removes it. */
private static final long STAGING_TTL_MS = 60 * 60 * 1000L; // 1 hour
@ -50,6 +47,7 @@ public class WebChatFileService {
private final Set<String> allowedExtensions;
private final int maxFilesPerConversation;
private final long maxTotalBytesPerConversation;
private final ChatUploadLocationResolver uploadLocationResolver;
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */
private final ConcurrentHashMap<String, StagedFile> staged = new ConcurrentHashMap<>();
@ -61,7 +59,8 @@ public class WebChatFileService {
+ "png,jpg,jpeg,gif,webp,bmp,pdf,txt,md,csv,json,log,"
+ "doc,docx,xls,xlsx,ppt,pptx,zip,mp3,wav,m4a,mp4,mov,webm}") String allowedExtensionsCsv,
@Value("${mateclaw.webchat.upload.max-files-per-conversation:50}") int maxFilesPerConversation,
@Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation) {
@Value("${mateclaw.webchat.upload.max-total-mb-per-conversation:200}") long maxTotalMbPerConversation,
ChatUploadLocationResolver uploadLocationResolver) {
this.enabled = enabled;
this.maxSizeBytes = maxSizeMb * 1024 * 1024;
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
@ -70,6 +69,7 @@ public class WebChatFileService {
.collect(Collectors.toUnmodifiableSet());
this.maxFilesPerConversation = maxFilesPerConversation;
this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024;
this.uploadLocationResolver = uploadLocationResolver;
}
/** Metadata for a staged upload. */
@ -111,7 +111,7 @@ public class WebChatFileService {
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
// Strip any directory components, then collapse to a safe charset.
String baseName = Paths.get(originalName).getFileName().toString();
String baseName = Path.of(originalName).getFileName().toString();
String ext = extensionOf(baseName);
if (ext.isEmpty() || !allowedExtensions.contains(ext)) {
throw new UploadRejectedException("File type not allowed: ." + ext);
@ -119,8 +119,9 @@ public class WebChatFileService {
String safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_");
String storedName = UUID.randomUUID() + "_" + safeName;
Path dir = UPLOAD_ROOT.resolve(conversationId).normalize();
if (!dir.startsWith(UPLOAD_ROOT.normalize())) {
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
Path dir = uploadRoot.resolve(conversationId).normalize();
if (!dir.startsWith(uploadRoot)) {
// conversationId is server-derived, so this should never happen; fail closed if it does.
throw new UploadRejectedException("Invalid conversation");
}
@ -169,12 +170,16 @@ public class WebChatFileService {
if (storedName == null || storedName.isBlank()) {
return Optional.empty();
}
Path base = UPLOAD_ROOT.resolve(conversationId).normalize();
Path file = base.resolve(storedName).normalize();
if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) {
return Optional.empty();
// Check every candidate root (workspace-scoped dir + legacy default dir)
// so files written before the workspace-aware relocation still resolve.
for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
Path base = root.resolve(conversationId).normalize();
Path file = base.resolve(storedName).normalize();
if (file.startsWith(base) && Files.exists(file) && Files.isRegularFile(file)) {
return Optional.of(file);
}
}
return Optional.of(file);
return Optional.empty();
}
/** Map a content type to the MessageContentPart type the agent/UI understands. */

View File

@ -280,6 +280,12 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
*/
private final vip.mate.tool.document.GeneratedFileCache generatedFileCache;
/**
* Workspace/agent-aware upload-root resolver, set by the production factory.
* Null in unit tests (the legacy {@code data/chat-uploads} default applies).
*/
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
@ -297,11 +303,30 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
WeComKeepaliveScheduler keepaliveScheduler,
vip.mate.tool.document.GeneratedFileCache generatedFileCache) {
this(channelEntity, messageRouter, objectMapper, approvalNotificationService,
cardDispatcher, keepaliveScheduler, generatedFileCache, null);
}
/**
* Full constructor used by the production factory (ChannelManager). The
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
* behaviour.
*/
public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
vip.mate.channel.notification.ApprovalNotificationService approvalNotificationService,
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
WeComKeepaliveScheduler keepaliveScheduler,
vip.mate.tool.document.GeneratedFileCache generatedFileCache,
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
super(channelEntity, messageRouter, objectMapper);
this.approvalNotificationService = approvalNotificationService;
this.cardDispatcher = cardDispatcher;
this.keepaliveScheduler = keepaliveScheduler;
this.generatedFileCache = generatedFileCache;
this.chatUploadLocationResolver = chatUploadLocationResolver;
// Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential)
// so the UI eventually settles in ERROR instead of getting stuck in
// RECONNECTING forever. User config still overrides (-1 = infinite).
@ -2936,14 +2961,17 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
*/
private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId,
String fileNameHint, String conversationId) {
// Store under data/chat-uploads/{conversationId} so the existing
// Store under the workspace/agent-aware upload root ({convId}/ subdir),
// falling back to the legacy data/chat-uploads default, so the existing
// /api/v1/chat/files/{convId}/{storedName} endpoint serves the file
// back to the chat bubble the WeCom CDN URL carries a short-lived
// signature that expires before a browser can fetch it. The shared
// pipeline owns retry/backoff, magic-byte type detection, and the
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here
// inside the byte source so a fetch + decrypt is retried as one unit.
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
Path uploadDir = (chatUploadLocationResolver != null)
? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId)
: Path.of("data", "chat-uploads", conversationId);
String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
return InboundMediaDownloader.download(
() -> {

View File

@ -2,7 +2,9 @@ package vip.mate.channel.wecom.cards.tool_guard;
import lombok.extern.slf4j.Slf4j;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.PendingApproval;
import vip.mate.approval.ResolveOutcome;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.wecom.WeComChannelAdapter;
import vip.mate.channel.wecom.cards.WeComCardHandler;
@ -37,10 +39,20 @@ import java.util.Optional;
public class ToolGuardCardHandler implements WeComCardHandler {
private final ApprovalService approvalService;
/**
* ISSUE #413 P2-B3: resolves workflow-scoped ({@code wf-}) approvals
* inline the synthetic /approve injection is a dead end for wf- ids
* (their conversationId is {@code workflow:run:{runId}}, unmatched by
* any IM conversation). May be null in narrow test contexts.
*/
private final ApprovalWorkflowService approvalWorkflowService;
private final ToolGuardButtonKey buttonKey;
public ToolGuardCardHandler(ApprovalService approvalService, ToolGuardButtonKey buttonKey) {
public ToolGuardCardHandler(ApprovalService approvalService,
ApprovalWorkflowService approvalWorkflowService,
ToolGuardButtonKey buttonKey) {
this.approvalService = approvalService;
this.approvalWorkflowService = approvalWorkflowService;
this.buttonKey = buttonKey;
}
@ -76,6 +88,19 @@ public class ToolGuardCardHandler implements WeComCardHandler {
PendingApproval pending = opt.get();
// ---- 3. Identity check (fail-closed) ----
// Workflow-scoped approvals (wf- prefix, ISSUE #413 P2-B3) have no
// human requester (userId is null the run is system-initiated).
// Their cards only reach the channels declared in await_approval's
// approverChannels, so any audience member is a legitimate approver.
// We resolve inline (no synthetic injection: the router path can't
// route a workflow:run:{runId} conversationId) and the
// ApprovalResumeBridge resumes the run off the resolved event.
if (pendingId.startsWith("wf-")) {
handleWorkflowApproval(adapter, eventReqId, taskId, pendingId,
decoded.toolName(), action, clickerUserId);
return;
}
// Agent/cron ("system") or unattributed (null) approvals have no human
// requester to match the clicker against; a group card would let any
// member resolve a guarded action. Reject here (mirrors the feishu card
@ -117,6 +142,48 @@ public class ToolGuardCardHandler implements WeComCardHandler {
}
}
// ------------------------------------------------------------------
// Workflow-scoped approval (ISSUE #413 P2-B3)
// ------------------------------------------------------------------
/**
* Resolve a {@code wf-} workflow approval inline, then render the
* resolved card both within the WeCom 5s callback window. The
* synthetic /approve injection is bypassed because the router cannot
* route a {@code workflow:run:{runId}} conversationId. The
* {@link vip.mate.workflow.runtime.ApprovalResumeBridge} picks up the
* {@code WorkflowApprovalResolvedEvent} published inside resolve and
* resumes the paused run asynchronously.
*
* <p>Identity: any audience member may resolve the card only reaches
* the channels declared in {@code await_approval.approverChannels}.
*/
private void handleWorkflowApproval(WeComChannelAdapter adapter, String eventReqId, String taskId,
String pendingId, String toolName,
ToolGuardButtonKey.Action action, String clickerUserId) {
if (approvalWorkflowService == null) {
log.warn("[wecom-toolguard] ApprovalWorkflowService unavailable, cannot resolve wf- {} "
+ "(use the admin console)", pendingId);
renderExpired(adapter, eventReqId, taskId, toolName);
return;
}
String decision = action == ToolGuardButtonKey.Action.APPROVE ? "approved" : "denied";
try {
ResolveOutcome outcome = approvalWorkflowService.resolve(pendingId, clickerUserId, decision);
if (!outcome.dbSynced()) {
log.info("[wecom-toolguard] wf- {} already resolved: {}", pendingId, outcome.decision());
renderExpired(adapter, eventReqId, taskId, toolName);
return;
}
log.info("[wecom-toolguard] Resolved wf- {} as {} by {} (run resume delegated to bridge)",
pendingId, decision, abbrev(clickerUserId));
renderResolved(adapter, eventReqId, taskId, toolName, action, clickerUserId);
} catch (Exception e) {
log.error("[wecom-toolguard] Failed to resolve wf- {}: {}", pendingId, e.getMessage(), e);
renderExpired(adapter, eventReqId, taskId, toolName);
}
}
// ------------------------------------------------------------------
// Card rendering helpers
// ------------------------------------------------------------------

View File

@ -3,6 +3,7 @@ package vip.mate.channel.wecom.cards.tool_guard;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.wecom.cards.WeComCardKind;
/**
@ -34,17 +35,23 @@ public class ToolGuardCardKindFactory {
public static final String MESSAGE_TYPE = "tool_guard_approval";
private final ApprovalService approvalService;
/** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */
private final ApprovalWorkflowService approvalWorkflowService;
private final ObjectMapper objectMapper;
public ToolGuardCardKindFactory(ApprovalService approvalService, ObjectMapper objectMapper) {
public ToolGuardCardKindFactory(ApprovalService approvalService,
ApprovalWorkflowService approvalWorkflowService,
ObjectMapper objectMapper) {
this.approvalService = approvalService;
this.approvalWorkflowService = approvalWorkflowService;
this.objectMapper = objectMapper;
}
public WeComCardKind create() {
ToolGuardButtonKey buttonKey = new ToolGuardButtonKey(objectMapper);
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonKey);
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonKey);
ToolGuardCardHandler handler = new ToolGuardCardHandler(
approvalService, approvalWorkflowService, buttonKey);
return new WeComCardKind(
"tool_guard_approval",
MESSAGE_TYPE,

View File

@ -148,12 +148,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
/** 用于文件 URL 下载的 HttpClient */
private HttpClient uploadHttpClient;
/**
* Workspace/agent-aware upload-root resolver, set by the production factory.
* Null in unit tests (the legacy {@code data/chat-uploads} default applies).
*/
private vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver;
public WeixinChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
super(channelEntity, messageRouter, objectMapper);
}
/**
* Full constructor used by the production factory (ChannelManager). The
* trailing {@code chatUploadLocationResolver} enables workspace/agent-aware
* attachment storage; {@code null} keeps the legacy {@code data/chat-uploads}
* behaviour.
*/
public WeixinChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper,
vip.mate.workspace.core.service.ChatUploadLocationResolver chatUploadLocationResolver) {
super(channelEntity, messageRouter, objectMapper);
this.chatUploadLocationResolver = chatUploadLocationResolver;
}
@Override
public String getChannelType() {
return CHANNEL_TYPE;
@ -666,7 +686,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
return null;
}
Path uploadDir = Path.of("data", "chat-uploads", conversationId);
Path uploadDir = (chatUploadLocationResolver != null)
? chatUploadLocationResolver.resolveUploadRoot(conversationId).resolve(conversationId)
: Path.of("data", "chat-uploads", conversationId);
return InboundMediaDownloader.download(
() -> client.downloadMedia("", aesKey, encryptQueryParam),
filenameHint,

View File

@ -0,0 +1,80 @@
package vip.mate.cli;
import org.springframework.stereotype.Component;
import vip.mate.operational.service.OperationalDataExportService;
import java.io.IOException;
import java.time.LocalDate;
/**
* {@code --cli.command=export} generate a 9-sheet operational data report.
*
* <p>Writes the ZIP bytes to stdout so the caller can redirect:</p>
* <pre>{@code
* java -jar app.jar --cli.command=export \
* --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
* }</pre>
*/
@Component
public class ExportCommand implements MateClawCli.CliCommand {
private final OperationalDataExportService exportService;
public ExportCommand(OperationalDataExportService exportService) {
this.exportService = exportService;
}
@Override public String name() { return "export"; }
@Override public String description() { return "Generate the operational data report (9-sheet Excel, written to stdout)"; }
@Override public String usage() {
return """
\s
export generate the operational data report; ZIP bytes are written to stdout
\s
Required:
--cli.start=YYYY-MM-DD start date (inclusive)
--cli.end=YYYY-MM-DD end date (inclusive)
\s
Optional:
--cli.dry-run dry-run mode
\s
Example:
java -jar app.jar --cli.command=export \\
\s --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
\s""";
}
@Override
public void execute(MateClawCli.CliContext ctx) {
LocalDate start = ctx.requireDate("cli.start");
LocalDate end = ctx.requireDate("cli.end");
if (ctx.isDryRun()) {
ctx.header("Export dry-run");
ctx.info("Date range", start + " ~ " + end);
ctx.info("Result", "ZIP bytes would be written to stdout (not executed)");
ctx.done("Dry-run complete");
ctx.exit(0);
return;
}
byte[] zip = exportService.exportBackendBytes(start, end);
try {
// Diagnostics go to stderr so stdout stays a clean binary stream for redirection.
System.err.println("=== Operational data export ===");
System.err.printf(" Date range : %s ~ %s%n", start, end);
System.err.printf(" Size : %d KB%n", zip.length / 1024);
System.err.printf(" File name : ops_data_%s_%s.zip%n", start, end);
System.err.println("\n=== Writing to stdout (redirect: ... > report.zip) ===");
System.out.write(zip);
System.out.flush();
System.err.println("=== Export complete ===");
} catch (IOException e) {
ctx.error("Failed to write to stdout: " + e.getMessage());
}
ctx.exit(0);
}
}

View File

@ -0,0 +1,178 @@
package vip.mate.cli;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.ExitCodeGenerator;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
/**
* MateClaw CLI framework single-file core containing:
* <ul>
* <li>{@link CliCommand} interface for pluggable commands</li>
* <li>{@link CliContext} argument parsing, output formatting, lifecycle</li>
* <li>{@link CliRunner} auto-discovery dispatcher</li>
* </ul>
*
* <h3>Usage</h3>
* <pre>{@code
* java -jar app.jar --cli.command=help
* java -jar app.jar --cli.command=export \
* --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
* }</pre>
*
* <h3>Adding a new command</h3>
* <pre>{@code
* @Component
* public class MyCommand implements MateClawCli.CliCommand {
* public String name() { return "mycmd"; }
* public String description() { return "does something"; }
* public String usage() { return " --cli.x=...\n example: ..."; }
* public void execute(MateClawCli.CliContext ctx) {
* ctx.exit(0);
* }
* }
* }</pre>
*/
public final class MateClawCli { private MateClawCli() { /* namespace */ }
// CliCommand interface for pluggable commands
public interface CliCommand {
String name();
String description();
default String usage() { return ""; }
void execute(CliContext ctx);
}
// CliContext argument parsing & output
public static class CliContext {
private static final Logger log = LoggerFactory.getLogger(CliContext.class);
private final ApplicationArguments args;
private final ApplicationContext springCtx;
private final boolean dryRun;
public CliContext(ApplicationArguments args, ApplicationContext springCtx) {
this.args = args;
this.springCtx = springCtx;
this.dryRun = args.getOptionNames().contains("cli.dry-run");
}
/** Read optional string param (null if absent). */
public String arg(String key) {
var vals = args.getOptionValues(key);
return vals != null && !vals.isEmpty() ? vals.get(0) : null;
}
/** Read required string param. Absent = error + exit. */
public String requireArg(String key) {
String val = arg(key);
if (val == null || val.isBlank()) error("Missing required parameter: --" + key);
return val;
}
/** Read required date param (YYYY-MM-DD). Bad format = error + exit. */
public LocalDate requireDate(String key) {
String raw = requireArg(key);
try { return LocalDate.parse(raw); }
catch (DateTimeParseException e) { error("Invalid date format: --" + key + "=" + raw + " (expected YYYY-MM-DD)"); return null; }
}
/** True when {@code --cli.dry-run} was passed. */
public boolean isDryRun() { return dryRun; }
// output
public void header(String title) { System.out.println(); System.out.println("=== " + title + " ==="); }
public void info(String key, Object val) { System.out.printf(" %-12s : %s%n", key, val); }
public void done(String msg) { System.out.println(); System.out.println("=== " + msg + " ==="); System.out.println(); }
public void warn(String msg) { log.warn(msg); System.err.println("[WARN] " + msg); }
public void error(String msg) { log.error(msg); System.err.println("[ERROR] " + msg); exit(1); }
public void exit(int code) {
System.out.flush(); System.err.flush();
try { Thread.sleep(200); } catch (InterruptedException ignored) {}
SpringApplication.exit(springCtx, (ExitCodeGenerator) () -> code);
System.exit(code);
}
}
// CliRunner auto-discovery ApplicationRunner
@Component
@Order(9999)
public static class CliRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(CliRunner.class);
private final ApplicationContext springCtx;
private final Map<String, CliCommand> registry;
public CliRunner(List<CliCommand> commands, ApplicationContext springCtx) {
var tmp = new TreeMap<String, CliCommand>();
for (var c : commands) {
if (tmp.containsKey(c.name())) throw new IllegalStateException("Duplicate CLI command name: '" + c.name() + "'");
tmp.put(c.name(), c);
}
this.registry = Collections.unmodifiableMap(tmp);
this.springCtx = springCtx;
log.info("CLI ready, registered {} command(s): {}", registry.size(), registry.keySet());
}
@Override public void run(ApplicationArguments args) {
String cmdName = arg(args, "cli.command");
// No CLI command requested: normal web startup, stay inert.
if (cmdName == null) return;
CliContext ctx = new CliContext(args, springCtx);
if ("help".equalsIgnoreCase(cmdName)) { printHelp(); ctx.exit(0); return; }
CliCommand cmd = registry.get(cmdName.toLowerCase());
if (cmd == null) {
System.err.println("[ERROR] Unknown command: " + cmdName);
System.err.println(" Available commands: " + String.join(", ", registry.keySet()));
ctx.exit(1); return;
}
try {
log.info("CLI executing: {}", cmd.name());
cmd.execute(ctx);
} catch (Exception e) {
log.error("Command '{}' failed", cmd.name(), e);
System.err.println("\n[ERROR] Command '" + cmd.name() + "' threw an exception");
System.err.println(" " + e.getClass().getSimpleName() + ": " + e.getMessage());
var trace = e.getStackTrace();
for (int i = 0; i < Math.min(8, trace.length); i++) System.err.println(" at " + trace[i]);
ctx.exit(1);
}
}
private void printHelp() {
System.out.println("\n MateClaw CLI\n ═══════════════════════════════════════");
System.out.println(" java -jar app.jar --cli.command=<name> [options]");
System.out.println(" Docker: docker exec <container> java -jar /app/app.jar --cli.command=<name> [options]");
System.out.println("\n Global options:");
System.out.println(" --cli.command=<name> command to run");
System.out.println(" --cli.dry-run dry-run mode");
System.out.println("\n Available commands:");
System.out.printf(" %-12s %s%n", "help", "show this help");
for (var c : registry.values()) System.out.printf(" %-12s %s%n", c.name(), c.description());
System.out.println("\n Detailed usage:");
for (var c : registry.values()) { String u = c.usage(); if (!u.isBlank()) System.out.println(u); }
System.out.println(" Spring Boot options may be appended directly (--spring.profiles.active, etc.)\n");
}
static String arg(ApplicationArguments args, String key) {
var vals = args.getOptionValues(key);
return vals != null && !vals.isEmpty() ? vals.get(0) : null;
}
}
}

View File

@ -0,0 +1,138 @@
package vip.mate.common.net;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.util.Collection;
/**
* Shared matching logic for the outbound-request SSRF allowlist.
*
* <p>Outbound HTTP guards (browser navigation, hook webhooks, image download)
* block loopback, private, link-local and cloud-metadata targets by default.
* Administrators can punch a narrow hole for a specific internal host via the
* allowlist; each entry is one of:
* <ul>
* <li>a literal hostname {@code internal.corp}</li>
* <li>a literal IP {@code 192.168.100.100}</li>
* <li>an IPv4 CIDR block {@code 192.168.100.0/24}</li>
* </ul>
*
* <p>{@link #matchesHost} compares against the URL host string as written (no
* DNS lookup); {@link #matchesAddress} compares against an already-resolved
* address. A guard that resolves DNS should consult both so that neither the
* literal host nor any resolved address is missed.
*/
public final class SsrfAllowlist {
private SsrfAllowlist() {}
/** True when the literal host string (hostname or IP literal) matches an allowlist entry. */
public static boolean matchesHost(String host, Collection<String> allowlist) {
if (host == null || host.isBlank() || allowlist == null || allowlist.isEmpty()) {
return false;
}
String h = stripBrackets(host.trim());
Integer hostIp = ipv4ToInt(h); // non-null only when h is an IPv4 literal
for (String raw : allowlist) {
String entry = trimOrNull(raw);
if (entry == null) {
continue;
}
if (entry.indexOf('/') >= 0) {
if (hostIp != null && ipv4InCidr(hostIp, entry)) {
return true;
}
} else if (entry.equalsIgnoreCase(h)) {
return true;
}
}
return false;
}
/** True when a resolved address matches an allowlist entry (literal IP or IPv4 CIDR). */
public static boolean matchesAddress(InetAddress addr, Collection<String> allowlist) {
if (addr == null || allowlist == null || allowlist.isEmpty()) {
return false;
}
String ip = addr.getHostAddress();
Integer addrIp = (addr instanceof Inet4Address) ? bytesToInt(addr.getAddress()) : null;
for (String raw : allowlist) {
String entry = trimOrNull(raw);
if (entry == null) {
continue;
}
if (entry.indexOf('/') >= 0) {
if (addrIp != null && ipv4InCidr(addrIp, entry)) {
return true;
}
} else if (entry.equalsIgnoreCase(ip)) {
return true;
}
}
return false;
}
private static String trimOrNull(String raw) {
if (raw == null) {
return null;
}
String t = raw.trim();
return t.isEmpty() ? null : t;
}
private static String stripBrackets(String host) {
return host.startsWith("[") && host.endsWith("]")
? host.substring(1, host.length() - 1)
: host;
}
/** Membership test for an IPv4 address (as a 32-bit int) against a {@code a.b.c.d/prefix} block. */
private static boolean ipv4InCidr(int addrBits, String cidr) {
int slash = cidr.indexOf('/');
Integer networkBits = ipv4ToInt(cidr.substring(0, slash).trim());
if (networkBits == null) {
return false;
}
int prefix;
try {
prefix = Integer.parseInt(cidr.substring(slash + 1).trim());
} catch (NumberFormatException e) {
return false;
}
if (prefix < 0 || prefix > 32) {
return false;
}
int mask = prefix == 0 ? 0 : 0xFFFFFFFF << (32 - prefix);
return (addrBits & mask) == (networkBits & mask);
}
/** Parse a dotted-quad IPv4 literal into a 32-bit int, or null if it is not one. */
private static Integer ipv4ToInt(String ip) {
String[] parts = ip.split("\\.");
if (parts.length != 4) {
return null;
}
int result = 0;
for (String part : parts) {
int octet;
try {
octet = Integer.parseInt(part);
} catch (NumberFormatException e) {
return null;
}
if (octet < 0 || octet > 255) {
return null;
}
result = (result << 8) | octet;
}
return result;
}
private static int bytesToInt(byte[] bytes) {
int result = 0;
for (byte b : bytes) {
result = (result << 8) | (b & 0xFF);
}
return result;
}
}

Some files were not shown because too many files have changed in this diff Show More