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再退回相对路径。反代后部署建议显式设置。 # 留空时回退到当前请求的 host再退回相对路径。反代后部署建议显式设置。
MATECLAW_PUBLIC_BASE_URL= 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+ 位随机串。 # SearXNG 会话密钥(容器内部用,留空会用开发默认值)。生产部署请设成 32+ 位随机串。
# openssl rand -hex 32 # openssl rand -hex 32
SEARXNG_SECRET= SEARXNG_SECRET=
@ -55,6 +60,20 @@ MATECLAW_BROWSER_CDP_URL=
MATECLAW_BROWSER_CHROME_PATH= MATECLAW_BROWSER_CHROME_PATH=
MATECLAW_BROWSER_CHANNEL= 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 OAuthDocker可选 ====================
# #
# OpenAI ChatGPT OAuth 使用 Codex CLI 的 public client + PKCE / device code # 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 CLI local artifacts
.codex/ .codex/
# Codebase memory (local agent index / graph artifact; do not commit)
.codebase-memory/
# Sync tooling local state (generated each run; report is intentionally tracked) # Sync tooling local state (generated each run; report is intentionally tracked)
scripts/.*-sync-state.json 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> <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) [![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) [![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) [![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/) [![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) [![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/) [![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) [![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)] [[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 ### 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 └── .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 ## Tech stack
@ -217,25 +217,29 @@ Full docs at **[claw.mate.vip/docs](https://claw.mate.vip/docs)** — setup, arc
## Roadmap ## 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.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.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.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 ## Contributing
```bash ```bash
git clone https://github.com/matevip/mateclaw.git git clone https://github.com/mateaix/mateclaw.git
cd mateclaw cd mateclaw
cd mateclaw-server && mvn clean compile cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && pnpm install && pnpm dev 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> <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/文档-在线-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/演示-在线-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) [![官网](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/) [![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) [![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/) [![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://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)] [[官网](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 └── .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.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.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.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 ```bash
git clone https://github.com/matevip/mateclaw.git git clone https://github.com/mateaix/mateclaw.git
cd mateclaw cd mateclaw
cd mateclaw-server && mvn clean compile cd mateclaw-server && mvn clean compile
cd ../mateclaw-ui && pnpm install && pnpm dev cd ../mateclaw-ui && pnpm install && pnpm dev

View File

@ -92,6 +92,30 @@ services:
MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-} MATECLAW_BROWSER_CDP_URL: ${MATECLAW_BROWSER_CDP_URL:-}
MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-} MATECLAW_BROWSER_CHROME_PATH: ${MATECLAW_BROWSER_CHROME_PATH:-}
MATECLAW_BROWSER_CHANNEL: ${MATECLAW_BROWSER_CHANNEL:-} 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。 # OAuth 模式默认保持 autolocalhost 访问走 LOCALIP/域名访问走 DEVICE_CODE。
# 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。 # 本机 Docker 若要强制使用 localhost:1455 回调,可在 .env 显式设为 local。
MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE: ${MATECLAW_OAUTH_OPENAI_DEPLOYMENT_MODE:-} 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 org.springframework.ai.tool.ToolCallback;
import vip.mate.plugin.api.channel.PluginChannelAdapter; import vip.mate.plugin.api.channel.PluginChannelAdapter;
import vip.mate.plugin.api.memory.PluginMemoryProvider; import vip.mate.plugin.api.memory.PluginMemoryProvider;
import vip.mate.plugin.api.search.PluginSearchProvider;
import java.util.function.Supplier; import java.util.function.Supplier;
@ -60,6 +61,19 @@ public interface PluginContext {
*/ */
void registerMemoryProvider(PluginMemoryProvider provider); 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. * Read a configuration value from the plugin's config.
* *

View File

@ -17,5 +17,8 @@ public enum PluginType {
CHANNEL, CHANNEL,
/** Register new memory providers */ /** 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.ModelConfigEntity;
import vip.mate.llm.model.ModelFamily; import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProtocol; 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.model.ModelProviderEntity;
import vip.mate.llm.probe.ModelContextWindowResolver;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.llm.routing.ProviderRouter; import vip.mate.llm.routing.ProviderRouter;
import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.service.ModelConfigService;
import vip.mate.llm.service.ModelProviderService; 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.skill.service.SkillService;
import vip.mate.system.service.SystemSettingService; import vip.mate.system.service.SystemSettingService;
import vip.mate.tool.ToolRegistry; import vip.mate.tool.ToolRegistry;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.memory.spi.MemoryManager; import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.WorkspaceFileService;
import vip.mate.tool.guard.service.ToolGuardService; import vip.mate.tool.guard.service.ToolGuardService;
@ -96,6 +102,9 @@ public class AgentGraphBuilder {
private final ConversationService conversationService; private final ConversationService conversationService;
private final ModelConfigService modelConfigService; private final ModelConfigService modelConfigService;
private final ModelProviderService modelProviderService; 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 vip.mate.llm.service.ModelCapabilityService modelCapabilityService;
private final ProviderRouter providerRouter; private final ProviderRouter providerRouter;
private final PlanningService planningService; private final PlanningService planningService;
@ -353,6 +362,12 @@ public class AgentGraphBuilder {
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel()); 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 两者协同而非互斥 // 内置搜索检测DashScope / Kimi但不再移除 WebSearchTool 两者协同而非互斥
boolean builtinSearchEnabled = false; boolean builtinSearchEnabled = false;
Map<String, Object> providerKwargs = modelProviderService.readProviderGenerateKwargs(provider); 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, // Runtime skill-catalog renderer captures this agent's bound skills,
// effective tool allowlist, model window and workspace; invoked each // effective tool allowlist, model window and workspace; invoked each
// turn by the reasoning / step-execution nodes with the skills loaded // 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. // so far this run so load_skill pins float to the top of the catalog.
SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer( SkillCatalogRenderer skillCatalogRenderer = buildSkillCatalogRenderer(
entity, boundTools, runtimeModel.getMaxInputTokens()); entity, boundTools, effectiveMaxInputTokens);
// Extension-tool catalog only for ReAct. The dynamic tool split runs // Extension-tool catalog only for ReAct. The dynamic tool split runs
// in ReasoningNode; Plan-Execute keeps advertising every tool (it has no // in ReasoningNode; Plan-Execute keeps advertising every tool (it has no
// action node to record enable_tool), so baking the catalog there would // action node to record enable_tool), so baking the catalog there would
// describe an enable_tool flow that can never take effect. // 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()); boolean isPlanExecute = "plan_execute".equals(entity.getAgentType());
Set<String> autoDemotedTools = Set.of();
if (!isPlanExecute) { if (!isPlanExecute) {
if (prefixBudgetPlan.enabled()) {
autoDemotedTools = toolDisclosureService.computeAutoDemotions(
toolSet, prefixBudgetPlan.toolSchemaBudgetTokens());
}
String extensionCatalog = toolDisclosureService.renderExtensionCatalog( String extensionCatalog = toolDisclosureService.renderExtensionCatalog(
toolSet, runtimeModel.getMaxInputTokens()); toolSet, effectiveMaxInputTokens, autoDemotedTools);
if (extensionCatalog != null && !extensionCatalog.isBlank()) { if (extensionCatalog != null && !extensionCatalog.isBlank()) {
enhancedPrompt = enhancedPrompt + extensionCatalog; enhancedPrompt = enhancedPrompt + extensionCatalog;
} }
@ -423,7 +460,8 @@ public class AgentGraphBuilder {
log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})", log.info("Built StateGraph Plan-Execute agent: {} (maxIterations={}, tools={}, protocol={})",
entity.getName(), maxIter, toolSet.size(), protocol.getId()); entity.getName(), maxIter, toolSet.size(), protocol.getId());
} else { } else {
agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer); agent = buildReActAgent(toolSet, runtimeModel, maxIter, entity.getId(), skillCatalogRenderer,
prefixBudgetPlan, autoDemotedTools);
// StateGraph 路径下工具调用由 ActionNode 控制始终启用 // StateGraph 路径下工具调用由 ActionNode 控制始终启用
toolCallingEnabled = true; toolCallingEnabled = true;
log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})", log.info("Built StateGraph ReAct agent: {} (maxIterations={}, tools={}, protocol={})",
@ -451,7 +489,7 @@ public class AgentGraphBuilder {
agent.userLocale = resolveLocale(); agent.userLocale = resolveLocale();
agent.temperature = runtimeModel.getTemperature(); agent.temperature = runtimeModel.getTemperature();
agent.maxTokens = runtimeModel.getMaxTokens(); agent.maxTokens = runtimeModel.getMaxTokens();
agent.maxInputTokens = runtimeModel.getMaxInputTokens(); agent.maxInputTokens = effectiveMaxInputTokens;
agent.topP = runtimeModel.getTopP(); agent.topP = runtimeModel.getTopP();
agent.toolCallingEnabled = toolCallingEnabled; agent.toolCallingEnabled = toolCallingEnabled;
@ -510,11 +548,17 @@ public class AgentGraphBuilder {
StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel, StateGraphReActAgent buildReActAgent(AgentToolSet toolSet, ModelConfigEntity runtimeModel,
int maxIter, Long agentId, SkillCatalogRenderer skillCatalogRenderer) { 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); ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
ChatClient chatClient = ChatClient.create(chatModel); ChatClient chatClient = ChatClient.create(chatModel);
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel); String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort,
runtimeModel, agentId, skillCatalogRenderer); runtimeModel, agentId, skillCatalogRenderer, prefixBudgetPlan, autoDemotedTools);
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph, return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
chatModel, conversationWindowManager, toolSet); chatModel, conversationWindowManager, toolSet);
} }
@ -565,6 +609,14 @@ public class AgentGraphBuilder {
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker, streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null, primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool); 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( ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker, toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@ -573,6 +625,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it // LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error. // the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService); executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through // Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test). // the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) { if (auditEventService != null) {
@ -647,6 +700,9 @@ public class AgentGraphBuilder {
// Token Usage // Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_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.LLM_CALL_COUNT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
@ -829,12 +885,28 @@ public class AgentGraphBuilder {
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
String reasoningEffort, ModelConfigEntity primaryModelConfig, String reasoningEffort, ModelConfigEntity primaryModelConfig,
Long agentId, SkillCatalogRenderer skillCatalogRenderer) { 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 { try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId); List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig, agentId);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper( NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker, streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null, primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool); 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( ToolExecutionExecutor executor = new ToolExecutionExecutor(
toolSet, toolGuardService, approvalService, streamTracker, toolSet, toolGuardService, approvalService, streamTracker,
toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry,
@ -843,6 +915,7 @@ public class AgentGraphBuilder {
// LLM mis-calls a skill name as a tool, the response tells it // LLM mis-calls a skill name as a tool, the response tells it
// the right invocation pattern instead of a dead-end error. // the right invocation pattern instead of a dead-end error.
executor.setSkillRuntimeService(skillRuntimeService); executor.setSkillRuntimeService(skillRuntimeService);
executor.setUsageRecencyTracker(toolUsageRecencyTracker);
// Optional: route child-agent denied-tool audit events through // Optional: route child-agent denied-tool audit events through
// the audit pipeline. Null when audit is not wired (legacy / test). // the audit pipeline. Null when audit is not wired (legacy / test).
if (auditEventService != null) { if (auditEventService != null) {
@ -853,10 +926,21 @@ public class AgentGraphBuilder {
// capability from reasoningEffort == null. // capability from reasoningEffort == null.
boolean supportsReasoningEffort = primaryModelConfig != null boolean supportsReasoningEffort = primaryModelConfig != null
&& ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort(); && 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, ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
supportsReasoningEffort, supportsReasoningEffort,
streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService, streamingHelper, conversationWindowManager, streamTracker,
configuredMaxOutputTokens, wikiContextService,
skillCatalogRenderer, toolDisclosureService, progressLedgerService); skillCatalogRenderer, toolDisclosureService, progressLedgerService);
reasoningNode.setPrefixBudgetPlan(prefixBudgetPlan);
reasoningNode.setAutoDemotedTools(autoDemotedTools);
ActionNode actionNode = new ActionNode(executor, streamTracker); ActionNode actionNode = new ActionNode(executor, streamTracker);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties); ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker); ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
@ -935,6 +1019,9 @@ public class AgentGraphBuilder {
// Token Usage // Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_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_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的 // SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
@ -1184,68 +1271,101 @@ public class AgentGraphBuilder {
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null; String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null; String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
// RFC-009 PR-3: bias by agent preferences (if any). Listed providers win // RFC-090 §9.2 调整 C lift providers that satisfy the bound-skill
// their declared order; everything else keeps the global priority order. // capability set (vision / video / audio) ahead of those that don't.
List<String> preferred = agentId == null // Run before planning so the non-preferred tail inherits this order;
? java.util.Collections.emptyList() // the explicit preferred-model head keeps the user's declared order.
: 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.
try { try {
providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers)); providers = new ArrayList<>(providerRouter.reorderForCapabilities(agentId, providers));
} catch (Exception e) { } catch (Exception e) {
log.debug("[ProviderRouter] chain reorder failed: {}", e.getMessage()); 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<>(); List<vip.mate.llm.failover.FallbackEntry> chain = new ArrayList<>();
for (ModelProviderEntity p : providers) { Set<String> seen = new java.util.HashSet<>();
// Don't put the primary provider's row into the fallback chain same-instance if (primaryProviderId != null && primaryModelName != null) {
// skipping is also done in the runtime walker, but excluding here saves building seen.add(primaryProviderId + "::" + primaryModelName);
// a duplicate ChatModel at agent-build time. }
if (primaryProviderId != null && primaryProviderId.equals(p.getProviderId())) { for (ProviderModelRef ref : plan) {
log.debug("[LlmFailover] skipping primary provider {} in fallback chain", primaryProviderId); String pid = ref.providerId();
continue; // RFC-009 Phase 4: skip providers known-bad at build time. The runtime
} // walker re-checks pool membership per request, so a provider that
// RFC-009 Phase 4: skip providers known-bad at build time. The runtime walker in // re-enters the pool later still gets used (graph rebuilt on
// NodeStreamingChatHelper re-checks pool membership per request, so a provider
// that re-enters the pool later still gets used (the graph is rebuilt on
// ModelConfigChangedEvent). // ModelConfigChangedEvent).
if (providerPool != null && !providerPool.contains(p.getProviderId())) { if (providerPool != null && !providerPool.contains(pid)) {
log.debug("[LlmFailover] skipping provider {} — not in available pool", log.debug("[LlmFailover] skipping provider {} — not in available pool", pid);
p.getProviderId());
continue; continue;
} }
ModelConfigEntity fallbackConfig = pickFallbackModel(p.getProviderId()); ModelConfigEntity fallbackConfig = resolveChainModel(ref);
if (fallbackConfig == null) { if (fallbackConfig == null) {
log.debug("[LlmFailover] skipping provider {} — no enabled chat model", log.debug("[LlmFailover] skipping {} — no usable chat model", pid);
p.getProviderId());
continue; continue;
} }
if (primaryModelName != null && primaryModelName.equals(fallbackConfig.getModelName())) { String key = pid + "::" + fallbackConfig.getModelName();
// Same model name picked for a different provider exact same call, skip. if (!seen.add(key)) {
// Exact (provider, model) already queued or equal to the primary.
continue; continue;
} }
try { try {
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build()); ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
chain.add(new vip.mate.llm.failover.FallbackEntry(p.getProviderId(), m)); chain.add(new vip.mate.llm.failover.FallbackEntry(pid, m));
log.info("[LlmFailover] chain[{}] = {}/{} (priority={})", log.info("[LlmFailover] chain[{}] = {}/{}", chain.size(), pid, fallbackConfig.getModelName());
chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
p.getFallbackPriority());
} catch (Exception e) { } catch (Exception e) {
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}", pid, e.getMessage());
p.getProviderId(), e.getMessage());
} }
} }
return chain; 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: * Pick a chat model to use as a fallback for the given provider:
* <ol> * <ol>
@ -1275,33 +1395,40 @@ public class AgentGraphBuilder {
} }
/** /**
* Reorder a provider list by an agent's preference list. Listed provider * Plan the fallback order as a list of (provider, model) refs.
* ids come first in their preference order; any provider not in the *
* preference list keeps its original position relative to other unlisted * <p>Head: the agent's explicit preference entries in declared order,
* providers (stable partition). Preference entries that don't match any * model-granular the same provider may appear more than once with
* actual provider are silently dropped. * 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<ProviderModelRef> planFallbackOrder(List<ProviderModelRef> preferred,
static List<ModelProviderEntity> reorderByPreferences(List<ModelProviderEntity> providers, List<String> globalProviderIds) {
List<String> preferredOrder) { List<ProviderModelRef> plan = new ArrayList<>();
Map<String, ModelProviderEntity> byId = new java.util.LinkedHashMap<>(); Set<String> headEntryKeys = new java.util.HashSet<>();
for (ModelProviderEntity p : providers) { Set<String> headProviderIds = new java.util.HashSet<>();
byId.put(p.getProviderId(), p); if (preferred != null) {
} for (ProviderModelRef ref : preferred) {
List<ModelProviderEntity> reordered = new ArrayList<>(providers.size()); if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
Set<String> placed = new java.util.HashSet<>(); String key = ref.providerId() + "::" + (ref.modelId() == null ? "" : ref.modelId());
for (String prefId : preferredOrder) { if (!headEntryKeys.add(key)) continue; // exact (provider, model) dup
ModelProviderEntity p = byId.get(prefId); plan.add(ref);
if (p != null && placed.add(prefId)) { headProviderIds.add(ref.providerId());
reordered.add(p);
} }
} }
for (ModelProviderEntity p : providers) { if (globalProviderIds != null) {
if (placed.add(p.getProviderId())) { for (String pid : globalProviderIds) {
reordered.add(p); 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 * @throws IllegalArgumentException when an absolute override escapes the
* workspace root * workspace root
*/ */
static String resolveAgentBasePath(String agentOverride, String workspaceBase) { public static String resolveAgentBasePath(String agentOverride, String workspaceBase) {
boolean hasOverride = agentOverride != null && !agentOverride.isBlank(); boolean hasOverride = agentOverride != null && !agentOverride.isBlank();
boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank(); boolean hasWorkspace = workspaceBase != null && !workspaceBase.isBlank();
if (!hasOverride) { if (!hasOverride) {
@ -1347,6 +1474,15 @@ public class AgentGraphBuilder {
return agentOverride; return agentOverride;
} }
if (hasWorkspace) { 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 Paths.get(workspaceBase).resolve(agentOverride).toString();
} }
return agentOverride; return agentOverride;
@ -1393,6 +1529,10 @@ public class AgentGraphBuilder {
"""; """;
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled) { 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 / // The agent's own systemPrompt encodes its identity (role / goal /
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md, // backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
// PROFILE.md, MEMORY.md, ...) augments that identity with durable // 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 // dropped the identity prompt, so editor-side identity changes never
// reached runtime if the agent had any workspace files. // reached runtime if the agent had any workspace files.
String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : ""; 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(); StringBuilder basePromptBuilder = new StringBuilder();
if (!identityPrompt.isEmpty()) { if (!identityPrompt.isEmpty()) {
basePromptBuilder.append(identityPrompt); basePromptBuilder.append(identityPrompt);
@ -1480,10 +1620,11 @@ public class AgentGraphBuilder {
adopting a KB article as the user's project. adopting a KB article as the user's project.
## Session Search ## 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="recent": list recent conversations (titles, times, message counts)
- mode="search": keyword full-text search across past messages - 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 - 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 ## Tool Usage Guidelines
When you have available tools, use them to access local system information, files, or execute commands. 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> * <p>
* 上传文件存储在 data/chat-uploads/ 是相对于 Spring Boot 工作目录的路径 * The storage location of uploaded files is resolved by
* MCP 工具的工作目录可能不同所以这里直接解析为绝对路径 * {@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 图片注入 * 构建当前用户消息的 UserMessage multimodal 图片注入

View File

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

View File

@ -29,6 +29,16 @@ public class AgentProviderPreference {
/** Provider id (matches {@code mate_model_provider.provider_id}). */ /** Provider id (matches {@code mate_model_provider.provider_id}). */
private String providerId; 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. */ /** Lower wins. Two rows with the same value tie-break on provider_id alphabetically. */
private Integer sortOrder; 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.agent.repository.AgentMapper;
import vip.mate.exception.MateClawException; import vip.mate.exception.MateClawException;
import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.llm.routing.AgentBindingResolver;
import vip.mate.llm.routing.ProviderModelRef;
import vip.mate.skill.acp.AcpSkillBridge; import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.mcp.McpSkillBridge; import vip.mate.skill.mcp.McpSkillBridge;
import vip.mate.skill.lifecycle.BlockedByBindingRow; import vip.mate.skill.lifecycle.BlockedByBindingRow;
@ -935,30 +936,33 @@ public class AgentBindingService implements AgentBindingResolver {
* fallback chain order per agent.</p> * fallback chain order per agent.</p>
*/ */
@Override @Override
public List<String> getPreferredProviderIds(Long agentId) { public List<ProviderModelRef> getPreferredProviderModels(Long agentId) {
if (agentId == null) return Collections.emptyList(); if (agentId == null) return Collections.emptyList();
return listProviderPreferences(agentId).stream() return listProviderPreferences(agentId).stream()
.filter(p -> Boolean.TRUE.equals(p.getEnabled())) .filter(p -> Boolean.TRUE.equals(p.getEnabled()))
.map(AgentProviderPreference::getProviderId) .map(p -> new ProviderModelRef(p.getProviderId(), p.getModelId()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/** /**
* Replace the full preference list for an agent. {@code providerIds} * Replace the full preference list for an agent with (provider, model)
* is the new ordered preference (index 0 = highest preference). * entries. {@code refs} is the new ordered preference (index 0 = highest);
* Empty / null list clears all preferences for the agent. * 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( providerPreferenceMapper.delete(
new LambdaQueryWrapper<AgentProviderPreference>() new LambdaQueryWrapper<AgentProviderPreference>()
.eq(AgentProviderPreference::getAgentId, agentId)); .eq(AgentProviderPreference::getAgentId, agentId));
if (providerIds == null) return; if (refs == null) return;
int order = 0; int order = 0;
for (String providerId : providerIds) { for (ProviderModelRef ref : refs) {
if (providerId == null || providerId.isBlank()) continue; if (ref == null || ref.providerId() == null || ref.providerId().isBlank()) continue;
AgentProviderPreference row = new AgentProviderPreference(); AgentProviderPreference row = new AgentProviderPreference();
row.setAgentId(agentId); row.setAgentId(agentId);
row.setProviderId(providerId.trim()); row.setProviderId(ref.providerId().trim());
row.setModelId(ref.modelId());
row.setSortOrder(order++); row.setSortOrder(order++);
row.setEnabled(true); row.setEnabled(true);
providerPreferenceMapper.insert(row); providerPreferenceMapper.insert(row);

View File

@ -66,7 +66,16 @@ public record ChatOrigin(
* can still mint absolute download links. Null for IM/cron origins, which * 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}. * 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}. */ /** 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. */ /** Sentinel used by AgentService default overloads where no origin is supplied. */
public static final ChatOrigin EMPTY = 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 ---------------- // ---------------- Factories per entry point ----------------
@ -90,9 +99,25 @@ public record ChatOrigin(
@Nullable Long workspaceId, @Nullable Long workspaceId,
@Nullable String workspaceBasePath, @Nullable String workspaceBasePath,
@Nullable String baseUrl) { @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, return new ChatOrigin(null, conversationId,
requesterId != null ? requesterId : "", 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, public static ChatOrigin cron(@Nullable String conversationId,
@ -101,7 +126,7 @@ public record ChatOrigin(
@Nullable Long channelId, @Nullable Long channelId,
@Nullable ChannelTarget target) { @Nullable ChannelTarget target) {
return new ChatOrigin(null, conversationId, "system", 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 ---------------- // ---------------- Wither-style updates ----------------
@ -109,27 +134,27 @@ public record ChatOrigin(
public ChatOrigin withAgent(@Nullable Long newAgentId) { public ChatOrigin withAgent(@Nullable Long newAgentId) {
return new ChatOrigin(newAgentId, conversationId, requesterId, return new ChatOrigin(newAgentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl); senderName, channelType, chatId, baseUrl, requesterUserId);
} }
public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId, public ChatOrigin withWorkspace(@Nullable Long newWorkspaceId,
@Nullable String newWorkspaceBasePath) { @Nullable String newWorkspaceBasePath) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin, newWorkspaceId, newWorkspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl); senderName, channelType, chatId, baseUrl, requesterUserId);
} }
public ChatOrigin withConversationId(@Nullable String newConversationId) { public ChatOrigin withConversationId(@Nullable String newConversationId) {
return new ChatOrigin(agentId, newConversationId, requesterId, return new ChatOrigin(agentId, newConversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, baseUrl); senderName, channelType, chatId, baseUrl, requesterUserId);
} }
/** Carry a request-derived public base URL (see {@link #baseUrl()}). */ /** Carry a request-derived public base URL (see {@link #baseUrl()}). */
public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) { public ChatOrigin withBaseUrl(@Nullable String newBaseUrl) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
senderName, channelType, chatId, newBaseUrl); senderName, channelType, chatId, newBaseUrl, requesterUserId);
} }
/** /**
@ -143,7 +168,7 @@ public record ChatOrigin(
@Nullable String newChatId) { @Nullable String newChatId) {
return new ChatOrigin(agentId, conversationId, requesterId, return new ChatOrigin(agentId, conversationId, requesterId,
workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin, workspaceId, workspaceBasePath, channelId, channelTarget, cronOrigin,
newSenderName, newChannelType, newChatId, baseUrl); newSenderName, newChannelType, newChatId, baseUrl, requesterUserId);
} }
// ---------------- Spring AI ToolContext interop ---------------- // ---------------- Spring AI ToolContext interop ----------------

View File

@ -3,6 +3,7 @@ package vip.mate.agent.context;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage; 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.Message;
import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.ToolResponseMessage;
@ -118,6 +119,18 @@ public class ConversationWindowManager {
private final MemoryManager memoryManager; private final MemoryManager memoryManager;
private final ConversationService conversationService; 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 * Optional spill store, injected via setter so unit tests and the two
* existing 3-arg constructor callers in tests stay source-compatible. * existing 3-arg constructor callers in tests stay source-compatible.
@ -251,7 +264,12 @@ public class ConversationWindowManager {
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0) int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens(); ? 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 systemTokens = TokenEstimator.estimateTokens(systemPrompt);
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD; 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.]"; + "' 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 字符 * Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
* <p>Spill-marker responses are left untouched so their on-disk pointer * <p>Spill-marker responses are left untouched so their on-disk pointer
@ -1063,7 +1108,8 @@ public class ConversationWindowManager {
replaced.add(r); replaced.add(r);
continue; 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; changed = true;
} }
if (changed) { 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.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/** /**
* 节点级流式 LLM 调用辅助 * 节点级流式 LLM 调用辅助
@ -162,6 +163,19 @@ public class NodeStreamingChatHelper {
this.providerPool = providerPool; 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) { private static List<vip.mate.llm.failover.FallbackEntry> wrap(ChatModel m) {
// Legacy single-fallback path: providerId is unknown so health tracking // Legacy single-fallback path: providerId is unknown so health tracking
// is silently disabled for that one entry (it gets a synthetic id). // 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, private StreamResult streamCallInternal(ChatModel chatModel, Prompt prompt,
String conversationId, String phase, String conversationId, String phase,
boolean broadcast) { 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 调用前检查停止标志 // 在开始 LLM 调用前检查停止标志
if (streamTracker.isStopRequested(conversationId)) { if (streamTracker.isStopRequested(conversationId)) {
log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId); log.info("[{}] Stop requested before LLM call, aborting: conversationId={}", phase, conversationId);
@ -632,7 +655,7 @@ public class NodeStreamingChatHelper {
} }
llmCallCount++; llmCallCount++;
if (attempt > 0) retryCount++; if (attempt > 0) retryCount++;
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt); lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt, true);
if (lastResult != null) { if (lastResult != null) {
// PTL: 不重试直接返回给上层 Node 处理 // PTL: 不重试直接返回给上层 Node 处理
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) { if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
@ -774,7 +797,7 @@ public class NodeStreamingChatHelper {
failoverCount++; failoverCount++;
llmCallCount++; llmCallCount++;
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId, 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 // Accept only fully successful fallbacks. Non-successful results (auth
// error, client error, still-rate-limited) propagate to the next // error, client error, still-rate-limited) propagate to the next
// fallback instead of being surfaced as the final result. // fallback instead of being surfaced as the final result.
@ -821,7 +844,7 @@ public class NodeStreamingChatHelper {
*/ */
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt, private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase, String conversationId, String phase,
boolean broadcast, int attempt) { boolean broadcast, int attempt, boolean primaryCall) {
// Collapse every SystemMessage in the prompt into a single SystemMessage // Collapse every SystemMessage in the prompt into a single SystemMessage
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in // at index 0. Some OpenAI-compatible providers (LM Studio's built-in
// server, certain strict vLLM / SGLang deployments) reject 400 // server, certain strict vLLM / SGLang deployments) reject 400
@ -874,7 +897,7 @@ public class NodeStreamingChatHelper {
} }
try { try {
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt); return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt, primaryCall);
} finally { } finally {
// Idempotent: if consumer already took the entry, discard is a no-op. // Idempotent: if consumer already took the entry, discard is a no-op.
if (relayToken != null) { if (relayToken != null) {
@ -906,7 +929,7 @@ public class NodeStreamingChatHelper {
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt, private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
String conversationId, String phase, String conversationId, String phase,
boolean broadcast, int attempt) { boolean broadcast, int attempt, boolean primaryCall) {
if (attempt > 0) { if (attempt > 0) {
long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs); long delay = Math.min(backoffBaseMs * (1L << (attempt - 1)), backoffCapMs);
// 加入 jitter 防止雷群效应 // 加入 jitter 防止雷群效应
@ -945,9 +968,10 @@ public class NodeStreamingChatHelper {
AtomicReference<Throwable> errorRef = new AtomicReference<>(); AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicInteger promptTokens = new AtomicInteger(0); AtomicInteger promptTokens = new AtomicInteger(0);
AtomicInteger completionTokens = 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 cacheReadTokens = new AtomicInteger(0);
AtomicInteger cacheWriteTokens = new AtomicInteger(0); AtomicInteger cacheWriteTokens = new AtomicInteger(0);
AtomicInteger reasoningTokens = new AtomicInteger(0);
// thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅 // thinking-only soft cap 触发后设为 true外层轮询线程据此 dispose 订阅
// 注意内容流的字符级 / 句子级重复检测已整体移除设计取舍 // 注意内容流的字符级 / 句子级重复检测已整体移除设计取舍
@ -1133,10 +1157,12 @@ public class NodeStreamingChatHelper {
if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) { if (usage.getCompletionTokens() != null && usage.getCompletionTokens() > 0) {
completionTokens.set(usage.getCompletionTokens().intValue()); 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); var cache = vip.mate.llm.cache.CacheUsageExtractor.extract(usage);
if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens()); if (cache.cacheReadTokens() > 0) cacheReadTokens.set(cache.cacheReadTokens());
if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens()); if (cache.cacheWriteTokens() > 0) cacheWriteTokens.set(cache.cacheWriteTokens());
if (cache.reasoningTokens() > 0) reasoningTokens.set(cache.reasoningTokens());
} }
}) })
.subscribe( .subscribe(
@ -1188,7 +1214,8 @@ public class NodeStreamingChatHelper {
toolCallAccumulators.size(), conversationId); toolCallAccumulators.size(), conversationId);
return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators, return assembleStoppedResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), 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={}", log.info("[{}] Stop requested during LLM call, no content accumulated, aborting: conversationId={}",
phase, conversationId); phase, conversationId);
@ -1222,6 +1249,7 @@ public class NodeStreamingChatHelper {
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(),
phase, true, error.getMessage()); phase, true, error.getMessage());
} }
@ -1232,6 +1260,17 @@ public class NodeStreamingChatHelper {
if (errorType == ErrorType.PROMPT_TOO_LONG) { if (errorType == ErrorType.PROMPT_TOO_LONG) {
log.warn("[{}] Prompt too long error, returning to node for compaction: {}", log.warn("[{}] Prompt too long error, returning to node for compaction: {}",
phase, error.getMessage()); 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), return buildErrorResultWithType("Prompt 过长: " + extractUserFriendlyError(error),
conversationId, phase, errorType); conversationId, phase, errorType);
} }
@ -1310,7 +1349,8 @@ public class NodeStreamingChatHelper {
: null; : null;
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators, return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
promptTokens.get(), completionTokens.get(), promptTokens.get(), completionTokens.get(),
cacheReadTokens.get(), cacheWriteTokens.get(), phase, cacheReadTokens.get(), cacheWriteTokens.get(),
reasoningTokens.get(), phase,
truncated, truncated,
truncationReason); truncationReason);
} }
@ -1319,7 +1359,8 @@ public class NodeStreamingChatHelper {
private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum, private StreamResult assembleStoppedResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators, List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok, int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok, String phase) { int cacheReadTok, int cacheWriteTok,
int reasoningTok, String phase) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators); List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString(); String fullContent = contentAccum.toString();
String fullThinking = thinkingAccum.toString(); String fullThinking = thinkingAccum.toString();
@ -1342,14 +1383,14 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok,
true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok); true, null, ErrorType.NONE, true, cacheReadTok, cacheWriteTok, reasoningTok);
} }
/** 组装最终 StreamResult成功或 partial */ /** 组装最终 StreamResult成功或 partial */
private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum, private StreamResult assembleResult(StringBuilder contentAccum, StringBuilder thinkingAccum,
List<ToolCallAccumulator> toolCallAccumulators, List<ToolCallAccumulator> toolCallAccumulators,
int promptTok, int completionTok, int promptTok, int completionTok,
int cacheReadTok, int cacheWriteTok, int cacheReadTok, int cacheWriteTok, int reasoningTok,
String phase, boolean partial, String errorMsg) { String phase, boolean partial, String errorMsg) {
List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators); List<AssistantMessage.ToolCall> finalToolCalls = buildFinalToolCalls(toolCallAccumulators);
String fullContent = contentAccum.toString(); String fullContent = contentAccum.toString();
@ -1375,7 +1416,7 @@ public class NodeStreamingChatHelper {
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok); recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage, return new StreamResult(fullContent, fullThinking, assembledMessage,
finalToolCalls, !finalToolCalls.isEmpty(), promptTok, completionTok, 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()); 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) { private StreamResult buildErrorResult(String errorMsg, String conversationId, String phase) {
log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg); log.error("[{}] Building error result for conversation {}: {}", phase, conversationId, errorMsg);
if (streamTracker != null && conversationId != null) { if (streamTracker != null && conversationId != null) {
@ -1753,17 +1854,19 @@ public class NodeStreamingChatHelper {
ErrorType errorType, ErrorType errorType,
/** 用户主动停止stopRequested导致的提前返回 */ /** 用户主动停止stopRequested导致的提前返回 */
boolean stopped, boolean stopped,
/** RFC-014: Anthropic prompt cache 命中字节数(其它 provider 为 0 */ /** Prompt cache 命中 tokensprovider 未上报时为 0 */
int cacheReadTokens, int cacheReadTokens,
/** RFC-014: Anthropic prompt cache 写入字节数(其它 provider 为 0 */ /** Prompt cache 写入 tokensprovider 未上报时为 0 */
int cacheWriteTokens int cacheWriteTokens,
/** 思考reasoning阶段消耗的 completion tokensprovider 未上报时为 0 */
int reasoningTokens
) { ) {
/** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */ /** 兼容旧调用方 — 无 partial/error/stopped 的正常结果 */
public StreamResult(String text, String thinking, AssistantMessage assistantMessage, public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls, List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens) { int promptTokens, int completionTokens) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls, 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 调用点 */ /** 兼容 10-arg 调用点 */
@ -1772,17 +1875,17 @@ public class NodeStreamingChatHelper {
int promptTokens, int completionTokens, int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType) { boolean partial, String errorMessage, ErrorType errorType) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls, 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, public StreamResult(String text, String thinking, AssistantMessage assistantMessage,
List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls, List<AssistantMessage.ToolCall> toolCalls, boolean hasToolCalls,
int promptTokens, int completionTokens, int promptTokens, int completionTokens,
boolean partial, String errorMessage, ErrorType errorType, boolean partial, String errorMessage, ErrorType errorType,
boolean stopped) { boolean stopped) {
this(text, thinking, assistantMessage, toolCalls, hasToolCalls, 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.AgentService;
import vip.mate.agent.AgentState; import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent; import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.ConversationWindowManager;
@ -197,6 +198,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
AtomicInteger sentEventCount = new AtomicInteger(0); AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = 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> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>(""); AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护 chatStructuredStream // 防重保护 chatStructuredStream
@ -268,6 +272,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0)); finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_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, "")); finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -282,10 +289,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
return deltas; return deltas;
}) })
.concatWith(Mono.fromSupplier(() -> { .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( return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(), "promptTokens", promptTokens,
"completionTokens", finalCompletionTokens.get(), "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -304,6 +322,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> { .doOnError(e -> {
log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage()); log.error("[{}] StateGraph replay stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR); 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) { } catch (Exception e) {
setState(AgentState.ERROR); setState(AgentState.ERROR);
@ -333,6 +357,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// Token usage 追踪每次 NodeOutput 更新最新累计值最后一次即最终值 // Token usage 追踪每次 NodeOutput 更新最新累计值最后一次即最终值
AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = 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> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = new AtomicReference<>(""); AtomicReference<String> finalProviderId = new AtomicReference<>("");
// 防重保护StateGraph 对每个节点都 emit NodeOutputFINAL_ANSWER 一旦写入后续节点都携带 // 防重保护StateGraph 对每个节点都 emit NodeOutputFINAL_ANSWER 一旦写入后续节点都携带
@ -418,6 +445,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 3. 更新最新累计 token usage // 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0)); finalPromptTokens.set(output.state().value(PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(COMPLETION_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, "")); finalModelName.set(output.state().value(RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, "")); finalProviderId.set(output.state().value(RUNTIME_PROVIDER_ID, ""));
@ -434,10 +464,21 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
}) })
// 流正常完成后追加内部 usage 事件 // 流正常完成后追加内部 usage 事件
.concatWith(Mono.fromSupplier(() -> { .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( return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(), "promptTokens", promptTokens,
"completionTokens", finalCompletionTokens.get(), "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -457,6 +498,12 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
.doOnError(e -> { .doOnError(e -> {
log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage()); log.error("[{}] StateGraph structured stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR); 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) { } catch (Exception e) {
setState(AgentState.ERROR); setState(AgentState.ERROR);
@ -522,6 +569,9 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
inputs.put(FORCED_TOOL_CALL, ""); inputs.put(FORCED_TOOL_CALL, "");
inputs.put(PROMPT_TOKENS, 0); inputs.put(PROMPT_TOKENS, 0);
inputs.put(COMPLETION_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_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : ""); inputs.put(RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(TRACE_ID, UUID.randomUUID().toString().substring(0, 8)); 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.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallback;
import vip.mate.tool.builtin.ToolExecutionContext; import vip.mate.tool.builtin.ToolExecutionContext;
import vip.mate.tool.disclosure.ToolUsageRecencyTracker;
import vip.mate.agent.AgentToolSet; import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.context.ChatOrigin; import vip.mate.agent.context.ChatOrigin;
@ -251,6 +252,13 @@ public class ToolExecutionExecutor {
*/ */
private vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService; 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) { public void setSkillRuntimeService(vip.mate.skill.runtime.SkillRuntimeService s) {
this.skillRuntimeService = s; this.skillRuntimeService = s;
} }
@ -885,6 +893,12 @@ public class ToolExecutionExecutor {
ToolExecutionContext.clear(); 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; int rawLen = result != null ? result.length() : 0;
// RFC-052: returnDirect tools bypass spill / truncation / LLM context. // RFC-052: returnDirect tools bypass spill / truncation / LLM context.
// Their full text goes to the user verbatim and is never persisted to // 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; package vip.mate.agent.graph.executor;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.context.StructuredTruncator; import vip.mate.agent.context.StructuredTruncator;
import vip.mate.tool.guard.WorkspacePathGuard;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -73,6 +75,30 @@ public class ToolResultStorage {
this.excludedToolsSnapshot = props.excludedToolsSet(); 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). */ /** D-6: current cumulative spill count (monotonically increasing). */
public long getSpillCount() { public long getSpillCount() {
return spillCount.get(); return spillCount.get();
@ -283,18 +309,30 @@ public class ToolResultStorage {
private Path resolveBaseDir(String workspaceBasePath) { private Path resolveBaseDir(String workspaceBasePath) {
Path base; Path base;
boolean outsideWorkspace;
if (!props.getStorageBaseDir().isEmpty()) { if (!props.getStorageBaseDir().isEmpty()) {
base = Paths.get(props.getStorageBaseDir()); base = Paths.get(props.getStorageBaseDir());
outsideWorkspace = true;
} else if (workspaceBasePath != null && !workspaceBasePath.isBlank()) { } 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"); base = Paths.get(workspaceBasePath, ".mateclaw", "tool-results");
outsideWorkspace = false;
} else { } else {
String tmp = System.getProperty("java.io.tmpdir"); String tmp = System.getProperty("java.io.tmpdir");
if (tmp == null || tmp.isEmpty()) return null; if (tmp == null || tmp.isEmpty()) return null;
base = Paths.get(tmp, "mateclaw", "tool-results"); base = Paths.get(tmp, "mateclaw", "tool-results");
outsideWorkspace = true;
} }
// Register so the retention sweep and conversation-delete hook can // Register so the retention sweep and conversation-delete hook can
// reach this root even when the workspace path is no longer in scope. // reach this root even when the workspace path is no longer in scope.
observedRoots.add(base); 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; return base;
} }

View File

@ -265,12 +265,15 @@ public class FinalAnswerNode implements NodeAction {
/** /**
* Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss) * Replace fake {@code /api/v1/files/generated/{id}} URLs (cache-miss)
* with a user-visible warning. No-op when no cache is wired (legacy * with a user-visible warning, and wrap live bare URLs into
* tests) or when the answer is empty. * {@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) { private String scrubFakeUrls(String text) {
if (generatedFileCache == null || text == null || text.isEmpty()) return 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) { 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.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager; import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.LoopBudgetConfig; import vip.mate.agent.context.LoopBudgetConfig;
import vip.mate.agent.context.PrefixBudgetPlan;
import vip.mate.agent.context.LoopMessageBudgeter; import vip.mate.agent.context.LoopMessageBudgeter;
import vip.mate.agent.context.RuntimeContextInjector; import vip.mate.agent.context.RuntimeContextInjector;
import vip.mate.agent.context.TokenEstimator; import vip.mate.agent.context.TokenEstimator;
@ -350,6 +351,52 @@ public class ReasoningNode implements NodeAction {
*/ */
private final vip.mate.agent.progress.ProgressLedgerService progressLedgerService; 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, public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
NodeStreamingChatHelper streamingHelper, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager, ConversationWindowManager conversationWindowManager,
@ -470,6 +517,12 @@ public class ReasoningNode implements NodeAction {
* otherwise the documented fallback. * otherwise the documented fallback.
*/ */
private int loopContextWindowTokens() { 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) { if (conversationWindowManager != null) {
int v = conversationWindowManager.getDefaultMaxInputTokens(); int v = conversationWindowManager.getDefaultMaxInputTokens();
if (v > 0) return v; 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. // 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. // Falls back to the full tool set when no disclosure service is wired.
List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null) List<ToolCallback> activeCallbacks = (toolDisclosureService != null && toolSet != null)
? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools()).activeCallbacks() ? toolDisclosureService.split(toolSet, accessor.enabledExtensionTools(), autoDemotedTools)
.activeCallbacks()
: toolCallbacks; : toolCallbacks;
ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks); ChatOptions options = buildChatOptions(effectiveReasoning, activeCallbacks);
Prompt prompt = new Prompt(promptMessages, options); 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 调用区域 ======= // ======= LLM 调用区域 =======
// nextLlmCallCount 在首次 streamCall 之前计算 // nextLlmCallCount 在首次 streamCall 之前计算
// 所有退出路径正常stoppedfatal errorCancellationException都必须写回此值 // 所有退出路径正常stoppedfatal errorCancellationException都必须写回此值
@ -745,7 +820,19 @@ public class ReasoningNode implements NodeAction {
NodeStreamingChatHelper.StreamResult result; NodeStreamingChatHelper.StreamResult result;
try { 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 保证重试 // PTL 处理结构化压缩后重试复用 nonHistoryPrefix 保证重试
// Prompt 仍带 wiki / runtime context早期的 tail-only 路径会把 // Prompt 仍带 wiki / runtime context早期的 tail-only 路径会把
@ -1119,7 +1206,9 @@ public class ReasoningNode implements NodeAction {
if (!projectRecalled && wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) { if (!projectRecalled && wikiContextService != null && agentIdStr != null && !agentIdStr.isEmpty()) {
try { try {
Long parsedAgentId = Long.parseLong(agentIdStr); 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()) { if (wikiRelevant != null && !wikiRelevant.isBlank()) {
prefix.add(new UserMessage(wikiRelevant)); prefix.add(new UserMessage(wikiRelevant));
} }
@ -1166,11 +1255,11 @@ public class ReasoningNode implements NodeAction {
default -> 16384; default -> 16384;
}; };
builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens); builder.thinking(org.springframework.ai.anthropic.api.AnthropicApi.ThinkingType.ENABLED, budgetTokens);
builder.maxTokens(budgetTokens + maxOutputTokens); builder.maxTokens(budgetTokens + effectiveMaxOutputTokens());
builder.temperature(1.0); builder.temperature(1.0);
log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens); log.info("[ReasoningNode] Anthropic extended thinking enabled: model={}, budget={}", currentModel, budgetTokens);
} else { } else {
builder.maxTokens(maxOutputTokens); builder.maxTokens(effectiveMaxOutputTokens());
if (thinkingOn && !isClaudeModel) { if (thinkingOn && !isClaudeModel) {
log.debug("[ReasoningNode] Anthropic protocol model {} does not support thinking, skipping", currentModel); 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 // DashScope rejects max_tokens above its 8192 ceiling with a 400 that
// the failover layer misreads as "model not found"; clamp so a // the failover layer misreads as "model not found"; clamp so a
// DashScope-backed model never overflows the provider limit. // DashScope-backed model never overflows the provider limit.
int effectiveMaxTokens = maxOutputTokens; int effectiveMaxTokens = effectiveMaxOutputTokens();
if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel if (chatModel instanceof com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel
&& effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) { && effectiveMaxTokens > DASHSCOPE_MAX_OUTPUT_TOKENS) {
log.debug("[ReasoningNode] Clamping max_tokens {} -> {} for DashScope-backed model", 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.AgentService;
import vip.mate.agent.AgentState; import vip.mate.agent.AgentState;
import vip.mate.agent.BaseAgent; import vip.mate.agent.BaseAgent;
import vip.mate.agent.delegation.DelegatedUsageAccumulator;
import vip.mate.agent.GraphEventPublisher; import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.StructuredStreamCapable; import vip.mate.agent.StructuredStreamCapable;
import vip.mate.agent.graph.plan.state.PlanStateKeys; 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); log.info("[{}] Plan-Execute replay stream: conversationId={}", agentName, conversationId);
Map<String, Object> inputs = buildInitialState(userMessage, conversationId); Map<String, Object> inputs = buildInitialState(userMessage, conversationId);
// DB 恢复 awaiting_approval 状态的计划上下文 // DB 恢复 awaiting_approval 状态的计划上下文 conversationId 过滤避免并发会话误取
PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(); PlanningService.PlanResumeContext ctx = planningService.findAwaitingApprovalContext(conversationId);
if (ctx != null) { if (ctx != null) {
inputs.put(PlanStateKeys.PLAN_ID, ctx.planId()); inputs.put(PlanStateKeys.PLAN_ID, ctx.planId());
inputs.put(PlanStateKeys.PLAN_STEPS, ctx.steps()); inputs.put(PlanStateKeys.PLAN_STEPS, ctx.steps());
@ -142,8 +143,15 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
AtomicInteger sentEventCount = new AtomicInteger(0); AtomicInteger sentEventCount = new AtomicInteger(0);
AtomicInteger finalPromptTokens = new AtomicInteger(0); AtomicInteger finalPromptTokens = new AtomicInteger(0);
AtomicInteger finalCompletionTokens = 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> finalModelName = new AtomicReference<>("");
AtomicReference<String> finalProviderId = 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 上一步内容 // 去重记录上一次已持久化的 step 结果和 thinking防止 PlanSummaryNode 重复 emit 上一步内容
AtomicReference<String> lastPersistedStepResult = new AtomicReference<>(""); AtomicReference<String> lastPersistedStepResult = new AtomicReference<>("");
AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>(""); AtomicReference<String> lastPersistedStepThinking = new AtomicReference<>("");
@ -203,16 +211,33 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// 3. 更新最新累计 token usage // 3. 更新最新累计 token usage
finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0)); finalPromptTokens.set(output.state().value(MateClawStateKeys.PROMPT_TOKENS, 0));
finalCompletionTokens.set(output.state().value(MateClawStateKeys.COMPLETION_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, "")); finalModelName.set(output.state().value(MateClawStateKeys.RUNTIME_MODEL_NAME, ""));
finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, "")); finalProviderId.set(output.state().value(MateClawStateKeys.RUNTIME_PROVIDER_ID, ""));
return deltas; return deltas;
}) })
.concatWith(Mono.fromSupplier(() -> { .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( return AgentService.StreamDelta.event("_usage_final", Map.of(
"promptTokens", finalPromptTokens.get(), "promptTokens", promptTokens,
"completionTokens", finalCompletionTokens.get(), "completionTokens", completionTokens,
"delegatedPromptTokens", delegated.promptTokens(),
"delegatedCompletionTokens", delegated.completionTokens(),
"cacheReadTokens", finalCacheReadTokens.get(),
"cacheWriteTokens", finalCacheWriteTokens.get(),
"reasoningTokens", finalReasoningTokens.get(),
"runtimeModelName", finalModelName.get(), "runtimeModelName", finalModelName.get(),
"runtimeProviderId", finalProviderId.get() "runtimeProviderId", finalProviderId.get()
)); ));
@ -223,6 +248,13 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
.doOnError(e -> { .doOnError(e -> {
log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage()); log.error("[{}] Plan-Execute stream error: {}", agentName, e.getMessage());
setState(AgentState.ERROR); 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.REQUESTER_ID, "");
inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0); inputs.put(MateClawStateKeys.PROMPT_TOKENS, 0);
inputs.put(MateClawStateKeys.COMPLETION_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_MODEL_NAME, modelName != null ? modelName : "");
inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : ""); inputs.put(MateClawStateKeys.RUNTIME_PROVIDER_ID, runtimeProviderId != null ? runtimeProviderId : "");
inputs.put(MateClawStateKeys.TRACE_ID, UUID.randomUUID().toString().substring(0, 8)); 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.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -190,6 +191,48 @@ public class PlanGenerationNode implements NodeAction {
return goal; 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, public PlanGenerationNode(ChatModel chatModel, PlanningService planningService,
NodeStreamingChatHelper streamingHelper, NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager, ConversationWindowManager conversationWindowManager,
@ -260,7 +303,7 @@ public class PlanGenerationNode implements NodeAction {
if (goalService.findActiveByConversation(convId) != null) { if (goalService.findActiveByConversation(convId) != null) {
return null; // respect an existing goal (incl. re-plan passes) 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(); GoalCreateRequest req = new GoalCreateRequest();
req.setConversationId(convId); req.setConversationId(convId);
req.setAgentId(origin.agentId()); req.setAgentId(origin.agentId());
@ -368,10 +411,16 @@ public class PlanGenerationNode implements NodeAction {
String agentId = state.value(MateClawStateKeys.AGENT_ID, ""); String agentId = state.value(MateClawStateKeys.AGENT_ID, "");
String conversationId = accessor.conversationId(); 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<>(); 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. // Replay path: plan is already in state (injected by chatWithReplayStream); skip LLM.
Long existingPlanId = state.<Long>value(PlanStateKeys.PLAN_ID).orElse(null); 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; " log.warn("[PlanGeneration] Evidence gate overrode direct-answer route; "
+ "downgrading to single-step plan so tools can execute (goal: {})", + "downgrading to single-step plan so tools can execute (goal: {})",
goal.length() > 60 ? goal.substring(0, 60) + "..." : goal); goal.length() > 60 ? goal.substring(0, 60) + "..." : goal);
List<String> gatedSteps = List.of(goal); List<String> gatedSteps = List.of(persistGoal);
var gatedPlan = planningService.createPlan(agentId, conversationId, goal, gatedSteps); var gatedPlan = planningService.createPlan(agentId, conversationId, persistGoal, gatedSteps);
events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps)); events.add(GraphEventPublisher.planCreated(gatedPlan.getId(), gatedSteps));
return PlanStateAccessor.output() return PlanStateAccessor.output()
.needsPlanning(true) .needsPlanning(true)
@ -547,7 +596,7 @@ public class PlanGenerationNode implements NodeAction {
// can still reach the tools. (Previous behavior dropped back to // can still reach the tools. (Previous behavior dropped back to
// direct_answer, which silently stripped tool capability.) // direct_answer, which silently stripped tool capability.)
log.warn("[PlanGeneration] needs_planning=true with empty steps; falling back to single-step plan"); 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 // 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, List<Long> stepAgentIds = resolveStepAgents(steps,
triage != null ? triage.stepAgents() : null, triage != null ? triage.stepAgents() : null,
chatOrigin.workspaceId(), agentId); 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={} ({}){}", log.info("[PlanGeneration] Plan created: id={}, steps={} ({}){}",
plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step", plan.getId(), steps.size(), steps.size() == 1 ? "single-step" : "multi-step",
stepAgentIds != null ? ", per-step delegation=" + stepAgentIds : ""); 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 // answer. This preserves tool access on the failure path; the previous
// "direct answer" fallback silently degraded tool-requiring tasks. // "direct answer" fallback silently degraded tool-requiring tasks.
try { try {
var plan = planningService.createPlan(agentId, conversationId, goal, List.of(goal)); var plan = planningService.createPlan(agentId, conversationId, persistGoal, List.of(persistGoal));
events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(goal))); events.add(GraphEventPublisher.planCreated(plan.getId(), List.of(persistGoal)));
return PlanStateAccessor.output() return PlanStateAccessor.output()
.needsPlanning(true) .needsPlanning(true)
.planId(plan.getId()) .planId(plan.getId())
.planSteps(List.of(goal)) .planSteps(List.of(persistGoal))
.planValid(true) .planValid(true)
.currentStepIndex(0) .currentStepIndex(0)
.currentPhase("plan_generated") .currentPhase("plan_generated")

View File

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

View File

@ -258,10 +258,37 @@ public final class PlanStateAccessor {
int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0); int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0);
map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens()); 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); map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
return this; 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() { public Map<String, Object> build() {
return map; return map;
} }

View File

@ -537,6 +537,12 @@ public final class MateClawStateAccessor {
int existingCompletion = currentState.value(COMPLETION_TOKENS, 0); int existingCompletion = currentState.value(COMPLETION_TOKENS, 0);
map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(COMPLETION_TOKENS, existingCompletion + result.completionTokens()); 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; return this;
} }

View File

@ -155,6 +155,12 @@ public final class MateClawStateKeys {
// ===== Token Usage 累计REPLACE 策略节点内累加后写回===== // ===== Token Usage 累计REPLACE 策略节点内累加后写回=====
public static final String PROMPT_TOKENS = "prompt_tokens"; public static final String PROMPT_TOKENS = "prompt_tokens";
public static final String COMPLETION_TOKENS = "completion_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 注入===== // ===== 运行时模型快照REPLACE 策略buildInitialState 注入=====
public static final String RUNTIME_MODEL_NAME = "runtime_model_name"; public static final String RUNTIME_MODEL_NAME = "runtime_model_name";

View File

@ -335,6 +335,29 @@ public class ApprovalWorkflowService implements ApplicationRunner {
approvalMapper.insert(entity); approvalMapper.insert(entity);
log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}", log.info("[ApprovalWorkflow] requested workflow approval row id={}, runId={}, workspace={}, kind={}",
entity.getId(), runId, workspaceId, 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(); return entity.getId();
} catch (Exception e) { } catch (Exception e) {
log.warn("[ApprovalWorkflow] requestWorkflowApproval failed: {}", e.getMessage()); 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) { public PendingApproval findPendingByConversation(String conversationId) {
return approvalService.findPendingByConversation(conversationId); return approvalService.findPendingByConversation(conversationId);
} }

View File

@ -50,7 +50,8 @@ public class AuthService {
.eq(UserEntity::getUsername, request.getUsername()) .eq(UserEntity::getUsername, request.getUsername())
.eq(UserEntity::getEnabled, true)); .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, "用户名或密码错误"); throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
} }
@ -212,7 +213,10 @@ public class AuthService {
return userMapper.selectById(userId); return userMapper.selectById(userId);
} }
private String generateToken(UserEntity user) { /**
* 生成 JWT tokenSSO 登录路径复用此方法签发格式一致的 token
*/
public String generateToken(UserEntity user) {
return Jwts.builder() return Jwts.builder()
.subject(user.getUsername()) .subject(user.getUsername())
.claim("userId", user.getId()) .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() ? message.getChannelType()
: channel.getChannelType(), : channel.getChannelType(),
/* chatId */ message.getChatId(), /* 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; 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 */ /** 运行中的渠道适配器channelId -> adapter */
private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>(); private final Map<Long, ChannelAdapter> activeAdapters = new HashMap<>();
@ -1197,14 +1204,16 @@ public class ChannelManager {
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache); case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper, case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager, feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager,
feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService); feishuCardDispatcher, feishuClientFactory, generatedFileCache, sttService,
chatUploadLocationResolver);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper); case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper); case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper, case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler, approvalNotificationService, weComCardDispatcher, weComKeepaliveScheduler,
generatedFileCache); generatedFileCache, chatUploadLocationResolver);
case "qq" -> new QQChannelAdapter(channel, messageRouter, objectMapper); 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 "slack" -> new vip.mate.channel.slack.SlackChannelAdapter(channel, messageRouter, objectMapper);
case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper); case "webchat" -> new vip.mate.channel.webchat.WebChatChannelAdapter(channel, messageRouter, objectMapper);
default -> throw new IllegalArgumentException("Unsupported channel type: " + type); default -> throw new IllegalArgumentException("Unsupported channel type: " + type);

View File

@ -70,6 +70,13 @@ public class ChannelMessageRouter {
@Autowired(required = false) @Autowired(required = false)
private ApplicationEventPublisher events; 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) {} private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@ -783,7 +790,7 @@ public class ChannelMessageRouter {
StringBuilder replyAccumulator = new StringBuilder(); StringBuilder replyAccumulator = new StringBuilder();
final String channelType = adapter.getChannelType(); final String channelType = adapter.getChannelType();
// Token usage + model attribution: capture _usage_final event emitted at stream end // 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] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
agentService.chatStructuredStream(agentId, promptText, conversationId, agentService.chatStructuredStream(agentId, promptText, conversationId,
message.getSenderId(), chatOrigin) message.getSenderId(), chatOrigin)
@ -793,6 +800,9 @@ public class ChannelMessageRouter {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 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 model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId"); Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString(); if (model != null) modelInfo[0] = model.toString();
@ -833,7 +843,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed"; String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage( MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", reply, null, status, 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; savedAssistantId = saved != null ? saved.getId() : null;
if (!isError) { if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin); publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply, chatOrigin);
@ -948,13 +958,16 @@ public class ChannelMessageRouter {
// plan_step_* events, leaving the Web Console mirror with no // plan_step_* events, leaving the Web Console mirror with no
// PlanStepsPanel for IM-routed conversations. // PlanStepsPanel for IM-routed conversations.
// Token usage + model attribution: capture _usage_final event emitted at stream end // 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] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> { Flux<AgentService.StreamDelta> mirroredStream = stream.doOnNext(delta -> {
if (delta.isEvent() && "_usage_final".equals(delta.eventType())) { if (delta.isEvent() && "_usage_final".equals(delta.eventType())) {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 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 model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId"); Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString(); if (model != null) modelInfo[0] = model.toString();
@ -982,7 +995,7 @@ public class ChannelMessageRouter {
String status = isError ? "error" : "completed"; String status = isError ? "error" : "completed";
MessageEntity saved = conversationService.saveMessage( MessageEntity saved = conversationService.saveMessage(
conversationId, "assistant", finalContent, null, status, 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) { if (!isError) {
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin); publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent, chatOrigin);
} }
@ -1491,10 +1504,12 @@ public class ChannelMessageRouter {
// 构建音频 MessageContentPart // 构建音频 MessageContentPart
String audioUrl = (String) result.get("audioUrl"); String audioUrl = (String) result.get("audioUrl");
String fileName = Paths.get(audioUrl).getFileName().toString(); 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)) { if (audioPath == null) {
log.warn("[voice-reply] TTS output file not found: {}", audioPath); log.warn("[voice-reply] TTS output file not found for conversation {} ({})", conversationId, fileName);
return; 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(); .build();
// Package-private for testing: redirect to a temp directory without touching real disk. // 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"); 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, public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter, ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
@ -291,6 +331,28 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
FeishuClientFactory clientFactory, FeishuClientFactory clientFactory,
vip.mate.tool.document.GeneratedFileCache generatedFileCache, vip.mate.tool.document.GeneratedFileCache generatedFileCache,
vip.mate.stt.SttService sttService) { 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); super(channelEntity, messageRouter, objectMapper);
this.mediaUploader = mediaUploader; this.mediaUploader = mediaUploader;
this.generatedFileScrubber = generatedFileScrubber; this.generatedFileScrubber = generatedFileScrubber;
@ -299,6 +361,7 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
this.clientFactory = clientFactory; this.clientFactory = clientFactory;
this.generatedFileCache = generatedFileCache; this.generatedFileCache = generatedFileCache;
this.sttService = sttService; this.sttService = sttService;
this.chatUploadLocationResolver = chatUploadLocationResolver;
// Feishu WebSocket reconnect: 2s4s8s16s30s, infinite retry // Feishu WebSocket reconnect: 2s4s8s16s30s, infinite retry
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1); 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); : maybeDownloadResource(messageId, fileKey, type, fileName);
if (dl == null) return null; if (dl == null) return null;
// Save to data/chat-uploads/{conversationId}/ // Save under the workspace/agent-aware upload root ({convId}/ subdir)
Path uploadDir = chatUploadsRoot.resolve(conversationId); Path uploadDir = chatUploadRootFor(conversationId).resolve(conversationId);
Files.createDirectories(uploadDir); Files.createDirectories(uploadDir);
String rawName = (dl.fileName() != null && !dl.fileName().isBlank()) String rawName = (dl.fileName() != null && !dl.fileName().isBlank())
? dl.fileName() : fileKey; ? dl.fileName() : fileKey;
@ -1796,15 +1859,20 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
} }
/** /**
* Scan {@code data/chat-uploads/{conversationId}/} on disk and return * Scan the conversation's upload dir(s) on disk and return the most recent
* the most recent files as {@link RecentFileEntry}s. Used as a * files as {@link RecentFileEntry}s. Used as a fallback when the in-memory
* fallback when the in-memory Caffeine cache has been evicted * Caffeine cache has been evicted (process restart, TTL expiry, GC pressure)
* (process restart, TTL expiry, GC pressure) but the staged copies * but the staged copies are still on disk. Probes every candidate root
* are still on disk. * (workspace-scoped + legacy default) so files written before the
* workspace-aware relocation are still found.
*/ */
private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) { private List<RecentFileEntry> loadRecentFilesFromDisk(String conversationId) {
long cutoff = System.currentTimeMillis() - RECENT_FILE_TTL_MINUTES * 60_000L; 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 com.lark.oapi.event.cardcallback.model.P2CardActionTriggerResponse;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import vip.mate.approval.ApprovalService; import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.PendingApproval; import vip.mate.approval.PendingApproval;
import vip.mate.approval.ResolveOutcome;
import vip.mate.channel.ChannelMessage; import vip.mate.channel.ChannelMessage;
import vip.mate.channel.feishu.FeishuChannelAdapter; import vip.mate.channel.feishu.FeishuChannelAdapter;
import vip.mate.channel.feishu.cards.FeishuCardHandler; import vip.mate.channel.feishu.cards.FeishuCardHandler;
@ -59,11 +61,23 @@ import java.util.Optional;
public class ToolGuardCardHandler implements FeishuCardHandler { public class ToolGuardCardHandler implements FeishuCardHandler {
private final ApprovalService approvalService; 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; private final ToolGuardButtonValue buttonValue;
public ToolGuardCardHandler(ApprovalService approvalService, public ToolGuardCardHandler(ApprovalService approvalService,
ApprovalWorkflowService approvalWorkflowService,
ToolGuardButtonValue buttonValue) { ToolGuardButtonValue buttonValue) {
this.approvalService = approvalService; this.approvalService = approvalService;
this.approvalWorkflowService = approvalWorkflowService;
this.buttonValue = buttonValue; this.buttonValue = buttonValue;
} }
@ -101,6 +115,19 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
PendingApproval pending = opt.get(); PendingApproval pending = opt.get();
// ---- 3. Identity check (fail-closed) // ---- 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 // Agent/cron ("system") or unattributed (null) approvals have no human
// requester to match the clicker against. A guarded-tool card landing in // 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 // 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); 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} // 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 com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import vip.mate.approval.ApprovalService; import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.channel.feishu.cards.FeishuCardKind; import vip.mate.channel.feishu.cards.FeishuCardKind;
/** /**
@ -27,21 +28,27 @@ public class ToolGuardCardKindFactory {
public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX; public static final String ACTION_PREFIX = ToolGuardButtonValue.ACTION_PREFIX;
private final ApprovalService approvalService; private final ApprovalService approvalService;
/** ISSUE #413 P2-B3: resolves workflow-scoped (wf-) approvals from card clicks. */
private final ApprovalWorkflowService approvalWorkflowService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ToolGuardCardKindFactory(ApprovalService approvalService, public ToolGuardCardKindFactory(ApprovalService approvalService,
ApprovalWorkflowService approvalWorkflowService,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.approvalService = approvalService; this.approvalService = approvalService;
this.approvalWorkflowService = approvalWorkflowService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public FeishuCardKind create() { public FeishuCardKind create() {
ToolGuardButtonValue buttonValue = new ToolGuardButtonValue(objectMapper); ToolGuardButtonValue buttonValue = new ToolGuardButtonValue(objectMapper);
ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonValue); ToolGuardCardRenderer renderer = new ToolGuardCardRenderer(buttonValue);
// Handler no longer needs ApprovalWorkflowService the canonical // ISSUE #413 P2-B3: handler needs ApprovalWorkflowService to resolve
// resolve + replay path runs via a synthetic /approve|/deny // wf- workflow approvals inline (the synthetic /approve injection is
// message injected back into the router. // a dead end for wf- ids). Regular tool approvals still go through
ToolGuardCardHandler handler = new ToolGuardCardHandler(approvalService, buttonValue); // the synthetic /approve | /deny router path as before.
ToolGuardCardHandler handler = new ToolGuardCardHandler(
approvalService, approvalWorkflowService, buttonValue);
return new FeishuCardKind(KIND_NAME, ACTION_PREFIX, renderer, handler); 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.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import java.io.IOException; import java.io.IOException;
import reactor.core.Disposable; import reactor.core.Disposable;
@ -38,6 +37,8 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
@ -62,7 +63,7 @@ public class ChatController {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ConversationCompletionPublisher completionPublisher; private final ConversationCompletionPublisher completionPublisher;
private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; 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() // 使用虚拟线程池处理 SSEJava 17+ 兼容Java 21 可用 Executors.newVirtualThreadPerTaskExecutor()
private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
@ -356,6 +357,9 @@ public class ChatController {
persistStatus, persistStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); // includes toolCalls metadata accumulator.toMetadataJson()); // includes toolCalls metadata
@ -435,6 +439,9 @@ public class ChatController {
errStatus, errStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -555,7 +562,7 @@ public class ChatController {
// tools that need a workspace path read it from the agent (origin // tools that need a workspace path read it from the agent (origin
// is enriched with workspaceBasePath in StateGraph buildInitialState). // is enriched with workspaceBasePath in StateGraph buildInitialState).
vip.mate.agent.context.ChatOrigin webOrigin = vip.mate.agent.context.ChatOrigin webOrigin =
memoryOrigin(conversationId, username, workspaceId, request.getEndUserId()) memoryOrigin(conversationId, username, requesterUserIdOf(auth), workspaceId, request.getEndUserId())
.withBaseUrl(requestBaseUrl); .withBaseUrl(requestBaseUrl);
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin) Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
.doOnNext(delta -> { .doOnNext(delta -> {
@ -628,6 +635,9 @@ public class ChatController {
persistStatus, persistStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -746,6 +756,9 @@ public class ChatController {
status, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -848,6 +861,9 @@ public class ChatController {
status, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1056,7 +1072,7 @@ public class ChatController {
// Carry the web origin so per-owner memory recall (read) and the // Carry the web origin so per-owner memory recall (read) and the
// post-conversation memory write below agree on the same owner key. // post-conversation memory write below agree on the same owner key.
vip.mate.agent.context.ChatOrigin webOrigin = 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); AgentService.ChatResult result = agentService.chatWithUsage(agentId, promptText, request.getConversationId(), webOrigin);
String response = result.content(); String response = result.content();
conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed", conversationService.saveMessage(request.getConversationId(), "assistant", response, null, "completed",
@ -1075,7 +1091,9 @@ public class ChatController {
Authentication auth) throws IOException { Authentication auth) throws IOException {
String username = auth != null ? auth.getName() : "anonymous"; String username = auth != null ? auth.getName() : "anonymous";
// 校验会话归属会话可能尚未创建此时允许上传后续 stream/chat 会创建并绑定用户 // 校验会话归属会话可能尚未创建此时允许上传后续 stream/chat 会创建并绑定用户
// 注意会话尚不存在时附件暂存到默认目录resolveUploadRoot 查不到会话即回退
// 会话创建后读取走双重查找仍能命中
if (conversationService.conversationExists(conversationId) if (conversationService.conversationExists(conversationId)
&& !conversationService.isConversationOwner(conversationId, username)) { && !conversationService.isConversationOwner(conversationId, username)) {
return R.fail(403, "无权操作该会话"); return R.fail(403, "无权操作该会话");
@ -1087,6 +1105,7 @@ public class ChatController {
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_"); String safeFilename = Path.of(originalFilename).getFileName().toString().replaceAll("[^a-zA-Z0-9._-]", "_");
String storedName = System.currentTimeMillis() + "_" + safeFilename; String storedName = System.currentTimeMillis() + "_" + safeFilename;
Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId);
Path conversationDir = uploadRoot.resolve(conversationId); Path conversationDir = uploadRoot.resolve(conversationId);
Files.createDirectories(conversationDir); Files.createDirectories(conversationDir);
Path target = conversationDir.resolve(storedName); Path target = conversationDir.resolve(storedName);
@ -1099,8 +1118,8 @@ public class ChatController {
response.setFileName(originalFilename); response.setFileName(originalFilename);
response.setStoredName(storedName); response.setStoredName(storedName);
response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName); response.setUrl("/api/v1/chat/files/" + conversationId + "/" + storedName);
// 使用相对路径避免暴露服务端绝对路径 // root 相对路径避免暴露服务端绝对路径uploadRoot 现在恒为绝对路径
response.setPath(uploadRoot.resolve(conversationId).resolve(storedName).toString()); response.setPath(toRelativeUploadPath(uploadRoot, conversationId, storedName));
response.setSize(file.getSize()); response.setSize(file.getSize());
response.setContentType(file.getContentType()); response.setContentType(file.getContentType());
return R.ok(response); return R.ok(response);
@ -1119,8 +1138,20 @@ public class ChatController {
return ResponseEntity.status(403).build(); return ResponseEntity.status(403).build();
} }
Path filePath = uploadRoot.resolve(conversationId).resolve(storedName).normalize(); // Check every candidate root (workspace-scoped dir + legacy default dir)
if (!Files.exists(filePath) || !filePath.startsWith(uploadRoot.resolve(conversationId).normalize())) { // 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(); return ResponseEntity.notFound().build();
} }
@ -1154,17 +1185,33 @@ public class ChatController {
* MateClaw user ({@code user:<username>}). * MateClaw user ({@code user:<username>}).
*/ */
private vip.mate.agent.context.ChatOrigin memoryOrigin(String conversationId, String 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 // 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 // the origin into async tool execution where no request is bound. Tools
// then mint absolute download links without operator config. // then mint absolute download links without operator config.
String baseUrl = resolveRequestBaseUrl(); String baseUrl = resolveRequestBaseUrl();
if (endUserId != null && !endUserId.isBlank()) { 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 return vip.mate.agent.context.ChatOrigin
.web(conversationId, endUserId.trim(), workspaceId, null, baseUrl) .web(conversationId, endUserId.trim(), workspaceId, null, baseUrl)
.withSender(null, "api", null); .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, persistStatus,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1395,6 +1445,9 @@ public class ChatController {
"failed", "failed",
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1484,6 +1537,31 @@ public class ChatController {
return savedAssistant != null; 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, private MessageEntity saveEmptyAssistantPlaceholder(String conversationId, String status,
StreamAccumulator accumulator, String source) { StreamAccumulator accumulator, String source) {
log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}", log.warn("{} with empty accumulator: conversationId={}, status={}, finishReason={}, phase={}, hasSegments={}",
@ -1493,6 +1571,9 @@ public class ChatController {
emptyAssistantPlaceholder(status), null, status, emptyAssistantPlaceholder(status), null, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1540,6 +1621,19 @@ public class ChatController {
} }
if (promptTokens > 0) payload.put("promptTokens", promptTokens); if (promptTokens > 0) payload.put("promptTokens", promptTokens);
if (completionTokens > 0) payload.put("completionTokens", completionTokens); 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); payload.put("persisted", persisted);
if (messageCount != null) payload.put("messageCount", messageCount); if (messageCount != null) payload.put("messageCount", messageCount);
return payload; return payload;
@ -1594,6 +1688,9 @@ public class ChatController {
status, status,
accumulator.getPromptTokens(), accumulator.getPromptTokens(),
accumulator.getCompletionTokens(), accumulator.getCompletionTokens(),
accumulator.getCacheReadTokens(),
accumulator.getCacheWriteTokens(),
accumulator.getReasoningTokens(),
accumulator.getRuntimeModelName(), accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(), accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); accumulator.toMetadataJson());
@ -1717,6 +1814,11 @@ public class ChatController {
|| lower.contains("client abort") || lower.contains("closed"); || 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 * 流式累积器 收集 StreamDelta 事件持久化到 DB
* <p> * <p>
@ -1739,9 +1841,14 @@ public class ChatController {
private final List<Map<String, Object>> planStepResults = new ArrayList<>(); private final List<Map<String, Object>> planStepResults = new ArrayList<>();
/** RFC-052: tool names whose returnDirect output was folded into the assistant message */ /** RFC-052: tool names whose returnDirect output was folded into the assistant message */
private final List<String> directToolNames = new ArrayList<>(); 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 segCounter = 0;
private int promptTokens = 0; private int promptTokens = 0;
private int completionTokens = 0; private int completionTokens = 0;
private int cacheReadTokens = 0;
private int cacheWriteTokens = 0;
private int reasoningTokens = 0;
private String runtimeModelName = ""; private String runtimeModelName = "";
private String runtimeProviderId = ""; private String runtimeProviderId = "";
private boolean awaitingApproval = false; private boolean awaitingApproval = false;
@ -1788,6 +1895,9 @@ public class ChatController {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); promptTokens = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
completionTokens = ((Number) data.getOrDefault("completionTokens", 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", "")); runtimeModelName = String.valueOf(data.getOrDefault("runtimeModelName", ""));
runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", "")); runtimeProviderId = String.valueOf(data.getOrDefault("runtimeProviderId", ""));
return; return;
@ -2010,6 +2120,30 @@ public class ChatController {
break; 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(); } String getThinking() { return thinking.toString().trim(); }
int getPromptTokens() { return promptTokens; } int getPromptTokens() { return promptTokens; }
int getCompletionTokens() { return completionTokens; } int getCompletionTokens() { return completionTokens; }
int getCacheReadTokens() { return cacheReadTokens; }
int getCacheWriteTokens() { return cacheWriteTokens; }
int getReasoningTokens() { return reasoningTokens; }
String getRuntimeModelName() { return runtimeModelName; } String getRuntimeModelName() { return runtimeModelName; }
String getRuntimeProviderId() { return runtimeProviderId; } String getRuntimeProviderId() { return runtimeProviderId; }
String getCurrentPhase() { return currentPhase; } String getCurrentPhase() { return currentPhase; }
@ -2133,6 +2270,9 @@ public class ChatController {
// historical messages as "data returned directly by tool". // historical messages as "data returned directly by tool".
metadata.put("directToolNames", directToolNames); metadata.put("directToolNames", directToolNames);
} }
if (!generatedFiles.isEmpty()) {
metadata.put("generatedFiles", generatedFiles);
}
if (!finishReason.isEmpty()) { if (!finishReason.isEmpty()) {
// Surface graph FinishReason so MemorySummarizationGate and // Surface graph FinishReason so MemorySummarizationGate and
// any other downstream consumer can branch on a structured // 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.concurrent.Executors;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; 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 嵌入式对话接口 * WebChat 嵌入式对话接口
@ -79,6 +83,14 @@ public class WebChatController {
private final vip.mate.skill.repository.SkillMapper skillMapper; private final vip.mate.skill.repository.SkillMapper skillMapper;
private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper; private final vip.mate.wiki.repository.WikiPageMapper wikiPageMapper;
private final vip.mate.wiki.repository.WikiKnowledgeBaseMapper wikiKbMapper; 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. */ /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */
static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; 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. // delta is not a persistence-only echo of content already streamed by inner nodes.
StringBuilder assistantReply = new StringBuilder(); StringBuilder assistantReply = new StringBuilder();
// Token usage + model attribution: capture _usage_final event emitted at stream end // 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] final String[] modelInfo = {null, null}; // [runtimeModel, runtimeProvider]
// Attribute memory to this external visitor so each end-user // Attribute memory to this external visitor so each end-user
@ -218,6 +230,9 @@ public class WebChatController {
Map<String, Object> data = delta.eventData(); Map<String, Object> data = delta.eventData();
usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue(); usage[0] = ((Number) data.getOrDefault("promptTokens", 0)).intValue();
usage[1] = ((Number) data.getOrDefault("completionTokens", 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 model = data.get("runtimeModelName");
Object provider = data.get("runtimeProviderId"); Object provider = data.get("runtimeProviderId");
if (model != null) modelInfo[0] = model.toString(); if (model != null) modelInfo[0] = model.toString();
@ -253,7 +268,7 @@ public class WebChatController {
if (!reply.isBlank()) { if (!reply.isBlank()) {
conversationService.saveMessage( conversationService.saveMessage(
conversationId, "assistant", reply, List.of(), 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( completionPublisher.publish(
resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey); resolvedAgentId, conversationId, message, reply, "webchat", webchatOwnerKey);
@ -1013,9 +1028,12 @@ public class WebChatController {
* Disposable 实际中断 Flux;返回 {@code stopped=false} 表示当前没有活跃流 * Disposable 实际中断 Flux;返回 {@code stopped=false} 表示当前没有活跃流
* (幂等,不报错) * (幂等,不报错)
* <p> * <p>
* 不做 approval sweep:webchat 渠道目前不暴露 approval UI,且无 MateClaw * Approval sweep (ISSUE #413 P1-A4): deny any pending approvals on this
* username 可传给 {@code denyAllByConversation}若未来 webchat 接入审批流, * 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 = "停止访客会话线程的进行中流") @Operation(summary = "停止访客会话线程的进行中流")
@PostMapping("/sessions/stop") @PostMapping("/sessions/stop")
@ -1049,9 +1067,319 @@ public class WebChatController {
conversationId, visitorId, stopped); conversationId, visitorId, stopped);
audit(channel, visitorId, "webchat.stop-session", conversationId, audit(channel, visitorId, "webchat.stop-session", conversationId,
"{\"sessionId\":\"" + sid + "\",\"stopped\":" + stopped + "}"); "{\"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)); 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> * <p>

View File

@ -5,11 +5,11 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays; import java.util.Arrays;
import java.util.Locale; import java.util.Locale;
import java.util.Optional; import java.util.Optional;
@ -39,9 +39,6 @@ import java.util.stream.Stream;
@Service @Service
public class WebChatFileService { 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. */ /** 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 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 Set<String> allowedExtensions;
private final int maxFilesPerConversation; private final int maxFilesPerConversation;
private final long maxTotalBytesPerConversation; private final long maxTotalBytesPerConversation;
private final ChatUploadLocationResolver uploadLocationResolver;
/** fileId (== storedName) -> staged metadata, pending a /stream reference. */ /** fileId (== storedName) -> staged metadata, pending a /stream reference. */
private final ConcurrentHashMap<String, StagedFile> staged = new ConcurrentHashMap<>(); 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," + "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, + "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-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.enabled = enabled;
this.maxSizeBytes = maxSizeMb * 1024 * 1024; this.maxSizeBytes = maxSizeMb * 1024 * 1024;
this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(",")) this.allowedExtensions = Arrays.stream(allowedExtensionsCsv.split(","))
@ -70,6 +69,7 @@ public class WebChatFileService {
.collect(Collectors.toUnmodifiableSet()); .collect(Collectors.toUnmodifiableSet());
this.maxFilesPerConversation = maxFilesPerConversation; this.maxFilesPerConversation = maxFilesPerConversation;
this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024; this.maxTotalBytesPerConversation = maxTotalMbPerConversation * 1024 * 1024;
this.uploadLocationResolver = uploadLocationResolver;
} }
/** Metadata for a staged upload. */ /** Metadata for a staged upload. */
@ -111,7 +111,7 @@ public class WebChatFileService {
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file"; String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "file";
// Strip any directory components, then collapse to a safe charset. // 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); String ext = extensionOf(baseName);
if (ext.isEmpty() || !allowedExtensions.contains(ext)) { if (ext.isEmpty() || !allowedExtensions.contains(ext)) {
throw new UploadRejectedException("File type not allowed: ." + 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 safeName = baseName.replaceAll("[^a-zA-Z0-9._-]", "_");
String storedName = UUID.randomUUID() + "_" + safeName; String storedName = UUID.randomUUID() + "_" + safeName;
Path dir = UPLOAD_ROOT.resolve(conversationId).normalize(); Path uploadRoot = uploadLocationResolver.resolveUploadRoot(conversationId).normalize();
if (!dir.startsWith(UPLOAD_ROOT.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. // conversationId is server-derived, so this should never happen; fail closed if it does.
throw new UploadRejectedException("Invalid conversation"); throw new UploadRejectedException("Invalid conversation");
} }
@ -169,12 +170,16 @@ public class WebChatFileService {
if (storedName == null || storedName.isBlank()) { if (storedName == null || storedName.isBlank()) {
return Optional.empty(); return Optional.empty();
} }
Path base = UPLOAD_ROOT.resolve(conversationId).normalize(); // Check every candidate root (workspace-scoped dir + legacy default dir)
Path file = base.resolve(storedName).normalize(); // so files written before the workspace-aware relocation still resolve.
if (!file.startsWith(base) || !Files.exists(file) || !Files.isRegularFile(file)) { for (Path root : uploadLocationResolver.resolveCandidateUploadRoots(conversationId)) {
return Optional.empty(); 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. */ /** 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; 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, public WeComChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter, ChannelMessageRouter messageRouter,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@ -297,11 +303,30 @@ public class WeComChannelAdapter extends AbstractChannelAdapter {
vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher, vip.mate.channel.wecom.cards.WeComCardDispatcher cardDispatcher,
WeComKeepaliveScheduler keepaliveScheduler, WeComKeepaliveScheduler keepaliveScheduler,
vip.mate.tool.document.GeneratedFileCache generatedFileCache) { 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); super(channelEntity, messageRouter, objectMapper);
this.approvalNotificationService = approvalNotificationService; this.approvalNotificationService = approvalNotificationService;
this.cardDispatcher = cardDispatcher; this.cardDispatcher = cardDispatcher;
this.keepaliveScheduler = keepaliveScheduler; this.keepaliveScheduler = keepaliveScheduler;
this.generatedFileCache = generatedFileCache; this.generatedFileCache = generatedFileCache;
this.chatUploadLocationResolver = chatUploadLocationResolver;
// Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential) // Default to 8 bounded attempts (~4 minutes total at 2s..30s exponential)
// so the UI eventually settles in ERROR instead of getting stuck in // so the UI eventually settles in ERROR instead of getting stuck in
// RECONNECTING forever. User config still overrides (-1 = infinite). // 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, private InboundMediaDownloader.DownloadedMedia downloadInboundMedia(String url, String aesKey, String msgId,
String fileNameHint, String conversationId) { 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 // /api/v1/chat/files/{convId}/{storedName} endpoint serves the file
// back to the chat bubble the WeCom CDN URL carries a short-lived // back to the chat bubble the WeCom CDN URL carries a short-lived
// signature that expires before a browser can fetch it. The shared // signature that expires before a browser can fetch it. The shared
// pipeline owns retry/backoff, magic-byte type detection, and the // pipeline owns retry/backoff, magic-byte type detection, and the
// dedup-named write; the WeCom-specific AES-256-CBC decrypt stays here // 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. // 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; String hint = (fileNameHint == null || fileNameHint.isBlank()) ? null : fileNameHint;
return InboundMediaDownloader.download( return InboundMediaDownloader.download(
() -> { () -> {

View File

@ -2,7 +2,9 @@ package vip.mate.channel.wecom.cards.tool_guard;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import vip.mate.approval.ApprovalService; import vip.mate.approval.ApprovalService;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.PendingApproval; import vip.mate.approval.PendingApproval;
import vip.mate.approval.ResolveOutcome;
import vip.mate.channel.ChannelMessage; import vip.mate.channel.ChannelMessage;
import vip.mate.channel.wecom.WeComChannelAdapter; import vip.mate.channel.wecom.WeComChannelAdapter;
import vip.mate.channel.wecom.cards.WeComCardHandler; import vip.mate.channel.wecom.cards.WeComCardHandler;
@ -37,10 +39,20 @@ import java.util.Optional;
public class ToolGuardCardHandler implements WeComCardHandler { public class ToolGuardCardHandler implements WeComCardHandler {
private final ApprovalService approvalService; 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; private final ToolGuardButtonKey buttonKey;
public ToolGuardCardHandler(ApprovalService approvalService, ToolGuardButtonKey buttonKey) { public ToolGuardCardHandler(ApprovalService approvalService,
ApprovalWorkflowService approvalWorkflowService,
ToolGuardButtonKey buttonKey) {
this.approvalService = approvalService; this.approvalService = approvalService;
this.approvalWorkflowService = approvalWorkflowService;
this.buttonKey = buttonKey; this.buttonKey = buttonKey;
} }
@ -76,6 +88,19 @@ public class ToolGuardCardHandler implements WeComCardHandler {
PendingApproval pending = opt.get(); PendingApproval pending = opt.get();
// ---- 3. Identity check (fail-closed) ---- // ---- 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 // Agent/cron ("system") or unattributed (null) approvals have no human
// requester to match the clicker against; a group card would let any // requester to match the clicker against; a group card would let any
// member resolve a guarded action. Reject here (mirrors the feishu card // 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 // Card rendering helpers
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View File

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

View File

@ -148,12 +148,32 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
/** 用于文件 URL 下载的 HttpClient */ /** 用于文件 URL 下载的 HttpClient */
private HttpClient uploadHttpClient; 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, public WeixinChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter, ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
super(channelEntity, messageRouter, 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 @Override
public String getChannelType() { public String getChannelType() {
return CHANNEL_TYPE; return CHANNEL_TYPE;
@ -666,7 +686,9 @@ public class WeixinChannelAdapter extends AbstractChannelAdapter {
return null; 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( return InboundMediaDownloader.download(
() -> client.downloadMedia("", aesKey, encryptQueryParam), () -> client.downloadMedia("", aesKey, encryptQueryParam),
filenameHint, 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